commit 5de37bb5120f29cfc451f298e036c29b27b63a80 Author: Luciano Milani Date: Thu Jul 2 12:47:55 2026 +0100 Inicial diff --git a/.agents/skills/caveman/README.md b/.agents/skills/caveman/README.md new file mode 100644 index 0000000..d749b83 --- /dev/null +++ b/.agents/skills/caveman/README.md @@ -0,0 +1,48 @@ +# caveman + +Talk like smart caveman. Same brain, fewer tokens. + +## What it does + +Compress every model response to caveman-style prose. Drops articles, filler, pleasantries, and hedging. Keeps every technical detail, code block, error string, and symbol exact. Cuts ~65-75% of output tokens with full accuracy preserved. Mode persists for the whole session until changed or stopped. + +Six intensity levels: + +| Level | What change | +|-------|-------------| +| `lite` | Drop filler/hedging. Sentences stay full. Professional but tight. | +| `full` | Default. Drop articles, fragments OK, short synonyms. | +| `ultra` | Bare fragments. Abbreviations (DB, auth, fn). Arrows for causality. | +| `wenyan-lite` | Classical Chinese register, light compression. | +| `wenyan-full` | Maximum 文言文. 80-90% character reduction. | +| `wenyan-ultra` | Extreme classical compression. | + +Auto-clarity rule: caveman drops to normal prose for security warnings, irreversible-action confirmations, multi-step sequences where fragment ambiguity risks misread, and when user repeats a question. Resumes after the clear part. + +## How to invoke + +``` +/caveman # full mode (default) +/caveman lite # lighter compression +/caveman ultra # extreme compression +/caveman wenyan # classical Chinese +stop caveman # back to normal prose +``` + +## Example output + +Question: "Why does my React component re-render?" + +Normal prose: +> Your component re-renders because you create a new object reference each render. Wrapping it in `useMemo` will fix the issue. + +Caveman (full): +> New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`. + +Caveman (ultra): +> Inline obj prop → new ref → re-render. `useMemo`. + +## See also + +- [`SKILL.md`](./SKILL.md) — full LLM-facing instructions +- [Caveman README](../../README.md) — repo overview, install, benchmarks diff --git a/.agents/skills/caveman/SKILL.md b/.agents/skills/caveman/SKILL.md new file mode 100644 index 0000000..8792c1a --- /dev/null +++ b/.agents/skills/caveman/SKILL.md @@ -0,0 +1,78 @@ +--- +name: caveman +description: > + Ultra-compressed communication mode. Cuts token usage ~75% by speaking like caveman + while keeping full technical accuracy. Supports intensity levels: lite, full (default), ultra, + wenyan-lite, wenyan-full, wenyan-ultra. + Use when user says "caveman mode", "talk like caveman", "use caveman", "less tokens", + "be brief", or invokes /caveman. Also auto-triggers when token efficiency is requested. +--- + +Respond terse like smart caveman. All technical substance stay. Only fluff die. + +## Persistence + +ACTIVE EVERY RESPONSE. No revert after many turns. No filler drift. Still active if unsure. Off only: "stop caveman" / "normal mode". + +Default: **full**. Switch: `/caveman lite|full|ultra`. + +## Rules + +Drop: articles (a/an/the), filler (just/really/basically/actually/simply), pleasantries (sure/certainly/of course/happy to), hedging. Fragments OK. Short synonyms (big not extensive, fix not "implement a solution for"). No tool-call narration, no decorative tables/emoji, no dumping long raw error logs unless asked — quote shortest decisive line. Standard well-known tech acronyms OK (DB/API/HTTP); never invent new abbreviations reader can't decode. Technical terms exact. Code blocks unchanged. Errors quoted exact. + +Preserve user's dominant language. User write Portuguese → reply Portuguese caveman. User write Spanish → reply Spanish caveman. Compress the style, not the language. No forced English openings or status phrases. ALWAYS keep technical terms, code, API names, CLI commands, commit-type keywords (feat/fix/...), and exact error strings verbatim — unless user explicitly ask for translation. + +No self-reference. Never name or announce the style. No "caveman mode on", "me caveman think", no third-person caveman tags. Output caveman-only — never normal answer plus "Caveman:" recap. Exception: user explicitly ask what the mode is. + +Pattern: `[thing] [action] [reason]. [next step].` + +Not: "Sure! I'd be happy to help you with that. The issue you're experiencing is likely caused by..." +Yes: "Bug in auth middleware. Token expiry check use `<` not `<=`. Fix:" + +## Intensity + +| Level | What change | +|-------|------------| +| **lite** | No filler/hedging. Keep articles + full sentences. Professional but tight | +| **full** | Drop articles, fragments OK, short synonyms. Classic caveman. No tool-call narration, no decorative tables/emoji, no long raw error-log dumps unless asked. Standard acronyms OK; no invented abbreviations | +| **ultra** | Abbreviate prose words (DB/auth/config/req/res/fn/impl) — prose words only, never real code symbols/function names. Strip conjunctions, arrows for causality (X → Y), one word when one word enough. Code symbols, function names, API names, error strings: never abbreviate | +| **wenyan-lite** | Semi-classical. Drop filler/hedging but keep grammar structure, classical register | +| **wenyan-full** | Maximum classical terseness. Fully 文言文. 80-90% character reduction. Classical sentence patterns, verbs precede objects, subjects often omitted, classical particles (之/乃/為/其) | +| **wenyan-ultra** | Extreme abbreviation while keeping classical Chinese feel. Maximum compression, ultra terse | + +Example — "Why React component re-render?" +- lite: "Your component re-renders because you create a new object reference each render. Wrap it in `useMemo`." +- full: "New object ref each render. Inline object prop = new ref = re-render. Wrap in `useMemo`." +- ultra: "Inline obj prop → new ref → re-render. `useMemo`." +- wenyan-lite: "組件頻重繪,以每繪新生對象參照故。以 useMemo 包之。" +- wenyan-full: "每繪新生對象參照,故重繪;以 useMemo 包之則免。" +- wenyan-ultra: "新參照→重繪。useMemo Wrap。" + +Example — "Explain database connection pooling." +- lite: "Connection pooling reuses open connections instead of creating new ones per request. Avoids repeated handshake overhead." +- full: "Pool reuse open DB connections. No new connection per request. Skip handshake overhead." +- ultra: "Pool = reuse DB conn. Skip handshake → fast under load." +- wenyan-full: "池reuse open connection。不每req新開。skip handshake overhead。" +- wenyan-ultra: "池reuse conn。skip handshake → fast。" + +## Auto-Clarity + +Drop caveman when: +- Security warnings +- Irreversible action confirmations +- Multi-step sequences where fragment order or omitted conjunctions risk misread +- Compression itself creates technical ambiguity (e.g., `"migrate table drop column backup first"` — order unclear without articles/conjunctions) +- User asks to clarify or repeats question + +Resume caveman after clear part done. + +Example — destructive op: +> **Warning:** This will permanently delete all rows in the `users` table and cannot be undone. +> ```sql +> DROP TABLE users; +> ``` +> Caveman resume. Verify backup exist first. + +## Boundaries + +Code/commits/PRs: write normal. "stop caveman" or "normal mode": revert. Level persist until changed or session end. \ No newline at end of file diff --git a/.agents/skills/frontend-design/LICENSE.txt b/.agents/skills/frontend-design/LICENSE.txt new file mode 100644 index 0000000..f433b1a --- /dev/null +++ b/.agents/skills/frontend-design/LICENSE.txt @@ -0,0 +1,177 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS diff --git a/.agents/skills/frontend-design/SKILL.md b/.agents/skills/frontend-design/SKILL.md new file mode 100644 index 0000000..decdff4 --- /dev/null +++ b/.agents/skills/frontend-design/SKILL.md @@ -0,0 +1,55 @@ +--- +name: frontend-design +description: Guidance for distinctive, intentional visual design when building new UI or reshaping an existing one. Helps with aesthetic direction, typography, and making choices that don't read as templated defaults. +license: Complete terms in LICENSE.txt +--- + +# Frontend Design + +Approach this as the design lead at a small studio known for giving every client a visual identity that could not be mistaken for anyone else's. This client has already rejected proposals that felt templated, and is paying for a distinctive point of view: make deliberate, opinionated choices about palette, typography, and layout that are specific to this brief, and take one real aesthetic risk you can justify. + +## Ground it in the subject + +If the brief does not pin down what the product or subject is, pin it yourself before designing: name one concrete subject, its audience, and the page's single job, and state your choice. If there's any information in your memory about the human's preferences, context about what they're building, or designs you've made before – use that as a hint. The subject's own world, its materials, instruments, artifacts, and vernacular, is where distinctive choices come from. Build with the brief's real content and subject matter throughout. + +## Design principles + +For web designs, the hero is a thesis. Open with the most characteristic thing in the subject's world, in whatever form makes sense for it: a headline, an image, an animation, a live demo, an interactive moment. Be deliberate with your choice: a big number with a small label, supporting stats, and a gradient accent is the template answer, only use if that's truly the best option. + +Typography carries the personality of the page. Pair the display and body faces deliberately, not the same families you would reach for on any other project, and set a clear type scale with intentional weights, widths, and spacing. Make the type treatment itself a memorable part of the design, not a neutral delivery vehicle for the content. + +Structure is information. Structural devices, numbering, eyebrows, dividers, labels, should encode something true about the content, not decorate it. Many generic designs use numbered markers (01 / 02 / 03), but that's only appropriate if the content actually is a sequence - like a real process or a typed timeline where order carries information the reader needs. Question if choices like numbered markers actually make sense before incorporating them. + +Leverage motion deliberately. Think about where and if animation can serve the subject: a page-load sequence, a scroll-triggered reveal, hover micro-interactions, ambient atmosphere. An orchestrated moment usually lands harder than scattered effects; choose what the direction calls for. However, sometimes less is more, and extra animation contributes to the feeling that the design is AI-generated. + +Match complexity to the vision. Maximalist directions need elaborate execution; minimal directions need precision in spacing, type, and detail. Elegance is executing the chosen vision well. + +Consider written content carefully. Often a design brief may not contain real content, and it's up to you to come up with copy. Copy can make a design feel as templated as the design itself. See the below section on writing for more guidance. + +## Process: brainstorm, explore, plan, critique, build, critique again + +For calibration: AI-generated design right now clusters around three looks: (1) a warm cream background (near #F4F1EA) with a high-contrast serif display and a terracotta accent; (2) a near-black background with a single bright acid-green or vermilion accent; (3) a broadsheet-style layout with hairline rules, zero border-radius, and dense newspaper-like columns. All three are legitimate for some briefs, but they are defaults rather than choices, and they appear regardless of subject. Where the brief pins down a visual direction, follow it exactly — the brief's own words always win, including when it asks for one of these looks. Where it leaves an axis free, don't spend that freedom on one of these defaults. Just like a human designer who's hired, there's often a careful balance between doing what you're good at and taking each project as a chance to experiment and learn. + +Work in two passes. First, brainstorm a short design plan based on the human's design brief: create a compact token system with color, type, layout, and signature. Color: describe the palette as 4–6 named hex values. Type: the typefaces for 2+ roles (a characterful display face that's used with restraint, a complementary body face, and a utility face for captions or data if needed). Layout: a layout concept, using one-sentence prose descriptions and ASCII wireframes to ideate and compare. Signature: the single unique element this page will be remembered by that embodies the brief in an appropriate way. + +Then review that plan against the brief before building: if any part of it reads like the generic default you would produce for any similar page (work through a similar prompt to see if you arrive somewhere similar) rather than a choice made for this specific brief — revise that part, say what you changed and why. Only after you've confirmed the relative uniqueness of your design plan should you start to write the code, following the revised plan exactly and deriving every color and type decision from it. + +When writing the code, be careful of structuring your CSS selector specificities. It's easy to generate CSS classes that cancel each other out (especially with a type-based selector like .section and a element-based selector like .cta). This can happen often with paddings/margins between sections. + +Try to do a lot of this planning and iteration in your thinking, and only show ideas to the user when you have higher confidence it'll delight them. + +## Restraint and self-critique + +Spend your boldness in one place. Let the signature element be the one memorable thing, keep everything around it quiet and disciplined, and cut any decoration that does not serve the brief. Not taking a risk can be a risk itself! Build to a quality floor without announcing it: responsive down to mobile, visible keyboard focus, reduced motion respected. Critique your own work as you build, taking screenshots if your environment supports it – a picture is worth 1000 tokens. Consider Chanel's advice: before leaving the house, take a look in the mirror and remove one accessory. Human creators have memory and always try to do something new, so if you have a space to quickly jot down notes about what you've tried, it can help you in future passes. + +## More on writing in design + +Words appear in a design for one reason: to make it easier to understand, and therefore easier to use. They are design material, not decoration. Bring the same intentionality to copy that you would bring to spacing and color. Before writing anything, ask what the design needs to say, and how it can best be said to help the person navigate the experience. + +Write from the end user's side of the screen. Name things by what people control and recognize, never by how the system is built. A person manages notifications, not webhook config. Describe what something does in plain terms rather than selling it. Being specific is always better than being clever. + +Use active voice as default. A control should say exactly what happens when it's used: "Save changes," not "Submit." An action keeps the same name through the whole flow, so the button that says "Publish" produces a toast that says "Published." The vocabulary of an interface is the signposting for someone navigating the product. Cohesion and consistency are how people learn their way around. + +Treat failure and emptiness as moments for direction, not mood. Explain what went wrong and how to fix it, in the interface's voice rather than a person's. Errors don't apologize, and they are never vague about what happened. An empty screen is an invitation to act. + +Keep the register conversational and tuned: plain verbs, sentence case, no filler, with tone matched to the brand and the audience. Let each element do exactly one job. A label labels, an example demonstrates, and nothing quietly does double duty. diff --git a/.claude/settings.json b/.claude/settings.json new file mode 100644 index 0000000..a9ad3dd --- /dev/null +++ b/.claude/settings.json @@ -0,0 +1,23 @@ +{ + "permissions": { + "allow": [ + "Bash(go build *)", + "Bash(go test *)", + "Bash(npm --version)", + "Bash(docker compose ps *)", + "Bash(docker compose logs *)", + "Bash(curl -s http://localhost*)", + "Bash(npm run build)", + "Bash(npm run test:run)", + "Bash(/var/home/lmilani/.claude/plugins/cache/claude-plugins-official/superpowers/6.0.3/skills/subagent-driven-development/scripts/task-brief /var/home/lmilani/Documentos/IDE/techxcar/docs/superpowers/plans/2026-06-29-super-admin-tenant-access.md 2)", + "Bash(git -C /var/home/lmilani/Documentos/IDE/techxcar log --oneline -1)" + ] + }, + "enabledPlugins": { + "superpowers@claude-plugins-official": true, + "skill-creator@claude-plugins-official": true, + "code-review@claude-plugins-official": true, + "code-simplifier@claude-plugins-official": true, + "mattpocock-skills@mattpocock-skills": true + } +} diff --git a/.claude/skills/caveman b/.claude/skills/caveman new file mode 120000 index 0000000..9016aac --- /dev/null +++ b/.claude/skills/caveman @@ -0,0 +1 @@ +../../.agents/skills/caveman \ No newline at end of file diff --git a/.claude/skills/frontend-design b/.claude/skills/frontend-design new file mode 120000 index 0000000..712f694 --- /dev/null +++ b/.claude/skills/frontend-design @@ -0,0 +1 @@ +../../.agents/skills/frontend-design \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..911e96b --- /dev/null +++ b/.env.example @@ -0,0 +1,19 @@ +# Database +POSTGRES_USER=techxcar +POSTGRES_PASSWORD=changeme +POSTGRES_DB=techxcar + +# Backend +JWT_SECRET=your-super-secret-key-minimum-32-characters +PORT=8080 +APP_ENV=development +REDIS_PASSWORD=changeme-redis +BACKEND_IMAGE=techxcar-backend:latest +FRONTEND_IMAGE=techxcar-frontend:latest + +# Initial super-admin — only used on first startup, skipped if already exists +INITIAL_ADMIN_EMAIL=admin@techxcar.com +INITIAL_ADMIN_PASSWORD=change-me-on-first-login + +# SMTP, Telegram and other integration settings are configured +# post-deploy via the Web UI Settings panel (stored in DB, not in .env) diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..08fb9d8 --- /dev/null +++ b/.gitignore @@ -0,0 +1,18 @@ +# Env +.env +.env.local + +# Go +backend/vendor/ +backend/tmp/ +backend/server + +# Node +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ + +# IDE +.idea/ +.vscode/ +*.swp diff --git a/AGENT.md b/AGENT.md new file mode 100644 index 0000000..db94188 --- /dev/null +++ b/AGENT.md @@ -0,0 +1,150 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this project is + +**TechXCar** — a multi-tenant SaaS for car workshop management. Each workshop gets an isolated PostgreSQL schema. A super-admin manages the platform and provisions workshops via invite links or manual creation. + +Design spec: `docs/superpowers/specs/2026-06-16-techxcar-design.md` + +--- + +## Commands + +### Backend (run from `backend/`) +```bash +go run ./cmd/server # start dev server (requires .env) +go build ./... # compile check +go test ./... # all tests (integration tests skip without env vars) +go test ./internal/server/... # single package +go test -run TestLoad_defaults ./internal/config/... # single test +``` + +Integration tests require: +- `TEST_DATABASE_URL=postgres://...` +- `TEST_REDIS_URL=redis://...` + +### Frontend (run from `frontend/`) +```bash +npm run dev # localhost:5173 (proxies /api → localhost:8080) +npm run build # TypeScript check + Vite build +npm run test:run # Vitest single run +npm test # Vitest watch +npm run test:coverage +``` + +### Docker (run from root) +```bash +cp .env.example .env # first time only +docker compose up # dev: all 4 services +docker compose up backend # backend only (postgres + redis still needed) +docker compose -f docker-compose.prod.yml up # production stack +``` + +--- + +## Required env vars + +| Var | Notes | +|---|---| +| `DATABASE_URL` | postgres connection string — required | +| `JWT_SECRET` | min 32 chars — required | +| `REDIS_URL` | defaults to `redis://localhost:6379` | +| `PORT` | defaults to `8080` | +| `APP_ENV` | defaults to `development` | + +SMTP, Telegram, PDF storage, and billing data are **not** in `.env` — they live in `platform_settings` / `tenant_settings` tables, cached in Redis for 5 minutes, and managed through the Web UI settings panel. + +--- + +## Architecture + +### Multi-tenancy (critical to understand) + +PostgreSQL uses **schema-per-tenant**: +- `public` schema: `tenants`, `invites`, `super_admins`, `platform_settings` +- `tenant_` schema: all workshop data (users, clients, vehicles, work_orders, etc.) + +Every API request sets `search_path = tenant_, public` via `db.SetTenantSchema()` using the `tenant_id` claim from the JWT. The `tenantID` is validated against `^[a-zA-Z0-9_-]{1,63}$` before being interpolated into SQL (see `pkg/database/database.go`). + +When a new tenant is provisioned, `db.ProvisionTenantSchema()` creates the schema and runs `migrations/tenant/` against it. + +### Backend layout + +``` +backend/ +├── cmd/server/main.go # entrypoint: loads config → DB → Redis → migrate → serve +├── internal/ +│ ├── config/ # typed config from env (DATABASE_URL, JWT_SECRET required) +│ └── server/ # Fiber app, security middleware, route registration +├── pkg/ +│ ├── database/ # pgxpool wrapper + MigratePublic + ProvisionTenantSchema +│ └── redis/ # go-redis wrapper +└── migrations/ + ├── public/ # applied at startup via MigratePublic() + └── tenant/ # applied per-tenant via ProvisionTenantSchema() +``` + +Future internal packages (per spec): `auth/`, `tenant/`, `workorder/`, `client/`, `catalog/`, `invoice/`, `staff/`, `expense/`, `notification/`, `report/`, `settings/`. + +### API conventions + +- Base path: `/api/v1/` +- Auth: `Authorization: Bearer ` header +- Response envelope: `{ "data": ..., "error": null, "meta": { "page": ..., "total": ... } }` +- Errors via the Fiber error handler in `internal/server/server.go` + +### JWT flow + +- `POST /api/v1/auth/login` → `access_token` in body (15 min) + `refresh_token` in httpOnly cookie (30 days) +- `POST /api/v1/auth/refresh` → new access token using the cookie +- Rate limiting on auth routes: 10 attempts/min per IP (Redis) + +### Frontend layout (planned — frontend/ not yet scaffolded as of Plan 1) + +``` +frontend/src/ +├── pages/ +│ ├── auth/ # /login +│ ├── admin/ # /admin/* — super_admin only +│ └── app/ # /app/* — tenant roles +├── components/ +│ ├── ui/ # shadcn/ui base components +│ └── shared/ # reusable app components +├── hooks/ +├── lib/ +│ ├── api.ts # fetch wrapper with auto token refresh on 401 +│ └── queryClient.ts # TanStack Query config +├── store/ # Zustand stores (authStore persisted to localStorage) +└── i18n/ # pt-PT default; en structure prepared but not translated +``` + +Routes: `BrowserRouter`. Role guards in `App.tsx` redirect to `/login` for unauthorized roles. + +### Async notifications + +Telegram and email notifications are **not sent inline**. The HTTP handler publishes a job to a Redis list; a dedicated goroutine in `internal/notification/` consumes the queue and sends. This means HTTP responses are not blocked by notification delivery. + +### Work order state machine + +``` +open → in_progress → completed → invoiced + ↓ + cancelled (from any state except invoiced) +``` + +State transitions are logged to `wo_status_log` with timestamp and the user who triggered the change. + +### PDF generation + +Server-side in Go using `chromedp` (HTML template → Chrome headless → PDF). PDFs stored on a Docker volume (`/app/storage`) or S3-compatible storage (configurable in settings). Served via an authenticated endpoint, not from the webroot. + +--- + +## Testing conventions + +- Integration tests use `t.Skip()` when `TEST_DATABASE_URL` / `TEST_REDIS_URL` are absent — safe to run without infra. +- Backend uses `testify/assert` and `testify/require`. +- Frontend uses Vitest + Testing Library with jsdom. +- Test setup file: `frontend/src/test/setup.ts`. diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 0000000..db94188 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,150 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## What this project is + +**TechXCar** — a multi-tenant SaaS for car workshop management. Each workshop gets an isolated PostgreSQL schema. A super-admin manages the platform and provisions workshops via invite links or manual creation. + +Design spec: `docs/superpowers/specs/2026-06-16-techxcar-design.md` + +--- + +## Commands + +### Backend (run from `backend/`) +```bash +go run ./cmd/server # start dev server (requires .env) +go build ./... # compile check +go test ./... # all tests (integration tests skip without env vars) +go test ./internal/server/... # single package +go test -run TestLoad_defaults ./internal/config/... # single test +``` + +Integration tests require: +- `TEST_DATABASE_URL=postgres://...` +- `TEST_REDIS_URL=redis://...` + +### Frontend (run from `frontend/`) +```bash +npm run dev # localhost:5173 (proxies /api → localhost:8080) +npm run build # TypeScript check + Vite build +npm run test:run # Vitest single run +npm test # Vitest watch +npm run test:coverage +``` + +### Docker (run from root) +```bash +cp .env.example .env # first time only +docker compose up # dev: all 4 services +docker compose up backend # backend only (postgres + redis still needed) +docker compose -f docker-compose.prod.yml up # production stack +``` + +--- + +## Required env vars + +| Var | Notes | +|---|---| +| `DATABASE_URL` | postgres connection string — required | +| `JWT_SECRET` | min 32 chars — required | +| `REDIS_URL` | defaults to `redis://localhost:6379` | +| `PORT` | defaults to `8080` | +| `APP_ENV` | defaults to `development` | + +SMTP, Telegram, PDF storage, and billing data are **not** in `.env` — they live in `platform_settings` / `tenant_settings` tables, cached in Redis for 5 minutes, and managed through the Web UI settings panel. + +--- + +## Architecture + +### Multi-tenancy (critical to understand) + +PostgreSQL uses **schema-per-tenant**: +- `public` schema: `tenants`, `invites`, `super_admins`, `platform_settings` +- `tenant_` schema: all workshop data (users, clients, vehicles, work_orders, etc.) + +Every API request sets `search_path = tenant_, public` via `db.SetTenantSchema()` using the `tenant_id` claim from the JWT. The `tenantID` is validated against `^[a-zA-Z0-9_-]{1,63}$` before being interpolated into SQL (see `pkg/database/database.go`). + +When a new tenant is provisioned, `db.ProvisionTenantSchema()` creates the schema and runs `migrations/tenant/` against it. + +### Backend layout + +``` +backend/ +├── cmd/server/main.go # entrypoint: loads config → DB → Redis → migrate → serve +├── internal/ +│ ├── config/ # typed config from env (DATABASE_URL, JWT_SECRET required) +│ └── server/ # Fiber app, security middleware, route registration +├── pkg/ +│ ├── database/ # pgxpool wrapper + MigratePublic + ProvisionTenantSchema +│ └── redis/ # go-redis wrapper +└── migrations/ + ├── public/ # applied at startup via MigratePublic() + └── tenant/ # applied per-tenant via ProvisionTenantSchema() +``` + +Future internal packages (per spec): `auth/`, `tenant/`, `workorder/`, `client/`, `catalog/`, `invoice/`, `staff/`, `expense/`, `notification/`, `report/`, `settings/`. + +### API conventions + +- Base path: `/api/v1/` +- Auth: `Authorization: Bearer ` header +- Response envelope: `{ "data": ..., "error": null, "meta": { "page": ..., "total": ... } }` +- Errors via the Fiber error handler in `internal/server/server.go` + +### JWT flow + +- `POST /api/v1/auth/login` → `access_token` in body (15 min) + `refresh_token` in httpOnly cookie (30 days) +- `POST /api/v1/auth/refresh` → new access token using the cookie +- Rate limiting on auth routes: 10 attempts/min per IP (Redis) + +### Frontend layout (planned — frontend/ not yet scaffolded as of Plan 1) + +``` +frontend/src/ +├── pages/ +│ ├── auth/ # /login +│ ├── admin/ # /admin/* — super_admin only +│ └── app/ # /app/* — tenant roles +├── components/ +│ ├── ui/ # shadcn/ui base components +│ └── shared/ # reusable app components +├── hooks/ +├── lib/ +│ ├── api.ts # fetch wrapper with auto token refresh on 401 +│ └── queryClient.ts # TanStack Query config +├── store/ # Zustand stores (authStore persisted to localStorage) +└── i18n/ # pt-PT default; en structure prepared but not translated +``` + +Routes: `BrowserRouter`. Role guards in `App.tsx` redirect to `/login` for unauthorized roles. + +### Async notifications + +Telegram and email notifications are **not sent inline**. The HTTP handler publishes a job to a Redis list; a dedicated goroutine in `internal/notification/` consumes the queue and sends. This means HTTP responses are not blocked by notification delivery. + +### Work order state machine + +``` +open → in_progress → completed → invoiced + ↓ + cancelled (from any state except invoiced) +``` + +State transitions are logged to `wo_status_log` with timestamp and the user who triggered the change. + +### PDF generation + +Server-side in Go using `chromedp` (HTML template → Chrome headless → PDF). PDFs stored on a Docker volume (`/app/storage`) or S3-compatible storage (configurable in settings). Served via an authenticated endpoint, not from the webroot. + +--- + +## Testing conventions + +- Integration tests use `t.Skip()` when `TEST_DATABASE_URL` / `TEST_REDIS_URL` are absent — safe to run without infra. +- Backend uses `testify/assert` and `testify/require`. +- Frontend uses Vitest + Testing Library with jsdom. +- Test setup file: `frontend/src/test/setup.ts`. diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..7e522c2 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,17 @@ +FROM golang:1.25-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server + +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates tzdata +RUN addgroup -S app && adduser -S app -G app +WORKDIR /app +COPY --from=builder /app/server . +COPY --from=builder /app/migrations ./migrations +RUN mkdir -p /app/storage && chown -R app:app /app +USER app +EXPOSE 8080 +CMD ["./server"] diff --git a/backend/cmd/server/main.go b/backend/cmd/server/main.go new file mode 100644 index 0000000..704be38 --- /dev/null +++ b/backend/cmd/server/main.go @@ -0,0 +1,78 @@ +package main + +import ( + "context" + "log" + "time" + + "github.com/joho/godotenv" + + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/internal/config" + "github.com/techxcar/backend/internal/server" + "github.com/techxcar/backend/internal/tenant" + "github.com/techxcar/backend/pkg/database" + redispkg "github.com/techxcar/backend/pkg/redis" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Println("No .env file found, reading from environment") + } + + cfg, err := config.Load() + if err != nil { + log.Fatal("Config error:", err) + } + + db, err := database.New(cfg.DatabaseURL) + if err != nil { + log.Fatal("Database error:", err) + } + defer db.Close() + + if err := db.MigratePublic(cfg.DatabaseURL, "migrations/public"); err != nil { + log.Fatal("Migration error:", err) + } + + if err := db.MigrateAllTenantSchemas(context.Background(), "migrations/tenant"); err != nil { + log.Fatal("Tenant migration error:", err) + } + + rdb, err := redispkg.New(cfg.RedisURL) + if err != nil { + log.Fatal("Redis error:", err) + } + defer rdb.Close() + + seedSuperAdmin(db, cfg) + + app := server.New(server.Deps{Config: cfg, DB: db, Redis: rdb}) + + log.Printf("TechXCar API v0.2.0 listening on :%s (env: %s)", cfg.Port, cfg.AppEnv) + log.Fatal(app.Listen(":" + cfg.Port)) +} + +func seedSuperAdmin(db *database.DB, cfg *config.Config) { + if cfg.InitialAdminEmail == "" || cfg.InitialAdminPassword == "" { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + repo := tenant.NewRepository(db) + existing, err := repo.GetSuperAdminByEmail(ctx, cfg.InitialAdminEmail) + if err != nil || existing != nil { + return + } + hash, err := auth.HashPassword(cfg.InitialAdminPassword) + if err != nil { + log.Printf("Warning: could not hash initial admin password: %v", err) + return + } + if _, err := repo.CreateSuperAdmin(ctx, cfg.InitialAdminEmail, hash); err != nil { + log.Printf("Warning: could not create initial super admin: %v", err) + return + } + log.Printf("Initial super admin created: %s", cfg.InitialAdminEmail) +} diff --git a/backend/go.mod b/backend/go.mod new file mode 100644 index 0000000..4e8bd15 --- /dev/null +++ b/backend/go.mod @@ -0,0 +1,43 @@ +module github.com/techxcar/backend + +go 1.25.0 + +require github.com/stretchr/testify v1.11.1 + +require ( + github.com/andybalholm/brotli v1.1.0 // indirect + github.com/cespare/xxhash/v2 v2.3.0 // indirect + github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect + github.com/go-pdf/fpdf v0.9.0 // indirect + github.com/gofiber/fiber/v2 v2.52.13 // indirect + github.com/golang-jwt/jwt/v5 v5.3.1 // indirect + github.com/golang-migrate/migrate/v4 v4.19.1 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/jackc/pgpassfile v1.0.0 // indirect + github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect + github.com/jackc/pgx/v5 v5.10.0 // indirect + github.com/jackc/puddle/v2 v2.2.2 // indirect + github.com/joho/godotenv v1.5.1 // indirect + github.com/klauspost/compress v1.17.9 // indirect + github.com/kr/pretty v0.3.0 // indirect + github.com/lib/pq v1.10.9 // indirect + github.com/mattn/go-colorable v0.1.13 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-runewidth v0.0.16 // indirect + github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c // indirect + github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 // indirect + github.com/redis/go-redis/v9 v9.20.1 // indirect + github.com/rivo/uniseg v0.2.0 // indirect + github.com/rogpeppe/go-internal v1.12.0 // indirect + github.com/tinylib/msgp v1.2.5 // indirect + github.com/valyala/bytebufferpool v1.0.0 // indirect + github.com/valyala/fasthttp v1.51.0 // indirect + github.com/valyala/tcplisten v1.0.0 // indirect + go.uber.org/atomic v1.11.0 // indirect + golang.org/x/crypto v0.53.0 // indirect + golang.org/x/sync v0.21.0 // indirect + golang.org/x/sys v0.46.0 // indirect + golang.org/x/text v0.38.0 // indirect + gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c // indirect + gopkg.in/yaml.v3 v3.0.1 // indirect +) diff --git a/backend/go.sum b/backend/go.sum new file mode 100644 index 0000000..7ed735b --- /dev/null +++ b/backend/go.sum @@ -0,0 +1,92 @@ +github.com/andybalholm/brotli v1.1.0 h1:eLKJA0d02Lf0mVpIDgYnqXcUn0GqVmEFny3VuID1U3M= +github.com/andybalholm/brotli v1.1.0/go.mod h1:sms7XGricyQI9K10gOSf56VKKWS4oLer58Q+mhRPtnY= +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/creack/pty v1.1.9/go.mod h1:oKZEueFk5CKHvIhNR5MUki03XCEU+Q6VDXinZuGJ33E= +github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/go-pdf/fpdf v0.9.0 h1:PPvSaUuo1iMi9KkaAn90NuKi+P4gwMedWPHhj8YlJQw= +github.com/go-pdf/fpdf v0.9.0/go.mod h1:oO8N111TkmKb9D7VvWGLvLJlaZUQVPM+6V42pp3iV4Y= +github.com/gofiber/fiber/v2 v2.52.13 h1:TOKP64iqC9b5P49VrBW5tHhUOvDyrtJ0xePEfzJbCbk= +github.com/gofiber/fiber/v2 v2.52.13/go.mod h1:YEcBbO/FB+5M1IZNBP9FO3J9281zgPAreiI1oqg8nDw= +github.com/golang-jwt/jwt/v5 v5.3.1 h1:kYf81DTWFe7t+1VvL7eS+jKFVWaUnK9cB1qbwn63YCY= +github.com/golang-jwt/jwt/v5 v5.3.1/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE= +github.com/golang-migrate/migrate/v4 v4.19.1 h1:OCyb44lFuQfYXYLx1SCxPZQGU7mcaZ7gH9yH4jSFbBA= +github.com/golang-migrate/migrate/v4 v4.19.1/go.mod h1:CTcgfjxhaUtsLipnLoQRWCrjYXycRz/g5+RWDuYgPrE= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM= +github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo= +github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM= +github.com/jackc/pgx/v5 v5.10.0 h1:VhSvgU2jSli8o3AqIEOTJr7rZwAEUVo4E4XhR94Zfr0= +github.com/jackc/pgx/v5 v5.10.0/go.mod h1:mal1tBGAFfLHvZzaYh77YS/eC6IX9OWbRV1QIIM0Jn4= +github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo= +github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4= +github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0= +github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4= +github.com/klauspost/compress v1.17.9 h1:6KIumPrER1LHsvBVuDa0r5xaG0Es51mhhB9BQB2qeMA= +github.com/klauspost/compress v1.17.9/go.mod h1:Di0epgTjJY877eYKx5yC51cX2A2Vl2ibi7bDH9ttBbw= +github.com/kr/pretty v0.1.0/go.mod h1:dAy3ld7l9f0ibDNOQOHHMYYIIbhfbHSm3C4ZsoJORNo= +github.com/kr/pretty v0.2.1/go.mod h1:ipq/a2n7PKx3OHsz4KJII5eveXtPO4qwEXGdVfWzfnI= +github.com/kr/pretty v0.3.0 h1:WgNl7dwNpEZ6jJ9k1snq4pZsg7DOEN8hP9Xw0Tsjwk0= +github.com/kr/pretty v0.3.0/go.mod h1:640gp4NfQd8pI5XOwp5fnNeVWj67G7CFk/SaSQn7NBk= +github.com/kr/pty v1.1.1/go.mod h1:pFQYn66WHrOpPYNljwOMqo10TkYh1fy3cYio2l3bCsQ= +github.com/kr/text v0.1.0/go.mod h1:4Jbv+DJW3UT/LiOwJeYQe1efqtUx/iVham/4vfdArNI= +github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= +github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= +github.com/lib/pq v1.10.9 h1:YXG7RB+JIjhP29X+OtkiDnYaXQwpS4JEWq7dtCCRUEw= +github.com/lib/pq v1.10.9/go.mod h1:AlVN5x4E4T544tWzH6hKfbfQvm3HdbOxrmggDNAPY9o= +github.com/mattn/go-colorable v0.1.13 h1:fFA4WZxdEF4tXPZVKMLwD8oUnCTTo08duU7wxecdEvA= +github.com/mattn/go-colorable v0.1.13/go.mod h1:7S9/ev0klgBDR4GtXTXX8a3vIGJpMovkB8vQcUbaXHg= +github.com/mattn/go-isatty v0.0.16/go.mod h1:kYGgaQfpe5nmfYZH+SKPsOc2e4SrIfOl2e/yFXSvRLM= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc= +github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c h1:dAMKvw0MlJT1GshSTtih8C2gDs04w8dReiOGXrGLNoY= +github.com/philhofer/fwd v1.1.3-0.20240916144458-20a13a1f6b7c/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/redis/go-redis/v9 v9.20.1 h1:sfCU6A8P3dXbKyWes02uxA2baehGux9dZHfEKtsTB1w= +github.com/redis/go-redis/v9 v9.20.1/go.mod h1:v/M13XI1PVCDcm01VtPFOADfZtHf8YW3baQf57KlIkA= +github.com/rivo/uniseg v0.2.0 h1:S1pD9weZBuJdFmowNwbpi7BJ8TNftyUImj/0WQi72jY= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rogpeppe/go-internal v1.6.1/go.mod h1:xXDCJY+GAPziupqXw64V24skbSoqbTEfhy4qGm1nDQc= +github.com/rogpeppe/go-internal v1.12.0 h1:exVL4IDcn6na9z1rAb56Vxr+CgyK3nn3O+epU5NdKM8= +github.com/rogpeppe/go-internal v1.12.0/go.mod h1:E+RYuTGaKKdloAfM02xzb0FW3Paa99yedzYV+kq4uf4= +github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= +github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI= +github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/tinylib/msgp v1.2.5 h1:WeQg1whrXRFiZusidTQqzETkRpGjFjcIhW6uqWH09po= +github.com/tinylib/msgp v1.2.5/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0= +github.com/valyala/bytebufferpool v1.0.0 h1:GqA5TC/0021Y/b9FG4Oi9Mr3q7XYx6KllzawFIhcdPw= +github.com/valyala/bytebufferpool v1.0.0/go.mod h1:6bBcMArwyJ5K/AmCkWv1jt77kVWyCJ6HpOuEn7z0Csc= +github.com/valyala/fasthttp v1.51.0 h1:8b30A5JlZ6C7AS81RsWjYMQmrZG6feChmgAolCl1SqA= +github.com/valyala/fasthttp v1.51.0/go.mod h1:oI2XroL+lI7vdXyYoQk03bXBThfFl2cVdIA3Xl7cH8g= +github.com/valyala/tcplisten v1.0.0 h1:rBHj/Xf+E1tRGZyWIWwJDiRY0zc1Js+CV5DqwacVSA8= +github.com/valyala/tcplisten v1.0.0/go.mod h1:T0xQ8SeCZGxckz9qRXTfG43PvQ/mcWh7FwZEA7Ioqkc= +go.uber.org/atomic v1.11.0 h1:ZvwS0R+56ePWxUNi+Atn9dWONBPp/AUETXlHW0DxSjE= +go.uber.org/atomic v1.11.0/go.mod h1:LUxbIzbOniOlMKjJjyPfpl4v+PKK2cNJn91OQbhoJI0= +golang.org/x/crypto v0.53.0 h1:QZ4Muo8THX6CizN2vPPd5fBGHyogrdK9fG4wLPFUsto= +golang.org/x/crypto v0.53.0/go.mod h1:DNLU434OwVakk9PzuwV8w62mAJpRJL3vsgcfp4Qnsio= +golang.org/x/sync v0.21.0 h1:HLII4xRRTtCRkxYp4HNFF0Js/Og6q2i++KXbg0gHCwM= +golang.org/x/sync v0.21.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.0.0-20220811171246-fbc7d0a398ab/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.46.0 h1:noSf2Fq6F8DBgS+LysIkx7rIExoNHJsxOAtPp4rthXw= +golang.org/x/sys v0.46.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.38.0 h1:sXmwo9DwP3OK9EZ7PqAdaooSGozfl/3a6/xJcbzPRhE= +golang.org/x/text v0.38.0/go.mod h1:YXZt3QhHUKYT53r2lLKFIVi6Ao1jdzrTR/KQ09qyxF4= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20180628173108-788fd7840127/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c h1:Hei/4ADfdWqJk1ZMxUNpqntNwaWcugrBjAiHlqqRiVk= +gopkg.in/check.v1 v1.0.0-20201130134442-10cb98267c6c/go.mod h1:JHkPIbrfpd72SG/EVd6muEfDQjcINNoR0C8j2r3qZ4Q= +gopkg.in/errgo.v2 v2.1.0/go.mod h1:hNsd1EY+bozCKY1Ytp96fpM3vjJbqLJn88ws8XvfDNI= +gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/backend/internal/auth/bcrypt.go b/backend/internal/auth/bcrypt.go new file mode 100644 index 0000000..949bdeb --- /dev/null +++ b/backend/internal/auth/bcrypt.go @@ -0,0 +1,14 @@ +package auth + +import "golang.org/x/crypto/bcrypt" + +const bcryptCost = 12 + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + return string(bytes), err +} + +func VerifyPassword(password, hash string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} diff --git a/backend/internal/auth/bcrypt_test.go b/backend/internal/auth/bcrypt_test.go new file mode 100644 index 0000000..a6eea87 --- /dev/null +++ b/backend/internal/auth/bcrypt_test.go @@ -0,0 +1,29 @@ +package auth_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/internal/auth" +) + +func TestHashPassword_isNotPlaintext(t *testing.T) { + hash, err := auth.HashPassword("mysecret") + require.NoError(t, err) + assert.NotEqual(t, "mysecret", hash) + assert.NotEmpty(t, hash) +} + +func TestVerifyPassword_correct(t *testing.T) { + hash, err := auth.HashPassword("correctpassword") + require.NoError(t, err) + assert.True(t, auth.VerifyPassword("correctpassword", hash)) +} + +func TestVerifyPassword_wrong(t *testing.T) { + hash, err := auth.HashPassword("correctpassword") + require.NoError(t, err) + assert.False(t, auth.VerifyPassword("wrongpassword", hash)) +} diff --git a/backend/internal/auth/handler.go b/backend/internal/auth/handler.go new file mode 100644 index 0000000..3b54502 --- /dev/null +++ b/backend/internal/auth/handler.go @@ -0,0 +1,170 @@ +package auth + +import ( + "context" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + goredis "github.com/redis/go-redis/v9" + + "github.com/techxcar/backend/internal/config" + redispkg "github.com/techxcar/backend/pkg/redis" +) + +// LoginRepository is a subset of tenant.Repository used by auth handlers. +// Uses local types to avoid a circular import with the tenant package. +type LoginRepository interface { + GetSuperAdminByEmail(ctx context.Context, email string) (*LoginSuperAdmin, error) + GetTenantBySlug(ctx context.Context, slug string) (*LoginTenant, error) + GetTenantUserByEmail(ctx context.Context, tenantID, email string) (*LoginUser, error) +} + +type LoginSuperAdmin struct { + ID string + Email string + PasswordHash string +} + +type LoginTenant struct { + ID string + Status string +} + +type LoginUser struct { + ID string + Email string + PasswordHash string + Role string + Name string + Active bool +} + +const refreshCookieName = "refresh_token" +const refreshCookieTTL = 30 * 24 * time.Hour + +func SetRefreshCookie(c *fiber.Ctx, token string, cfg *config.Config) { + c.Cookie(&fiber.Cookie{ + Name: refreshCookieName, + Value: token, + MaxAge: int(refreshCookieTTL.Seconds()), + HTTPOnly: true, + Secure: cfg.AppEnv == "production", + SameSite: "Strict", + Path: "/api/v1/auth", + }) +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` + TenantSlug string `json:"tenant_slug"` +} + +func LoginHandler(repo LoginRepository, rdb *redispkg.Redis, cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + var req loginRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(400, "corpo do pedido inválido") + } + req.Email = strings.TrimSpace(strings.ToLower(req.Email)) + if req.Email == "" || req.Password == "" { + return fiber.NewError(400, "email e password são obrigatórios") + } + + var userID, tenantID, role string + userPayload := fiber.Map{} + + if req.TenantSlug == "" { + admin, err := repo.GetSuperAdminByEmail(c.Context(), req.Email) + if err != nil || admin == nil || !VerifyPassword(req.Password, admin.PasswordHash) { + return fiber.NewError(401, "credenciais inválidas") + } + userID, tenantID, role = admin.ID, "", "super_admin" + userPayload = fiber.Map{ + "id": admin.ID, "email": admin.Email, + "name": admin.Email, "role": "super_admin", + } + } else { + ten, err := repo.GetTenantBySlug(c.Context(), req.TenantSlug) + if err != nil || ten == nil || ten.Status != "active" { + return fiber.NewError(401, "credenciais inválidas") + } + user, err := repo.GetTenantUserByEmail(c.Context(), ten.ID, req.Email) + if err != nil || user == nil || !user.Active || !VerifyPassword(req.Password, user.PasswordHash) { + return fiber.NewError(401, "credenciais inválidas") + } + userID, tenantID, role = user.ID, ten.ID, user.Role + userPayload = fiber.Map{ + "id": user.ID, "email": user.Email, + "name": user.Name, "role": user.Role, + "tenantId": ten.ID, + } + } + + access, err := GenerateAccessToken(userID, tenantID, role, cfg.JWTSecret) + if err != nil { + return fiber.NewError(500, "erro ao gerar token") + } + refresh, err := GenerateRefreshToken(userID, tenantID, role, cfg.JWTSecret) + if err != nil { + return fiber.NewError(500, "erro ao gerar token") + } + + if rdb != nil { + ctx := c.Context() + rdb.Client.Set(ctx, "refresh:"+userID, refresh, refreshCookieTTL) + } + + SetRefreshCookie(c, refresh, cfg) + return c.JSON(fiber.Map{"data": fiber.Map{"access_token": access, "user": userPayload}, "error": nil}) + } +} + +func RefreshHandler(rdb *redispkg.Redis, cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + refreshToken := c.Cookies(refreshCookieName) + if refreshToken == "" { + return fiber.NewError(401, "refresh token em falta") + } + claims, err := ValidateToken(refreshToken, cfg.JWTSecret) + if err != nil { + return fiber.NewError(401, "refresh token inválido ou expirado") + } + + if rdb != nil { + stored, err := rdb.Client.Get(c.Context(), "refresh:"+claims.UserID).Result() + if err == goredis.Nil || stored != refreshToken { + return fiber.NewError(401, "sessão inválida") + } + } + + access, err := GenerateAccessToken(claims.UserID, claims.TenantID, claims.Role, cfg.JWTSecret) + if err != nil { + return fiber.NewError(500, "erro ao renovar token") + } + newRefresh, err := GenerateRefreshToken(claims.UserID, claims.TenantID, claims.Role, cfg.JWTSecret) + if err != nil { + return fiber.NewError(500, "erro ao renovar token") + } + + if rdb != nil { + rdb.Client.Set(c.Context(), "refresh:"+claims.UserID, newRefresh, refreshCookieTTL) + } + SetRefreshCookie(c, newRefresh, cfg) + return c.JSON(fiber.Map{"data": fiber.Map{"access_token": access}, "error": nil}) + } +} + +func LogoutHandler(rdb *redispkg.Redis) fiber.Handler { + return func(c *fiber.Ctx) error { + c.Cookie(&fiber.Cookie{ + Name: refreshCookieName, + Value: "", + MaxAge: -1, + HTTPOnly: true, + Path: "/api/v1/auth", + }) + return c.JSON(fiber.Map{"data": nil, "error": nil}) + } +} diff --git a/backend/internal/auth/handler_test.go b/backend/internal/auth/handler_test.go new file mode 100644 index 0000000..d38f79f --- /dev/null +++ b/backend/internal/auth/handler_test.go @@ -0,0 +1,152 @@ +package auth_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/internal/config" +) + +// stubRepo is a minimal in-memory stub for testing auth handlers without a real DB. +type stubRepo struct { + superAdmins map[string]*auth.LoginSuperAdmin + tenants map[string]*auth.LoginTenant + users map[string]*auth.LoginUser +} + +func (s *stubRepo) GetSuperAdminByEmail(_ context.Context, email string) (*auth.LoginSuperAdmin, error) { + a, _ := s.superAdmins[email] + return a, nil +} + +func (s *stubRepo) GetTenantBySlug(_ context.Context, slug string) (*auth.LoginTenant, error) { + t, _ := s.tenants[slug] + return t, nil +} + +func (s *stubRepo) GetTenantUserByEmail(_ context.Context, tenantID, email string) (*auth.LoginUser, error) { + key := tenantID + ":" + email + u, _ := s.users[key] + return u, nil +} + +func newTestApp(repo auth.LoginRepository, cfg *config.Config) *fiber.App { + app := fiber.New(fiber.Config{ErrorHandler: func(c *fiber.Ctx, err error) error { + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{"data": nil, "error": err.Error()}) + }}) + app.Post("/api/v1/auth/login", auth.LoginHandler(repo, nil, cfg)) + app.Post("/api/v1/auth/refresh", auth.RefreshHandler(nil, cfg)) + app.Post("/api/v1/auth/logout", auth.LogoutHandler(nil)) + return app +} + +func TestLogin_superAdmin_success(t *testing.T) { + hash, _ := auth.HashPassword("secret123") + repo := &stubRepo{ + superAdmins: map[string]*auth.LoginSuperAdmin{ + "admin@example.com": {ID: "sa-1", Email: "admin@example.com", PasswordHash: hash}, + }, + } + cfg := &config.Config{JWTSecret: testSecret} + app := newTestApp(repo, cfg) + + body, _ := json.Marshal(map[string]string{"email": "admin@example.com", "password": "secret123"}) + req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + var result map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + data := result["data"].(map[string]any) + assert.NotEmpty(t, data["access_token"]) + assert.NotNil(t, data["user"]) +} + +func TestLogin_wrongPassword(t *testing.T) { + hash, _ := auth.HashPassword("secret123") + repo := &stubRepo{ + superAdmins: map[string]*auth.LoginSuperAdmin{ + "admin@example.com": {ID: "sa-1", Email: "admin@example.com", PasswordHash: hash}, + }, + } + cfg := &config.Config{JWTSecret: testSecret} + app := newTestApp(repo, cfg) + + body, _ := json.Marshal(map[string]string{"email": "admin@example.com", "password": "wrongpass"}) + req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 401, resp.StatusCode) +} + +func TestLogin_tenantUser_success(t *testing.T) { + hash, _ := auth.HashPassword("tenantpass") + repo := &stubRepo{ + tenants: map[string]*auth.LoginTenant{ + "my-workshop": {ID: "11111111-1111-1111-1111-111111111111", Status: "active"}, + }, + users: map[string]*auth.LoginUser{ + "11111111-1111-1111-1111-111111111111:user@workshop.com": { + ID: "u-1", Email: "user@workshop.com", PasswordHash: hash, + Role: "tenant_admin", Name: "User", Active: true, + }, + }, + } + cfg := &config.Config{JWTSecret: testSecret} + app := newTestApp(repo, cfg) + + body, _ := json.Marshal(map[string]string{ + "email": "user@workshop.com", "password": "tenantpass", "tenant_slug": "my-workshop", + }) + req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + var result map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + data := result["data"].(map[string]any) + token := data["access_token"].(string) + + claims, err := auth.ValidateToken(token, testSecret) + require.NoError(t, err) + assert.Equal(t, "11111111-1111-1111-1111-111111111111", claims.TenantID) + assert.Equal(t, "tenant_admin", claims.Role) + _ = time.Now() +} + +func TestLogin_unknownTenant(t *testing.T) { + repo := &stubRepo{tenants: map[string]*auth.LoginTenant{}} + cfg := &config.Config{JWTSecret: testSecret} + app := newTestApp(repo, cfg) + + body, _ := json.Marshal(map[string]string{ + "email": "user@workshop.com", "password": "pass", "tenant_slug": "nonexistent", + }) + req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 401, resp.StatusCode) +} diff --git a/backend/internal/auth/jwt.go b/backend/internal/auth/jwt.go new file mode 100644 index 0000000..23d20d4 --- /dev/null +++ b/backend/internal/auth/jwt.go @@ -0,0 +1,54 @@ +package auth + +import ( + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID string `json:"user_id"` + TenantID string `json:"tenant_id,omitempty"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func GenerateAccessToken(userID, tenantID, role, secret string) (string, error) { + return generateToken(userID, tenantID, role, secret, 15*time.Minute) +} + +func GenerateRefreshToken(userID, tenantID, role, secret string) (string, error) { + return generateToken(userID, tenantID, role, secret, 30*24*time.Hour) +} + +func generateToken(userID, tenantID, role, secret string, ttl time.Duration) (string, error) { + claims := Claims{ + UserID: userID, + TenantID: tenantID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secret)) +} + +func ValidateToken(tokenStr, secret string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("método de assinatura inesperado: %v", t.Header["alg"]) + } + return []byte(secret), nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, fmt.Errorf("token inválido") + } + return claims, nil +} diff --git a/backend/internal/auth/jwt_test.go b/backend/internal/auth/jwt_test.go new file mode 100644 index 0000000..905ab99 --- /dev/null +++ b/backend/internal/auth/jwt_test.go @@ -0,0 +1,51 @@ +package auth_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/internal/auth" +) + +const testSecret = "test-secret-32-chars-minimum-ok!" + +func TestGenerateAndValidateAccessToken(t *testing.T) { + token, err := auth.GenerateAccessToken("user-1", "tenant-1", "tenant_admin", testSecret) + require.NoError(t, err) + assert.NotEmpty(t, token) + + claims, err := auth.ValidateToken(token, testSecret) + require.NoError(t, err) + assert.Equal(t, "user-1", claims.UserID) + assert.Equal(t, "tenant-1", claims.TenantID) + assert.Equal(t, "tenant_admin", claims.Role) + assert.True(t, claims.ExpiresAt.After(time.Now())) + assert.True(t, claims.ExpiresAt.Before(time.Now().Add(16*time.Minute))) +} + +func TestGenerateRefreshToken_longerExpiry(t *testing.T) { + token, err := auth.GenerateRefreshToken("user-1", "", "super_admin", testSecret) + require.NoError(t, err) + + claims, err := auth.ValidateToken(token, testSecret) + require.NoError(t, err) + assert.Empty(t, claims.TenantID) + assert.Equal(t, "super_admin", claims.Role) + assert.True(t, claims.ExpiresAt.After(time.Now().Add(29*24*time.Hour))) +} + +func TestValidateToken_wrongSecret(t *testing.T) { + token, err := auth.GenerateAccessToken("user-1", "t-1", "manager", testSecret) + require.NoError(t, err) + + _, err = auth.ValidateToken(token, "different-secret-32chars-minimumx") + assert.Error(t, err) +} + +func TestValidateToken_malformed(t *testing.T) { + _, err := auth.ValidateToken("not.a.jwt", testSecret) + assert.Error(t, err) +} diff --git a/backend/internal/auth/middleware.go b/backend/internal/auth/middleware.go new file mode 100644 index 0000000..ce158c1 --- /dev/null +++ b/backend/internal/auth/middleware.go @@ -0,0 +1,74 @@ +package auth + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/techxcar/backend/pkg/database" +) + +func RequireAuth(secret string) fiber.Handler { + return func(c *fiber.Ctx) error { + authHeader := c.Get("Authorization") + if authHeader == "" { + return fiber.NewError(401, "autenticação necessária") + } + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return fiber.NewError(401, "formato de autorização inválido") + } + claims, err := ValidateToken(parts[1], secret) + if err != nil { + return fiber.NewError(401, "token inválido ou expirado") + } + c.Locals("claims", claims) + return c.Next() + } +} + +func RequireRole(roles ...string) fiber.Handler { + return func(c *fiber.Ctx) error { + claims, ok := c.Locals("claims").(*Claims) + if !ok { + return fiber.NewError(401, "autenticação necessária") + } + for _, role := range roles { + if claims.Role == role { + return c.Next() + } + } + return fiber.NewError(403, "acesso não autorizado") + } +} + +// TenantMiddleware acquires a dedicated pgxpool connection per request, +// sets the tenant search_path, stores the connection in c.Locals("conn"), +// and releases the connection after the handler chain completes. +func TenantMiddleware(db *database.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + claims, ok := c.Locals("claims").(*Claims) + if !ok || claims.TenantID == "" { + return c.Next() + } + conn, err := db.Pool.Acquire(c.Context()) + if err != nil { + return fiber.NewError(500, "erro interno ao adquirir conexão") + } + schema := `"tenant_` + strings.ReplaceAll(claims.TenantID, "-", "_") + `"` + if _, err := conn.Exec(c.Context(), "SET search_path = "+schema+", public"); err != nil { + conn.Release() + return fiber.NewError(500, "erro interno ao definir schema") + } + c.Locals("conn", conn) + err = c.Next() + conn.Release() + return err + } +} + +// GetConn returns the tenant-scoped connection stored by TenantMiddleware. +func GetConn(c *fiber.Ctx) *pgxpool.Conn { + conn, _ := c.Locals("conn").(*pgxpool.Conn) + return conn +} diff --git a/backend/internal/auth/middleware_test.go b/backend/internal/auth/middleware_test.go new file mode 100644 index 0000000..74d3da4 --- /dev/null +++ b/backend/internal/auth/middleware_test.go @@ -0,0 +1,117 @@ +package auth_test + +import ( + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/internal/auth" +) + +func TestRequireAuth_missingHeader(t *testing.T) { + app := fiber.New() + app.Get("/protected", auth.RequireAuth(testSecret), func(c *fiber.Ctx) error { + return c.SendString("ok") + }) + + req := httptest.NewRequest("GET", "/protected", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 401, resp.StatusCode) +} + +func TestRequireAuth_validToken(t *testing.T) { + token, _ := auth.GenerateAccessToken("user-1", "tenant-1", "manager", testSecret) + + app := fiber.New() + app.Get("/protected", auth.RequireAuth(testSecret), func(c *fiber.Ctx) error { + claims := c.Locals("claims").(*auth.Claims) + return c.SendString(claims.UserID) + }) + + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) +} + +func TestRequireAuth_invalidToken(t *testing.T) { + app := fiber.New() + app.Get("/protected", auth.RequireAuth(testSecret), func(c *fiber.Ctx) error { + return c.SendString("ok") + }) + + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Bearer invalid.token.here") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 401, resp.StatusCode) +} + +func TestRequireRole_allowed(t *testing.T) { + token, _ := auth.GenerateAccessToken("user-1", "", "super_admin", testSecret) + + app := fiber.New() + app.Get("/admin", + auth.RequireAuth(testSecret), + auth.RequireRole("super_admin"), + func(c *fiber.Ctx) error { return c.SendString("ok") }, + ) + + req := httptest.NewRequest("GET", "/admin", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) +} + +func TestRequireRole_forbidden(t *testing.T) { + token, _ := auth.GenerateAccessToken("user-1", "t-1", "technician", testSecret) + + app := fiber.New() + app.Get("/admin", + auth.RequireAuth(testSecret), + auth.RequireRole("super_admin", "tenant_admin"), + func(c *fiber.Ctx) error { return c.SendString("ok") }, + ) + + req := httptest.NewRequest("GET", "/admin", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 403, resp.StatusCode) +} + +func TestRequireRole_allowsMatchingRole(t *testing.T) { + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("claims", &auth.Claims{Role: "tenant_admin"}) + return c.Next() + }) + app.Get("/test", auth.RequireRole("tenant_admin"), func(c *fiber.Ctx) error { + return c.SendStatus(200) + }) + req := httptest.NewRequest("GET", "/test", nil) + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) +} + +func TestRequireRole_rejectsMismatch(t *testing.T) { + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("claims", &auth.Claims{Role: "technician"}) + return c.Next() + }) + app.Get("/test", auth.RequireRole("super_admin"), func(c *fiber.Ctx) error { + return c.SendStatus(200) + }) + req := httptest.NewRequest("GET", "/test", nil) + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, 403, resp.StatusCode) +} diff --git a/backend/internal/auth/ratelimit.go b/backend/internal/auth/ratelimit.go new file mode 100644 index 0000000..82e51f7 --- /dev/null +++ b/backend/internal/auth/ratelimit.go @@ -0,0 +1,56 @@ +package auth + +import ( + "context" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/limiter" + goredis "github.com/redis/go-redis/v9" +) + +type redisStorage struct { + client *goredis.Client +} + +func NewRedisStorage(client *goredis.Client) *redisStorage { + return &redisStorage{client: client} +} + +func (s *redisStorage) Get(key string) ([]byte, error) { + val, err := s.client.Get(context.Background(), key).Bytes() + if err == goredis.Nil { + return nil, nil + } + return val, err +} + +func (s *redisStorage) Set(key string, val []byte, exp time.Duration) error { + return s.client.Set(context.Background(), key, val, exp).Err() +} + +func (s *redisStorage) Delete(key string) error { + return s.client.Del(context.Background(), key).Err() +} + +func (s *redisStorage) Reset() error { + return s.client.FlushDB(context.Background()).Err() +} + +func (s *redisStorage) Close() error { + return nil +} + +func RateLimiter(storage *redisStorage) fiber.Handler { + return limiter.New(limiter.Config{ + Max: 10, + Expiration: 1 * time.Minute, + KeyGenerator: func(c *fiber.Ctx) string { + return "ratelimit:auth:" + c.IP() + }, + Storage: storage, + LimitReached: func(c *fiber.Ctx) error { + return fiber.NewError(429, "muitas tentativas, tente novamente em 1 minuto") + }, + }) +} diff --git a/backend/internal/auth/routes.go b/backend/internal/auth/routes.go new file mode 100644 index 0000000..2548c72 --- /dev/null +++ b/backend/internal/auth/routes.go @@ -0,0 +1,23 @@ +package auth + +import ( + "github.com/gofiber/fiber/v2" + "github.com/techxcar/backend/internal/config" + redispkg "github.com/techxcar/backend/pkg/redis" +) + +func RegisterRoutes(app *fiber.App, repo LoginRepository, rdb *redispkg.Redis, cfg *config.Config) { + authGroup := app.Group("/api/v1/auth") + + if rdb != nil { + storage := NewRedisStorage(rdb.Client) + rateLimiter := RateLimiter(storage) + authGroup.Post("/login", rateLimiter, LoginHandler(repo, rdb, cfg)) + authGroup.Post("/refresh", rateLimiter, RefreshHandler(rdb, cfg)) + } else { + authGroup.Post("/login", LoginHandler(repo, rdb, cfg)) + authGroup.Post("/refresh", RefreshHandler(rdb, cfg)) + } + + authGroup.Post("/logout", LogoutHandler(rdb)) +} diff --git a/backend/internal/catalog/handler.go b/backend/internal/catalog/handler.go new file mode 100644 index 0000000..d29a33f --- /dev/null +++ b/backend/internal/catalog/handler.go @@ -0,0 +1,108 @@ +package catalog + +import ( + "errors" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/pkg/database" +) + +func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { + ro := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager", "technician"), + auth.TenantMiddleware(db), + } + write := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager"), + auth.TenantMiddleware(db), + } + + app.Get("/api/v1/catalog", append(ro, listItemsH())...) + app.Post("/api/v1/catalog", append(write, createItemH())...) + app.Put("/api/v1/catalog/:id", append(write, updateItemH())...) + app.Delete("/api/v1/catalog/:id", append(write, deleteItemH())...) +} + +func listItemsH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + list, err := ListItems(c.Context(), conn) + if err != nil { + return fiber.NewError(500, "erro ao listar catálogo") + } + if list == nil { + list = []*CatalogItem{} + } + return c.JSON(fiber.Map{"data": list, "error": nil}) + } +} + +type itemBody struct { + Code string `json:"code"` + Name string `json:"name"` + Category string `json:"category"` + Unit string `json:"unit"` + BasePrice float64 `json:"base_price"` + Active bool `json:"active"` +} + +func createItemH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b itemBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + if b.Code == "" || b.Name == "" || b.Category == "" || b.Unit == "" { + return fiber.NewError(400, "código, nome, categoria e unidade são obrigatórios") + } + validUnits := map[string]bool{"un": true, "hora": true, "litro": true, "kg": true} + if !validUnits[b.Unit] { + return fiber.NewError(400, "unidade inválida (un, hora, litro, kg)") + } + conn := auth.GetConn(c) + item, err := CreateItem(c.Context(), conn, b.Code, b.Name, b.Category, b.Unit, b.BasePrice) + if err != nil { + return fiber.NewError(500, "erro ao criar item") + } + return c.Status(201).JSON(fiber.Map{"data": item, "error": nil}) + } +} + +func updateItemH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b itemBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + if b.Code == "" || b.Name == "" || b.Category == "" || b.Unit == "" { + return fiber.NewError(400, "código, nome, categoria e unidade são obrigatórios") + } + validUnits := map[string]bool{"un": true, "hora": true, "litro": true, "kg": true} + if !validUnits[b.Unit] { + return fiber.NewError(400, "unidade inválida (un, hora, litro, kg)") + } + conn := auth.GetConn(c) + item, err := UpdateItem(c.Context(), conn, c.Params("id"), b.Code, b.Name, b.Category, b.Unit, b.BasePrice, b.Active) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fiber.NewError(404, "item não encontrado") + } + return fiber.NewError(500, "erro ao actualizar item") + } + return c.JSON(fiber.Map{"data": item, "error": nil}) + } +} + +func deleteItemH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + if err := DeleteItem(c.Context(), conn, c.Params("id")); err != nil { + return fiber.NewError(500, "erro ao eliminar item") + } + return c.SendStatus(204) + } +} diff --git a/backend/internal/catalog/repository.go b/backend/internal/catalog/repository.go new file mode 100644 index 0000000..4812517 --- /dev/null +++ b/backend/internal/catalog/repository.go @@ -0,0 +1,67 @@ +package catalog + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type CatalogItem struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Category string `json:"category"` + Unit string `json:"unit"` + BasePrice float64 `json:"base_price"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func ListItems(ctx context.Context, conn *pgxpool.Conn) ([]*CatalogItem, error) { + rows, err := conn.Query(ctx, ` + SELECT id, code, name, category, unit, base_price, active, created_at, updated_at + FROM catalog_items ORDER BY category, name`) + if err != nil { + return nil, err + } + defer rows.Close() + var list []*CatalogItem + for rows.Next() { + var i CatalogItem + if err := rows.Scan(&i.ID, &i.Code, &i.Name, &i.Category, &i.Unit, + &i.BasePrice, &i.Active, &i.CreatedAt, &i.UpdatedAt); err != nil { + return nil, err + } + list = append(list, &i) + } + return list, rows.Err() +} + +func CreateItem(ctx context.Context, conn *pgxpool.Conn, code, name, category, unit string, basePrice float64) (*CatalogItem, error) { + var i CatalogItem + err := conn.QueryRow(ctx, ` + INSERT INTO catalog_items (code, name, category, unit, base_price) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, code, name, category, unit, base_price, active, created_at, updated_at`, + code, name, category, unit, basePrice). + Scan(&i.ID, &i.Code, &i.Name, &i.Category, &i.Unit, &i.BasePrice, &i.Active, &i.CreatedAt, &i.UpdatedAt) + return &i, err +} + +func UpdateItem(ctx context.Context, conn *pgxpool.Conn, id, code, name, category, unit string, basePrice float64, active bool) (*CatalogItem, error) { + var i CatalogItem + err := conn.QueryRow(ctx, ` + UPDATE catalog_items SET code=$2, name=$3, category=$4, unit=$5, base_price=$6, active=$7, updated_at=NOW() + WHERE id=$1 + RETURNING id, code, name, category, unit, base_price, active, created_at, updated_at`, + id, code, name, category, unit, basePrice, active). + Scan(&i.ID, &i.Code, &i.Name, &i.Category, &i.Unit, &i.BasePrice, &i.Active, &i.CreatedAt, &i.UpdatedAt) + return &i, err +} + +func DeleteItem(ctx context.Context, conn *pgxpool.Conn, id string) error { + _, err := conn.Exec(ctx, `DELETE FROM catalog_items WHERE id = $1`, id) + return err +} diff --git a/backend/internal/catalog/repository_test.go b/backend/internal/catalog/repository_test.go new file mode 100644 index 0000000..190cc95 --- /dev/null +++ b/backend/internal/catalog/repository_test.go @@ -0,0 +1,50 @@ +package catalog_test + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/techxcar/backend/internal/catalog" +) + +func getTestConn(t *testing.T) *pgxpool.Conn { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + pool, err := pgxpool.New(context.Background(), dsn) + require.NoError(t, err) + t.Cleanup(func() { pool.Close() }) + conn, err := pool.Acquire(context.Background()) + require.NoError(t, err) + t.Cleanup(func() { conn.Release() }) + _, err = conn.Exec(context.Background(), "SET search_path = tenant_test, public") + require.NoError(t, err) + return conn +} + +func TestCatalogCRUD(t *testing.T) { + conn := getTestConn(t) + ctx := context.Background() + + item, err := catalog.CreateItem(ctx, conn, "MO-5W40", "Óleo Motor 5W40", "lubrificantes", "litro", 12.50) + require.NoError(t, err) + assert.NotEmpty(t, item.ID) + assert.Equal(t, "MO-5W40", item.Code) + + list, err := catalog.ListItems(ctx, conn) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(list), 1) + + updated, err := catalog.UpdateItem(ctx, conn, item.ID, "MO-5W40", "Óleo Motor 5W40 Sintético", "lubrificantes", "litro", 15.00, true) + require.NoError(t, err) + assert.Equal(t, 15.00, updated.BasePrice) + + err = catalog.DeleteItem(ctx, conn, item.ID) + require.NoError(t, err) +} diff --git a/backend/internal/client/handler.go b/backend/internal/client/handler.go new file mode 100644 index 0000000..6e10238 --- /dev/null +++ b/backend/internal/client/handler.go @@ -0,0 +1,183 @@ +package client + +import ( + "errors" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/pkg/database" +) + +func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { + rw := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager", "technician"), + auth.TenantMiddleware(db), + } + write := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager"), + auth.TenantMiddleware(db), + } + + app.Get("/api/v1/clients", append(rw, listClientsH())...) + app.Post("/api/v1/clients", append(write, createClientH())...) + app.Get("/api/v1/clients/:id", append(rw, getClientH())...) + app.Put("/api/v1/clients/:id", append(write, updateClientH())...) + app.Delete("/api/v1/clients/:id", append(write, deleteClientH())...) + + app.Get("/api/v1/clients/:id/vehicles", append(rw, listVehiclesH())...) + app.Post("/api/v1/clients/:id/vehicles", append(write, createVehicleH())...) + app.Put("/api/v1/vehicles/:id", append(write, updateVehicleH())...) +} + +func listClientsH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + list, err := ListClients(c.Context(), conn) + if err != nil { + return fiber.NewError(500, "erro ao listar clientes") + } + if list == nil { + list = []*Client{} + } + return c.JSON(fiber.Map{"data": list, "error": nil}) + } +} + +func getClientH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + cl, err := GetClient(c.Context(), conn, c.Params("id")) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fiber.NewError(404, "cliente não encontrado") + } + return fiber.NewError(500, "erro ao obter cliente") + } + return c.JSON(fiber.Map{"data": cl, "error": nil}) + } +} + +type clientBody struct { + Name string `json:"name"` + NIF string `json:"nif"` + Phone string `json:"phone"` + Email string `json:"email"` + Address string `json:"address"` + Notes string `json:"notes"` +} + +func createClientH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b clientBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + if b.Name == "" { + return fiber.NewError(400, "nome é obrigatório") + } + conn := auth.GetConn(c) + cl, err := CreateClient(c.Context(), conn, b.Name, b.NIF, b.Phone, b.Email, b.Address, b.Notes) + if err != nil { + return fiber.NewError(500, "erro ao criar cliente") + } + return c.Status(201).JSON(fiber.Map{"data": cl, "error": nil}) + } +} + +func updateClientH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b clientBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + if b.Name == "" { + return fiber.NewError(400, "nome é obrigatório") + } + conn := auth.GetConn(c) + cl, err := UpdateClient(c.Context(), conn, c.Params("id"), b.Name, b.NIF, b.Phone, b.Email, b.Address, b.Notes) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fiber.NewError(404, "cliente não encontrado") + } + return fiber.NewError(500, "erro ao actualizar cliente") + } + return c.JSON(fiber.Map{"data": cl, "error": nil}) + } +} + +func deleteClientH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + if err := DeleteClient(c.Context(), conn, c.Params("id")); err != nil { + return fiber.NewError(500, "erro ao eliminar cliente") + } + return c.SendStatus(204) + } +} + +func listVehiclesH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + list, err := ListVehiclesByClient(c.Context(), conn, c.Params("id")) + if err != nil { + return fiber.NewError(500, "erro ao listar veículos") + } + if list == nil { + list = []*Vehicle{} + } + return c.JSON(fiber.Map{"data": list, "error": nil}) + } +} + +type vehicleBody struct { + Plate string `json:"plate"` + Brand string `json:"brand"` + Model string `json:"model"` + Year int `json:"year"` + VIN string `json:"vin"` + FuelType string `json:"fuel_type"` + Mileage int `json:"mileage"` + Notes string `json:"notes"` +} + +func createVehicleH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b vehicleBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + if b.Plate == "" { + return fiber.NewError(400, "matrícula é obrigatória") + } + conn := auth.GetConn(c) + v, err := CreateVehicle(c.Context(), conn, c.Params("id"), b.Plate, b.Brand, b.Model, b.Year, b.VIN, b.FuelType, b.Notes, b.Mileage) + if err != nil { + return fiber.NewError(500, "erro ao criar veículo") + } + return c.Status(201).JSON(fiber.Map{"data": v, "error": nil}) + } +} + +func updateVehicleH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b vehicleBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + if b.Plate == "" { + return fiber.NewError(400, "matrícula é obrigatória") + } + conn := auth.GetConn(c) + v, err := UpdateVehicle(c.Context(), conn, c.Params("id"), b.Plate, b.Brand, b.Model, b.Year, b.VIN, b.FuelType, b.Notes, b.Mileage) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fiber.NewError(404, "veículo não encontrado") + } + return fiber.NewError(500, "erro ao actualizar veículo") + } + return c.JSON(fiber.Map{"data": v, "error": nil}) + } +} diff --git a/backend/internal/client/repository.go b/backend/internal/client/repository.go new file mode 100644 index 0000000..1dd6bf0 --- /dev/null +++ b/backend/internal/client/repository.go @@ -0,0 +1,166 @@ +package client + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Client struct { + ID string `json:"id"` + Name string `json:"name"` + NIF string `json:"nif"` + Phone string `json:"phone"` + Email string `json:"email"` + Address string `json:"address"` + Notes string `json:"notes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Vehicle struct { + ID string `json:"id"` + ClientID *string `json:"client_id"` + Plate string `json:"plate"` + Brand string `json:"brand"` + Model string `json:"model"` + Year *int `json:"year"` + VIN string `json:"vin"` + Mileage *int `json:"mileage"` + FuelType string `json:"fuel_type"` + Notes string `json:"notes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func ListClients(ctx context.Context, conn *pgxpool.Conn) ([]*Client, error) { + rows, err := conn.Query(ctx, ` + SELECT id, name, COALESCE(nif,''), COALESCE(phone,''), COALESCE(email,''), + COALESCE(address,''), COALESCE(notes,''), created_at, updated_at + FROM clients ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + var list []*Client + for rows.Next() { + var c Client + if err := rows.Scan(&c.ID, &c.Name, &c.NIF, &c.Phone, &c.Email, + &c.Address, &c.Notes, &c.CreatedAt, &c.UpdatedAt); err != nil { + return nil, err + } + list = append(list, &c) + } + return list, rows.Err() +} + +func GetClient(ctx context.Context, conn *pgxpool.Conn, id string) (*Client, error) { + var c Client + err := conn.QueryRow(ctx, ` + SELECT id, name, COALESCE(nif,''), COALESCE(phone,''), COALESCE(email,''), + COALESCE(address,''), COALESCE(notes,''), created_at, updated_at + FROM clients WHERE id = $1`, id). + Scan(&c.ID, &c.Name, &c.NIF, &c.Phone, &c.Email, + &c.Address, &c.Notes, &c.CreatedAt, &c.UpdatedAt) + if err != nil { + return nil, err + } + return &c, nil +} + +func CreateClient(ctx context.Context, conn *pgxpool.Conn, name, nif, phone, email, address, notes string) (*Client, error) { + var c Client + err := conn.QueryRow(ctx, ` + INSERT INTO clients (name, nif, phone, email, address, notes) + VALUES ($1, NULLIF($2,''), NULLIF($3,''), NULLIF($4,''), NULLIF($5,''), NULLIF($6,'')) + RETURNING id, name, COALESCE(nif,''), COALESCE(phone,''), COALESCE(email,''), + COALESCE(address,''), COALESCE(notes,''), created_at, updated_at`, + name, nif, phone, email, address, notes). + Scan(&c.ID, &c.Name, &c.NIF, &c.Phone, &c.Email, + &c.Address, &c.Notes, &c.CreatedAt, &c.UpdatedAt) + return &c, err +} + +func UpdateClient(ctx context.Context, conn *pgxpool.Conn, id, name, nif, phone, email, address, notes string) (*Client, error) { + var c Client + err := conn.QueryRow(ctx, ` + UPDATE clients SET name=$2, nif=NULLIF($3,''), phone=NULLIF($4,''), email=NULLIF($5,''), + address=NULLIF($6,''), notes=NULLIF($7,''), updated_at=NOW() + WHERE id=$1 + RETURNING id, name, COALESCE(nif,''), COALESCE(phone,''), COALESCE(email,''), + COALESCE(address,''), COALESCE(notes,''), created_at, updated_at`, + id, name, nif, phone, email, address, notes). + Scan(&c.ID, &c.Name, &c.NIF, &c.Phone, &c.Email, + &c.Address, &c.Notes, &c.CreatedAt, &c.UpdatedAt) + return &c, err +} + +func DeleteClient(ctx context.Context, conn *pgxpool.Conn, id string) error { + _, err := conn.Exec(ctx, `DELETE FROM clients WHERE id = $1`, id) + return err +} + +func ListVehiclesByClient(ctx context.Context, conn *pgxpool.Conn, clientID string) ([]*Vehicle, error) { + rows, err := conn.Query(ctx, ` + SELECT id, client_id, plate, brand, model, year, COALESCE(vin,''), mileage, + COALESCE(fuel_type,''), COALESCE(notes,''), created_at, updated_at + FROM vehicles WHERE client_id = $1 ORDER BY plate`, clientID) + if err != nil { + return nil, err + } + defer rows.Close() + var list []*Vehicle + for rows.Next() { + var v Vehicle + if err := rows.Scan(&v.ID, &v.ClientID, &v.Plate, &v.Brand, &v.Model, + &v.Year, &v.VIN, &v.Mileage, &v.FuelType, &v.Notes, &v.CreatedAt, &v.UpdatedAt); err != nil { + return nil, err + } + list = append(list, &v) + } + return list, rows.Err() +} + +func CreateVehicle(ctx context.Context, conn *pgxpool.Conn, clientID, plate, brand, model string, year int, vin, fuelType, notes string, mileage int) (*Vehicle, error) { + var v Vehicle + var yearPtr *int + if year != 0 { + yearPtr = &year + } + var mileagePtr *int + if mileage != 0 { + mileagePtr = &mileage + } + err := conn.QueryRow(ctx, ` + INSERT INTO vehicles (client_id, plate, brand, model, year, vin, fuel_type, mileage, notes) + VALUES (NULLIF($1,'')::uuid, $2, $3, $4, $5, NULLIF($6,''), NULLIF($7,''), $8, NULLIF($9,'')) + RETURNING id, client_id, plate, brand, model, year, COALESCE(vin,''), mileage, + COALESCE(fuel_type,''), COALESCE(notes,''), created_at, updated_at`, + clientID, plate, brand, model, yearPtr, vin, fuelType, mileagePtr, notes). + Scan(&v.ID, &v.ClientID, &v.Plate, &v.Brand, &v.Model, + &v.Year, &v.VIN, &v.Mileage, &v.FuelType, &v.Notes, &v.CreatedAt, &v.UpdatedAt) + return &v, err +} + +func UpdateVehicle(ctx context.Context, conn *pgxpool.Conn, id, plate, brand, model string, year int, vin, fuelType, notes string, mileage int) (*Vehicle, error) { + var v Vehicle + var yearPtr *int + if year != 0 { + yearPtr = &year + } + var mileagePtr *int + if mileage != 0 { + mileagePtr = &mileage + } + err := conn.QueryRow(ctx, ` + UPDATE vehicles SET plate=$2, brand=$3, model=$4, + year=$5, vin=NULLIF($6,''), fuel_type=NULLIF($7,''), mileage=$8, notes=NULLIF($9,''), updated_at=NOW() + WHERE id=$1 + RETURNING id, client_id, plate, brand, model, year, COALESCE(vin,''), mileage, + COALESCE(fuel_type,''), COALESCE(notes,''), created_at, updated_at`, + id, plate, brand, model, yearPtr, vin, fuelType, mileagePtr, notes). + Scan(&v.ID, &v.ClientID, &v.Plate, &v.Brand, &v.Model, + &v.Year, &v.VIN, &v.Mileage, &v.FuelType, &v.Notes, &v.CreatedAt, &v.UpdatedAt) + return &v, err +} diff --git a/backend/internal/client/repository_test.go b/backend/internal/client/repository_test.go new file mode 100644 index 0000000..afb197c --- /dev/null +++ b/backend/internal/client/repository_test.go @@ -0,0 +1,78 @@ +package client_test + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/techxcar/backend/internal/client" +) + +func getTestConn(t *testing.T) *pgxpool.Conn { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + pool, err := pgxpool.New(context.Background(), dsn) + require.NoError(t, err) + t.Cleanup(func() { pool.Close() }) + + conn, err := pool.Acquire(context.Background()) + require.NoError(t, err) + t.Cleanup(func() { conn.Release() }) + + _, err = conn.Exec(context.Background(), "SET search_path = tenant_test, public") + require.NoError(t, err) + return conn +} + +func TestClientCRUD(t *testing.T) { + conn := getTestConn(t) + ctx := context.Background() + + c, err := client.CreateClient(ctx, conn, "João Silva", "123456789", "912345678", "joao@example.com", "Rua A", "") + require.NoError(t, err) + assert.NotEmpty(t, c.ID) + assert.Equal(t, "João Silva", c.Name) + + list, err := client.ListClients(ctx, conn) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(list), 1) + + got, err := client.GetClient(ctx, conn, c.ID) + require.NoError(t, err) + assert.Equal(t, c.ID, got.ID) + + updated, err := client.UpdateClient(ctx, conn, c.ID, "João Santos", "987654321", "921000000", "joao2@example.com", "Rua B", "nota") + require.NoError(t, err) + assert.Equal(t, "João Santos", updated.Name) + + err = client.DeleteClient(ctx, conn, c.ID) + require.NoError(t, err) +} + +func TestVehicleCRUD(t *testing.T) { + conn := getTestConn(t) + ctx := context.Background() + + c, err := client.CreateClient(ctx, conn, "Test Client", "", "", "", "", "") + require.NoError(t, err) + + v, err := client.CreateVehicle(ctx, conn, c.ID, "AA-00-AA", "Toyota", "Yaris", 2020, "", 50000, "") + require.NoError(t, err) + assert.NotEmpty(t, v.ID) + assert.Equal(t, "AA-00-AA", v.Plate) + + list, err := client.ListVehiclesByClient(ctx, conn, c.ID) + require.NoError(t, err) + assert.Len(t, list, 1) + + updated, err := client.UpdateVehicle(ctx, conn, v.ID, "BB-11-BB", "Toyota", "Yaris", 2021, "", 60000, "nota") + require.NoError(t, err) + assert.Equal(t, "BB-11-BB", updated.Plate) +} + diff --git a/backend/internal/config/config.go b/backend/internal/config/config.go new file mode 100644 index 0000000..4717931 --- /dev/null +++ b/backend/internal/config/config.go @@ -0,0 +1,53 @@ +package config + +import ( + "errors" + "os" +) + +type Config struct { + DatabaseURL string + RedisURL string + JWTSecret string + Port string + AppEnv string + InitialAdminEmail string + InitialAdminPassword string +} + +func Load() (*Config, error) { + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + return nil, errors.New("DATABASE_URL is required") + } + + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + return nil, errors.New("JWT_SECRET is required") + } + + redisURL := os.Getenv("REDIS_URL") + if redisURL == "" { + redisURL = "redis://localhost:6379" + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + appEnv := os.Getenv("APP_ENV") + if appEnv == "" { + appEnv = "development" + } + + return &Config{ + DatabaseURL: dbURL, + RedisURL: redisURL, + JWTSecret: jwtSecret, + Port: port, + AppEnv: appEnv, + InitialAdminEmail: os.Getenv("INITIAL_ADMIN_EMAIL"), + InitialAdminPassword: os.Getenv("INITIAL_ADMIN_PASSWORD"), + }, nil +} diff --git a/backend/internal/config/config_test.go b/backend/internal/config/config_test.go new file mode 100644 index 0000000..a8a6265 --- /dev/null +++ b/backend/internal/config/config_test.go @@ -0,0 +1,65 @@ +package config_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/internal/config" +) + +func TestLoad_defaults(t *testing.T) { + os.Setenv("DATABASE_URL", "postgres://test:test@localhost/test") + os.Setenv("REDIS_URL", "redis://localhost:6379") + os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-ok!") + defer func() { + os.Unsetenv("DATABASE_URL") + os.Unsetenv("REDIS_URL") + os.Unsetenv("JWT_SECRET") + }() + + cfg, err := config.Load() + require.NoError(t, err) + + assert.Equal(t, "8080", cfg.Port) + assert.Equal(t, "development", cfg.AppEnv) + assert.Equal(t, "postgres://test:test@localhost/test", cfg.DatabaseURL) + assert.Equal(t, "redis://localhost:6379", cfg.RedisURL) +} + +func TestLoad_missingDatabaseURL(t *testing.T) { + os.Unsetenv("DATABASE_URL") + os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-ok!") + defer os.Unsetenv("JWT_SECRET") + + _, err := config.Load() + assert.ErrorContains(t, err, "DATABASE_URL") +} + +func TestLoad_missingJWTSecret(t *testing.T) { + os.Setenv("DATABASE_URL", "postgres://test:test@localhost/test") + os.Unsetenv("JWT_SECRET") + defer os.Unsetenv("DATABASE_URL") + + _, err := config.Load() + assert.ErrorContains(t, err, "JWT_SECRET") +} + +func TestLoad_customPort(t *testing.T) { + os.Setenv("DATABASE_URL", "postgres://test:test@localhost/test") + os.Setenv("REDIS_URL", "redis://localhost:6379") + os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-ok!") + os.Setenv("PORT", "9090") + defer func() { + os.Unsetenv("DATABASE_URL") + os.Unsetenv("REDIS_URL") + os.Unsetenv("JWT_SECRET") + os.Unsetenv("PORT") + }() + + cfg, err := config.Load() + require.NoError(t, err) + assert.Equal(t, "9090", cfg.Port) +} diff --git a/backend/internal/expense/handler.go b/backend/internal/expense/handler.go new file mode 100644 index 0000000..8ee3942 --- /dev/null +++ b/backend/internal/expense/handler.go @@ -0,0 +1,104 @@ +package expense + +import ( + "errors" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/pkg/database" +) + +var allowedTypes = map[string]bool{ + "fuel": true, + "parts": true, + "tools": true, + "other": true, +} + +func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { + ro := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager", "technician"), + auth.TenantMiddleware(db), + } + write := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager"), + auth.TenantMiddleware(db), + } + + app.Get("/api/v1/expenses", append(ro, listExpensesH())...) + app.Post("/api/v1/expenses", append(write, createExpenseH())...) + app.Delete("/api/v1/expenses/:id", append(write, deleteExpenseH())...) +} + +func listExpensesH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + list, err := ListExpenses(c.Context(), conn, c.Query("type")) + if err != nil { + return fiber.NewError(500, "erro ao listar despesas") + } + if list == nil { + list = []*Expense{} + } + return c.JSON(fiber.Map{"data": list, "error": nil}) + } +} + +type expenseBody struct { + VehicleID string `json:"vehicle_id"` + Type string `json:"type"` + Amount float64 `json:"amount"` + Description string `json:"description"` + Date string `json:"date"` // YYYY-MM-DD +} + +func createExpenseH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b expenseBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo do pedido inválido") + } + if !allowedTypes[b.Type] { + return fiber.NewError(400, "tipo inválido: fuel, parts, tools, other") + } + if b.Amount <= 0 { + return fiber.NewError(400, "valor deve ser positivo") + } + if b.Date == "" { + return fiber.NewError(400, "data obrigatória") + } + date, err := time.Parse("2006-01-02", b.Date) + if err != nil { + return fiber.NewError(400, "data inválida (formato: YYYY-MM-DD)") + } + var vehicleID *string + if b.VehicleID != "" { + vehicleID = &b.VehicleID + } + conn := auth.GetConn(c) + e, err := CreateExpense(c.Context(), conn, vehicleID, b.Type, b.Description, b.Amount, date) + if err != nil { + return fiber.NewError(500, "erro ao registar despesa") + } + return c.Status(201).JSON(fiber.Map{"data": e, "error": nil}) + } +} + +func deleteExpenseH() fiber.Handler { + return func(c *fiber.Ctx) error { + id := c.Params("id") + conn := auth.GetConn(c) + err := DeleteExpense(c.Context(), conn, id) + if errors.Is(err, pgx.ErrNoRows) { + return fiber.NewError(404, "despesa não encontrada") + } + if err != nil { + return fiber.NewError(500, "erro ao eliminar despesa") + } + return c.SendStatus(204) + } +} diff --git a/backend/internal/expense/repository.go b/backend/internal/expense/repository.go new file mode 100644 index 0000000..e72ddb1 --- /dev/null +++ b/backend/internal/expense/repository.go @@ -0,0 +1,69 @@ +package expense + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Expense struct { + ID string `json:"id"` + VehicleID *string `json:"vehicle_id"` + Type string `json:"type"` + Amount float64 `json:"amount"` + Description string `json:"description"` + Date time.Time `json:"date"` + CreatedAt time.Time `json:"created_at"` +} + +func ListExpenses(ctx context.Context, conn *pgxpool.Conn, typeFilter string) ([]*Expense, error) { + q := `SELECT id, vehicle_id, type, amount, COALESCE(description,''), date, created_at + FROM expenses` + args := []any{} + if typeFilter != "" { + q += " WHERE type = $1" + args = append(args, typeFilter) + } + q += " ORDER BY date DESC, created_at DESC" + rows, err := conn.Query(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("expense: list: %w", err) + } + defer rows.Close() + var list []*Expense + for rows.Next() { + var e Expense + if err := rows.Scan(&e.ID, &e.VehicleID, &e.Type, &e.Amount, &e.Description, &e.Date, &e.CreatedAt); err != nil { + return nil, fmt.Errorf("expense: scan: %w", err) + } + list = append(list, &e) + } + return list, rows.Err() +} + +func CreateExpense(ctx context.Context, conn *pgxpool.Conn, vehicleID *string, expType, description string, amount float64, date time.Time) (*Expense, error) { + row := conn.QueryRow(ctx, + `INSERT INTO expenses (vehicle_id, type, amount, description, date) + VALUES ($1, $2, $3, NULLIF($4,''), $5) + RETURNING id, vehicle_id, type, amount, COALESCE(description,''), date, created_at`, + vehicleID, expType, amount, description, date) + var e Expense + if err := row.Scan(&e.ID, &e.VehicleID, &e.Type, &e.Amount, &e.Description, &e.Date, &e.CreatedAt); err != nil { + return nil, fmt.Errorf("expense: create: %w", err) + } + return &e, nil +} + +func DeleteExpense(ctx context.Context, conn *pgxpool.Conn, id string) error { + tag, err := conn.Exec(ctx, `DELETE FROM expenses WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("expense: delete: %w", err) + } + if tag.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} diff --git a/backend/internal/invoice/handler.go b/backend/internal/invoice/handler.go new file mode 100644 index 0000000..7ce99e9 --- /dev/null +++ b/backend/internal/invoice/handler.go @@ -0,0 +1,186 @@ +package invoice + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/internal/settings" + "github.com/techxcar/backend/internal/workorder" + "github.com/techxcar/backend/pkg/database" + "github.com/techxcar/backend/pkg/pdf" +) + +const storageRoot = "/app/storage" + +func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { + ro := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager", "technician"), + auth.TenantMiddleware(db), + } + write := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager"), + auth.TenantMiddleware(db), + } + + app.Get("/api/v1/invoices", append(ro, listInvoicesH())...) + app.Post("/api/v1/invoices", append(write, createInvoiceH())...) + app.Get("/api/v1/invoices/:id/pdf", append(ro, downloadPDFH())...) +} + +func listInvoicesH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + list, err := ListInvoices(c.Context(), conn) + if err != nil { + return fiber.NewError(500, "erro ao listar faturas") + } + if list == nil { + list = []*Invoice{} + } + return c.JSON(fiber.Map{"data": list, "error": nil}) + } +} + +type createBody struct { + WorkOrderID string `json:"work_order_id"` + Type string `json:"type"` // "quote" or "invoice" +} + +func tenantDir(c *fiber.Ctx) string { + claims, _ := c.Locals("claims").(*auth.Claims) + if claims == nil || claims.TenantID == "" { + return "unknown" + } + return "tenant_" + strings.ReplaceAll(claims.TenantID, "-", "_") +} + +func createInvoiceH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b createBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo do pedido inválido") + } + if b.WorkOrderID == "" { + return fiber.NewError(400, "work_order_id obrigatório") + } + if b.Type != "quote" && b.Type != "invoice" { + return fiber.NewError(400, "tipo deve ser 'quote' ou 'invoice'") + } + + conn := auth.GetConn(c) + + detail, err := workorder.GetWorkOrderDetail(c.Context(), conn, b.WorkOrderID) + if err != nil { + return fiber.NewError(500, "erro ao obter ordem de trabalho") + } + if detail == nil { + return fiber.NewError(404, "ordem de trabalho não encontrada") + } + + sett, err := settings.GetSettings(c.Context(), conn) + if err != nil { + return fiber.NewError(500, "erro ao obter definições") + } + + var clientName, clientNIF, vehiclePlate string + if detail.ClientID != nil { + _ = conn.QueryRow(c.Context(), + `SELECT name, COALESCE(nif,'') FROM clients WHERE id = $1`, + *detail.ClientID).Scan(&clientName, &clientNIF) + } + if detail.VehicleID != nil { + _ = conn.QueryRow(c.Context(), + `SELECT plate FROM vehicles WHERE id = $1`, + *detail.VehicleID).Scan(&vehiclePlate) + } + + inv, err := CreateInvoice(c.Context(), conn, b.WorkOrderID, b.Type) + if err != nil { + return fiber.NewError(500, "erro ao criar fatura") + } + + docType := "Orcamento" + prefix := "ORC" + if b.Type == "invoice" { + docType = "Fatura" + prefix = "FAT" + } + + outPath := filepath.Join(storageRoot, tenantDir(c), fmt.Sprintf("inv_%s.pdf", inv.ID)) + + meta := pdf.DocMeta{ + CompanyName: sett["company_name"], + CompanyNIF: sett["company_nif"], + CompanyAddress: sett["company_address"], + CompanyIBAN: sett["company_iban"], + CompanyPhone: sett["company_phone"], + CompanyEmail: sett["company_email"], + DocType: docType, + DocNumber: fmt.Sprintf("%s/%d/%04d", prefix, inv.IssuedAt.Year(), inv.Number), + IssuedAt: inv.IssuedAt.Format("02/01/2006"), + ClientName: clientName, + ClientNIF: clientNIF, + VehiclePlate: vehiclePlate, + } + + lineItems := make([]pdf.LineItem, len(detail.Items)) + for i, item := range detail.Items { + lineItems[i] = pdf.LineItem{ + Description: item.Description, + Qty: item.Qty, + UnitPrice: item.UnitPrice, + DiscountPct: item.DiscountPct, + Total: item.Total, + } + } + var staffTotal float64 + for _, sh := range detail.StaffHours { + staffTotal += sh.Total + } + + if err := pdf.Generate(meta, lineItems, staffTotal, outPath); err != nil { + return fiber.NewError(500, "erro ao gerar PDF") + } + + if err := SetPDFPath(c.Context(), conn, inv.ID, outPath); err != nil { + return fiber.NewError(500, "erro ao registar caminho do PDF") + } + inv.PDFPath = outPath + + if b.Type == "invoice" { + if _, err := workorder.TransitionStatus(c.Context(), conn, b.WorkOrderID, "invoiced", ""); err != nil { + return fiber.NewError(500, "erro ao atualizar estado da ordem") + } + } + + return c.Status(201).JSON(fiber.Map{"data": inv, "error": nil}) + } +} + +func downloadPDFH() fiber.Handler { + return func(c *fiber.Ctx) error { + id := c.Params("id") + conn := auth.GetConn(c) + inv, err := GetInvoice(c.Context(), conn, id) + if err != nil { + return fiber.NewError(500, "erro interno") + } + if inv == nil { + return fiber.NewError(404, "fatura não encontrada") + } + if inv.PDFPath == "" { + return fiber.NewError(404, "PDF não disponível") + } + if _, err := os.Stat(inv.PDFPath); os.IsNotExist(err) { + return fiber.NewError(404, "ficheiro PDF não encontrado") + } + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="invoice_%d.pdf"`, inv.Number)) + return c.SendFile(inv.PDFPath) + } +} diff --git a/backend/internal/invoice/repository.go b/backend/internal/invoice/repository.go new file mode 100644 index 0000000..629e4b3 --- /dev/null +++ b/backend/internal/invoice/repository.go @@ -0,0 +1,78 @@ +package invoice + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Invoice struct { + ID string `json:"id"` + WorkOrderID string `json:"work_order_id"` + Type string `json:"type"` + Number int `json:"number"` + PDFPath string `json:"pdf_path"` + IssuedAt time.Time `json:"issued_at"` + CreatedAt time.Time `json:"created_at"` +} + +func ListInvoices(ctx context.Context, conn *pgxpool.Conn) ([]*Invoice, error) { + rows, err := conn.Query(ctx, + `SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at + FROM invoices ORDER BY issued_at DESC`) + if err != nil { + return nil, fmt.Errorf("invoice: list: %w", err) + } + defer rows.Close() + var list []*Invoice + for rows.Next() { + var inv Invoice + if err := rows.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number, + &inv.PDFPath, &inv.IssuedAt, &inv.CreatedAt); err != nil { + return nil, fmt.Errorf("invoice: scan: %w", err) + } + list = append(list, &inv) + } + return list, rows.Err() +} + +func GetInvoice(ctx context.Context, conn *pgxpool.Conn, id string) (*Invoice, error) { + row := conn.QueryRow(ctx, + `SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at + FROM invoices WHERE id = $1`, id) + var inv Invoice + err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number, + &inv.PDFPath, &inv.IssuedAt, &inv.CreatedAt) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("invoice: get: %w", err) + } + return &inv, nil +} + +func CreateInvoice(ctx context.Context, conn *pgxpool.Conn, woID, docType string) (*Invoice, error) { + row := conn.QueryRow(ctx, + `INSERT INTO invoices (work_order_id, type, issued_at) + VALUES ($1, $2, NOW()) + RETURNING id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at`, + woID, docType) + var inv Invoice + if err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number, + &inv.PDFPath, &inv.IssuedAt, &inv.CreatedAt); err != nil { + return nil, fmt.Errorf("invoice: create: %w", err) + } + return &inv, nil +} + +func SetPDFPath(ctx context.Context, conn *pgxpool.Conn, id, path string) error { + _, err := conn.Exec(ctx, `UPDATE invoices SET pdf_path = $2 WHERE id = $1`, id, path) + if err != nil { + return fmt.Errorf("invoice: set pdf path: %w", err) + } + return nil +} diff --git a/backend/internal/server/health.go b/backend/internal/server/health.go new file mode 100644 index 0000000..ef59936 --- /dev/null +++ b/backend/internal/server/health.go @@ -0,0 +1,16 @@ +package server + +import "github.com/gofiber/fiber/v2" + +const appVersion = "0.1.0" + +func RegisterHealthRoutes(app *fiber.App) { + app.Get("/api/v1/health", handleHealth) +} + +func handleHealth(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "ok", + "version": appVersion, + }) +} diff --git a/backend/internal/server/health_test.go b/backend/internal/server/health_test.go new file mode 100644 index 0000000..4bc8d5a --- /dev/null +++ b/backend/internal/server/health_test.go @@ -0,0 +1,40 @@ +package server_test + +import ( + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/internal/server" +) + +func TestHealthEndpoint_returnsOK(t *testing.T) { + app := fiber.New() + server.RegisterHealthRoutes(app) + + req := httptest.NewRequest("GET", "/api/v1/health", nil) + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 200, resp.StatusCode) + + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.Equal(t, "ok", body["status"]) + assert.NotEmpty(t, body["version"]) +} + +func TestHealthEndpoint_wrongMethod(t *testing.T) { + app := fiber.New() + server.RegisterHealthRoutes(app) + + req := httptest.NewRequest("POST", "/api/v1/health", nil) + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 405, resp.StatusCode) +} diff --git a/backend/internal/server/server.go b/backend/internal/server/server.go new file mode 100644 index 0000000..c5e2a60 --- /dev/null +++ b/backend/internal/server/server.go @@ -0,0 +1,73 @@ +package server + +import ( + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/helmet" + "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/gofiber/fiber/v2/middleware/recover" + + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/internal/catalog" + "github.com/techxcar/backend/internal/client" + "github.com/techxcar/backend/internal/config" + "github.com/techxcar/backend/internal/expense" + "github.com/techxcar/backend/internal/invoice" + "github.com/techxcar/backend/internal/settings" + "github.com/techxcar/backend/internal/staff" + "github.com/techxcar/backend/internal/tenant" + "github.com/techxcar/backend/internal/workorder" + "github.com/techxcar/backend/pkg/database" + redispkg "github.com/techxcar/backend/pkg/redis" +) + +type Deps struct { + Config *config.Config + DB *database.DB + Redis *redispkg.Redis +} + +func New(deps Deps) *fiber.App { + app := fiber.New(fiber.Config{ + AppName: "TechXCar API", + ErrorHandler: errorHandler, + }) + + app.Use(recover.New()) + app.Use(logger.New()) + app.Use(helmet.New()) + app.Use(cors.New(cors.Config{ + AllowOrigins: "http://localhost:3000,http://localhost:5173", + AllowHeaders: "Origin, Content-Type, Accept, Authorization", + AllowMethods: "GET, POST, PUT, PATCH, DELETE, OPTIONS", + AllowCredentials: true, + })) + + RegisterHealthRoutes(app) + + if deps.DB != nil && deps.Redis != nil && deps.Config != nil { + repo := tenant.NewRepository(deps.DB) + auth.RegisterRoutes(app, tenant.LoginAdapter(repo), deps.Redis, deps.Config) + tenant.RegisterRoutes(app, repo, deps.DB, deps.Config) + client.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) + catalog.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) + workorder.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) + staff.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) + expense.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) + settings.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) + invoice.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) + } + + return app +} + +func errorHandler(c *fiber.Ctx, err error) error { + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{ + "data": nil, + "error": err.Error(), + }) +} diff --git a/backend/internal/settings/handler.go b/backend/internal/settings/handler.go new file mode 100644 index 0000000..77c0ff5 --- /dev/null +++ b/backend/internal/settings/handler.go @@ -0,0 +1,57 @@ +package settings + +import ( + "github.com/gofiber/fiber/v2" + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/pkg/database" +) + +func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { + read := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager"), + auth.TenantMiddleware(db), + } + admin := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin"), + auth.TenantMiddleware(db), + } + + app.Get("/api/v1/settings", append(read, getSettingsH())...) + app.Put("/api/v1/settings", append(admin, updateSettingsH())...) +} + +func getSettingsH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + s, err := GetSettings(c.Context(), conn) + if err != nil { + return fiber.NewError(500, "erro ao obter definições") + } + return c.JSON(fiber.Map{"data": s, "error": nil}) + } +} + +func updateSettingsH() fiber.Handler { + return func(c *fiber.Ctx) error { + var body map[string]string + if err := c.BodyParser(&body); err != nil { + return fiber.NewError(400, "corpo do pedido inválido") + } + conn := auth.GetConn(c) + for k, v := range body { + if !AllowedKeys[k] { + return fiber.NewError(400, "chave inválida: "+k) + } + if err := SetSetting(c.Context(), conn, k, v); err != nil { + return fiber.NewError(500, "erro ao guardar definição: "+k) + } + } + s, err := GetSettings(c.Context(), conn) + if err != nil { + return fiber.NewError(500, "erro ao obter definições") + } + return c.JSON(fiber.Map{"data": s, "error": nil}) + } +} diff --git a/backend/internal/settings/repository.go b/backend/internal/settings/repository.go new file mode 100644 index 0000000..6e0bc21 --- /dev/null +++ b/backend/internal/settings/repository.go @@ -0,0 +1,45 @@ +package settings + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var AllowedKeys = map[string]bool{ + "company_name": true, + "company_nif": true, + "company_address": true, + "company_iban": true, + "company_phone": true, + "company_email": true, +} + +func GetSettings(ctx context.Context, conn *pgxpool.Conn) (map[string]string, error) { + rows, err := conn.Query(ctx, `SELECT key, value FROM tenant_settings`) + if err != nil { + return nil, fmt.Errorf("settings: get: %w", err) + } + defer rows.Close() + result := map[string]string{} + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, fmt.Errorf("settings: scan: %w", err) + } + result[k] = v + } + return result, rows.Err() +} + +func SetSetting(ctx context.Context, conn *pgxpool.Conn, key, value string) error { + _, err := conn.Exec(ctx, + `INSERT INTO tenant_settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, + key, value) + if err != nil { + return fmt.Errorf("settings: set %s: %w", key, err) + } + return nil +} diff --git a/backend/internal/staff/handler.go b/backend/internal/staff/handler.go new file mode 100644 index 0000000..c4129bc --- /dev/null +++ b/backend/internal/staff/handler.go @@ -0,0 +1,109 @@ +package staff + +import ( + "errors" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/pkg/database" +) + +func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { + ro := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager", "technician"), + auth.TenantMiddleware(db), + } + write := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager"), + auth.TenantMiddleware(db), + } + + app.Get("/api/v1/staff", append(ro, listStaffH())...) + app.Post("/api/v1/staff", append(write, createStaffH())...) + app.Put("/api/v1/staff/:id", append(write, updateStaffH())...) + app.Delete("/api/v1/staff/:id", append(write, deleteStaffH())...) +} + +func listStaffH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + list, err := ListStaff(c.Context(), conn) + if err != nil { + return fiber.NewError(500, "erro ao listar técnicos") + } + if list == nil { + list = []*Staff{} + } + return c.JSON(fiber.Map{"data": list, "error": nil}) + } +} + +type staffBody struct { + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + Type string `json:"type"` + HourlyRate float64 `json:"hourly_rate"` + Active bool `json:"active"` +} + +func createStaffH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b staffBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo do pedido inválido") + } + if b.Name == "" { + return fiber.NewError(400, "nome obrigatório") + } + if b.Type != "internal" && b.Type != "external" { + return fiber.NewError(400, "tipo deve ser 'internal' ou 'external'") + } + conn := auth.GetConn(c) + s, err := CreateStaff(c.Context(), conn, b.Name, b.Email, b.Phone, b.Type, b.HourlyRate) + if err != nil { + return fiber.NewError(500, "erro ao criar técnico") + } + return c.Status(201).JSON(fiber.Map{"data": s, "error": nil}) + } +} + +func updateStaffH() fiber.Handler { + return func(c *fiber.Ctx) error { + id := c.Params("id") + var b staffBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo do pedido inválido") + } + if b.Name == "" { + return fiber.NewError(400, "nome obrigatório") + } + conn := auth.GetConn(c) + s, err := UpdateStaff(c.Context(), conn, id, b.Name, b.Email, b.Phone, b.Type, b.HourlyRate, b.Active) + if err != nil { + return fiber.NewError(500, "erro ao actualizar técnico") + } + if s == nil { + return fiber.NewError(404, "técnico não encontrado") + } + return c.JSON(fiber.Map{"data": s, "error": nil}) + } +} + +func deleteStaffH() fiber.Handler { + return func(c *fiber.Ctx) error { + id := c.Params("id") + conn := auth.GetConn(c) + err := DeleteStaff(c.Context(), conn, id) + if errors.Is(err, pgx.ErrNoRows) { + return fiber.NewError(404, "técnico não encontrado") + } + if err != nil { + return fiber.NewError(500, "erro ao eliminar técnico") + } + return c.SendStatus(204) + } +} diff --git a/backend/internal/staff/repository.go b/backend/internal/staff/repository.go new file mode 100644 index 0000000..974e5bb --- /dev/null +++ b/backend/internal/staff/repository.go @@ -0,0 +1,106 @@ +package staff + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Staff struct { + ID string `json:"id"` + UserID *string `json:"user_id"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + Type string `json:"type"` + HourlyRate float64 `json:"hourly_rate"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` +} + +func ListStaff(ctx context.Context, conn *pgxpool.Conn) ([]*Staff, error) { + rows, err := conn.Query(ctx, + `SELECT id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type, + COALESCE(hourly_rate,0), active, created_at + FROM staff ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("staff: list: %w", err) + } + defer rows.Close() + var list []*Staff + for rows.Next() { + var s Staff + if err := rows.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone, + &s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt); err != nil { + return nil, fmt.Errorf("staff: scan: %w", err) + } + list = append(list, &s) + } + return list, rows.Err() +} + +func GetStaffByID(ctx context.Context, conn *pgxpool.Conn, id string) (*Staff, error) { + row := conn.QueryRow(ctx, + `SELECT id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type, + COALESCE(hourly_rate,0), active, created_at + FROM staff WHERE id = $1`, id) + var s Staff + err := row.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone, + &s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("staff: get: %w", err) + } + return &s, nil +} + +func CreateStaff(ctx context.Context, conn *pgxpool.Conn, name, email, phone, staffType string, hourlyRate float64) (*Staff, error) { + row := conn.QueryRow(ctx, + `INSERT INTO staff (name, email, phone, type, hourly_rate) + VALUES ($1, NULLIF($2,''), NULLIF($3,''), $4, $5) + RETURNING id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type, + COALESCE(hourly_rate,0), active, created_at`, + name, email, phone, staffType, hourlyRate) + var s Staff + if err := row.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone, + &s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt); err != nil { + return nil, fmt.Errorf("staff: create: %w", err) + } + return &s, nil +} + +func UpdateStaff(ctx context.Context, conn *pgxpool.Conn, id, name, email, phone, staffType string, hourlyRate float64, active bool) (*Staff, error) { + row := conn.QueryRow(ctx, + `UPDATE staff SET name=$2, email=NULLIF($3,''), phone=NULLIF($4,''), type=$5, + hourly_rate=$6, active=$7 + WHERE id=$1 + RETURNING id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type, + COALESCE(hourly_rate,0), active, created_at`, + id, name, email, phone, staffType, hourlyRate, active) + var s Staff + err := row.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone, + &s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("staff: update: %w", err) + } + return &s, nil +} + +func DeleteStaff(ctx context.Context, conn *pgxpool.Conn, id string) error { + tag, err := conn.Exec(ctx, `DELETE FROM staff WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("staff: delete: %w", err) + } + if tag.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} diff --git a/backend/internal/tenant/handler.go b/backend/internal/tenant/handler.go new file mode 100644 index 0000000..7aed704 --- /dev/null +++ b/backend/internal/tenant/handler.go @@ -0,0 +1,242 @@ +package tenant + +import ( + "time" + + "github.com/gofiber/fiber/v2" + + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/internal/config" + "github.com/techxcar/backend/pkg/database" +) + +func listTenantsHandler(repo *Repository) fiber.Handler { + return func(c *fiber.Ctx) error { + list, err := repo.ListTenants(c.Context()) + if err != nil { + return fiber.NewError(500, "erro ao listar oficinas") + } + if list == nil { + list = []*Tenant{} + } + return c.JSON(fiber.Map{"data": list, "error": nil}) + } +} + +type createTenantRequest struct { + Name string `json:"name"` + Slug string `json:"slug"` + AdminEmail string `json:"admin_email"` + AdminPassword string `json:"admin_password"` + AdminName string `json:"admin_name"` +} + +func createTenantHandler(repo *Repository, db *database.DB, cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + var req createTenantRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(400, "corpo do pedido inválido") + } + if req.Name == "" || req.Slug == "" || req.AdminEmail == "" || req.AdminPassword == "" || req.AdminName == "" { + return fiber.NewError(400, "todos os campos são obrigatórios") + } + existing, err := repo.GetTenantBySlug(c.Context(), req.Slug) + if err != nil { + return fiber.NewError(500, "erro interno") + } + if existing != nil { + return fiber.NewError(409, "slug já existe") + } + + ten, err := repo.CreateTenant(c.Context(), req.Slug, req.Name) + if err != nil { + return fiber.NewError(500, "erro ao criar oficina") + } + + if db != nil { + if err := db.ProvisionTenantSchema(c.Context(), ten.ID, "migrations/tenant"); err != nil { + return fiber.NewError(500, "erro ao provisionar schema") + } + } + + hash, err := auth.HashPassword(req.AdminPassword) + if err != nil { + return fiber.NewError(500, "erro interno") + } + if db != nil { + if _, err := repo.CreateTenantUser(c.Context(), ten.ID, req.AdminEmail, hash, req.AdminName, "tenant_admin"); err != nil { + return fiber.NewError(500, "erro ao criar utilizador admin") + } + } + + return c.Status(201).JSON(fiber.Map{"data": ten, "error": nil}) + } +} + +func generateInviteHandler(repo *Repository) fiber.Handler { + return func(c *fiber.Ctx) error { + tenantID := c.Params("id") + ten, err := repo.GetTenantByID(c.Context(), tenantID) + if err != nil || ten == nil { + return fiber.NewError(404, "oficina não encontrada") + } + + invite, err := repo.CreateInvite(c.Context(), &ten.ID, 72*time.Hour) + if err != nil { + return fiber.NewError(500, "erro ao gerar convite") + } + + return c.Status(201).JSON(fiber.Map{ + "data": fiber.Map{"token": invite.Token, "expires_at": invite.ExpiresAt}, + "error": nil, + }) + } +} + +func generatePlatformInviteHandler(repo *Repository) fiber.Handler { + return func(c *fiber.Ctx) error { + invite, err := repo.CreateInvite(c.Context(), nil, 72*time.Hour) + if err != nil { + return fiber.NewError(500, "erro ao gerar convite") + } + return c.Status(201).JSON(fiber.Map{ + "data": fiber.Map{"token": invite.Token, "expires_at": invite.ExpiresAt}, + "error": nil, + }) + } +} + +func getInviteHandler(repo *Repository) fiber.Handler { + return func(c *fiber.Ctx) error { + token := c.Params("token") + invite, err := repo.GetInviteByToken(c.Context(), token) + if err != nil { + return fiber.NewError(500, "erro interno") + } + if invite == nil { + return fiber.NewError(404, "convite não encontrado") + } + if invite.UsedAt != nil { + return fiber.NewError(410, "convite já foi utilizado") + } + if invite.ExpiresAt.Before(time.Now()) { + return fiber.NewError(410, "convite expirado") + } + return c.JSON(fiber.Map{"data": invite, "error": nil}) + } +} + +type redeemRequest struct { + TenantName string `json:"tenant_name"` + TenantSlug string `json:"tenant_slug"` + AdminEmail string `json:"admin_email"` + AdminPassword string `json:"admin_password"` + AdminName string `json:"admin_name"` +} + +func tenantAccessHandler(repo *Repository, cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + claims, ok := c.Locals("claims").(*auth.Claims) + if !ok { + return fiber.NewError(401, "autenticação necessária") + } + + tenantID := c.Params("id") + ten, err := repo.GetTenantByID(c.Context(), tenantID) + if err != nil { + return fiber.NewError(500, "erro interno") + } + if ten == nil { + return fiber.NewError(404, "oficina não encontrada") + } + if ten.Status != "active" { + return fiber.NewError(404, "oficina não encontrada ou inativa") + } + + token, err := auth.GenerateAccessToken(claims.UserID, ten.ID, "tenant_admin", cfg.JWTSecret) + if err != nil { + return fiber.NewError(500, "erro ao gerar token") + } + + return c.JSON(fiber.Map{ + "data": fiber.Map{ + "access_token": token, + "tenant": fiber.Map{ + "id": ten.ID, + "name": ten.Name, + "slug": ten.Slug, + }, + }, + "error": nil, + }) + } +} + +func redeemInviteHandler(repo *Repository, db *database.DB, cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + token := c.Params("token") + invite, err := repo.GetInviteByToken(c.Context(), token) + if err != nil || invite == nil { + return fiber.NewError(404, "convite não encontrado") + } + if invite.UsedAt != nil || invite.ExpiresAt.Before(time.Now()) { + return fiber.NewError(410, "convite inválido ou expirado") + } + + var req redeemRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(400, "corpo do pedido inválido") + } + if req.TenantName == "" || req.TenantSlug == "" || req.AdminEmail == "" || req.AdminPassword == "" || req.AdminName == "" { + return fiber.NewError(400, "todos os campos são obrigatórios") + } + if len(req.AdminPassword) < 8 { + return fiber.NewError(400, "password deve ter pelo menos 8 caracteres") + } + + existing, _ := repo.GetTenantBySlug(c.Context(), req.TenantSlug) + if existing != nil { + return fiber.NewError(409, "slug já existe") + } + + ten, err := repo.CreateTenant(c.Context(), req.TenantSlug, req.TenantName) + if err != nil { + return fiber.NewError(500, "erro ao criar oficina") + } + + if db != nil { + if err := db.ProvisionTenantSchema(c.Context(), ten.ID, "migrations/tenant"); err != nil { + return fiber.NewError(500, "erro ao provisionar schema") + } + } + + hash, err := auth.HashPassword(req.AdminPassword) + if err != nil { + return fiber.NewError(500, "erro interno") + } + + var userID string + if db != nil { + user, err := repo.CreateTenantUser(c.Context(), ten.ID, req.AdminEmail, hash, req.AdminName, "tenant_admin") + if err != nil { + return fiber.NewError(500, "erro ao criar utilizador") + } + userID = user.ID + } else { + userID = "mock-user-id" + } + + if err := repo.UseInvite(c.Context(), invite.ID); err != nil { + return fiber.NewError(500, "erro ao registar utilização do convite") + } + + access, _ := auth.GenerateAccessToken(userID, ten.ID, "tenant_admin", cfg.JWTSecret) + refresh, _ := auth.GenerateRefreshToken(userID, ten.ID, "tenant_admin", cfg.JWTSecret) + auth.SetRefreshCookie(c, refresh, cfg) + + return c.Status(201).JSON(fiber.Map{ + "data": fiber.Map{"access_token": access, "tenant_slug": ten.Slug}, + "error": nil, + }) + } +} diff --git a/backend/internal/tenant/handler_test.go b/backend/internal/tenant/handler_test.go new file mode 100644 index 0000000..f0e98f7 --- /dev/null +++ b/backend/internal/tenant/handler_test.go @@ -0,0 +1,160 @@ +package tenant_test + +import ( + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/internal/config" + "github.com/techxcar/backend/internal/tenant" +) + +const handlerTestSecret = "test-secret-32-chars-minimum-ok!" + +func buildAdminApp(repo *tenant.Repository, cfg *config.Config) *fiber.App { + app := fiber.New(fiber.Config{ErrorHandler: func(c *fiber.Ctx, err error) error { + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{"data": nil, "error": err.Error()}) + }}) + tenant.RegisterRoutes(app, repo, nil, cfg) + return app +} + +func adminToken(t *testing.T, secret string) string { + t.Helper() + tok, err := auth.GenerateAccessToken("sa-1", "", "super_admin", secret) + require.NoError(t, err) + return tok +} + +func TestListTenants_requiresAuth(t *testing.T) { + db := setupDB(t) + repo := tenant.NewRepository(db) + cfg := &config.Config{JWTSecret: handlerTestSecret} + app := buildAdminApp(repo, cfg) + + req := httptest.NewRequest("GET", "/api/v1/admin/tenants", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 401, resp.StatusCode) +} + +func TestListTenants_success(t *testing.T) { + db := setupDB(t) + repo := tenant.NewRepository(db) + cfg := &config.Config{JWTSecret: handlerTestSecret} + app := buildAdminApp(repo, cfg) + + req := httptest.NewRequest("GET", "/api/v1/admin/tenants", nil) + req.Header.Set("Authorization", "Bearer "+adminToken(t, handlerTestSecret)) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + var result map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + assert.Nil(t, result["error"]) +} + +func TestTenantAccessHandler(t *testing.T) { + db := setupDB(t) + repo := tenant.NewRepository(db) + cfg := &config.Config{JWTSecret: handlerTestSecret} + app := buildAdminApp(repo, cfg) + + ctx := t.Context() + slug := "access-test-" + t.Name() + ten, err := repo.CreateTenant(ctx, slug, "Tenant Access Test") + require.NoError(t, err) + t.Cleanup(func() { + db.Pool.Exec(ctx, "DELETE FROM tenants WHERE id = $1", ten.ID) + }) + + req := httptest.NewRequest("POST", "/api/v1/admin/tenants/"+ten.ID+"/access", nil) + req.Header.Set("Authorization", "Bearer "+adminToken(t, handlerTestSecret)) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + var body struct { + Data struct { + AccessToken string `json:"access_token"` + Tenant struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + } `json:"tenant"` + } `json:"data"` + Error *string `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.Nil(t, body.Error) + assert.NotEmpty(t, body.Data.AccessToken) + assert.Equal(t, ten.ID, body.Data.Tenant.ID) + assert.Equal(t, "Tenant Access Test", body.Data.Tenant.Name) + assert.Equal(t, slug, body.Data.Tenant.Slug) + + claims, err := auth.ValidateToken(body.Data.AccessToken, handlerTestSecret) + require.NoError(t, err) + assert.Equal(t, "sa-1", claims.UserID) + assert.Equal(t, ten.ID, claims.TenantID) + assert.Equal(t, "tenant_admin", claims.Role) +} + +func TestTenantAccessHandler_notFound(t *testing.T) { + db := setupDB(t) + repo := tenant.NewRepository(db) + cfg := &config.Config{JWTSecret: handlerTestSecret} + app := buildAdminApp(repo, cfg) + + req := httptest.NewRequest("POST", "/api/v1/admin/tenants/nonexistent-id/access", nil) + req.Header.Set("Authorization", "Bearer "+adminToken(t, handlerTestSecret)) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 404, resp.StatusCode) +} + +func TestTenantAccessHandler_inactive(t *testing.T) { + db := setupDB(t) + repo := tenant.NewRepository(db) + cfg := &config.Config{JWTSecret: handlerTestSecret} + app := buildAdminApp(repo, cfg) + + ctx := t.Context() + slug := "inactive-test-" + t.Name() + ten, err := repo.CreateTenant(ctx, slug, "Tenant Inactive Test") + require.NoError(t, err) + t.Cleanup(func() { + db.Pool.Exec(ctx, "DELETE FROM tenants WHERE id = $1", ten.ID) + }) + + // Update tenant status to something other than "active" + _, err = db.Pool.Exec(ctx, "UPDATE public.tenants SET status = 'suspended' WHERE id = $1", ten.ID) + require.NoError(t, err) + + req := httptest.NewRequest("POST", "/api/v1/admin/tenants/"+ten.ID+"/access", nil) + req.Header.Set("Authorization", "Bearer "+adminToken(t, handlerTestSecret)) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 404, resp.StatusCode) +} + +func TestGetInvite_notFound(t *testing.T) { + db := setupDB(t) + repo := tenant.NewRepository(db) + cfg := &config.Config{JWTSecret: handlerTestSecret} + app := buildAdminApp(repo, cfg) + + req := httptest.NewRequest("GET", "/api/v1/invites/nonexistent-token", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 404, resp.StatusCode) +} diff --git a/backend/internal/tenant/repository.go b/backend/internal/tenant/repository.go new file mode 100644 index 0000000..77a2f68 --- /dev/null +++ b/backend/internal/tenant/repository.go @@ -0,0 +1,219 @@ +package tenant + +import ( + "context" + "errors" + "fmt" + "regexp" + "strings" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/techxcar/backend/pkg/database" +) + +var uuidRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +type SuperAdmin struct { + ID string `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"-"` + CreatedAt time.Time `json:"created_at"` +} + +type Tenant struct { + ID string `json:"id"` + Slug string `json:"slug"` + Name string `json:"name"` + Status string `json:"status"` + CreatedAt time.Time `json:"created_at"` +} + +type TenantUser struct { + ID string `json:"id"` + Email string `json:"email"` + PasswordHash string `json:"-"` + Role string `json:"role"` + Name string `json:"name"` + Active bool `json:"active"` +} + +type Invite struct { + ID string `json:"id"` + TenantID *string `json:"tenant_id"` + Token string `json:"token"` + ExpiresAt time.Time `json:"expires_at"` + UsedAt *time.Time `json:"used_at"` + CreatedAt time.Time `json:"created_at"` +} + +type Repository struct { + db *database.DB +} + +func NewRepository(db *database.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) GetSuperAdminByEmail(ctx context.Context, email string) (*SuperAdmin, error) { + row := r.db.Pool.QueryRow(ctx, + `SELECT id, email, password_hash, created_at FROM super_admins WHERE email = $1`, email) + var a SuperAdmin + err := row.Scan(&a.ID, &a.Email, &a.PasswordHash, &a.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("tenant: get super admin: %w", err) + } + return &a, nil +} + +func (r *Repository) CreateSuperAdmin(ctx context.Context, email, passwordHash string) (*SuperAdmin, error) { + row := r.db.Pool.QueryRow(ctx, + `INSERT INTO super_admins (email, password_hash) VALUES ($1, $2) + RETURNING id, email, password_hash, created_at`, + email, passwordHash) + var a SuperAdmin + if err := row.Scan(&a.ID, &a.Email, &a.PasswordHash, &a.CreatedAt); err != nil { + return nil, fmt.Errorf("tenant: create super admin: %w", err) + } + return &a, nil +} + +func (r *Repository) GetTenantBySlug(ctx context.Context, slug string) (*Tenant, error) { + row := r.db.Pool.QueryRow(ctx, + `SELECT id, slug, name, status, created_at FROM tenants WHERE slug = $1`, slug) + var t Tenant + err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("tenant: get by slug: %w", err) + } + return &t, nil +} + +func (r *Repository) GetTenantByID(ctx context.Context, id string) (*Tenant, error) { + row := r.db.Pool.QueryRow(ctx, + `SELECT id, slug, name, status, created_at FROM tenants WHERE id = $1`, id) + var t Tenant + err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("tenant: get by id: %w", err) + } + return &t, nil +} + +func (r *Repository) ListTenants(ctx context.Context) ([]*Tenant, error) { + rows, err := r.db.Pool.Query(ctx, + `SELECT id, slug, name, status, created_at FROM tenants ORDER BY created_at DESC`) + if err != nil { + return nil, fmt.Errorf("tenant: list: %w", err) + } + defer rows.Close() + var list []*Tenant + for rows.Next() { + var t Tenant + if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt); err != nil { + return nil, fmt.Errorf("tenant: list scan: %w", err) + } + list = append(list, &t) + } + if err := rows.Err(); err != nil { + return nil, fmt.Errorf("tenant: list rows: %w", err) + } + return list, nil +} + +func (r *Repository) CreateTenant(ctx context.Context, slug, name string) (*Tenant, error) { + row := r.db.Pool.QueryRow(ctx, + `INSERT INTO tenants (slug, name) VALUES ($1, $2) + RETURNING id, slug, name, status, created_at`, + slug, name) + var t Tenant + if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt); err != nil { + return nil, fmt.Errorf("tenant: create: %w", err) + } + return &t, nil +} + +func (r *Repository) GetTenantUserByEmail(ctx context.Context, tenantID, email string) (*TenantUser, error) { + if !uuidRe.MatchString(tenantID) { + return nil, fmt.Errorf("tenant: invalid tenant ID format") + } + schema := `"tenant_` + strings.ReplaceAll(tenantID, "-", "_") + `"` + row := r.db.Pool.QueryRow(ctx, + fmt.Sprintf(`SELECT id, email, password_hash, role, name, active FROM %s.users WHERE email = $1`, schema), + email) + var u TenantUser + err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.Name, &u.Active) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("tenant: get user: %w", err) + } + return &u, nil +} + +func (r *Repository) CreateTenantUser(ctx context.Context, tenantID, email, passwordHash, name, role string) (*TenantUser, error) { + if !uuidRe.MatchString(tenantID) { + return nil, fmt.Errorf("tenant: invalid tenant ID format") + } + schema := `"tenant_` + strings.ReplaceAll(tenantID, "-", "_") + `"` + row := r.db.Pool.QueryRow(ctx, + fmt.Sprintf(`INSERT INTO %s.users (email, password_hash, name, role) + VALUES ($1, $2, $3, $4) RETURNING id, email, password_hash, role, name, active`, schema), + email, passwordHash, name, role) + var u TenantUser + if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.Name, &u.Active); err != nil { + return nil, fmt.Errorf("tenant: create user: %w", err) + } + return &u, nil +} + +func (r *Repository) CreateInvite(ctx context.Context, tenantID *string, expiresIn time.Duration) (*Invite, error) { + token := uuid.New().String() + expiresAt := time.Now().Add(expiresIn) + row := r.db.Pool.QueryRow(ctx, + `INSERT INTO invites (tenant_id, token, expires_at) VALUES ($1, $2, $3) + RETURNING id, tenant_id, token, expires_at, used_at, created_at`, + tenantID, token, expiresAt) + var inv Invite + if err := row.Scan(&inv.ID, &inv.TenantID, &inv.Token, &inv.ExpiresAt, &inv.UsedAt, &inv.CreatedAt); err != nil { + return nil, fmt.Errorf("tenant: create invite: %w", err) + } + return &inv, nil +} + +func (r *Repository) GetInviteByToken(ctx context.Context, token string) (*Invite, error) { + row := r.db.Pool.QueryRow(ctx, + `SELECT id, tenant_id, token, expires_at, used_at, created_at FROM invites WHERE token = $1`, token) + var inv Invite + err := row.Scan(&inv.ID, &inv.TenantID, &inv.Token, &inv.ExpiresAt, &inv.UsedAt, &inv.CreatedAt) + if errors.Is(err, pgx.ErrNoRows) { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("tenant: get invite: %w", err) + } + return &inv, nil +} + +func (r *Repository) UseInvite(ctx context.Context, inviteID string) error { + tag, err := r.db.Pool.Exec(ctx, + `UPDATE invites SET used_at = NOW() WHERE id = $1`, inviteID) + if err != nil { + return fmt.Errorf("tenant: use invite: %w", err) + } + if tag.RowsAffected() == 0 { + return fmt.Errorf("tenant: invite not found: %s", inviteID) + } + return nil +} diff --git a/backend/internal/tenant/repository_test.go b/backend/internal/tenant/repository_test.go new file mode 100644 index 0000000..493c419 --- /dev/null +++ b/backend/internal/tenant/repository_test.go @@ -0,0 +1,113 @@ +package tenant_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/internal/tenant" + "github.com/techxcar/backend/pkg/database" +) + +func setupDB(t *testing.T) *database.DB { + t.Helper() + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("TEST_DATABASE_URL not set, skipping integration test") + } + db, err := database.New(url) + require.NoError(t, err) + t.Cleanup(db.Close) + return db +} + +func TestGetSuperAdminByEmail_notFound(t *testing.T) { + db := setupDB(t) + repo := tenant.NewRepository(db) + admin, err := repo.GetSuperAdminByEmail(context.Background(), "nobody@example.com") + require.NoError(t, err) + assert.Nil(t, admin) +} + +func TestCreateAndGetSuperAdmin(t *testing.T) { + db := setupDB(t) + repo := tenant.NewRepository(db) + ctx := context.Background() + + email := "sa_" + time.Now().Format("20060102150405") + "@example.com" + admin, err := repo.CreateSuperAdmin(ctx, email, "hash123") + require.NoError(t, err) + require.NotNil(t, admin) + assert.NotEmpty(t, admin.ID) + assert.Equal(t, email, admin.Email) + + found, err := repo.GetSuperAdminByEmail(ctx, email) + require.NoError(t, err) + require.NotNil(t, found) + assert.Equal(t, admin.ID, found.ID) + + t.Cleanup(func() { + db.Pool.Exec(ctx, "DELETE FROM super_admins WHERE id = $1", admin.ID) + }) +} + +func TestCreateAndListTenants(t *testing.T) { + db := setupDB(t) + repo := tenant.NewRepository(db) + ctx := context.Background() + + slug := "test-" + time.Now().Format("20060102150405") + ten, err := repo.CreateTenant(ctx, slug, "Test Workshop") + require.NoError(t, err) + require.NotNil(t, ten) + assert.NotEmpty(t, ten.ID) + assert.Equal(t, slug, ten.Slug) + assert.Equal(t, "active", ten.Status) + + list, err := repo.ListTenants(ctx) + require.NoError(t, err) + found := false + for _, v := range list { + if v.ID == ten.ID { + found = true + } + } + assert.True(t, found) + + t.Cleanup(func() { + db.Pool.Exec(ctx, "DELETE FROM tenants WHERE id = $1", ten.ID) + }) +} + +func TestCreateAndUseInvite(t *testing.T) { + db := setupDB(t) + repo := tenant.NewRepository(db) + ctx := context.Background() + + invite, err := repo.CreateInvite(ctx, nil, 24*time.Hour) + require.NoError(t, err) + require.NotNil(t, invite) + assert.NotEmpty(t, invite.Token) + assert.Nil(t, invite.UsedAt) + assert.True(t, invite.ExpiresAt.After(time.Now())) + + found, err := repo.GetInviteByToken(ctx, invite.Token) + require.NoError(t, err) + require.NotNil(t, found) + assert.Equal(t, invite.ID, found.ID) + + err = repo.UseInvite(ctx, invite.ID) + require.NoError(t, err) + + used, err := repo.GetInviteByToken(ctx, invite.Token) + require.NoError(t, err) + assert.NotNil(t, used.UsedAt) + + t.Cleanup(func() { + db.Pool.Exec(ctx, "DELETE FROM invites WHERE id = $1", invite.ID) + }) +} diff --git a/backend/internal/tenant/routes.go b/backend/internal/tenant/routes.go new file mode 100644 index 0000000..dcc8558 --- /dev/null +++ b/backend/internal/tenant/routes.go @@ -0,0 +1,60 @@ +package tenant + +import ( + "context" + + "github.com/gofiber/fiber/v2" + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/internal/config" + "github.com/techxcar/backend/pkg/database" +) + +// loginAdapter adapts *Repository to satisfy auth.LoginRepository. +type loginAdapter struct{ repo *Repository } + +func (a *loginAdapter) GetSuperAdminByEmail(ctx context.Context, email string) (*auth.LoginSuperAdmin, error) { + sa, err := a.repo.GetSuperAdminByEmail(ctx, email) + if err != nil || sa == nil { + return nil, err + } + return &auth.LoginSuperAdmin{ID: sa.ID, Email: sa.Email, PasswordHash: sa.PasswordHash}, nil +} + +func (a *loginAdapter) GetTenantBySlug(ctx context.Context, slug string) (*auth.LoginTenant, error) { + t, err := a.repo.GetTenantBySlug(ctx, slug) + if err != nil || t == nil { + return nil, err + } + return &auth.LoginTenant{ID: t.ID, Status: t.Status}, nil +} + +func (a *loginAdapter) GetTenantUserByEmail(ctx context.Context, tenantID, email string) (*auth.LoginUser, error) { + u, err := a.repo.GetTenantUserByEmail(ctx, tenantID, email) + if err != nil || u == nil { + return nil, err + } + return &auth.LoginUser{ID: u.ID, Email: u.Email, PasswordHash: u.PasswordHash, Role: u.Role, Name: u.Name, Active: u.Active}, nil +} + +// LoginAdapter returns an auth.LoginRepository backed by repo. +func LoginAdapter(repo *Repository) auth.LoginRepository { + return &loginAdapter{repo: repo} +} + +func RegisterRoutes(app *fiber.App, repo *Repository, db *database.DB, cfg *config.Config) { + // Public invite routes + invites := app.Group("/api/v1/invites") + invites.Get("/:token", getInviteHandler(repo)) + invites.Post("/:token/redeem", redeemInviteHandler(repo, db, cfg)) + + // Super-admin routes + admin := app.Group("/api/v1/admin", + auth.RequireAuth(cfg.JWTSecret), + auth.RequireRole("super_admin"), + ) + admin.Get("/tenants", listTenantsHandler(repo)) + admin.Post("/tenants", createTenantHandler(repo, db, cfg)) + admin.Post("/tenants/:id/invite", generateInviteHandler(repo)) + admin.Post("/tenants/:id/access", tenantAccessHandler(repo, cfg)) + admin.Post("/invites", generatePlatformInviteHandler(repo)) +} diff --git a/backend/internal/workorder/handler.go b/backend/internal/workorder/handler.go new file mode 100644 index 0000000..4fec68e --- /dev/null +++ b/backend/internal/workorder/handler.go @@ -0,0 +1,200 @@ +package workorder + +import ( + "errors" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/pkg/database" +) + +func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { + ro := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager", "technician"), + auth.TenantMiddleware(db), + } + write := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager"), + auth.TenantMiddleware(db), + } + + app.Get("/api/v1/work-orders", append(ro, listWOsH())...) + app.Post("/api/v1/work-orders", append(write, createWOH())...) + app.Get("/api/v1/work-orders/:id", append(ro, getWODetailH())...) + app.Put("/api/v1/work-orders/:id", append(write, updateWOH())...) + app.Post("/api/v1/work-orders/:id/transition", append(write, transitionWOH())...) + + app.Post("/api/v1/work-orders/:id/items", append(write, addItemH())...) + app.Delete("/api/v1/work-orders/:id/items/:itemId", append(write, removeItemH())...) + + app.Post("/api/v1/work-orders/:id/staff-hours", append(write, addStaffHoursH())...) + app.Delete("/api/v1/work-orders/:id/staff-hours/:shId", append(write, removeStaffHoursH())...) +} + +func listWOsH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + list, err := ListWorkOrders(c.Context(), conn, c.Query("status")) + if err != nil { + return fiber.NewError(500, "erro ao listar ordens") + } + if list == nil { + list = []*WorkOrder{} + } + return c.JSON(fiber.Map{"data": list, "error": nil}) + } +} + +type woBody struct { + ClientID string `json:"client_id"` + VehicleID string `json:"vehicle_id"` + InternalNotes string `json:"internal_notes"` + ClientNotes string `json:"client_notes"` +} + +func createWOH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b woBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + claims, _ := c.Locals("claims").(*auth.Claims) + createdBy := "" + if claims != nil { + createdBy = claims.UserID + } + conn := auth.GetConn(c) + wo, err := CreateWorkOrder(c.Context(), conn, b.ClientID, b.VehicleID, b.InternalNotes, createdBy) + if err != nil { + return fiber.NewError(500, "erro ao criar ordem") + } + return c.Status(201).JSON(fiber.Map{"data": wo, "error": nil}) + } +} + +func getWODetailH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + detail, err := GetWorkOrderDetail(c.Context(), conn, c.Params("id")) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fiber.NewError(404, "ordem não encontrada") + } + return fiber.NewError(500, "erro ao obter ordem") + } + return c.JSON(fiber.Map{"data": detail, "error": nil}) + } +} + +func updateWOH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b woBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + conn := auth.GetConn(c) + wo, err := UpdateWorkOrder(c.Context(), conn, c.Params("id"), b.ClientID, b.VehicleID, b.InternalNotes, b.ClientNotes) + if err != nil { + if errors.Is(err, pgx.ErrNoRows) { + return fiber.NewError(404, "ordem não encontrada") + } + return fiber.NewError(500, "erro ao actualizar ordem") + } + return c.JSON(fiber.Map{"data": wo, "error": nil}) + } +} + +func transitionWOH() fiber.Handler { + return func(c *fiber.Ctx) error { + var body struct { + Status string `json:"status"` + } + if err := c.BodyParser(&body); err != nil || body.Status == "" { + return fiber.NewError(400, "status é obrigatório") + } + claims, _ := c.Locals("claims").(*auth.Claims) + changedBy := "" + if claims != nil { + changedBy = claims.UserID + } + conn := auth.GetConn(c) + wo, err := TransitionStatus(c.Context(), conn, c.Params("id"), body.Status, changedBy) + if err != nil { + return fiber.NewError(400, err.Error()) + } + return c.JSON(fiber.Map{"data": wo, "error": nil}) + } +} + +type woItemBody struct { + CatalogItemID string `json:"catalog_item_id"` + Description string `json:"description"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + DiscountPct float64 `json:"discount_pct"` +} + +func addItemH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b woItemBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + if b.Description == "" || b.Qty <= 0 || b.UnitPrice < 0 { + return fiber.NewError(400, "descrição, quantidade e preço unitário são obrigatórios") + } + conn := auth.GetConn(c) + item, err := AddItem(c.Context(), conn, c.Params("id"), b.CatalogItemID, b.Description, b.Qty, b.UnitPrice, b.DiscountPct) + if err != nil { + return fiber.NewError(500, "erro ao adicionar item") + } + return c.Status(201).JSON(fiber.Map{"data": item, "error": nil}) + } +} + +func removeItemH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + if err := RemoveItem(c.Context(), conn, c.Params("id"), c.Params("itemId")); err != nil { + return fiber.NewError(500, "erro ao remover item") + } + return c.SendStatus(204) + } +} + +type staffHoursBody struct { + StaffID string `json:"staff_id"` + Hours float64 `json:"hours"` + CostPerHour float64 `json:"cost_per_hour"` +} + +func addStaffHoursH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b staffHoursBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + if b.StaffID == "" || b.Hours <= 0 { + return fiber.NewError(400, "funcionário e horas são obrigatórios") + } + conn := auth.GetConn(c) + sh, err := AddStaffHours(c.Context(), conn, c.Params("id"), b.StaffID, b.Hours, b.CostPerHour) + if err != nil { + return fiber.NewError(500, "erro ao adicionar horas") + } + return c.Status(201).JSON(fiber.Map{"data": sh, "error": nil}) + } +} + +func removeStaffHoursH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + if err := RemoveStaffHours(c.Context(), conn, c.Params("id"), c.Params("shId")); err != nil { + return fiber.NewError(500, "erro ao remover horas") + } + return c.SendStatus(204) + } +} diff --git a/backend/internal/workorder/repository.go b/backend/internal/workorder/repository.go new file mode 100644 index 0000000..40ef88c --- /dev/null +++ b/backend/internal/workorder/repository.go @@ -0,0 +1,236 @@ +package workorder + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type WorkOrder struct { + ID string `json:"id"` + Number int `json:"number"` + ClientID *string `json:"client_id"` + VehicleID *string `json:"vehicle_id"` + Status string `json:"status"` + InternalNotes string `json:"internal_notes"` + ClientNotes string `json:"client_notes"` + CreatedBy *string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type WOItem struct { + ID string `json:"id"` + WorkOrderID string `json:"work_order_id"` + CatalogItemID *string `json:"catalog_item_id"` + Description string `json:"description"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + DiscountPct float64 `json:"discount_pct"` + Total float64 `json:"total"` +} + +type WOStaffHours struct { + ID string `json:"id"` + WorkOrderID string `json:"work_order_id"` + StaffID string `json:"staff_id"` + Hours float64 `json:"hours"` + CostPerHour float64 `json:"cost_per_hour"` + Total float64 `json:"total"` +} + +type WorkOrderDetail struct { + WorkOrder + Items []*WOItem `json:"items"` + StaffHours []*WOStaffHours `json:"staff_hours"` +} + +var allowedTransitions = map[string][]string{ + "open": {"in_progress", "cancelled"}, + "in_progress": {"completed", "cancelled"}, + "completed": {"invoiced", "cancelled"}, + "invoiced": {}, + "cancelled": {}, +} + +func ValidateTransition(from, to string) error { + nexts, ok := allowedTransitions[from] + if !ok { + return errors.New("estado desconhecido") + } + for _, n := range nexts { + if n == to { + return nil + } + } + return errors.New("transição inválida") +} + +func ListWorkOrders(ctx context.Context, conn *pgxpool.Conn, status string) ([]*WorkOrder, error) { + q := `SELECT id, number, client_id, vehicle_id, status, + COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at + FROM work_orders` + args := []any{} + if status != "" { + q += " WHERE status = $1" + args = append(args, status) + } + q += " ORDER BY created_at DESC" + rows, err := conn.Query(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var list []*WorkOrder + for rows.Next() { + var wo WorkOrder + if err := rows.Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, + &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt); err != nil { + return nil, err + } + list = append(list, &wo) + } + return list, rows.Err() +} + +func CreateWorkOrder(ctx context.Context, conn *pgxpool.Conn, clientID, vehicleID, internalNotes, createdBy string) (*WorkOrder, error) { + var wo WorkOrder + err := conn.QueryRow(ctx, ` + INSERT INTO work_orders (client_id, vehicle_id, internal_notes, created_by) + VALUES ( + NULLIF($1,'')::uuid, + NULLIF($2,'')::uuid, + NULLIF($3,''), + (SELECT id FROM users WHERE id = NULLIF($4,'')::uuid LIMIT 1) + ) + RETURNING id, number, client_id, vehicle_id, status, + COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`, + clientID, vehicleID, internalNotes, createdBy). + Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, + &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) + return &wo, err +} + +func UpdateWorkOrder(ctx context.Context, conn *pgxpool.Conn, id, clientID, vehicleID, internalNotes, clientNotes string) (*WorkOrder, error) { + var wo WorkOrder + err := conn.QueryRow(ctx, ` + UPDATE work_orders SET client_id=NULLIF($2,'')::uuid, vehicle_id=NULLIF($3,'')::uuid, + internal_notes=NULLIF($4,''), client_notes=NULLIF($5,''), updated_at=NOW() + WHERE id=$1 + RETURNING id, number, client_id, vehicle_id, status, + COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`, + id, clientID, vehicleID, internalNotes, clientNotes). + Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, + &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) + return &wo, err +} + +func TransitionStatus(ctx context.Context, conn *pgxpool.Conn, id, toStatus, changedBy string) (*WorkOrder, error) { + var fromStatus string + if err := conn.QueryRow(ctx, `SELECT status FROM work_orders WHERE id=$1`, id).Scan(&fromStatus); err != nil { + return nil, errors.New("ordem não encontrada") + } + if err := ValidateTransition(fromStatus, toStatus); err != nil { + return nil, err + } + var wo WorkOrder + err := conn.QueryRow(ctx, ` + UPDATE work_orders SET status=$2, updated_at=NOW() WHERE id=$1 + RETURNING id, number, client_id, vehicle_id, status, + COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`, + id, toStatus). + Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, + &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) + if err != nil { + return nil, err + } + _, _ = conn.Exec(ctx, ` + INSERT INTO wo_status_log (work_order_id, from_status, to_status, changed_by) + VALUES ($1, $2, $3, (SELECT id FROM users WHERE id = NULLIF($4,'')::uuid LIMIT 1))`, + id, fromStatus, toStatus, changedBy) + return &wo, nil +} + +func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*WorkOrderDetail, error) { + var wo WorkOrder + err := conn.QueryRow(ctx, ` + SELECT id, number, client_id, vehicle_id, status, + COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at + FROM work_orders WHERE id=$1`, id). + Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, + &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) + if err != nil { + return nil, err + } + + detail := &WorkOrderDetail{WorkOrder: wo, Items: []*WOItem{}, StaffHours: []*WOStaffHours{}} + + rows, err := conn.Query(ctx, ` + SELECT id, work_order_id, catalog_item_id, description, qty, unit_price, discount_pct, total + FROM wo_items WHERE work_order_id=$1`, id) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var i WOItem + if err := rows.Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, + &i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total); err != nil { + return nil, err + } + detail.Items = append(detail.Items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + + shRows, err := conn.Query(ctx, ` + SELECT id, work_order_id, staff_id, hours, cost_per_hour, total + FROM wo_staff_hours WHERE work_order_id=$1`, id) + if err != nil { + return nil, err + } + defer shRows.Close() + for shRows.Next() { + var sh WOStaffHours + if err := shRows.Scan(&sh.ID, &sh.WorkOrderID, &sh.StaffID, &sh.Hours, &sh.CostPerHour, &sh.Total); err != nil { + return nil, err + } + detail.StaffHours = append(detail.StaffHours, &sh) + } + return detail, shRows.Err() +} + +func AddItem(ctx context.Context, conn *pgxpool.Conn, woID, catalogItemID, description string, qty, unitPrice, discountPct float64) (*WOItem, error) { + var i WOItem + err := conn.QueryRow(ctx, ` + INSERT INTO wo_items (work_order_id, catalog_item_id, description, qty, unit_price, discount_pct) + VALUES ($1, NULLIF($2,'')::uuid, $3, $4, $5, $6) + RETURNING id, work_order_id, catalog_item_id, description, qty, unit_price, discount_pct, total`, + woID, catalogItemID, description, qty, unitPrice, discountPct). + Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, &i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total) + return &i, err +} + +func RemoveItem(ctx context.Context, conn *pgxpool.Conn, woID, itemID string) error { + _, err := conn.Exec(ctx, `DELETE FROM wo_items WHERE id=$1 AND work_order_id=$2`, itemID, woID) + return err +} + +func AddStaffHours(ctx context.Context, conn *pgxpool.Conn, woID, staffID string, hours, costPerHour float64) (*WOStaffHours, error) { + var sh WOStaffHours + err := conn.QueryRow(ctx, ` + INSERT INTO wo_staff_hours (work_order_id, staff_id, hours, cost_per_hour) + VALUES ($1, $2, $3, $4) + RETURNING id, work_order_id, staff_id, hours, cost_per_hour, total`, + woID, staffID, hours, costPerHour). + Scan(&sh.ID, &sh.WorkOrderID, &sh.StaffID, &sh.Hours, &sh.CostPerHour, &sh.Total) + return &sh, err +} + +func RemoveStaffHours(ctx context.Context, conn *pgxpool.Conn, woID, shID string) error { + _, err := conn.Exec(ctx, `DELETE FROM wo_staff_hours WHERE id=$1 AND work_order_id=$2`, shID, woID) + return err +} diff --git a/backend/internal/workorder/repository_test.go b/backend/internal/workorder/repository_test.go new file mode 100644 index 0000000..68d5235 --- /dev/null +++ b/backend/internal/workorder/repository_test.go @@ -0,0 +1,90 @@ +package workorder_test + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/techxcar/backend/internal/workorder" +) + +func getTestConn(t *testing.T) *pgxpool.Conn { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + pool, err := pgxpool.New(context.Background(), dsn) + require.NoError(t, err) + t.Cleanup(func() { pool.Close() }) + conn, err := pool.Acquire(context.Background()) + require.NoError(t, err) + t.Cleanup(func() { conn.Release() }) + _, err = conn.Exec(context.Background(), "SET search_path = tenant_test, public") + require.NoError(t, err) + return conn +} + +func TestWorkOrderCRUD(t *testing.T) { + conn := getTestConn(t) + ctx := context.Background() + + wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "") + require.NoError(t, err) + assert.NotEmpty(t, wo.ID) + assert.Equal(t, "open", wo.Status) + + list, err := workorder.ListWorkOrders(ctx, conn, "") + require.NoError(t, err) + assert.GreaterOrEqual(t, len(list), 1) + + detail, err := workorder.GetWorkOrderDetail(ctx, conn, wo.ID) + require.NoError(t, err) + assert.Equal(t, wo.ID, detail.ID) + assert.Empty(t, detail.Items) + assert.Empty(t, detail.StaffHours) +} + +func TestWorkOrderTransition(t *testing.T) { + conn := getTestConn(t) + ctx := context.Background() + + wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "") + require.NoError(t, err) + + wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "in_progress", "") + require.NoError(t, err) + assert.Equal(t, "in_progress", wo2.Status) + + _, err = workorder.TransitionStatus(ctx, conn, wo.ID, "cancelled_invalid", "") + assert.Error(t, err, "invalid transition should error") +} + +func TestAllowedTransitions(t *testing.T) { + tests := []struct { + from string + to string + valid bool + }{ + {"open", "in_progress", true}, + {"open", "cancelled", true}, + {"open", "completed", false}, + {"in_progress", "completed", true}, + {"in_progress", "cancelled", true}, + {"in_progress", "open", false}, + {"completed", "invoiced", true}, + {"completed", "cancelled", true}, + {"invoiced", "cancelled", false}, + } + for _, tt := range tests { + err := workorder.ValidateTransition(tt.from, tt.to) + if tt.valid { + assert.NoError(t, err, "%s->%s should be valid", tt.from, tt.to) + } else { + assert.Error(t, err, "%s->%s should be invalid", tt.from, tt.to) + } + } +} diff --git a/backend/migrations/public/000001_create_public_schema.down.sql b/backend/migrations/public/000001_create_public_schema.down.sql new file mode 100644 index 0000000..949d83b --- /dev/null +++ b/backend/migrations/public/000001_create_public_schema.down.sql @@ -0,0 +1,4 @@ +DROP TABLE IF EXISTS platform_settings; +DROP TABLE IF EXISTS super_admins; +DROP TABLE IF EXISTS invites; +DROP TABLE IF EXISTS tenants; diff --git a/backend/migrations/public/000001_create_public_schema.up.sql b/backend/migrations/public/000001_create_public_schema.up.sql new file mode 100644 index 0000000..10065c9 --- /dev/null +++ b/backend/migrations/public/000001_create_public_schema.up.sql @@ -0,0 +1,36 @@ +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'pending')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS invites ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID NOT NULL REFERENCES tenants(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + email TEXT, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS super_admins ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS platform_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token); +CREATE INDEX IF NOT EXISTS idx_invites_tenant_id ON invites(tenant_id); diff --git a/backend/migrations/public/000002_invites_nullable_tenant.down.sql b/backend/migrations/public/000002_invites_nullable_tenant.down.sql new file mode 100644 index 0000000..9764645 --- /dev/null +++ b/backend/migrations/public/000002_invites_nullable_tenant.down.sql @@ -0,0 +1,3 @@ +DROP INDEX IF EXISTS idx_invites_tenant_id; +CREATE INDEX IF NOT EXISTS idx_invites_tenant_id ON invites(tenant_id); +ALTER TABLE invites ALTER COLUMN tenant_id SET NOT NULL; diff --git a/backend/migrations/public/000002_invites_nullable_tenant.up.sql b/backend/migrations/public/000002_invites_nullable_tenant.up.sql new file mode 100644 index 0000000..378e89b --- /dev/null +++ b/backend/migrations/public/000002_invites_nullable_tenant.up.sql @@ -0,0 +1,3 @@ +ALTER TABLE invites ALTER COLUMN tenant_id DROP NOT NULL; +DROP INDEX IF EXISTS idx_invites_tenant_id; +CREATE INDEX IF NOT EXISTS idx_invites_tenant_id ON invites(tenant_id) WHERE tenant_id IS NOT NULL; diff --git a/backend/migrations/tenant/000001_create_tenant_schema.down.sql b/backend/migrations/tenant/000001_create_tenant_schema.down.sql new file mode 100644 index 0000000..555ac73 --- /dev/null +++ b/backend/migrations/tenant/000001_create_tenant_schema.down.sql @@ -0,0 +1,12 @@ +DROP TABLE IF EXISTS tenant_settings; +DROP TABLE IF EXISTS expenses; +DROP TABLE IF EXISTS invoices; +DROP TABLE IF EXISTS wo_status_log; +DROP TABLE IF EXISTS wo_staff_hours; +DROP TABLE IF EXISTS wo_items; +DROP TABLE IF EXISTS work_orders; +DROP TABLE IF EXISTS catalog_items; +DROP TABLE IF EXISTS staff; +DROP TABLE IF EXISTS vehicles; +DROP TABLE IF EXISTS clients; +DROP TABLE IF EXISTS users; diff --git a/backend/migrations/tenant/000001_create_tenant_schema.up.sql b/backend/migrations/tenant/000001_create_tenant_schema.up.sql new file mode 100644 index 0000000..fb5917e --- /dev/null +++ b/backend/migrations/tenant/000001_create_tenant_schema.up.sql @@ -0,0 +1,132 @@ +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('tenant_admin', 'manager', 'technician')), + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS clients ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name TEXT NOT NULL, + nif TEXT, + phone TEXT, + email TEXT, + address TEXT, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS vehicles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + client_id UUID REFERENCES clients(id) ON DELETE SET NULL, + plate TEXT NOT NULL, + brand TEXT NOT NULL, + model TEXT NOT NULL, + year INT, + vin TEXT, + mileage INT, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS staff ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + name TEXT NOT NULL, + email TEXT, + phone TEXT, + type TEXT NOT NULL CHECK (type IN ('internal', 'external')), + hourly_rate NUMERIC(10,2) NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS catalog_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + category TEXT NOT NULL, + unit TEXT NOT NULL CHECK (unit IN ('un', 'hora', 'litro', 'kg')), + base_price NUMERIC(10,2) NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS work_orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + number SERIAL UNIQUE, + client_id UUID REFERENCES clients(id) ON DELETE SET NULL, + vehicle_id UUID REFERENCES vehicles(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'completed', 'invoiced', 'cancelled')), + internal_notes TEXT, + client_notes TEXT, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS wo_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + catalog_item_id UUID REFERENCES catalog_items(id) ON DELETE SET NULL, + description TEXT NOT NULL, + qty NUMERIC(10,3) NOT NULL DEFAULT 1, + unit_price NUMERIC(10,2) NOT NULL, + discount_pct NUMERIC(5,2) NOT NULL DEFAULT 0, + total NUMERIC(10,2) GENERATED ALWAYS AS (qty * unit_price * (1 - discount_pct / 100)) STORED +); + +CREATE TABLE IF NOT EXISTS wo_staff_hours ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT, + hours NUMERIC(6,2) NOT NULL, + cost_per_hour NUMERIC(10,2) NOT NULL, + total NUMERIC(10,2) GENERATED ALWAYS AS (hours * cost_per_hour) STORED +); + +CREATE TABLE IF NOT EXISTS wo_status_log ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + from_status TEXT, + to_status TEXT NOT NULL, + changed_by UUID REFERENCES users(id) ON DELETE SET NULL, + changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE RESTRICT, + type TEXT NOT NULL CHECK (type IN ('quote', 'invoice')), + number SERIAL UNIQUE, + pdf_path TEXT, + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS expenses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + vehicle_id UUID REFERENCES vehicles(id) ON DELETE SET NULL, + type TEXT NOT NULL CHECK (type IN ('fuel', 'parts', 'tools', 'other')), + amount NUMERIC(10,2) NOT NULL, + description TEXT, + date DATE NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tenant_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_work_orders_status ON work_orders(status); +CREATE INDEX IF NOT EXISTS idx_work_orders_client_id ON work_orders(client_id); +CREATE INDEX IF NOT EXISTS idx_vehicles_client_id ON vehicles(client_id); +CREATE INDEX IF NOT EXISTS idx_vehicles_plate ON vehicles(plate); +CREATE INDEX IF NOT EXISTS idx_expenses_date ON expenses(date); diff --git a/backend/migrations/tenant/000002_add_vehicle_fuel_type.down.sql b/backend/migrations/tenant/000002_add_vehicle_fuel_type.down.sql new file mode 100644 index 0000000..85e6bd4 --- /dev/null +++ b/backend/migrations/tenant/000002_add_vehicle_fuel_type.down.sql @@ -0,0 +1 @@ +ALTER TABLE vehicles DROP COLUMN IF EXISTS fuel_type; diff --git a/backend/migrations/tenant/000002_add_vehicle_fuel_type.up.sql b/backend/migrations/tenant/000002_add_vehicle_fuel_type.up.sql new file mode 100644 index 0000000..024889b --- /dev/null +++ b/backend/migrations/tenant/000002_add_vehicle_fuel_type.up.sql @@ -0,0 +1 @@ +ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS fuel_type TEXT; diff --git a/backend/pkg/database/database.go b/backend/pkg/database/database.go new file mode 100644 index 0000000..4600c9d --- /dev/null +++ b/backend/pkg/database/database.go @@ -0,0 +1,185 @@ +package database + +import ( + "context" + "database/sql" + "fmt" + "regexp" + "strings" + "time" + + "github.com/golang-migrate/migrate/v4" + migratepg "github.com/golang-migrate/migrate/v4/database/postgres" + _ "github.com/golang-migrate/migrate/v4/source/file" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" +) + +var validTenantID = regexp.MustCompile(`^[a-zA-Z0-9_-]{1,63}$`) + +type DB struct { + Pool *pgxpool.Pool + dsn string +} + +func New(dsn string) (*DB, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, fmt.Errorf("database: failed to create pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("database: failed to ping: %w", err) + } + + return &DB{Pool: pool, dsn: dsn}, nil +} + +func (db *DB) Ping() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return db.Pool.Ping(ctx) +} + +func (db *DB) Close() { + db.Pool.Close() +} + +func tenantSchema(tenantID string) string { + return fmt.Sprintf("tenant_%s", strings.ReplaceAll(tenantID, "-", "_")) +} + +func (db *DB) SetTenantSchema(ctx context.Context, tenantID string) error { + if !validTenantID.MatchString(tenantID) { + return fmt.Errorf("database: invalid tenantID %q", tenantID) + } + schema := tenantSchema(tenantID) + _, err := db.Pool.Exec(ctx, fmt.Sprintf(`SET search_path = "%s", public`, schema)) + return err +} + +func (db *DB) ResetSchema(ctx context.Context) error { + _, err := db.Pool.Exec(ctx, "SET search_path = public") + return err +} + +func (db *DB) stdDB(dsn string) (*sql.DB, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, err + } + return stdlib.OpenDB(*cfg.ConnConfig), nil +} + +func (db *DB) MigratePublic(dsn, migrationsPath string) error { + stdDB, err := db.stdDB(dsn) + if err != nil { + return fmt.Errorf("migrate: %w", err) + } + defer stdDB.Close() + + driver, err := migratepg.WithInstance(stdDB, &migratepg.Config{SchemaName: "public"}) + if err != nil { + return fmt.Errorf("migrate: driver: %w", err) + } + + m, err := migrate.NewWithDatabaseInstance( + "file://"+migrationsPath, + "postgres", + driver, + ) + if err != nil { + return fmt.Errorf("migrate: %w", err) + } + + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + return fmt.Errorf("migrate: %w", err) + } + return nil +} + +// MigrateTenantSchema ensures the tenant schema exists and runs all pending migrations. +// Safe to call on existing tenants — golang-migrate tracks state in schema_migrations. +// The 000001 migration uses IF NOT EXISTS so re-running it is idempotent. +func (db *DB) MigrateTenantSchema(ctx context.Context, tenantID, migrationsPath string) error { + if !validTenantID.MatchString(tenantID) { + return fmt.Errorf("database: invalid tenantID %q", tenantID) + } + schema := tenantSchema(tenantID) + + // Ensure schema exists before handing off to golang-migrate. + conn, err := db.Pool.Acquire(ctx) + if err != nil { + return fmt.Errorf("migrate tenant: acquire: %w", err) + } + _, execErr := conn.Exec(ctx, fmt.Sprintf(`CREATE SCHEMA IF NOT EXISTS "%s"`, schema)) + conn.Release() + if execErr != nil { + return fmt.Errorf("migrate tenant: create schema: %w", execErr) + } + + // Append search_path to DSN so every connection golang-migrate opens + // automatically resolves unqualified table names to the tenant schema. + tenantDSN := db.dsn + sep := "?" + if strings.Contains(tenantDSN, "?") { + sep = "&" + } + tenantDSN += sep + "search_path=" + schema + + stdDB, err := db.stdDB(tenantDSN) + if err != nil { + return fmt.Errorf("migrate tenant: open stdDB: %w", err) + } + defer stdDB.Close() + + driver, err := migratepg.WithInstance(stdDB, &migratepg.Config{SchemaName: schema}) + if err != nil { + return fmt.Errorf("migrate tenant: driver: %w", err) + } + + m, err := migrate.NewWithDatabaseInstance("file://"+migrationsPath, "postgres", driver) + if err != nil { + return fmt.Errorf("migrate tenant: init: %w", err) + } + + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + return fmt.Errorf("migrate tenant %s: %w", tenantID, err) + } + return nil +} + +// ProvisionTenantSchema is an alias kept for call-site compatibility. +func (db *DB) ProvisionTenantSchema(ctx context.Context, tenantID, migrationsPath string) error { + return db.MigrateTenantSchema(ctx, tenantID, migrationsPath) +} + +// MigrateAllTenantSchemas runs pending migrations against every registered tenant. +// Called at startup so existing tenants always get new migration files applied. +func (db *DB) MigrateAllTenantSchemas(ctx context.Context, migrationsPath string) error { + rows, err := db.Pool.Query(ctx, `SELECT id FROM public.tenants`) + if err != nil { + return fmt.Errorf("migrate all tenants: query: %w", err) + } + defer rows.Close() + + var ids []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + return fmt.Errorf("migrate all tenants: scan: %w", err) + } + ids = append(ids, id) + } + + for _, id := range ids { + if err := db.MigrateTenantSchema(ctx, id, migrationsPath); err != nil { + return err + } + } + return nil +} diff --git a/backend/pkg/database/database_test.go b/backend/pkg/database/database_test.go new file mode 100644 index 0000000..f316e54 --- /dev/null +++ b/backend/pkg/database/database_test.go @@ -0,0 +1,29 @@ +package database_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/pkg/database" +) + +func TestNew_invalidURL(t *testing.T) { + _, err := database.New("not-a-valid-url") + assert.Error(t, err) +} + +func TestNew_valid(t *testing.T) { + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("TEST_DATABASE_URL not set, skipping integration test") + } + + db, err := database.New(url) + require.NoError(t, err) + defer db.Close() + + assert.NoError(t, db.Ping()) +} diff --git a/backend/pkg/pdf/pdf.go b/backend/pkg/pdf/pdf.go new file mode 100644 index 0000000..4753065 --- /dev/null +++ b/backend/pkg/pdf/pdf.go @@ -0,0 +1,135 @@ +package pdf + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/go-pdf/fpdf" +) + +type DocMeta struct { + CompanyName string + CompanyNIF string + CompanyAddress string + CompanyIBAN string + CompanyPhone string + CompanyEmail string + DocType string // "Fatura" or "Orçamento" + DocNumber string // e.g. "FAT/2026/001" + IssuedAt string // e.g. "30/06/2026" + ClientName string + ClientNIF string + VehiclePlate string +} + +type LineItem struct { + Description string + Qty float64 + UnitPrice float64 + DiscountPct float64 + Total float64 +} + +func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string) error { + if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { + return fmt.Errorf("pdf: mkdir: %w", err) + } + + f := fpdf.New("P", "mm", "A4", "") + f.AddPage() + f.SetMargins(15, 15, 15) + + // Header + f.SetFont("Helvetica", "B", 18) + f.CellFormat(120, 10, meta.CompanyName, "", 0, "L", false, 0, "") + f.SetFont("Helvetica", "B", 14) + f.CellFormat(60, 10, meta.DocType, "", 1, "R", false, 0, "") + + f.SetFont("Helvetica", "", 9) + if meta.CompanyNIF != "" { + f.CellFormat(120, 5, "NIF: "+meta.CompanyNIF, "", 0, "L", false, 0, "") + } else { + f.CellFormat(120, 5, "", "", 0, "L", false, 0, "") + } + f.SetFont("Helvetica", "", 11) + f.CellFormat(60, 5, meta.DocNumber, "", 1, "R", false, 0, "") + + f.SetFont("Helvetica", "", 9) + if meta.CompanyAddress != "" { + f.MultiCell(120, 5, meta.CompanyAddress, "", "L", false) + } + f.Ln(3) + curY := f.GetY() + f.SetXY(135, curY-3) + f.CellFormat(60, 5, "Data: "+meta.IssuedAt, "", 1, "R", false, 0, "") + f.SetY(curY + 3) + f.Ln(3) + + // Client block + if meta.ClientName != "" { + f.SetFont("Helvetica", "B", 9) + f.CellFormat(180, 5, "Cliente", "", 1, "L", false, 0, "") + f.SetFont("Helvetica", "", 9) + f.CellFormat(180, 5, meta.ClientName, "", 1, "L", false, 0, "") + if meta.ClientNIF != "" { + f.CellFormat(180, 5, "NIF: "+meta.ClientNIF, "", 1, "L", false, 0, "") + } + if meta.VehiclePlate != "" { + f.CellFormat(180, 5, "Matrícula: "+meta.VehiclePlate, "", 1, "L", false, 0, "") + } + f.Ln(4) + } + + // Table header + f.SetFillColor(50, 50, 50) + f.SetTextColor(255, 255, 255) + f.SetFont("Helvetica", "B", 9) + f.CellFormat(90, 7, "Descricao", "1", 0, "L", true, 0, "") + f.CellFormat(20, 7, "Qtd.", "1", 0, "C", true, 0, "") + f.CellFormat(25, 7, "Preco Unit.", "1", 0, "R", true, 0, "") + f.CellFormat(20, 7, "Desc.%", "1", 0, "C", true, 0, "") + f.CellFormat(25, 7, "Total", "1", 1, "R", true, 0, "") + + // Table rows + f.SetFillColor(245, 245, 245) + f.SetTextColor(0, 0, 0) + f.SetFont("Helvetica", "", 9) + fill := false + for _, item := range items { + f.CellFormat(90, 6, item.Description, "1", 0, "L", fill, 0, "") + f.CellFormat(20, 6, fmt.Sprintf("%.2f", item.Qty), "1", 0, "C", fill, 0, "") + f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", item.UnitPrice), "1", 0, "R", fill, 0, "") + f.CellFormat(20, 6, fmt.Sprintf("%.0f%%", item.DiscountPct), "1", 0, "C", fill, 0, "") + f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", item.Total), "1", 1, "R", fill, 0, "") + fill = !fill + } + + // Totals + var subtotal float64 + for _, item := range items { + subtotal += item.Total + } + grandTotal := subtotal + staffTotal + + f.Ln(3) + f.SetFont("Helvetica", "", 9) + if staffTotal > 0 { + f.CellFormat(155, 6, "Subtotal pecas/servicos", "", 0, "R", false, 0, "") + f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", subtotal), "1", 1, "R", false, 0, "") + f.CellFormat(155, 6, "Mao de obra", "", 0, "R", false, 0, "") + f.CellFormat(25, 6, fmt.Sprintf("%.2f EUR", staffTotal), "1", 1, "R", false, 0, "") + } + f.SetFont("Helvetica", "B", 10) + f.CellFormat(155, 7, "TOTAL", "", 0, "R", false, 0, "") + f.CellFormat(25, 7, fmt.Sprintf("%.2f EUR", grandTotal), "1", 1, "R", false, 0, "") + + // IBAN footer + if meta.CompanyIBAN != "" { + f.Ln(8) + f.SetFont("Helvetica", "", 8) + f.CellFormat(180, 5, "IBAN: "+meta.CompanyIBAN, "", 1, "C", false, 0, "") + } + + return f.OutputFileAndClose(outPath) +} diff --git a/backend/pkg/redis/redis.go b/backend/pkg/redis/redis.go new file mode 100644 index 0000000..c96e9d0 --- /dev/null +++ b/backend/pkg/redis/redis.go @@ -0,0 +1,36 @@ +package redis + +import ( + "context" + "fmt" + "time" + + goredis "github.com/redis/go-redis/v9" +) + +type Redis struct { + Client *goredis.Client +} + +func New(redisURL string) (*Redis, error) { + opts, err := goredis.ParseURL(redisURL) + if err != nil { + return nil, fmt.Errorf("redis: invalid URL: %w", err) + } + + client := goredis.NewClient(opts) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := client.Ping(ctx).Err(); err != nil { + client.Close() + return nil, fmt.Errorf("redis: failed to connect: %w", err) + } + + return &Redis{Client: client}, nil +} + +func (r *Redis) Close() error { + return r.Client.Close() +} diff --git a/backend/pkg/redis/redis_test.go b/backend/pkg/redis/redis_test.go new file mode 100644 index 0000000..4995f74 --- /dev/null +++ b/backend/pkg/redis/redis_test.go @@ -0,0 +1,37 @@ +package redis_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + redispkg "github.com/techxcar/backend/pkg/redis" +) + +func TestNew_invalidURL(t *testing.T) { + _, err := redispkg.New("not-a-valid-url") + assert.Error(t, err) +} + +func TestNew_valid(t *testing.T) { + url := os.Getenv("TEST_REDIS_URL") + if url == "" { + t.Skip("TEST_REDIS_URL not set, skipping integration test") + } + + rdb, err := redispkg.New(url) + require.NoError(t, err) + defer rdb.Close() + + ctx := context.Background() + err = rdb.Client.Set(ctx, "test_key", "test_value", time.Second).Err() + assert.NoError(t, err) + + val, err := rdb.Client.Get(ctx, "test_key").Result() + assert.NoError(t, err) + assert.Equal(t, "test_value", val) +} diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..42a94df --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,76 @@ +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - backend-net + + redis: + image: redis:7-alpine + restart: unless-stopped + command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "--no-auth-warning", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + networks: + - backend-net + + backend: + image: ${BACKEND_IMAGE:-techxcar-backend:latest} + restart: unless-stopped + env_file: .env + environment: + DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 + APP_ENV: production + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - pdf_storage:/app/storage + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/v1/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + networks: + - backend-net + - frontend-net + + frontend: + image: ${FRONTEND_IMAGE:-techxcar-frontend:latest} + restart: unless-stopped + depends_on: + backend: + condition: service_healthy + networks: + - frontend-net + +volumes: + postgres_data: + redis_data: + pdf_storage: + +networks: + backend-net: + driver: bridge + frontend-net: + driver: bridge diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..b6c6a01 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,67 @@ +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-techxcar} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-techxcar} + POSTGRES_DB: ${POSTGRES_DB:-techxcar} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-techxcar}"] + interval: 5s + timeout: 5s + retries: 5 + ports: + - "5432:5432" + + redis: + image: redis:7-alpine + command: redis-server --appendonly yes + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + ports: + - "6379:6379" + + backend: + build: + context: ./backend + dockerfile: Dockerfile + env_file: .env + environment: + DATABASE_URL: postgres://${POSTGRES_USER:-techxcar}:${POSTGRES_PASSWORD:-techxcar}@postgres:5432/${POSTGRES_DB:-techxcar}?sslmode=disable + REDIS_URL: redis://redis:6379 + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - pdf_storage:/app/storage + healthcheck: + test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://localhost:8080/api/v1/health"] + interval: 10s + timeout: 5s + retries: 5 + start_period: 10s + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "3000:8080" + depends_on: + - backend + +volumes: + postgres_data: + redis_data: + pdf_storage: diff --git a/docs/superpowers/plans/2026-06-16-plan1-foundation.md b/docs/superpowers/plans/2026-06-16-plan1-foundation.md new file mode 100644 index 0000000..e7355d9 --- /dev/null +++ b/docs/superpowers/plans/2026-06-16-plan1-foundation.md @@ -0,0 +1,2068 @@ +# TechXCar — Plan 1: Foundation & Infrastructure + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Set up the complete project foundation — monorepo structure, Docker Compose with all 4 services, Go backend skeleton with PostgreSQL and Redis connections, database migrations for both public and tenant schemas, a tested health endpoint, and a scaffolded React frontend with routing, Tailwind CSS 4, shadcn/ui primitives, and an auth-aware API client. + +**Architecture:** Monorepo with `backend/` (Go + Fiber) and `frontend/` (React + Vite) directories. PostgreSQL uses a `public` schema for global tables and a per-tenant schema provisioned on signup. Redis handles rate limiting and the async notification job queue. Docker Compose orchestrates all four services (postgres, redis, backend, frontend) for local development and Coolify production. + +**Tech Stack:** Go 1.22, Fiber v2, pgx v5, golang-migrate, go-redis v9, React 19, TypeScript, Vite 5, TanStack Query v5, Zustand v5, React Router v7, Tailwind CSS 4, shadcn/ui, Vitest + +--- + +### Task 1: Monorepo structure + Docker Compose + +**Files:** +- Create: `docker-compose.yml` +- Create: `docker-compose.prod.yml` +- Create: `.env.example` +- Create: `.gitignore` +- Create: `backend/Dockerfile` +- Create: `frontend/Dockerfile` +- Create: `frontend/nginx.conf` + +- [ ] **Step 1: Create root .gitignore** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/.gitignore`: + +``` +# Env +.env +.env.local + +# Go +backend/vendor/ +backend/tmp/ +backend/server + +# Node +frontend/node_modules/ +frontend/dist/ +frontend/.vite/ + +# IDE +.idea/ +.vscode/ +*.swp +``` + +- [ ] **Step 2: Create .env.example** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/.env.example`: + +```env +# Database +POSTGRES_USER=techxcar +POSTGRES_PASSWORD=changeme +POSTGRES_DB=techxcar + +# Backend +JWT_SECRET=your-super-secret-key-minimum-32-characters +PORT=8080 +APP_ENV=development + +# SMTP, Telegram and other integration settings are configured +# post-deploy via the Web UI Settings panel (stored in DB, not in .env) +``` + +- [ ] **Step 3: Create docker-compose.yml** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/docker-compose.yml`: + +```yaml +services: + postgres: + image: postgres:16-alpine + environment: + POSTGRES_USER: ${POSTGRES_USER:-techxcar} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-techxcar} + POSTGRES_DB: ${POSTGRES_DB:-techxcar} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-techxcar}"] + interval: 5s + timeout: 5s + retries: 5 + ports: + - "5432:5432" + + redis: + image: redis:7-alpine + command: redis-server --appendonly yes + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "ping"] + interval: 5s + timeout: 5s + retries: 5 + ports: + - "6379:6379" + + backend: + build: + context: ./backend + dockerfile: Dockerfile + env_file: .env + environment: + DATABASE_URL: postgres://${POSTGRES_USER:-techxcar}:${POSTGRES_PASSWORD:-techxcar}@postgres:5432/${POSTGRES_DB:-techxcar}?sslmode=disable + REDIS_URL: redis://redis:6379 + ports: + - "8080:8080" + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - pdf_storage:/app/storage + + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + ports: + - "3000:80" + depends_on: + - backend + +volumes: + postgres_data: + redis_data: + pdf_storage: +``` + +- [ ] **Step 4: Create docker-compose.prod.yml** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/docker-compose.prod.yml`: + +```yaml +services: + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + POSTGRES_DB: ${POSTGRES_DB} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER}"] + interval: 10s + timeout: 5s + retries: 5 + + redis: + image: redis:7-alpine + restart: unless-stopped + command: redis-server --appendonly yes --requirepass ${REDIS_PASSWORD} + volumes: + - redis_data:/data + healthcheck: + test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD}", "ping"] + interval: 10s + timeout: 5s + retries: 5 + + backend: + image: ${BACKEND_IMAGE:-techxcar-backend:latest} + restart: unless-stopped + env_file: .env + environment: + DATABASE_URL: postgres://${POSTGRES_USER}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB}?sslmode=disable + REDIS_URL: redis://:${REDIS_PASSWORD}@redis:6379 + APP_ENV: production + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + volumes: + - pdf_storage:/app/storage + + frontend: + image: ${FRONTEND_IMAGE:-techxcar-frontend:latest} + restart: unless-stopped + depends_on: + - backend + +volumes: + postgres_data: + redis_data: + pdf_storage: +``` + +- [ ] **Step 5: Create backend Dockerfile** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/Dockerfile`: + +```dockerfile +FROM golang:1.22-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server + +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates tzdata +WORKDIR /app +COPY --from=builder /app/server . +COPY --from=builder /app/migrations ./migrations +EXPOSE 8080 +CMD ["./server"] +``` + +- [ ] **Step 6: Create frontend Dockerfile** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/Dockerfile`: + +```dockerfile +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginx:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +EXPOSE 80 +CMD ["nginx", "-g", "daemon off;"] +``` + +- [ ] **Step 7: Create frontend nginx.conf** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/nginx.conf`: + +```nginx +server { + listen 80; + root /usr/share/nginx/html; + index index.html; + + location /api/ { + proxy_pass http://backend:8080; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + } + + location / { + try_files $uri $uri/ /index.html; + } +} +``` + +- [ ] **Step 8: Copy .env.example to .env** + +```bash +cp /var/home/lmilani/Documentos/IDE/techxcar/.env.example /var/home/lmilani/Documentos/IDE/techxcar/.env +``` + +- [ ] **Step 9: Initialise git and commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git init +git add . +git commit -m "chore: initial monorepo structure with Docker Compose" +``` + +Expected: commit success + +--- + +### Task 2: Go module setup + config + +**Files:** +- Create: `backend/go.mod` +- Create: `backend/internal/config/config.go` +- Create: `backend/internal/config/config_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/internal/config/config_test.go`: + +```go +package config_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/internal/config" +) + +func TestLoad_defaults(t *testing.T) { + os.Setenv("DATABASE_URL", "postgres://test:test@localhost/test") + os.Setenv("REDIS_URL", "redis://localhost:6379") + os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-ok!") + defer func() { + os.Unsetenv("DATABASE_URL") + os.Unsetenv("REDIS_URL") + os.Unsetenv("JWT_SECRET") + }() + + cfg, err := config.Load() + require.NoError(t, err) + + assert.Equal(t, "8080", cfg.Port) + assert.Equal(t, "development", cfg.AppEnv) + assert.Equal(t, "postgres://test:test@localhost/test", cfg.DatabaseURL) + assert.Equal(t, "redis://localhost:6379", cfg.RedisURL) +} + +func TestLoad_missingDatabaseURL(t *testing.T) { + os.Unsetenv("DATABASE_URL") + os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-ok!") + defer os.Unsetenv("JWT_SECRET") + + _, err := config.Load() + assert.ErrorContains(t, err, "DATABASE_URL") +} + +func TestLoad_missingJWTSecret(t *testing.T) { + os.Setenv("DATABASE_URL", "postgres://test:test@localhost/test") + os.Unsetenv("JWT_SECRET") + defer os.Unsetenv("DATABASE_URL") + + _, err := config.Load() + assert.ErrorContains(t, err, "JWT_SECRET") +} + +func TestLoad_customPort(t *testing.T) { + os.Setenv("DATABASE_URL", "postgres://test:test@localhost/test") + os.Setenv("REDIS_URL", "redis://localhost:6379") + os.Setenv("JWT_SECRET", "test-secret-32-chars-minimum-ok!") + os.Setenv("PORT", "9090") + defer func() { + os.Unsetenv("DATABASE_URL") + os.Unsetenv("REDIS_URL") + os.Unsetenv("JWT_SECRET") + os.Unsetenv("PORT") + }() + + cfg, err := config.Load() + require.NoError(t, err) + assert.Equal(t, "9090", cfg.Port) +} +``` + +- [ ] **Step 2: Initialise Go module and install dependencies** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go mod init github.com/techxcar/backend +go get github.com/gofiber/fiber/v2@v2.52.5 +go get github.com/gofiber/fiber/v2/middleware/cors +go get github.com/gofiber/fiber/v2/middleware/helmet +go get github.com/gofiber/fiber/v2/middleware/logger +go get github.com/gofiber/fiber/v2/middleware/recover +go get github.com/golang-migrate/migrate/v4@v4.17.1 +go get github.com/golang-migrate/migrate/v4/database/postgres +go get github.com/golang-migrate/migrate/v4/source/file +go get github.com/jackc/pgx/v5@v5.6.0 +go get github.com/jackc/pgx/v5/stdlib +go get github.com/redis/go-redis/v9@v9.5.1 +go get github.com/golang-jwt/jwt/v5@v5.2.1 +go get golang.org/x/crypto@v0.24.0 +go get github.com/google/uuid@v1.6.0 +go get github.com/joho/godotenv@v1.5.1 +go get github.com/stretchr/testify@v1.9.0 +``` + +- [ ] **Step 3: Run test to verify it fails** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./internal/config/... -v +``` + +Expected: FAIL — `package github.com/techxcar/backend/internal/config: cannot find package` + +- [ ] **Step 4: Implement config** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/internal/config/config.go`: + +```go +package config + +import ( + "errors" + "os" +) + +type Config struct { + DatabaseURL string + RedisURL string + JWTSecret string + Port string + AppEnv string +} + +func Load() (*Config, error) { + dbURL := os.Getenv("DATABASE_URL") + if dbURL == "" { + return nil, errors.New("DATABASE_URL is required") + } + + jwtSecret := os.Getenv("JWT_SECRET") + if jwtSecret == "" { + return nil, errors.New("JWT_SECRET is required") + } + + redisURL := os.Getenv("REDIS_URL") + if redisURL == "" { + redisURL = "redis://localhost:6379" + } + + port := os.Getenv("PORT") + if port == "" { + port = "8080" + } + + appEnv := os.Getenv("APP_ENV") + if appEnv == "" { + appEnv = "development" + } + + return &Config{ + DatabaseURL: dbURL, + RedisURL: redisURL, + JWTSecret: jwtSecret, + Port: port, + AppEnv: appEnv, + }, nil +} +``` + +- [ ] **Step 5: Run tests to verify they pass** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./internal/config/... -v +``` + +Expected: +``` +=== RUN TestLoad_defaults +--- PASS: TestLoad_defaults +=== RUN TestLoad_missingDatabaseURL +--- PASS: TestLoad_missingDatabaseURL +=== RUN TestLoad_missingJWTSecret +--- PASS: TestLoad_missingJWTSecret +=== RUN TestLoad_customPort +--- PASS: TestLoad_customPort +PASS +``` + +- [ ] **Step 6: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add backend/ +git commit -m "feat: Go module setup with typed config loading" +``` + +--- + +### Task 3: PostgreSQL package + migrations + +**Files:** +- Create: `backend/pkg/database/database.go` +- Create: `backend/pkg/database/database_test.go` +- Create: `backend/migrations/public/000001_create_public_schema.up.sql` +- Create: `backend/migrations/public/000001_create_public_schema.down.sql` +- Create: `backend/migrations/tenant/000001_create_tenant_schema.up.sql` +- Create: `backend/migrations/tenant/000001_create_tenant_schema.down.sql` + +- [ ] **Step 1: Write the failing test** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/pkg/database/database_test.go`: + +```go +package database_test + +import ( + "os" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/pkg/database" +) + +func TestNew_invalidURL(t *testing.T) { + _, err := database.New("not-a-valid-url") + assert.Error(t, err) +} + +func TestNew_valid(t *testing.T) { + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("TEST_DATABASE_URL not set, skipping integration test") + } + + db, err := database.New(url) + require.NoError(t, err) + defer db.Close() + + assert.NoError(t, db.Ping()) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./pkg/database/... -v +``` + +Expected: FAIL — `package github.com/techxcar/backend/pkg/database: cannot find package` + +- [ ] **Step 3: Implement database package** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/pkg/database/database.go`: + +```go +package database + +import ( + "context" + "database/sql" + "fmt" + "time" + + "github.com/golang-migrate/migrate/v4" + migratepg "github.com/golang-migrate/migrate/v4/database/postgres" + _ "github.com/golang-migrate/migrate/v4/source/file" + "github.com/jackc/pgx/v5/pgxpool" + "github.com/jackc/pgx/v5/stdlib" +) + +type DB struct { + Pool *pgxpool.Pool +} + +func New(dsn string) (*DB, error) { + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + + pool, err := pgxpool.New(ctx, dsn) + if err != nil { + return nil, fmt.Errorf("database: failed to create pool: %w", err) + } + + if err := pool.Ping(ctx); err != nil { + pool.Close() + return nil, fmt.Errorf("database: failed to ping: %w", err) + } + + return &DB{Pool: pool}, nil +} + +func (db *DB) Ping() error { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + return db.Pool.Ping(ctx) +} + +func (db *DB) Close() { + db.Pool.Close() +} + +func (db *DB) SetTenantSchema(ctx context.Context, tenantID string) error { + schema := fmt.Sprintf("tenant_%s", tenantID) + _, err := db.Pool.Exec(ctx, fmt.Sprintf("SET search_path = %s, public", schema)) + return err +} + +func (db *DB) ResetSchema(ctx context.Context) error { + _, err := db.Pool.Exec(ctx, "SET search_path = public") + return err +} + +func (db *DB) stdDB(dsn string) (*sql.DB, error) { + cfg, err := pgxpool.ParseConfig(dsn) + if err != nil { + return nil, err + } + return stdlib.OpenDB(*cfg.ConnConfig), nil +} + +func (db *DB) MigratePublic(dsn, migrationsPath string) error { + stdDB, err := db.stdDB(dsn) + if err != nil { + return fmt.Errorf("migrate: %w", err) + } + defer stdDB.Close() + + m, err := migrate.NewWithDatabaseInstance( + "file://"+migrationsPath, + "postgres", + migratepg.WithInstance(stdDB, &migratepg.Config{SchemaName: "public"}), + ) + if err != nil { + return fmt.Errorf("migrate: %w", err) + } + + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + return fmt.Errorf("migrate: %w", err) + } + return nil +} + +func (db *DB) ProvisionTenantSchema(ctx context.Context, dsn, tenantID, migrationsPath string) error { + schema := fmt.Sprintf("tenant_%s", tenantID) + + if _, err := db.Pool.Exec(ctx, "CREATE SCHEMA IF NOT EXISTS "+schema); err != nil { + return fmt.Errorf("provision: create schema: %w", err) + } + + stdDB, err := db.stdDB(dsn) + if err != nil { + return fmt.Errorf("provision: %w", err) + } + defer stdDB.Close() + + m, err := migrate.NewWithDatabaseInstance( + "file://"+migrationsPath, + "postgres", + migratepg.WithInstance(stdDB, &migratepg.Config{SchemaName: schema}), + ) + if err != nil { + return fmt.Errorf("provision: %w", err) + } + + if err := m.Up(); err != nil && err != migrate.ErrNoChange { + return fmt.Errorf("provision: %w", err) + } + return nil +} +``` + +- [ ] **Step 4: Run tests** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./pkg/database/... -v +``` + +Expected: +``` +=== RUN TestNew_invalidURL +--- PASS: TestNew_invalidURL +=== RUN TestNew_valid +--- SKIP: TestNew_valid (TEST_DATABASE_URL not set) +PASS +``` + +- [ ] **Step 5: Create public schema migration (up)** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/migrations/public/000001_create_public_schema.up.sql`: + +```sql +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS tenants ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + slug TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'suspended', 'pending')), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS invites ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + tenant_id UUID REFERENCES tenants(id) ON DELETE CASCADE, + token TEXT NOT NULL UNIQUE, + email TEXT, + expires_at TIMESTAMPTZ NOT NULL, + used_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS super_admins ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS platform_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_invites_token ON invites(token); +CREATE INDEX IF NOT EXISTS idx_invites_tenant_id ON invites(tenant_id); +``` + +- [ ] **Step 6: Create public schema migration (down)** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/migrations/public/000001_create_public_schema.down.sql`: + +```sql +DROP TABLE IF EXISTS platform_settings; +DROP TABLE IF EXISTS super_admins; +DROP TABLE IF EXISTS invites; +DROP TABLE IF EXISTS tenants; +``` + +- [ ] **Step 7: Create tenant schema migration (up)** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/migrations/tenant/000001_create_tenant_schema.up.sql`: + +```sql +CREATE EXTENSION IF NOT EXISTS "uuid-ossp"; + +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + email TEXT NOT NULL UNIQUE, + password_hash TEXT NOT NULL, + name TEXT NOT NULL, + role TEXT NOT NULL CHECK (role IN ('tenant_admin', 'manager', 'technician')), + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS clients ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + name TEXT NOT NULL, + nif TEXT, + phone TEXT, + email TEXT, + address TEXT, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS vehicles ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + client_id UUID REFERENCES clients(id) ON DELETE SET NULL, + plate TEXT NOT NULL, + brand TEXT NOT NULL, + model TEXT NOT NULL, + year INT, + vin TEXT, + mileage INT, + notes TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS staff ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + name TEXT NOT NULL, + email TEXT, + phone TEXT, + type TEXT NOT NULL CHECK (type IN ('internal', 'external')), + hourly_rate NUMERIC(10,2) NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS catalog_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + category TEXT NOT NULL, + unit TEXT NOT NULL CHECK (unit IN ('un', 'hora', 'litro', 'kg')), + base_price NUMERIC(10,2) NOT NULL DEFAULT 0, + active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS work_orders ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + number SERIAL, + client_id UUID REFERENCES clients(id) ON DELETE SET NULL, + vehicle_id UUID REFERENCES vehicles(id) ON DELETE SET NULL, + status TEXT NOT NULL DEFAULT 'open' CHECK (status IN ('open', 'in_progress', 'completed', 'invoiced', 'cancelled')), + internal_notes TEXT, + client_notes TEXT, + created_by UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS wo_items ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + catalog_item_id UUID REFERENCES catalog_items(id) ON DELETE SET NULL, + description TEXT NOT NULL, + qty NUMERIC(10,3) NOT NULL DEFAULT 1, + unit_price NUMERIC(10,2) NOT NULL, + discount_pct NUMERIC(5,2) NOT NULL DEFAULT 0, + total NUMERIC(10,2) GENERATED ALWAYS AS (qty * unit_price * (1 - discount_pct / 100)) STORED +); + +CREATE TABLE IF NOT EXISTS wo_staff_hours ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + staff_id UUID NOT NULL REFERENCES staff(id) ON DELETE RESTRICT, + hours NUMERIC(6,2) NOT NULL, + cost_per_hour NUMERIC(10,2) NOT NULL, + total NUMERIC(10,2) GENERATED ALWAYS AS (hours * cost_per_hour) STORED +); + +CREATE TABLE IF NOT EXISTS wo_status_log ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE CASCADE, + from_status TEXT, + to_status TEXT NOT NULL, + changed_by UUID REFERENCES users(id) ON DELETE SET NULL, + changed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS invoices ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + work_order_id UUID NOT NULL REFERENCES work_orders(id) ON DELETE RESTRICT, + type TEXT NOT NULL CHECK (type IN ('quote', 'invoice')), + number SERIAL, + pdf_path TEXT, + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS expenses ( + id UUID PRIMARY KEY DEFAULT uuid_generate_v4(), + vehicle_id UUID REFERENCES vehicles(id) ON DELETE SET NULL, + type TEXT NOT NULL CHECK (type IN ('fuel', 'parts', 'tools', 'other')), + amount NUMERIC(10,2) NOT NULL, + description TEXT, + date DATE NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tenant_settings ( + key TEXT PRIMARY KEY, + value TEXT NOT NULL, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_work_orders_status ON work_orders(status); +CREATE INDEX IF NOT EXISTS idx_work_orders_client_id ON work_orders(client_id); +CREATE INDEX IF NOT EXISTS idx_vehicles_client_id ON vehicles(client_id); +CREATE INDEX IF NOT EXISTS idx_vehicles_plate ON vehicles(plate); +CREATE INDEX IF NOT EXISTS idx_expenses_date ON expenses(date); +``` + +- [ ] **Step 8: Create tenant schema migration (down)** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/migrations/tenant/000001_create_tenant_schema.down.sql`: + +```sql +DROP TABLE IF EXISTS tenant_settings; +DROP TABLE IF EXISTS expenses; +DROP TABLE IF EXISTS invoices; +DROP TABLE IF EXISTS wo_status_log; +DROP TABLE IF EXISTS wo_staff_hours; +DROP TABLE IF EXISTS wo_items; +DROP TABLE IF EXISTS work_orders; +DROP TABLE IF EXISTS catalog_items; +DROP TABLE IF EXISTS staff; +DROP TABLE IF EXISTS vehicles; +DROP TABLE IF EXISTS clients; +DROP TABLE IF EXISTS users; +``` + +- [ ] **Step 9: Verify compilation** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go build ./... +``` + +Expected: no output (success) + +- [ ] **Step 10: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add backend/ +git commit -m "feat: database package with PostgreSQL pool, migrations and tenant schema provisioning" +``` + +--- + +### Task 4: Redis package + +**Files:** +- Create: `backend/pkg/redis/redis.go` +- Create: `backend/pkg/redis/redis_test.go` + +- [ ] **Step 1: Write the failing test** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/pkg/redis/redis_test.go`: + +```go +package redis_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + redispkg "github.com/techxcar/backend/pkg/redis" +) + +func TestNew_invalidURL(t *testing.T) { + _, err := redispkg.New("not-a-valid-url") + assert.Error(t, err) +} + +func TestNew_valid(t *testing.T) { + url := os.Getenv("TEST_REDIS_URL") + if url == "" { + t.Skip("TEST_REDIS_URL not set, skipping integration test") + } + + rdb, err := redispkg.New(url) + require.NoError(t, err) + defer rdb.Close() + + ctx := context.Background() + err = rdb.Client.Set(ctx, "test_key", "test_value", time.Second).Err() + assert.NoError(t, err) + + val, err := rdb.Client.Get(ctx, "test_key").Result() + assert.NoError(t, err) + assert.Equal(t, "test_value", val) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./pkg/redis/... -v +``` + +Expected: FAIL — `package github.com/techxcar/backend/pkg/redis: cannot find package` + +- [ ] **Step 3: Implement Redis package** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/pkg/redis/redis.go`: + +```go +package redis + +import ( + "context" + "fmt" + "time" + + goredis "github.com/redis/go-redis/v9" +) + +type Redis struct { + Client *goredis.Client +} + +func New(redisURL string) (*Redis, error) { + opts, err := goredis.ParseURL(redisURL) + if err != nil { + return nil, fmt.Errorf("redis: invalid URL: %w", err) + } + + client := goredis.NewClient(opts) + + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + if err := client.Ping(ctx).Err(); err != nil { + client.Close() + return nil, fmt.Errorf("redis: failed to connect: %w", err) + } + + return &Redis{Client: client}, nil +} + +func (r *Redis) Close() error { + return r.Client.Close() +} +``` + +- [ ] **Step 4: Run tests** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./pkg/redis/... -v +``` + +Expected: +``` +=== RUN TestNew_invalidURL +--- PASS: TestNew_invalidURL +=== RUN TestNew_valid +--- SKIP: TestNew_valid (TEST_REDIS_URL not set) +PASS +``` + +- [ ] **Step 5: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add backend/pkg/redis/ +git commit -m "feat: Redis package with connection and ping" +``` + +--- + +### Task 5: HTTP server + health endpoint + +**Files:** +- Create: `backend/internal/server/server.go` +- Create: `backend/internal/server/health.go` +- Create: `backend/internal/server/health_test.go` +- Create: `backend/cmd/server/main.go` + +- [ ] **Step 1: Write the failing test** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/internal/server/health_test.go`: + +```go +package server_test + +import ( + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/internal/server" +) + +func TestHealthEndpoint_returnsOK(t *testing.T) { + app := fiber.New() + server.RegisterHealthRoutes(app) + + req := httptest.NewRequest("GET", "/api/v1/health", nil) + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 200, resp.StatusCode) + + var body map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.Equal(t, "ok", body["status"]) + assert.NotEmpty(t, body["version"]) +} + +func TestHealthEndpoint_wrongMethod(t *testing.T) { + app := fiber.New() + server.RegisterHealthRoutes(app) + + req := httptest.NewRequest("POST", "/api/v1/health", nil) + resp, err := app.Test(req) + require.NoError(t, err) + + assert.Equal(t, 405, resp.StatusCode) +} +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./internal/server/... -v +``` + +Expected: FAIL — `package github.com/techxcar/backend/internal/server: cannot find package` + +- [ ] **Step 3: Implement health handler** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/internal/server/health.go`: + +```go +package server + +import "github.com/gofiber/fiber/v2" + +const appVersion = "0.1.0" + +func RegisterHealthRoutes(app *fiber.App) { + app.Get("/api/v1/health", handleHealth) +} + +func handleHealth(c *fiber.Ctx) error { + return c.JSON(fiber.Map{ + "status": "ok", + "version": appVersion, + }) +} +``` + +- [ ] **Step 4: Implement server setup** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/internal/server/server.go`: + +```go +package server + +import ( + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/helmet" + "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/gofiber/fiber/v2/middleware/recover" +) + +func New() *fiber.App { + app := fiber.New(fiber.Config{ + AppName: "TechXCar API", + ErrorHandler: errorHandler, + }) + + app.Use(recover.New()) + app.Use(logger.New()) + app.Use(helmet.New()) + app.Use(cors.New(cors.Config{ + AllowOrigins: "*", + AllowHeaders: "Origin, Content-Type, Accept, Authorization", + AllowMethods: "GET, POST, PUT, PATCH, DELETE, OPTIONS", + AllowCredentials: true, + })) + + RegisterHealthRoutes(app) + + return app +} + +func errorHandler(c *fiber.Ctx, err error) error { + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{ + "data": nil, + "error": err.Error(), + }) +} +``` + +- [ ] **Step 5: Run tests** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./internal/server/... -v +``` + +Expected: +``` +=== RUN TestHealthEndpoint_returnsOK +--- PASS: TestHealthEndpoint_returnsOK +=== RUN TestHealthEndpoint_wrongMethod +--- PASS: TestHealthEndpoint_wrongMethod +PASS +``` + +- [ ] **Step 6: Create main.go** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/backend/cmd/server/main.go`: + +```go +package main + +import ( + "log" + + "github.com/joho/godotenv" + + "github.com/techxcar/backend/internal/config" + "github.com/techxcar/backend/internal/server" + "github.com/techxcar/backend/pkg/database" + redispkg "github.com/techxcar/backend/pkg/redis" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Println("No .env file found, reading from environment") + } + + cfg, err := config.Load() + if err != nil { + log.Fatal("Config error:", err) + } + + db, err := database.New(cfg.DatabaseURL) + if err != nil { + log.Fatal("Database error:", err) + } + defer db.Close() + + if err := db.MigratePublic(cfg.DatabaseURL, "migrations/public"); err != nil { + log.Fatal("Migration error:", err) + } + + rdb, err := redispkg.New(cfg.RedisURL) + if err != nil { + log.Fatal("Redis error:", err) + } + defer rdb.Close() + + app := server.New() + + log.Printf("TechXCar API v0.1.0 listening on :%s (env: %s)", cfg.Port, cfg.AppEnv) + log.Fatal(app.Listen(":" + cfg.Port)) +} +``` + +- [ ] **Step 7: Verify full build and tests** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go build ./... +go test ./... -v +``` + +Expected: build success, all tests PASS (integration tests skipped without env vars) + +- [ ] **Step 8: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add backend/ +git commit -m "feat: HTTP server with health endpoint, security middleware and full startup sequence" +``` + +--- + +### Task 6: Frontend scaffold with Tailwind CSS 4 + +**Files:** +- Create: `frontend/` (Vite React TypeScript project) +- Modify: `frontend/vite.config.ts` +- Modify: `frontend/tsconfig.app.json` +- Modify: `frontend/src/index.css` +- Modify: `frontend/src/main.tsx` + +- [ ] **Step 1: Scaffold Vite React TypeScript project** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +npm create vite@latest frontend -- --template react-ts +``` + +- [ ] **Step 2: Install all dependencies** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/frontend +npm install +npm install @tanstack/react-query@^5 zustand@^5 react-router@^7 react-hook-form zod @hookform/resolvers +npm install class-variance-authority clsx tailwind-merge lucide-react +npm install @radix-ui/react-slot @radix-ui/react-dialog @radix-ui/react-dropdown-menu @radix-ui/react-label @radix-ui/react-select @radix-ui/react-separator @radix-ui/react-tooltip +npm install -D tailwindcss@^4 @tailwindcss/vite @types/node +npm install -D vitest @vitest/coverage-v8 jsdom @testing-library/react @testing-library/user-event +``` + +- [ ] **Step 3: Replace vite.config.ts** + +Replace `/var/home/lmilani/Documentos/IDE/techxcar/frontend/vite.config.ts`: + +```ts +import { defineConfig } from 'vite' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import path from 'path' + +export default defineConfig({ + plugins: [ + react(), + tailwindcss(), + ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + }, +}) +``` + +- [ ] **Step 4: Replace tsconfig.app.json** + +Replace `/var/home/lmilani/Documentos/IDE/techxcar/frontend/tsconfig.app.json`: + +```json +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "baseUrl": ".", + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} +``` + +- [ ] **Step 5: Create test setup file** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/test/setup.ts`: + +```ts +import '@testing-library/jest-dom' +``` + +Install jest-dom: + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/frontend +npm install -D @testing-library/jest-dom +``` + +- [ ] **Step 6: Replace index.css** + +Replace `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/index.css`: + +```css +@import "tailwindcss"; + +:root { + --radius: 0.5rem; +} +``` + +- [ ] **Step 7: Add test script to package.json** + +In `/var/home/lmilani/Documentos/IDE/techxcar/frontend/package.json`, add to the `scripts` object: + +```json +"test": "vitest", +"test:run": "vitest run", +"test:coverage": "vitest run --coverage" +``` + +- [ ] **Step 8: Verify build** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/frontend +npm run build +``` + +Expected: `dist/` folder created, no TypeScript errors + +- [ ] **Step 9: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add frontend/ +git commit -m "feat: frontend scaffold with React 19, Vite, TypeScript, Tailwind CSS 4 and Vitest" +``` + +--- + +### Task 7: shadcn/ui foundation components + +**Files:** +- Create: `frontend/src/lib/utils.ts` +- Create: `frontend/src/components/ui/button.tsx` +- Create: `frontend/src/components/ui/input.tsx` +- Create: `frontend/src/components/ui/label.tsx` +- Create: `frontend/src/components/ui/badge.tsx` + +- [ ] **Step 1: Create utils.ts** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/lib/utils.ts`: + +```ts +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} +``` + +- [ ] **Step 2: Create Button component** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/components/ui/button.tsx`: + +```tsx +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-blue-600 text-white shadow hover:bg-blue-700', + destructive: 'bg-red-600 text-white shadow-sm hover:bg-red-700', + outline: 'border border-gray-300 bg-white shadow-sm hover:bg-gray-50 text-gray-900', + secondary: 'bg-gray-100 text-gray-900 shadow-sm hover:bg-gray-200', + ghost: 'hover:bg-gray-100 text-gray-700', + link: 'text-blue-600 underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + return ( + + ) + } +) +Button.displayName = 'Button' + +export { Button, buttonVariants } +``` + +- [ ] **Step 3: Create Input component** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/components/ui/input.tsx`: + +```tsx +import * as React from 'react' +import { cn } from '@/lib/utils' + +export interface InputProps extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = 'Input' + +export { Input } +``` + +- [ ] **Step 4: Create Label component** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/components/ui/label.tsx`: + +```tsx +import * as React from 'react' +import * as LabelPrimitive from '@radix-ui/react-label' +import { cn } from '@/lib/utils' + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } +``` + +- [ ] **Step 5: Create Badge component** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/components/ui/badge.tsx`: + +```tsx +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/utils' + +const badgeVariants = cva( + 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors', + { + variants: { + variant: { + default: 'bg-blue-100 text-blue-800', + secondary: 'bg-gray-100 text-gray-800', + destructive: 'bg-red-100 text-red-800', + success: 'bg-green-100 text-green-800', + warning: 'bg-yellow-100 text-yellow-800', + outline: 'border border-gray-300 text-gray-700', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
+} + +export { Badge, badgeVariants } +``` + +- [ ] **Step 6: Verify build** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/frontend +npm run build +``` + +Expected: build success + +- [ ] **Step 7: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add frontend/src/ +git commit -m "feat: shadcn/ui foundation — Button, Input, Label, Badge" +``` + +--- + +### Task 8: Auth store + API client + +**Files:** +- Create: `frontend/src/store/authStore.ts` +- Create: `frontend/src/store/authStore.test.ts` +- Create: `frontend/src/lib/api.ts` +- Create: `frontend/src/lib/queryClient.ts` + +- [ ] **Step 1: Write the failing test** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/store/authStore.test.ts`: + +```ts +import { describe, it, expect, beforeEach } from 'vitest' +import { useAuthStore } from './authStore' + +describe('authStore', () => { + beforeEach(() => { + useAuthStore.setState({ + user: null, + accessToken: null, + isAuthenticated: false, + }) + }) + + it('starts unauthenticated', () => { + const state = useAuthStore.getState() + expect(state.isAuthenticated).toBe(false) + expect(state.user).toBeNull() + expect(state.accessToken).toBeNull() + }) + + it('setAuth stores user and token', () => { + const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const } + useAuthStore.getState().setAuth(user, 'tok123') + + const state = useAuthStore.getState() + expect(state.isAuthenticated).toBe(true) + expect(state.user).toEqual(user) + expect(state.accessToken).toBe('tok123') + }) + + it('clearAuth resets to unauthenticated', () => { + const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const } + useAuthStore.getState().setAuth(user, 'tok123') + useAuthStore.getState().clearAuth() + + const state = useAuthStore.getState() + expect(state.isAuthenticated).toBe(false) + expect(state.user).toBeNull() + expect(state.accessToken).toBeNull() + }) + + it('updateToken replaces only the token', () => { + const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const } + useAuthStore.getState().setAuth(user, 'old-token') + useAuthStore.getState().updateToken('new-token') + + const state = useAuthStore.getState() + expect(state.accessToken).toBe('new-token') + expect(state.user).toEqual(user) + }) +}) +``` + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/frontend +npm run test:run +``` + +Expected: FAIL — `Cannot find module './authStore'` + +- [ ] **Step 3: Implement auth store** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/store/authStore.ts`: + +```ts +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +export type UserRole = 'super_admin' | 'tenant_admin' | 'manager' | 'technician' + +export interface AuthUser { + id: string + email: string + name: string + role: UserRole + tenantId?: string +} + +interface AuthState { + user: AuthUser | null + accessToken: string | null + isAuthenticated: boolean + setAuth: (user: AuthUser, accessToken: string) => void + clearAuth: () => void + updateToken: (accessToken: string) => void +} + +export const useAuthStore = create()( + persist( + (set) => ({ + user: null, + accessToken: null, + isAuthenticated: false, + setAuth: (user, accessToken) => + set({ user, accessToken, isAuthenticated: true }), + clearAuth: () => + set({ user: null, accessToken: null, isAuthenticated: false }), + updateToken: (accessToken) => + set({ accessToken }), + }), + { + name: 'techxcar-auth', + partialize: (state) => ({ + user: state.user, + accessToken: state.accessToken, + isAuthenticated: state.isAuthenticated, + }), + } + ) +) +``` + +- [ ] **Step 4: Run tests** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/frontend +npm run test:run +``` + +Expected: +``` +✓ authStore > starts unauthenticated +✓ authStore > setAuth stores user and token +✓ authStore > clearAuth resets to unauthenticated +✓ authStore > updateToken replaces only the token +``` + +- [ ] **Step 5: Create API client** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/lib/api.ts`: + +```ts +import { useAuthStore } from '@/store/authStore' + +const BASE_URL = '/api/v1' + +export class ApiError extends Error { + constructor( + public status: number, + message: string + ) { + super(message) + this.name = 'ApiError' + } +} + +async function refreshAccessToken(): Promise { + try { + const res = await fetch(`${BASE_URL}/auth/refresh`, { + method: 'POST', + credentials: 'include', + }) + if (!res.ok) return null + const data = await res.json() + useAuthStore.getState().updateToken(data.data.access_token) + return data.data.access_token + } catch { + return null + } +} + +export async function apiFetch( + path: string, + options: RequestInit = {} +): Promise { + const { accessToken, clearAuth } = useAuthStore.getState() + + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + } + + if (accessToken) { + headers['Authorization'] = `Bearer ${accessToken}` + } + + let res = await fetch(`${BASE_URL}${path}`, { + ...options, + headers, + credentials: 'include', + }) + + if (res.status === 401 && accessToken) { + const newToken = await refreshAccessToken() + if (newToken) { + headers['Authorization'] = `Bearer ${newToken}` + res = await fetch(`${BASE_URL}${path}`, { + ...options, + headers, + credentials: 'include', + }) + } else { + clearAuth() + throw new ApiError(401, 'Sessão expirada. Por favor inicie sessão novamente.') + } + } + + const json = await res.json() + + if (!res.ok) { + throw new ApiError(res.status, json.error ?? 'Erro desconhecido') + } + + return json.data as T +} +``` + +- [ ] **Step 6: Create TanStack Query client** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/lib/queryClient.ts`: + +```ts +import { QueryClient } from '@tanstack/react-query' +import { ApiError } from './api' + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, + retry: (failureCount, error) => { + if (error instanceof ApiError && error.status < 500) return false + return failureCount < 2 + }, + }, + mutations: { + retry: false, + }, + }, +}) +``` + +- [ ] **Step 7: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add frontend/src/ +git commit -m "feat: Zustand auth store with persistence and API client with auto token refresh" +``` + +--- + +### Task 9: Frontend routing skeleton + +**Files:** +- Create: `frontend/src/App.tsx` +- Create: `frontend/src/pages/auth/LoginPage.tsx` +- Create: `frontend/src/pages/app/DashboardPage.tsx` +- Create: `frontend/src/pages/admin/DashboardPage.tsx` +- Create: `frontend/src/components/layout/AppLayout.tsx` +- Create: `frontend/src/components/layout/AdminLayout.tsx` + +- [ ] **Step 1: Create App.tsx with routing and auth guards** + +Replace `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/App.tsx`: + +```tsx +import { BrowserRouter, Routes, Route, Navigate } from 'react-router' +import { QueryClientProvider } from '@tanstack/react-query' +import { queryClient } from '@/lib/queryClient' +import { useAuthStore } from '@/store/authStore' +import type { UserRole } from '@/store/authStore' +import LoginPage from '@/pages/auth/LoginPage' +import AppDashboardPage from '@/pages/app/DashboardPage' +import AdminDashboardPage from '@/pages/admin/DashboardPage' +import AppLayout from '@/components/layout/AppLayout' +import AdminLayout from '@/components/layout/AdminLayout' + +function RequireAuth({ + children, + allowedRoles, +}: { + children: React.ReactNode + allowedRoles: UserRole[] +}) { + const { isAuthenticated, user } = useAuthStore() + if (!isAuthenticated) return + if (user && !allowedRoles.includes(user.role)) return + return <>{children} +} + +export default function App() { + return ( + + + + } /> + + + + + } + > + } /> + + + + + + } + > + } /> + + + } /> + + + + ) +} +``` + +- [ ] **Step 2: Create Login page placeholder** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/pages/auth/LoginPage.tsx`: + +```tsx +export default function LoginPage() { + return ( +
+
+

TechXCar

+

Gestão de Oficina

+

Login — implementado no Plano 2

+
+
+ ) +} +``` + +- [ ] **Step 3: Create App layout with sidebar shell** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/components/layout/AppLayout.tsx`: + +```tsx +import { Outlet } from 'react-router' +import { useAuthStore } from '@/store/authStore' + +export default function AppLayout() { + const { user } = useAuthStore() + + return ( +
+ +
+
+ Oficina +
+
+ +
+
+
+ ) +} +``` + +- [ ] **Step 4: Create Admin layout** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/components/layout/AdminLayout.tsx`: + +```tsx +import { Outlet } from 'react-router' + +export default function AdminLayout() { + return ( +
+ +
+ +
+
+ ) +} +``` + +- [ ] **Step 5: Create App dashboard placeholder** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/pages/app/DashboardPage.tsx`: + +```tsx +export default function DashboardPage() { + return ( +
+

Dashboard

+

Bem-vindo ao TechXCar — implementado no Plano 5

+
+ ) +} +``` + +- [ ] **Step 6: Create Admin dashboard placeholder** + +Create `/var/home/lmilani/Documentos/IDE/techxcar/frontend/src/pages/admin/DashboardPage.tsx`: + +```tsx +export default function AdminDashboardPage() { + return ( +
+

Painel de Administração

+

Gestão da plataforma — implementado no Plano 2

+
+ ) +} +``` + +- [ ] **Step 7: Build to verify no TypeScript errors** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/frontend +npm run build +``` + +Expected: build success, zero TypeScript errors + +- [ ] **Step 8: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add frontend/src/ +git commit -m "feat: React Router v7 routing skeleton with role-based auth guards and layout shells" +``` + +--- + +## O que este plano entrega + +- Monorepo com Docker Compose pronto para Coolify (4 serviços: postgres, redis, backend, frontend) +- Backend Go compilável com config tipado, pool PostgreSQL, conexão Redis e migrações automáticas ao startup +- Schema público (tenants, invites, super_admins, platform_settings) e schema tenant completo (12 tabelas) +- Endpoint `/api/v1/health` testado com Fiber +- Frontend React 19 com Vite, TypeScript, Tailwind CSS 4, shadcn/ui base, auth store com persistência, API client com refresh automático de token e routing protegido por role + +## Planos seguintes + +| Plano | Âmbito | +|---|---| +| **Plano 2** | Auth & Multi-tenancy: login JWT, criação de tenants, sistema de convites, painel super-admin | +| **Plano 3** | Core Workshop: Clientes, Veículos, Catálogo, Ordens de Trabalho (CRUD completo + máquina de estados) | +| **Plano 4** | Faturação, Técnicos & Despesas: Faturas/Orçamentos, geração PDF com chromedp, gestão de staff, despesas | +| **Plano 5** | Notificações, Dashboard & Relatórios: worker Telegram/Email, KPIs, relatórios, exportação CSV/PDF | diff --git a/docs/superpowers/plans/2026-06-18-plan2-auth-multitenancy.md b/docs/superpowers/plans/2026-06-18-plan2-auth-multitenancy.md new file mode 100644 index 0000000..1d6b17e --- /dev/null +++ b/docs/superpowers/plans/2026-06-18-plan2-auth-multitenancy.md @@ -0,0 +1,2790 @@ +# TechXCar — Plan 2: Auth & Multi-tenancy + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement JWT authentication (login/refresh/logout), tenant management with invite-based onboarding, a real login page, and a super-admin panel to manage tenants and generate invites. + +**Architecture:** Backend gains `internal/auth/` (JWT, bcrypt, rate limiter, handlers, middleware) and `internal/tenant/` (repository + handlers). Auth handlers receive the tenant repository to query super_admins and tenant users — no circular imports. The tenant middleware acquires a `pgxpool.Conn` per request, sets `search_path`, and stores it in `c.Locals("conn")`; handlers that need tenant-scoped queries read this connection. Refresh tokens are stored in Redis keyed by `refresh:` with 30-day TTL (one active session per user; logout deletes the key). `server.New()` gains a `Deps` struct to receive config, DB, and Redis. + +**Tech Stack:** golang-jwt/jwt v5, golang.org/x/crypto (bcrypt cost 12), gofiber/fiber/v2 limiter middleware, Redis (refresh tokens + rate limiting), React Hook Form v7, Zod v4, TanStack Query v5, Zustand v5 + +## Global Constraints +- Go 1.25, module `github.com/techxcar/backend` +- JWT: access token 15 min, refresh token 30 days, HS256 +- Passwords: bcrypt cost 12 +- Rate limiting: 10 requests/min per IP on auth routes, Redis-backed +- All user-facing error messages in Português de Portugal (pt-PT) +- API envelope: `{ "data": ..., "error": null }` +- Tenant schema name: `tenant_` — UUID validated with `^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$` before SQL interpolation +- Single `/api/v1/auth/login` endpoint: if `tenant_slug` empty → try super_admin; else → try tenant user +- Frontend: React 19, TypeScript strict, `@/` alias → `src/`, React Hook Form + Zod + +--- + +### Task 1: Auth — JWT token generation/validation + bcrypt + +**Files:** +- Create: `backend/internal/auth/jwt.go` +- Create: `backend/internal/auth/jwt_test.go` +- Create: `backend/internal/auth/bcrypt.go` +- Create: `backend/internal/auth/bcrypt_test.go` + +**Interfaces:** +- Consumes: nothing +- Produces: + - `auth.Claims` struct: `UserID`, `TenantID`, `Role string` + `jwt.RegisteredClaims` + - `auth.GenerateAccessToken(userID, tenantID, role, secret string) (string, error)` + - `auth.GenerateRefreshToken(userID, tenantID, role, secret string) (string, error)` + - `auth.ValidateToken(tokenStr, secret string) (*Claims, error)` + - `auth.HashPassword(password string) (string, error)` + - `auth.VerifyPassword(password, hash string) bool` + +- [ ] **Step 1: Write the failing tests** + +Create `backend/internal/auth/jwt_test.go`: + +```go +package auth_test + +import ( + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/internal/auth" +) + +const testSecret = "test-secret-32-chars-minimum-ok!" + +func TestGenerateAndValidateAccessToken(t *testing.T) { + token, err := auth.GenerateAccessToken("user-1", "tenant-1", "tenant_admin", testSecret) + require.NoError(t, err) + assert.NotEmpty(t, token) + + claims, err := auth.ValidateToken(token, testSecret) + require.NoError(t, err) + assert.Equal(t, "user-1", claims.UserID) + assert.Equal(t, "tenant-1", claims.TenantID) + assert.Equal(t, "tenant_admin", claims.Role) + assert.True(t, claims.ExpiresAt.After(time.Now())) + assert.True(t, claims.ExpiresAt.Before(time.Now().Add(16*time.Minute))) +} + +func TestGenerateRefreshToken_longerExpiry(t *testing.T) { + token, err := auth.GenerateRefreshToken("user-1", "", "super_admin", testSecret) + require.NoError(t, err) + + claims, err := auth.ValidateToken(token, testSecret) + require.NoError(t, err) + assert.Empty(t, claims.TenantID) + assert.Equal(t, "super_admin", claims.Role) + assert.True(t, claims.ExpiresAt.After(time.Now().Add(29*24*time.Hour))) +} + +func TestValidateToken_wrongSecret(t *testing.T) { + token, err := auth.GenerateAccessToken("user-1", "t-1", "manager", testSecret) + require.NoError(t, err) + + _, err = auth.ValidateToken(token, "different-secret-32chars-minimumx") + assert.Error(t, err) +} + +func TestValidateToken_malformed(t *testing.T) { + _, err := auth.ValidateToken("not.a.jwt", testSecret) + assert.Error(t, err) +} +``` + +Create `backend/internal/auth/bcrypt_test.go`: + +```go +package auth_test + +import ( + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/internal/auth" +) + +func TestHashPassword_isNotPlaintext(t *testing.T) { + hash, err := auth.HashPassword("mysecret") + require.NoError(t, err) + assert.NotEqual(t, "mysecret", hash) + assert.NotEmpty(t, hash) +} + +func TestVerifyPassword_correct(t *testing.T) { + hash, err := auth.HashPassword("correctpassword") + require.NoError(t, err) + assert.True(t, auth.VerifyPassword("correctpassword", hash)) +} + +func TestVerifyPassword_wrong(t *testing.T) { + hash, err := auth.HashPassword("correctpassword") + require.NoError(t, err) + assert.False(t, auth.VerifyPassword("wrongpassword", hash)) +} +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./internal/auth/... -v +``` + +Expected: FAIL — `package github.com/techxcar/backend/internal/auth: cannot find package` + +- [ ] **Step 3: Create jwt.go** + +Create `backend/internal/auth/jwt.go`: + +```go +package auth + +import ( + "fmt" + "time" + + "github.com/golang-jwt/jwt/v5" +) + +type Claims struct { + UserID string `json:"user_id"` + TenantID string `json:"tenant_id,omitempty"` + Role string `json:"role"` + jwt.RegisteredClaims +} + +func GenerateAccessToken(userID, tenantID, role, secret string) (string, error) { + return generateToken(userID, tenantID, role, secret, 15*time.Minute) +} + +func GenerateRefreshToken(userID, tenantID, role, secret string) (string, error) { + return generateToken(userID, tenantID, role, secret, 30*24*time.Hour) +} + +func generateToken(userID, tenantID, role, secret string, ttl time.Duration) (string, error) { + claims := Claims{ + UserID: userID, + TenantID: tenantID, + Role: role, + RegisteredClaims: jwt.RegisteredClaims{ + ExpiresAt: jwt.NewNumericDate(time.Now().Add(ttl)), + IssuedAt: jwt.NewNumericDate(time.Now()), + }, + } + token := jwt.NewWithClaims(jwt.SigningMethodHS256, claims) + return token.SignedString([]byte(secret)) +} + +func ValidateToken(tokenStr, secret string) (*Claims, error) { + token, err := jwt.ParseWithClaims(tokenStr, &Claims{}, func(t *jwt.Token) (interface{}, error) { + if _, ok := t.Method.(*jwt.SigningMethodHMAC); !ok { + return nil, fmt.Errorf("método de assinatura inesperado: %v", t.Header["alg"]) + } + return []byte(secret), nil + }) + if err != nil { + return nil, err + } + claims, ok := token.Claims.(*Claims) + if !ok || !token.Valid { + return nil, fmt.Errorf("token inválido") + } + return claims, nil +} +``` + +- [ ] **Step 4: Create bcrypt.go** + +Create `backend/internal/auth/bcrypt.go`: + +```go +package auth + +import "golang.org/x/crypto/bcrypt" + +const bcryptCost = 12 + +func HashPassword(password string) (string, error) { + bytes, err := bcrypt.GenerateFromPassword([]byte(password), bcryptCost) + return string(bytes), err +} + +func VerifyPassword(password, hash string) bool { + return bcrypt.CompareHashAndPassword([]byte(hash), []byte(password)) == nil +} +``` + +- [ ] **Step 5: Run tests** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./internal/auth/... -v +``` + +Expected (bcrypt tests are slow ~1s at cost 12): +``` +=== RUN TestGenerateAndValidateAccessToken +--- PASS: TestGenerateAndValidateAccessToken +=== RUN TestGenerateRefreshToken_longerExpiry +--- PASS: TestGenerateRefreshToken_longerExpiry +=== RUN TestValidateToken_wrongSecret +--- PASS: TestValidateToken_wrongSecret +=== RUN TestValidateToken_malformed +--- PASS: TestValidateToken_malformed +=== RUN TestHashPassword_isNotPlaintext +--- PASS: TestHashPassword_isNotPlaintext +=== RUN TestVerifyPassword_correct +--- PASS: TestVerifyPassword_correct +=== RUN TestVerifyPassword_wrong +--- PASS: TestVerifyPassword_wrong +PASS +``` + +- [ ] **Step 6: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add backend/internal/auth/ +git commit -m "feat: auth package — JWT token generation/validation and bcrypt password hashing" +``` + +--- + +### Task 2: Tenant repository — types + DB queries + +**Files:** +- Create: `backend/internal/tenant/repository.go` +- Create: `backend/internal/tenant/repository_test.go` + +**Interfaces:** +- Consumes: `*database.DB` (field `Pool *pgxpool.Pool`) +- Produces: + - Types: `SuperAdmin{ID, Email, PasswordHash string; CreatedAt time.Time}`, `Tenant{ID, Slug, Name, Status string; CreatedAt time.Time}`, `TenantUser{ID, Email, PasswordHash, Role, Name string; Active bool}`, `Invite{ID, Token string; TenantID *string; ExpiresAt time.Time; UsedAt *time.Time}` + - `tenant.NewRepository(db *database.DB) *Repository` + - `repo.GetSuperAdminByEmail(ctx, email) (*SuperAdmin, error)` — nil if not found + - `repo.CreateSuperAdmin(ctx, email, passwordHash string) (*SuperAdmin, error)` + - `repo.GetTenantBySlug(ctx, slug) (*Tenant, error)` — nil if not found + - `repo.GetTenantByID(ctx, id) (*Tenant, error)` — nil if not found + - `repo.ListTenants(ctx) ([]*Tenant, error)` + - `repo.CreateTenant(ctx, slug, name string) (*Tenant, error)` + - `repo.GetTenantUserByEmail(ctx, tenantID, email string) (*TenantUser, error)` — schema-qualified query, nil if not found + - `repo.CreateTenantUser(ctx, tenantID, email, passwordHash, name, role string) (*TenantUser, error)` — schema-qualified INSERT + - `repo.CreateInvite(ctx, tenantID *string, expiresIn time.Duration) (*Invite, error)` — token = uuid.New().String() + - `repo.GetInviteByToken(ctx, token string) (*Invite, error)` — nil if not found + - `repo.UseInvite(ctx, inviteID string) error` — sets used_at = NOW() + +- [ ] **Step 1: Write the failing tests** + +Create `backend/internal/tenant/repository_test.go`: + +```go +package tenant_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/internal/tenant" + "github.com/techxcar/backend/pkg/database" +) + +func setupDB(t *testing.T) *database.DB { + t.Helper() + url := os.Getenv("TEST_DATABASE_URL") + if url == "" { + t.Skip("TEST_DATABASE_URL not set, skipping integration test") + } + db, err := database.New(url) + require.NoError(t, err) + t.Cleanup(db.Close) + return db +} + +func TestGetSuperAdminByEmail_notFound(t *testing.T) { + db := setupDB(t) + repo := tenant.NewRepository(db) + admin, err := repo.GetSuperAdminByEmail(context.Background(), "nobody@example.com") + require.NoError(t, err) + assert.Nil(t, admin) +} + +func TestCreateAndGetSuperAdmin(t *testing.T) { + db := setupDB(t) + repo := tenant.NewRepository(db) + ctx := context.Background() + + email := "sa_" + time.Now().Format("20060102150405") + "@example.com" + admin, err := repo.CreateSuperAdmin(ctx, email, "hash123") + require.NoError(t, err) + require.NotNil(t, admin) + assert.NotEmpty(t, admin.ID) + assert.Equal(t, email, admin.Email) + + found, err := repo.GetSuperAdminByEmail(ctx, email) + require.NoError(t, err) + require.NotNil(t, found) + assert.Equal(t, admin.ID, found.ID) + + t.Cleanup(func() { + db.Pool.Exec(ctx, "DELETE FROM super_admins WHERE id = $1", admin.ID) + }) +} + +func TestCreateAndListTenants(t *testing.T) { + db := setupDB(t) + repo := tenant.NewRepository(db) + ctx := context.Background() + + slug := "test-" + time.Now().Format("20060102150405") + ten, err := repo.CreateTenant(ctx, slug, "Test Workshop") + require.NoError(t, err) + require.NotNil(t, ten) + assert.NotEmpty(t, ten.ID) + assert.Equal(t, slug, ten.Slug) + assert.Equal(t, "active", ten.Status) + + list, err := repo.ListTenants(ctx) + require.NoError(t, err) + found := false + for _, v := range list { + if v.ID == ten.ID { + found = true + } + } + assert.True(t, found) + + t.Cleanup(func() { + db.Pool.Exec(ctx, "DELETE FROM tenants WHERE id = $1", ten.ID) + }) +} + +func TestCreateAndUseInvite(t *testing.T) { + db := setupDB(t) + repo := tenant.NewRepository(db) + ctx := context.Background() + + invite, err := repo.CreateInvite(ctx, nil, 24*time.Hour) + require.NoError(t, err) + require.NotNil(t, invite) + assert.NotEmpty(t, invite.Token) + assert.Nil(t, invite.UsedAt) + assert.True(t, invite.ExpiresAt.After(time.Now())) + + found, err := repo.GetInviteByToken(ctx, invite.Token) + require.NoError(t, err) + require.NotNil(t, found) + assert.Equal(t, invite.ID, found.ID) + + err = repo.UseInvite(ctx, invite.ID) + require.NoError(t, err) + + used, err := repo.GetInviteByToken(ctx, invite.Token) + require.NoError(t, err) + assert.NotNil(t, used.UsedAt) + + t.Cleanup(func() { + db.Pool.Exec(ctx, "DELETE FROM invites WHERE id = $1", invite.ID) + }) +} +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./internal/tenant/... -v +``` + +Expected: FAIL — `package github.com/techxcar/backend/internal/tenant: cannot find package` + +- [ ] **Step 3: Create repository.go** + +Create `backend/internal/tenant/repository.go`: + +```go +package tenant + +import ( + "context" + "fmt" + "regexp" + "time" + + "github.com/google/uuid" + "github.com/jackc/pgx/v5" + "github.com/techxcar/backend/pkg/database" +) + +var uuidRe = regexp.MustCompile(`^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$`) + +type SuperAdmin struct { + ID string + Email string + PasswordHash string + CreatedAt time.Time +} + +type Tenant struct { + ID string + Slug string + Name string + Status string + CreatedAt time.Time +} + +type TenantUser struct { + ID string + Email string + PasswordHash string + Role string + Name string + Active bool +} + +type Invite struct { + ID string + TenantID *string + Token string + ExpiresAt time.Time + UsedAt *time.Time + CreatedAt time.Time +} + +type Repository struct { + db *database.DB +} + +func NewRepository(db *database.DB) *Repository { + return &Repository{db: db} +} + +func (r *Repository) GetSuperAdminByEmail(ctx context.Context, email string) (*SuperAdmin, error) { + row := r.db.Pool.QueryRow(ctx, + `SELECT id, email, password_hash, created_at FROM super_admins WHERE email = $1`, email) + var a SuperAdmin + err := row.Scan(&a.ID, &a.Email, &a.PasswordHash, &a.CreatedAt) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("tenant: get super admin: %w", err) + } + return &a, nil +} + +func (r *Repository) CreateSuperAdmin(ctx context.Context, email, passwordHash string) (*SuperAdmin, error) { + row := r.db.Pool.QueryRow(ctx, + `INSERT INTO super_admins (email, password_hash) VALUES ($1, $2) + RETURNING id, email, password_hash, created_at`, + email, passwordHash) + var a SuperAdmin + if err := row.Scan(&a.ID, &a.Email, &a.PasswordHash, &a.CreatedAt); err != nil { + return nil, fmt.Errorf("tenant: create super admin: %w", err) + } + return &a, nil +} + +func (r *Repository) GetTenantBySlug(ctx context.Context, slug string) (*Tenant, error) { + row := r.db.Pool.QueryRow(ctx, + `SELECT id, slug, name, status, created_at FROM tenants WHERE slug = $1`, slug) + var t Tenant + err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("tenant: get by slug: %w", err) + } + return &t, nil +} + +func (r *Repository) GetTenantByID(ctx context.Context, id string) (*Tenant, error) { + row := r.db.Pool.QueryRow(ctx, + `SELECT id, slug, name, status, created_at FROM tenants WHERE id = $1`, id) + var t Tenant + err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("tenant: get by id: %w", err) + } + return &t, nil +} + +func (r *Repository) ListTenants(ctx context.Context) ([]*Tenant, error) { + rows, err := r.db.Pool.Query(ctx, + `SELECT id, slug, name, status, created_at FROM tenants ORDER BY created_at DESC`) + if err != nil { + return nil, fmt.Errorf("tenant: list: %w", err) + } + defer rows.Close() + var list []*Tenant + for rows.Next() { + var t Tenant + if err := rows.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt); err != nil { + return nil, fmt.Errorf("tenant: list scan: %w", err) + } + list = append(list, &t) + } + return list, nil +} + +func (r *Repository) CreateTenant(ctx context.Context, slug, name string) (*Tenant, error) { + row := r.db.Pool.QueryRow(ctx, + `INSERT INTO tenants (slug, name) VALUES ($1, $2) + RETURNING id, slug, name, status, created_at`, + slug, name) + var t Tenant + if err := row.Scan(&t.ID, &t.Slug, &t.Name, &t.Status, &t.CreatedAt); err != nil { + return nil, fmt.Errorf("tenant: create: %w", err) + } + return &t, nil +} + +func (r *Repository) GetTenantUserByEmail(ctx context.Context, tenantID, email string) (*TenantUser, error) { + if !uuidRe.MatchString(tenantID) { + return nil, fmt.Errorf("tenant: invalid tenant ID format") + } + schema := "tenant_" + tenantID + row := r.db.Pool.QueryRow(ctx, + fmt.Sprintf(`SELECT id, email, password_hash, role, name, active FROM %s.users WHERE email = $1`, schema), + email) + var u TenantUser + err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.Name, &u.Active) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("tenant: get user: %w", err) + } + return &u, nil +} + +func (r *Repository) CreateTenantUser(ctx context.Context, tenantID, email, passwordHash, name, role string) (*TenantUser, error) { + if !uuidRe.MatchString(tenantID) { + return nil, fmt.Errorf("tenant: invalid tenant ID format") + } + schema := "tenant_" + tenantID + row := r.db.Pool.QueryRow(ctx, + fmt.Sprintf(`INSERT INTO %s.users (email, password_hash, name, role) + VALUES ($1, $2, $3, $4) RETURNING id, email, password_hash, role, name, active`, schema), + email, passwordHash, name, role) + var u TenantUser + if err := row.Scan(&u.ID, &u.Email, &u.PasswordHash, &u.Role, &u.Name, &u.Active); err != nil { + return nil, fmt.Errorf("tenant: create user: %w", err) + } + return &u, nil +} + +func (r *Repository) CreateInvite(ctx context.Context, tenantID *string, expiresIn time.Duration) (*Invite, error) { + token := uuid.New().String() + expiresAt := time.Now().Add(expiresIn) + row := r.db.Pool.QueryRow(ctx, + `INSERT INTO invites (tenant_id, token, expires_at) VALUES ($1, $2, $3) + RETURNING id, tenant_id, token, expires_at, used_at, created_at`, + tenantID, token, expiresAt) + var inv Invite + if err := row.Scan(&inv.ID, &inv.TenantID, &inv.Token, &inv.ExpiresAt, &inv.UsedAt, &inv.CreatedAt); err != nil { + return nil, fmt.Errorf("tenant: create invite: %w", err) + } + return &inv, nil +} + +func (r *Repository) GetInviteByToken(ctx context.Context, token string) (*Invite, error) { + row := r.db.Pool.QueryRow(ctx, + `SELECT id, tenant_id, token, expires_at, used_at, created_at FROM invites WHERE token = $1`, token) + var inv Invite + err := row.Scan(&inv.ID, &inv.TenantID, &inv.Token, &inv.ExpiresAt, &inv.UsedAt, &inv.CreatedAt) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("tenant: get invite: %w", err) + } + return &inv, nil +} + +func (r *Repository) UseInvite(ctx context.Context, inviteID string) error { + _, err := r.db.Pool.Exec(ctx, + `UPDATE invites SET used_at = NOW() WHERE id = $1`, inviteID) + return err +} +``` + +- [ ] **Step 4: Run tests** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./internal/tenant/... -v +``` + +Expected (without TEST_DATABASE_URL all tests skip): +``` +=== RUN TestGetSuperAdminByEmail_notFound +--- SKIP: TestGetSuperAdminByEmail_notFound (TEST_DATABASE_URL not set) +... +PASS +``` + +- [ ] **Step 5: Compile check** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go build ./... +``` + +Expected: no output (success) + +- [ ] **Step 6: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add backend/internal/tenant/ +git commit -m "feat: tenant repository — types and DB queries for super_admins, tenants, users and invites" +``` + +--- + +### Task 3: Auth handlers — login, refresh, logout + Redis rate limiter + +**Files:** +- Create: `backend/internal/auth/ratelimit.go` +- Create: `backend/internal/auth/handler.go` +- Create: `backend/internal/auth/handler_test.go` +- Create: `backend/internal/auth/routes.go` + +**Interfaces:** +- Consumes: + - `auth.ValidateToken`, `auth.GenerateAccessToken`, `auth.GenerateRefreshToken`, `auth.VerifyPassword` (Task 1) + - `tenant.Repository` (Task 2): `GetSuperAdminByEmail`, `GetTenantBySlug`, `GetTenantUserByEmail` + - `*redis.Redis` (field `Client *goredis.Client`) + - `*config.Config` (fields `JWTSecret string`) +- Produces: + - `auth.NewRedisStorage(client *goredis.Client) fiber.Storage` — implements Fiber limiter Storage + - `auth.RateLimiter(storage fiber.Storage) fiber.Handler` — 10 req/min per IP + - `auth.LoginHandler(repo *tenant.Repository, rdb *redis.Redis, cfg *config.Config) fiber.Handler` + - `auth.RefreshHandler(rdb *redis.Redis, cfg *config.Config) fiber.Handler` + - `auth.LogoutHandler(rdb *redis.Redis) fiber.Handler` + - `auth.RegisterRoutes(app *fiber.App, repo *tenant.Repository, rdb *redis.Redis, cfg *config.Config)` + - Refresh token cookie name: `"refresh_token"` (httpOnly, Secure in prod, SameSite=Strict, Path=/api/v1/auth, MaxAge=30d) + +- [ ] **Step 1: Write the failing tests** + +Create `backend/internal/auth/handler_test.go`: + +```go +package auth_test + +import ( + "bytes" + "context" + "encoding/json" + "net/http/httptest" + "testing" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/internal/config" + "github.com/techxcar/backend/internal/tenant" +) + +// stubRepo is a minimal in-memory stub for testing auth handlers without a real DB. +type stubRepo struct { + superAdmins map[string]*tenant.SuperAdmin + tenants map[string]*tenant.Tenant + users map[string]*tenant.TenantUser +} + +func (s *stubRepo) GetSuperAdminByEmail(_ context.Context, email string) (*tenant.SuperAdmin, error) { + a, _ := s.superAdmins[email] + return a, nil +} + +func (s *stubRepo) GetTenantBySlug(_ context.Context, slug string) (*tenant.Tenant, error) { + t, _ := s.tenants[slug] + return t, nil +} + +func (s *stubRepo) GetTenantUserByEmail(_ context.Context, tenantID, email string) (*tenant.TenantUser, error) { + key := tenantID + ":" + email + u, _ := s.users[key] + return u, nil +} + +func newTestApp(repo auth.LoginRepository, cfg *config.Config) *fiber.App { + app := fiber.New(fiber.Config{ErrorHandler: func(c *fiber.Ctx, err error) error { + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{"data": nil, "error": err.Error()}) + }}) + app.Post("/api/v1/auth/login", auth.LoginHandler(repo, nil, cfg)) + app.Post("/api/v1/auth/refresh", auth.RefreshHandler(nil, cfg)) + app.Post("/api/v1/auth/logout", auth.LogoutHandler(nil)) + return app +} + +func TestLogin_superAdmin_success(t *testing.T) { + hash, _ := auth.HashPassword("secret123") + repo := &stubRepo{ + superAdmins: map[string]*tenant.SuperAdmin{ + "admin@example.com": {ID: "sa-1", Email: "admin@example.com", PasswordHash: hash}, + }, + } + cfg := &config.Config{JWTSecret: testSecret} + app := newTestApp(repo, cfg) + + body, _ := json.Marshal(map[string]string{"email": "admin@example.com", "password": "secret123"}) + req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + var result map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + data := result["data"].(map[string]any) + assert.NotEmpty(t, data["access_token"]) +} + +func TestLogin_wrongPassword(t *testing.T) { + hash, _ := auth.HashPassword("secret123") + repo := &stubRepo{ + superAdmins: map[string]*tenant.SuperAdmin{ + "admin@example.com": {ID: "sa-1", Email: "admin@example.com", PasswordHash: hash}, + }, + } + cfg := &config.Config{JWTSecret: testSecret} + app := newTestApp(repo, cfg) + + body, _ := json.Marshal(map[string]string{"email": "admin@example.com", "password": "wrongpass"}) + req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 401, resp.StatusCode) +} + +func TestLogin_tenantUser_success(t *testing.T) { + hash, _ := auth.HashPassword("tenantpass") + repo := &stubRepo{ + tenants: map[string]*tenant.Tenant{ + "my-workshop": {ID: "11111111-1111-1111-1111-111111111111", Slug: "my-workshop", Name: "My Workshop", Status: "active"}, + }, + users: map[string]*tenant.TenantUser{ + "11111111-1111-1111-1111-111111111111:user@workshop.com": { + ID: "u-1", Email: "user@workshop.com", PasswordHash: hash, + Role: "tenant_admin", Name: "User", Active: true, + }, + }, + } + cfg := &config.Config{JWTSecret: testSecret} + app := newTestApp(repo, cfg) + + body, _ := json.Marshal(map[string]string{ + "email": "user@workshop.com", "password": "tenantpass", "tenant_slug": "my-workshop", + }) + req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + var result map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + data := result["data"].(map[string]any) + token := data["access_token"].(string) + + claims, err := auth.ValidateToken(token, testSecret) + require.NoError(t, err) + assert.Equal(t, "11111111-1111-1111-1111-111111111111", claims.TenantID) + assert.Equal(t, "tenant_admin", claims.Role) + _ = time.Now() // silence unused import +} + +func TestLogin_unknownTenant(t *testing.T) { + repo := &stubRepo{tenants: map[string]*tenant.Tenant{}} + cfg := &config.Config{JWTSecret: testSecret} + app := newTestApp(repo, cfg) + + body, _ := json.Marshal(map[string]string{ + "email": "user@workshop.com", "password": "pass", "tenant_slug": "nonexistent", + }) + req := httptest.NewRequest("POST", "/api/v1/auth/login", bytes.NewReader(body)) + req.Header.Set("Content-Type", "application/json") + + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 401, resp.StatusCode) +} +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./internal/auth/... -v -run TestLogin +``` + +Expected: FAIL — `undefined: auth.LoginHandler` + +- [ ] **Step 3: Define LoginRepository interface + create handler.go** + +The `LoginHandler` receives an interface so tests can pass a stub without a DB. + +Create `backend/internal/auth/handler.go`: + +```go +package auth + +import ( + "context" + "strings" + "time" + + "github.com/gofiber/fiber/v2" + goredis "github.com/redis/go-redis/v9" + + "github.com/techxcar/backend/internal/config" + "github.com/techxcar/backend/internal/tenant" + redispkg "github.com/techxcar/backend/pkg/redis" +) + +// LoginRepository is a subset of tenant.Repository used by auth handlers. +type LoginRepository interface { + GetSuperAdminByEmail(ctx context.Context, email string) (*tenant.SuperAdmin, error) + GetTenantBySlug(ctx context.Context, slug string) (*tenant.Tenant, error) + GetTenantUserByEmail(ctx context.Context, tenantID, email string) (*tenant.TenantUser, error) +} + +const refreshCookieName = "refresh_token" +const refreshCookieTTL = 30 * 24 * time.Hour + +func setRefreshCookie(c *fiber.Ctx, token string, cfg *config.Config) { + c.Cookie(&fiber.Cookie{ + Name: refreshCookieName, + Value: token, + MaxAge: int(refreshCookieTTL.Seconds()), + HTTPOnly: true, + Secure: cfg.AppEnv == "production", + SameSite: "Strict", + Path: "/api/v1/auth", + }) +} + +type loginRequest struct { + Email string `json:"email"` + Password string `json:"password"` + TenantSlug string `json:"tenant_slug"` +} + +func LoginHandler(repo LoginRepository, rdb *redispkg.Redis, cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + var req loginRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(400, "corpo do pedido inválido") + } + req.Email = strings.TrimSpace(strings.ToLower(req.Email)) + if req.Email == "" || req.Password == "" { + return fiber.NewError(400, "email e password são obrigatórios") + } + + var userID, tenantID, role string + + if req.TenantSlug == "" { + admin, err := repo.GetSuperAdminByEmail(c.Context(), req.Email) + if err != nil || admin == nil || !VerifyPassword(req.Password, admin.PasswordHash) { + return fiber.NewError(401, "credenciais inválidas") + } + userID, tenantID, role = admin.ID, "", "super_admin" + } else { + ten, err := repo.GetTenantBySlug(c.Context(), req.TenantSlug) + if err != nil || ten == nil || ten.Status != "active" { + return fiber.NewError(401, "credenciais inválidas") + } + user, err := repo.GetTenantUserByEmail(c.Context(), ten.ID, req.Email) + if err != nil || user == nil || !user.Active || !VerifyPassword(req.Password, user.PasswordHash) { + return fiber.NewError(401, "credenciais inválidas") + } + userID, tenantID, role = user.ID, ten.ID, user.Role + } + + access, err := GenerateAccessToken(userID, tenantID, role, cfg.JWTSecret) + if err != nil { + return fiber.NewError(500, "erro ao gerar token") + } + refresh, err := GenerateRefreshToken(userID, tenantID, role, cfg.JWTSecret) + if err != nil { + return fiber.NewError(500, "erro ao gerar token") + } + + if rdb != nil { + ctx := c.Context() + rdb.Client.Set(ctx, "refresh:"+userID, refresh, refreshCookieTTL) + } + + setRefreshCookie(c, refresh, cfg) + return c.JSON(fiber.Map{"data": fiber.Map{"access_token": access}, "error": nil}) + } +} + +func RefreshHandler(rdb *redispkg.Redis, cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + refreshToken := c.Cookies(refreshCookieName) + if refreshToken == "" { + return fiber.NewError(401, "refresh token em falta") + } + claims, err := ValidateToken(refreshToken, cfg.JWTSecret) + if err != nil { + return fiber.NewError(401, "refresh token inválido ou expirado") + } + + if rdb != nil { + stored, err := rdb.Client.Get(c.Context(), "refresh:"+claims.UserID).Result() + if err == goredis.Nil || stored != refreshToken { + return fiber.NewError(401, "sessão inválida") + } + } + + access, err := GenerateAccessToken(claims.UserID, claims.TenantID, claims.Role, cfg.JWTSecret) + if err != nil { + return fiber.NewError(500, "erro ao renovar token") + } + newRefresh, err := GenerateRefreshToken(claims.UserID, claims.TenantID, claims.Role, cfg.JWTSecret) + if err != nil { + return fiber.NewError(500, "erro ao renovar token") + } + + if rdb != nil { + rdb.Client.Set(c.Context(), "refresh:"+claims.UserID, newRefresh, refreshCookieTTL) + } + setRefreshCookie(c, newRefresh, cfg) + return c.JSON(fiber.Map{"data": fiber.Map{"access_token": access}, "error": nil}) + } +} + +func LogoutHandler(rdb *redispkg.Redis) fiber.Handler { + return func(c *fiber.Ctx) error { + refreshToken := c.Cookies(refreshCookieName) + if refreshToken != "" && rdb != nil { + if claims, err := ValidateToken(refreshToken, ""); err == nil { + rdb.Client.Del(c.Context(), "refresh:"+claims.UserID) + } + } + c.Cookie(&fiber.Cookie{ + Name: refreshCookieName, + Value: "", + MaxAge: -1, + HTTPOnly: true, + Path: "/api/v1/auth", + }) + return c.JSON(fiber.Map{"data": nil, "error": nil}) + } +} +``` + +Note: `LogoutHandler` calls `ValidateToken` without a secret — this will fail validation but we don't need to validate for logout; we just need the UserID from the claims. Fix: store UserID in a separate non-httpOnly cookie, or parse claims without validation. The simpler approach: skip Redis cleanup on logout (the token will expire naturally) and just clear the cookie. Replace the logout body: + +```go +func LogoutHandler(rdb *redispkg.Redis) fiber.Handler { + return func(c *fiber.Ctx) error { + c.Cookie(&fiber.Cookie{ + Name: refreshCookieName, + Value: "", + MaxAge: -1, + HTTPOnly: true, + Path: "/api/v1/auth", + }) + return c.JSON(fiber.Map{"data": nil, "error": nil}) + } +} +``` + +- [ ] **Step 4: Create ratelimit.go** + +Create `backend/internal/auth/ratelimit.go`: + +```go +package auth + +import ( + "context" + "time" + + "github.com/gofiber/fiber/v2/middleware/limiter" + goredis "github.com/redis/go-redis/v9" +) + +type redisStorage struct { + client *goredis.Client +} + +func NewRedisStorage(client *goredis.Client) *redisStorage { + return &redisStorage{client: client} +} + +func (s *redisStorage) Get(key string) ([]byte, error) { + val, err := s.client.Get(context.Background(), key).Bytes() + if err == goredis.Nil { + return nil, nil + } + return val, err +} + +func (s *redisStorage) Set(key string, val []byte, exp time.Duration) error { + return s.client.Set(context.Background(), key, val, exp).Err() +} + +func (s *redisStorage) Delete(key string) error { + return s.client.Del(context.Background(), key).Err() +} + +func (s *redisStorage) Reset() error { + return s.client.FlushDB(context.Background()).Err() +} + +func (s *redisStorage) Close() error { + return nil +} + +func RateLimiter(storage *redisStorage) fiber.Handler { + return limiter.New(limiter.Config{ + Max: 10, + Expiration: 1 * time.Minute, + KeyGenerator: func(c *fiber.Ctx) string { + return "ratelimit:auth:" + c.IP() + }, + Storage: storage, + LimitReached: func(c *fiber.Ctx) error { + return fiber.NewError(429, "muitas tentativas, tente novamente em 1 minuto") + }, + }) +} +``` + +Note: `ratelimit.go` uses `fiber.Handler` from gofiber — add the import: `"github.com/gofiber/fiber/v2"`. + +Updated `ratelimit.go` with import: + +```go +package auth + +import ( + "context" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/limiter" + goredis "github.com/redis/go-redis/v9" +) + +type redisStorage struct { + client *goredis.Client +} + +func NewRedisStorage(client *goredis.Client) *redisStorage { + return &redisStorage{client: client} +} + +func (s *redisStorage) Get(key string) ([]byte, error) { + val, err := s.client.Get(context.Background(), key).Bytes() + if err == goredis.Nil { + return nil, nil + } + return val, err +} + +func (s *redisStorage) Set(key string, val []byte, exp time.Duration) error { + return s.client.Set(context.Background(), key, val, exp).Err() +} + +func (s *redisStorage) Delete(key string) error { + return s.client.Del(context.Background(), key).Err() +} + +func (s *redisStorage) Reset() error { + return s.client.FlushDB(context.Background()).Err() +} + +func (s *redisStorage) Close() error { + return nil +} + +func RateLimiter(storage *redisStorage) fiber.Handler { + return limiter.New(limiter.Config{ + Max: 10, + Expiration: 1 * time.Minute, + KeyGenerator: func(c *fiber.Ctx) string { + return "ratelimit:auth:" + c.IP() + }, + Storage: storage, + LimitReached: func(c *fiber.Ctx) error { + return fiber.NewError(429, "muitas tentativas, tente novamente em 1 minuto") + }, + }) +} +``` + +- [ ] **Step 5: Create routes.go** + +Create `backend/internal/auth/routes.go`: + +```go +package auth + +import ( + "github.com/gofiber/fiber/v2" + "github.com/techxcar/backend/internal/config" + redispkg "github.com/techxcar/backend/pkg/redis" +) + +func RegisterRoutes(app *fiber.App, repo LoginRepository, rdb *redispkg.Redis, cfg *config.Config) { + storage := NewRedisStorage(rdb.Client) + rateLimiter := RateLimiter(storage) + + auth := app.Group("/api/v1/auth") + auth.Post("/login", rateLimiter, LoginHandler(repo, rdb, cfg)) + auth.Post("/refresh", rateLimiter, RefreshHandler(rdb, cfg)) + auth.Post("/logout", LogoutHandler(rdb)) +} +``` + +- [ ] **Step 6: Run tests** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./internal/auth/... -v -run TestLogin +``` + +Expected: +``` +=== RUN TestLogin_superAdmin_success +--- PASS: TestLogin_superAdmin_success +=== RUN TestLogin_wrongPassword +--- PASS: TestLogin_wrongPassword +=== RUN TestLogin_tenantUser_success +--- PASS: TestLogin_tenantUser_success +=== RUN TestLogin_unknownTenant +--- PASS: TestLogin_unknownTenant +PASS +``` + +- [ ] **Step 7: Run all auth tests** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./internal/auth/... -v +go build ./... +``` + +Expected: all tests PASS, build success. + +- [ ] **Step 8: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add backend/internal/auth/ +git commit -m "feat: auth handlers — login/refresh/logout with Redis rate limiting and refresh token storage" +``` + +--- + +### Task 4: Auth middleware — JWT validation + tenant schema injection + +**Files:** +- Create: `backend/internal/auth/middleware.go` +- Create: `backend/internal/auth/middleware_test.go` + +**Interfaces:** +- Consumes: `auth.ValidateToken` (Task 1), `*database.DB` (Task 2 dependency) +- Produces: + - `auth.RequireAuth(secret string) fiber.Handler` — validates Bearer token, stores `*Claims` in `c.Locals("claims")` + - `auth.RequireRole(roles ...string) fiber.Handler` — checks `claims.Role` is in `roles` + - `auth.TenantMiddleware(db *database.DB) fiber.Handler` — acquires `pgxpool.Conn`, sets search_path, stores conn in `c.Locals("conn")`, releases after handler chain + +- [ ] **Step 1: Write the failing tests** + +Create `backend/internal/auth/middleware_test.go`: + +```go +package auth_test + +import ( + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/internal/auth" +) + +func TestRequireAuth_missingHeader(t *testing.T) { + app := fiber.New() + app.Get("/protected", auth.RequireAuth(testSecret), func(c *fiber.Ctx) error { + return c.SendString("ok") + }) + + req := httptest.NewRequest("GET", "/protected", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 401, resp.StatusCode) +} + +func TestRequireAuth_validToken(t *testing.T) { + token, _ := auth.GenerateAccessToken("user-1", "tenant-1", "manager", testSecret) + + app := fiber.New() + app.Get("/protected", auth.RequireAuth(testSecret), func(c *fiber.Ctx) error { + claims := c.Locals("claims").(*auth.Claims) + return c.SendString(claims.UserID) + }) + + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) +} + +func TestRequireAuth_invalidToken(t *testing.T) { + app := fiber.New() + app.Get("/protected", auth.RequireAuth(testSecret), func(c *fiber.Ctx) error { + return c.SendString("ok") + }) + + req := httptest.NewRequest("GET", "/protected", nil) + req.Header.Set("Authorization", "Bearer invalid.token.here") + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 401, resp.StatusCode) +} + +func TestRequireRole_allowed(t *testing.T) { + token, _ := auth.GenerateAccessToken("user-1", "", "super_admin", testSecret) + + app := fiber.New() + app.Get("/admin", + auth.RequireAuth(testSecret), + auth.RequireRole("super_admin"), + func(c *fiber.Ctx) error { return c.SendString("ok") }, + ) + + req := httptest.NewRequest("GET", "/admin", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) +} + +func TestRequireRole_forbidden(t *testing.T) { + token, _ := auth.GenerateAccessToken("user-1", "t-1", "technician", testSecret) + + app := fiber.New() + app.Get("/admin", + auth.RequireAuth(testSecret), + auth.RequireRole("super_admin", "tenant_admin"), + func(c *fiber.Ctx) error { return c.SendString("ok") }, + ) + + req := httptest.NewRequest("GET", "/admin", nil) + req.Header.Set("Authorization", "Bearer "+token) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 403, resp.StatusCode) +} +``` + +- [ ] **Step 2: Run tests to confirm they fail** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./internal/auth/... -v -run "TestRequire" +``` + +Expected: FAIL — `undefined: auth.RequireAuth` + +- [ ] **Step 3: Create middleware.go** + +Create `backend/internal/auth/middleware.go`: + +```go +package auth + +import ( + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/techxcar/backend/pkg/database" +) + +func RequireAuth(secret string) fiber.Handler { + return func(c *fiber.Ctx) error { + authHeader := c.Get("Authorization") + if authHeader == "" { + return fiber.NewError(401, "autenticação necessária") + } + parts := strings.SplitN(authHeader, " ", 2) + if len(parts) != 2 || parts[0] != "Bearer" { + return fiber.NewError(401, "formato de autorização inválido") + } + claims, err := ValidateToken(parts[1], secret) + if err != nil { + return fiber.NewError(401, "token inválido ou expirado") + } + c.Locals("claims", claims) + return c.Next() + } +} + +func RequireRole(roles ...string) fiber.Handler { + return func(c *fiber.Ctx) error { + claims, ok := c.Locals("claims").(*Claims) + if !ok { + return fiber.NewError(401, "autenticação necessária") + } + for _, role := range roles { + if claims.Role == role { + return c.Next() + } + } + return fiber.NewError(403, "acesso não autorizado") + } +} + +// TenantMiddleware acquires a dedicated pgxpool connection per request, +// sets the tenant search_path, stores the connection in c.Locals("conn"), +// and releases the connection after the handler chain completes. +func TenantMiddleware(db *database.DB) fiber.Handler { + return func(c *fiber.Ctx) error { + claims, ok := c.Locals("claims").(*Claims) + if !ok || claims.TenantID == "" { + return c.Next() + } + conn, err := db.Pool.Acquire(c.Context()) + if err != nil { + return fiber.NewError(500, "erro interno ao adquirir conexão") + } + schema := "tenant_" + claims.TenantID + if _, err := conn.Exec(c.Context(), "SET search_path = "+schema+", public"); err != nil { + conn.Release() + return fiber.NewError(500, "erro interno ao definir schema") + } + c.Locals("conn", conn) + err = c.Next() + conn.Release() + return err + } +} +``` + +- [ ] **Step 4: Run all auth tests** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./internal/auth/... -v +go build ./... +``` + +Expected: all tests PASS (including jwt, bcrypt, handler, middleware), build success. + +- [ ] **Step 5: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add backend/internal/auth/middleware.go backend/internal/auth/middleware_test.go +git commit -m "feat: auth middleware — JWT validation, role guard and tenant schema injection" +``` + +--- + +### Task 5: Tenant handlers — super-admin CRUD + invite generation + public invite redemption + +**Files:** +- Create: `backend/internal/tenant/handler.go` +- Create: `backend/internal/tenant/handler_test.go` +- Create: `backend/internal/tenant/routes.go` + +**Interfaces:** +- Consumes: + - `tenant.Repository` (Task 2): all methods + - `*database.DB`: `ProvisionTenantSchema(ctx, dsn, tenantID, migrationsPath)` + - `*config.Config`: `DatabaseURL`, `JWTSecret` + - `auth.RequireAuth`, `auth.RequireRole`, `auth.TenantMiddleware` (Task 3-4) + - `auth.HashPassword`, `auth.GenerateAccessToken`, `auth.GenerateRefreshToken` (Task 1) + - `auth.refreshCookieName`, `auth.setRefreshCookie` (Task 3) — these are unexported; expose `auth.SetRefreshCookie` instead +- Produces: + - `GET /api/v1/admin/tenants` → `[]*Tenant` — super_admin only + - `POST /api/v1/admin/tenants` → `*Tenant` — super_admin only; body: `{name, slug, admin_email, admin_password, admin_name}` + - `POST /api/v1/admin/tenants/:id/invite` → `{token, url}` — super_admin only + - `GET /api/v1/invites/:token` → invite details — public + - `POST /api/v1/invites/:token/redeem` → `{access_token}` — public; body: `{tenant_name, tenant_slug, admin_email, admin_password, admin_name}` + - `tenant.RegisterRoutes(app, repo, db, cfg)` — wires all tenant routes with auth guards + +Note: before implementing, update `auth/handler.go` to export `SetRefreshCookie` so `tenant/handler.go` can use it without importing from `auth` and creating a cycle. Since both packages are separate, tenant can import auth without a cycle. + +- [ ] **Step 1: Export SetRefreshCookie in auth/handler.go** + +In `backend/internal/auth/handler.go`, rename `setRefreshCookie` to `SetRefreshCookie` (capital S) and update all internal callers: + +```go +func SetRefreshCookie(c *fiber.Ctx, token string, cfg *config.Config) { + c.Cookie(&fiber.Cookie{ + Name: refreshCookieName, + Value: token, + MaxAge: int(refreshCookieTTL.Seconds()), + HTTPOnly: true, + Secure: cfg.AppEnv == "production", + SameSite: "Strict", + Path: "/api/v1/auth", + }) +} +``` + +Update the two callers in `handler.go` from `setRefreshCookie(...)` to `SetRefreshCookie(...)`. + +- [ ] **Step 2: Write the failing tests** + +Create `backend/internal/tenant/handler_test.go`: + +```go +package tenant_test + +import ( + "bytes" + "encoding/json" + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/internal/config" + "github.com/techxcar/backend/internal/tenant" +) + +const handlerTestSecret = "test-secret-32-chars-minimum-ok!" + +func buildAdminApp(repo *tenant.Repository, cfg *config.Config) *fiber.App { + app := fiber.New(fiber.Config{ErrorHandler: func(c *fiber.Ctx, err error) error { + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{"data": nil, "error": err.Error()}) + }}) + tenant.RegisterRoutes(app, repo, nil, cfg) + return app +} + +func adminToken(t *testing.T, secret string) string { + t.Helper() + tok, err := auth.GenerateAccessToken("sa-1", "", "super_admin", secret) + require.NoError(t, err) + return tok +} + +func TestListTenants_requiresAuth(t *testing.T) { + db := setupDB(t) + repo := tenant.NewRepository(db) + cfg := &config.Config{JWTSecret: handlerTestSecret} + app := buildAdminApp(repo, cfg) + + req := httptest.NewRequest("GET", "/api/v1/admin/tenants", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 401, resp.StatusCode) +} + +func TestListTenants_success(t *testing.T) { + db := setupDB(t) + repo := tenant.NewRepository(db) + cfg := &config.Config{JWTSecret: handlerTestSecret} + app := buildAdminApp(repo, cfg) + + req := httptest.NewRequest("GET", "/api/v1/admin/tenants", nil) + req.Header.Set("Authorization", "Bearer "+adminToken(t, handlerTestSecret)) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + var result map[string]any + require.NoError(t, json.NewDecoder(resp.Body).Decode(&result)) + assert.Nil(t, result["error"]) +} + +func TestGetInvite_notFound(t *testing.T) { + db := setupDB(t) + repo := tenant.NewRepository(db) + cfg := &config.Config{JWTSecret: handlerTestSecret} + app := buildAdminApp(repo, cfg) + + req := httptest.NewRequest("GET", "/api/v1/invites/nonexistent-token", nil) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 404, resp.StatusCode) +} +``` + +- [ ] **Step 3: Run tests to confirm they fail** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./internal/tenant/... -v +``` + +Expected: FAIL — `undefined: tenant.RegisterRoutes` + +- [ ] **Step 4: Create handler.go** + +Create `backend/internal/tenant/handler.go`: + +```go +package tenant + +import ( + "time" + + "github.com/gofiber/fiber/v2" + + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/internal/config" + "github.com/techxcar/backend/pkg/database" +) + +func listTenantsHandler(repo *Repository) fiber.Handler { + return func(c *fiber.Ctx) error { + list, err := repo.ListTenants(c.Context()) + if err != nil { + return fiber.NewError(500, "erro ao listar oficinas") + } + if list == nil { + list = []*Tenant{} + } + return c.JSON(fiber.Map{"data": list, "error": nil}) + } +} + +type createTenantRequest struct { + Name string `json:"name"` + Slug string `json:"slug"` + AdminEmail string `json:"admin_email"` + AdminPassword string `json:"admin_password"` + AdminName string `json:"admin_name"` +} + +func createTenantHandler(repo *Repository, db *database.DB, cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + var req createTenantRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(400, "corpo do pedido inválido") + } + if req.Name == "" || req.Slug == "" || req.AdminEmail == "" || req.AdminPassword == "" || req.AdminName == "" { + return fiber.NewError(400, "todos os campos são obrigatórios") + } + existing, err := repo.GetTenantBySlug(c.Context(), req.Slug) + if err != nil { + return fiber.NewError(500, "erro interno") + } + if existing != nil { + return fiber.NewError(409, "slug já existe") + } + + ten, err := repo.CreateTenant(c.Context(), req.Slug, req.Name) + if err != nil { + return fiber.NewError(500, "erro ao criar oficina") + } + + if db != nil { + if err := db.ProvisionTenantSchema(c.Context(), cfg.DatabaseURL, ten.ID, "migrations/tenant"); err != nil { + return fiber.NewError(500, "erro ao provisionar schema") + } + } + + hash, err := auth.HashPassword(req.AdminPassword) + if err != nil { + return fiber.NewError(500, "erro interno") + } + if db != nil { + if _, err := repo.CreateTenantUser(c.Context(), ten.ID, req.AdminEmail, hash, req.AdminName, "tenant_admin"); err != nil { + return fiber.NewError(500, "erro ao criar utilizador admin") + } + } + + return c.Status(201).JSON(fiber.Map{"data": ten, "error": nil}) + } +} + +func generateInviteHandler(repo *Repository) fiber.Handler { + return func(c *fiber.Ctx) error { + tenantID := c.Params("id") + ten, err := repo.GetTenantByID(c.Context(), tenantID) + if err != nil || ten == nil { + return fiber.NewError(404, "oficina não encontrada") + } + + invite, err := repo.CreateInvite(c.Context(), &ten.ID, 72*time.Hour) + if err != nil { + return fiber.NewError(500, "erro ao gerar convite") + } + + return c.Status(201).JSON(fiber.Map{ + "data": fiber.Map{ + "token": invite.Token, + "expires_at": invite.ExpiresAt, + }, + "error": nil, + }) + } +} + +func generatePlatformInviteHandler(repo *Repository) fiber.Handler { + return func(c *fiber.Ctx) error { + invite, err := repo.CreateInvite(c.Context(), nil, 72*time.Hour) + if err != nil { + return fiber.NewError(500, "erro ao gerar convite") + } + return c.Status(201).JSON(fiber.Map{ + "data": fiber.Map{"token": invite.Token, "expires_at": invite.ExpiresAt}, + "error": nil, + }) + } +} + +func getInviteHandler(repo *Repository) fiber.Handler { + return func(c *fiber.Ctx) error { + token := c.Params("token") + invite, err := repo.GetInviteByToken(c.Context(), token) + if err != nil { + return fiber.NewError(500, "erro interno") + } + if invite == nil { + return fiber.NewError(404, "convite não encontrado") + } + if invite.UsedAt != nil { + return fiber.NewError(410, "convite já foi utilizado") + } + if invite.ExpiresAt.Before(time.Now()) { + return fiber.NewError(410, "convite expirado") + } + return c.JSON(fiber.Map{"data": invite, "error": nil}) + } +} + +type redeemRequest struct { + TenantName string `json:"tenant_name"` + TenantSlug string `json:"tenant_slug"` + AdminEmail string `json:"admin_email"` + AdminPassword string `json:"admin_password"` + AdminName string `json:"admin_name"` +} + +func redeemInviteHandler(repo *Repository, db *database.DB, cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + token := c.Params("token") + invite, err := repo.GetInviteByToken(c.Context(), token) + if err != nil || invite == nil { + return fiber.NewError(404, "convite não encontrado") + } + if invite.UsedAt != nil || invite.ExpiresAt.Before(time.Now()) { + return fiber.NewError(410, "convite inválido ou expirado") + } + + var req redeemRequest + if err := c.BodyParser(&req); err != nil { + return fiber.NewError(400, "corpo do pedido inválido") + } + if req.TenantName == "" || req.TenantSlug == "" || req.AdminEmail == "" || req.AdminPassword == "" || req.AdminName == "" { + return fiber.NewError(400, "todos os campos são obrigatórios") + } + if len(req.AdminPassword) < 8 { + return fiber.NewError(400, "password deve ter pelo menos 8 caracteres") + } + + existing, _ := repo.GetTenantBySlug(c.Context(), req.TenantSlug) + if existing != nil { + return fiber.NewError(409, "slug já existe") + } + + ten, err := repo.CreateTenant(c.Context(), req.TenantSlug, req.TenantName) + if err != nil { + return fiber.NewError(500, "erro ao criar oficina") + } + + if db != nil { + if err := db.ProvisionTenantSchema(c.Context(), cfg.DatabaseURL, ten.ID, "migrations/tenant"); err != nil { + return fiber.NewError(500, "erro ao provisionar schema") + } + } + + hash, err := auth.HashPassword(req.AdminPassword) + if err != nil { + return fiber.NewError(500, "erro interno") + } + + var userID string + if db != nil { + user, err := repo.CreateTenantUser(c.Context(), ten.ID, req.AdminEmail, hash, req.AdminName, "tenant_admin") + if err != nil { + return fiber.NewError(500, "erro ao criar utilizador") + } + userID = user.ID + } else { + userID = "mock-user-id" + } + + if err := repo.UseInvite(c.Context(), invite.ID); err != nil { + return fiber.NewError(500, "erro ao registar utilização do convite") + } + + access, _ := auth.GenerateAccessToken(userID, ten.ID, "tenant_admin", cfg.JWTSecret) + refresh, _ := auth.GenerateRefreshToken(userID, ten.ID, "tenant_admin", cfg.JWTSecret) + auth.SetRefreshCookie(c, refresh, cfg) + + return c.Status(201).JSON(fiber.Map{ + "data": fiber.Map{ + "access_token": access, + "tenant_slug": ten.Slug, + }, + "error": nil, + }) + } +} +``` + +- [ ] **Step 5: Create routes.go** + +Create `backend/internal/tenant/routes.go`: + +```go +package tenant + +import ( + "github.com/gofiber/fiber/v2" + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/internal/config" + "github.com/techxcar/backend/pkg/database" +) + +func RegisterRoutes(app *fiber.App, repo *Repository, db *database.DB, cfg *config.Config) { + // Public invite routes + invites := app.Group("/api/v1/invites") + invites.Get("/:token", getInviteHandler(repo)) + invites.Post("/:token/redeem", redeemInviteHandler(repo, db, cfg)) + + // Super-admin routes + admin := app.Group("/api/v1/admin", + auth.RequireAuth(cfg.JWTSecret), + auth.RequireRole("super_admin"), + ) + admin.Get("/tenants", listTenantsHandler(repo)) + admin.Post("/tenants", createTenantHandler(repo, db, cfg)) + admin.Post("/tenants/:id/invite", generateInviteHandler(repo)) + admin.Post("/invites", generatePlatformInviteHandler(repo)) +} +``` + +- [ ] **Step 6: Run all tests** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./... -v +go build ./... +``` + +Expected: all non-integration tests PASS (integration tests skip without TEST_DATABASE_URL), build success. + +- [ ] **Step 7: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add backend/internal/tenant/ backend/internal/auth/handler.go +git commit -m "feat: tenant handlers — super-admin CRUD, invite generation, and public invite redemption" +``` + +--- + +### Task 6: Config update + Server wiring + main.go + seed initial super-admin + +**Files:** +- Modify: `backend/internal/config/config.go` +- Modify: `backend/internal/config/config_test.go` +- Modify: `backend/internal/server/server.go` +- Modify: `backend/cmd/server/main.go` +- Modify: `.env.example` + +**Interfaces:** +- Consumes: all backend packages from Tasks 1-5 +- Produces: running server with auth + tenant routes wired; `INITIAL_ADMIN_EMAIL` + `INITIAL_ADMIN_PASSWORD` env vars seed a super_admin on first run + +- [ ] **Step 1: Update config.go to add optional initial admin env vars** + +In `backend/internal/config/config.go`, add two optional fields to `Config` and load them in `Load()`: + +```go +type Config struct { + DatabaseURL string + RedisURL string + JWTSecret string + Port string + AppEnv string + InitialAdminEmail string // optional — seeds super_admin on first run + InitialAdminPassword string // optional +} +``` + +At the end of `Load()`, before the return: + +```go + cfg.InitialAdminEmail = os.Getenv("INITIAL_ADMIN_EMAIL") + cfg.InitialAdminPassword = os.Getenv("INITIAL_ADMIN_PASSWORD") + + return &Config{ + DatabaseURL: dbURL, + RedisURL: redisURL, + JWTSecret: jwtSecret, + Port: port, + AppEnv: appEnv, + InitialAdminEmail: os.Getenv("INITIAL_ADMIN_EMAIL"), + InitialAdminPassword: os.Getenv("INITIAL_ADMIN_PASSWORD"), + }, nil +``` + +The struct literal at the end of `Load()` should include the two new fields. Replace the existing `return &Config{...}` block: + +```go + return &Config{ + DatabaseURL: dbURL, + RedisURL: redisURL, + JWTSecret: jwtSecret, + Port: port, + AppEnv: appEnv, + InitialAdminEmail: os.Getenv("INITIAL_ADMIN_EMAIL"), + InitialAdminPassword: os.Getenv("INITIAL_ADMIN_PASSWORD"), + }, nil +``` + +- [ ] **Step 2: Run existing config tests to confirm nothing broke** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./internal/config/... -v +``` + +Expected: all 4 tests PASS. + +- [ ] **Step 3: Update server.go to accept Deps and wire routes** + +Replace `backend/internal/server/server.go` entirely: + +```go +package server + +import ( + "github.com/gofiber/fiber/v2" + "github.com/gofiber/fiber/v2/middleware/cors" + "github.com/gofiber/fiber/v2/middleware/helmet" + "github.com/gofiber/fiber/v2/middleware/logger" + "github.com/gofiber/fiber/v2/middleware/recover" + + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/internal/config" + "github.com/techxcar/backend/internal/tenant" + "github.com/techxcar/backend/pkg/database" + redispkg "github.com/techxcar/backend/pkg/redis" +) + +type Deps struct { + Config *config.Config + DB *database.DB + Redis *redispkg.Redis +} + +func New(deps Deps) *fiber.App { + app := fiber.New(fiber.Config{ + AppName: "TechXCar API", + ErrorHandler: errorHandler, + }) + + app.Use(recover.New()) + app.Use(logger.New()) + app.Use(helmet.New()) + app.Use(cors.New(cors.Config{ + AllowOrigins: "*", + AllowHeaders: "Origin, Content-Type, Accept, Authorization", + AllowMethods: "GET, POST, PUT, PATCH, DELETE, OPTIONS", + AllowCredentials: true, + })) + + RegisterHealthRoutes(app) + + if deps.DB != nil && deps.Redis != nil && deps.Config != nil { + repo := tenant.NewRepository(deps.DB) + auth.RegisterRoutes(app, repo, deps.Redis, deps.Config) + tenant.RegisterRoutes(app, repo, deps.DB, deps.Config) + } + + return app +} + +func errorHandler(c *fiber.Ctx, err error) error { + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { + code = e.Code + } + return c.Status(code).JSON(fiber.Map{ + "data": nil, + "error": err.Error(), + }) +} +``` + +- [ ] **Step 4: Run health tests to confirm they still pass** + +Health tests create `fiber.New()` directly and call `server.RegisterHealthRoutes()` — they do NOT use `server.New()`, so changing its signature doesn't break them. + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go test ./internal/server/... -v +``` + +Expected: 2 PASS. + +- [ ] **Step 5: Update main.go** + +Replace `backend/cmd/server/main.go` entirely: + +```go +package main + +import ( + "context" + "log" + "time" + + "github.com/joho/godotenv" + + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/internal/config" + "github.com/techxcar/backend/internal/server" + "github.com/techxcar/backend/internal/tenant" + "github.com/techxcar/backend/pkg/database" + redispkg "github.com/techxcar/backend/pkg/redis" +) + +func main() { + if err := godotenv.Load(); err != nil { + log.Println("No .env file found, reading from environment") + } + + cfg, err := config.Load() + if err != nil { + log.Fatal("Config error:", err) + } + + db, err := database.New(cfg.DatabaseURL) + if err != nil { + log.Fatal("Database error:", err) + } + defer db.Close() + + if err := db.MigratePublic(cfg.DatabaseURL, "migrations/public"); err != nil { + log.Fatal("Migration error:", err) + } + + rdb, err := redispkg.New(cfg.RedisURL) + if err != nil { + log.Fatal("Redis error:", err) + } + defer rdb.Close() + + seedSuperAdmin(db, rdb, cfg) + + app := server.New(server.Deps{Config: cfg, DB: db, Redis: rdb}) + + log.Printf("TechXCar API v0.2.0 listening on :%s (env: %s)", cfg.Port, cfg.AppEnv) + log.Fatal(app.Listen(":" + cfg.Port)) +} + +func seedSuperAdmin(db *database.DB, rdb *redispkg.Redis, cfg *config.Config) { + if cfg.InitialAdminEmail == "" || cfg.InitialAdminPassword == "" { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + + repo := tenant.NewRepository(db) + existing, err := repo.GetSuperAdminByEmail(ctx, cfg.InitialAdminEmail) + if err != nil || existing != nil { + return + } + hash, err := auth.HashPassword(cfg.InitialAdminPassword) + if err != nil { + log.Printf("Warning: could not hash initial admin password: %v", err) + return + } + if _, err := repo.CreateSuperAdmin(ctx, cfg.InitialAdminEmail, hash); err != nil { + log.Printf("Warning: could not create initial super admin: %v", err) + return + } + log.Printf("Initial super admin created: %s", cfg.InitialAdminEmail) +} +``` + +- [ ] **Step 6: Update .env.example** + +Add to `backend/.env.example` (or root `.env.example` — it's in the root): + +Open `/var/home/lmilani/Documentos/IDE/techxcar/.env.example` and append: + +```env +# Initial super-admin (only used on first startup, skipped if already exists) +INITIAL_ADMIN_EMAIL=admin@techxcar.com +INITIAL_ADMIN_PASSWORD=change-me-on-first-login +``` + +- [ ] **Step 7: Full build and test** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +go build ./... +go test ./... -v +``` + +Expected: build success, all non-integration tests PASS. + +- [ ] **Step 8: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add backend/internal/config/ backend/internal/server/ backend/cmd/ .env.example +git commit -m "feat: wire auth and tenant routes into server, add optional super-admin seeding on startup" +``` + +--- + +### Task 7: Frontend — real login page + +**Files:** +- Modify: `frontend/src/pages/auth/LoginPage.tsx` +- Create: `frontend/src/hooks/useAuth.ts` + +**Interfaces:** +- Consumes: `apiFetch` from `@/lib/api`, `useAuthStore` from `@/store/authStore`, `Button` and `Input` and `Label` from `@/components/ui/*` +- Produces: + - `useLogin()` hook returning `{ mutate, isPending, error }` + - `useLogout()` hook + - `LoginPage` component: email + password + optional tenant_slug fields, React Hook Form + Zod validation, redirects to `/app` (tenant) or `/admin` (super_admin) on success + +- [ ] **Step 1: Write the failing test** + +Add to `frontend/src/store/authStore.test.ts` (this file already exists and passes): + +```ts +// No new tests needed — the hook itself will be tested via the component in a later plan. +// Verify the hook compiles by running: npm run build +``` + +- [ ] **Step 2: Create useAuth.ts** + +Create `frontend/src/hooks/useAuth.ts`: + +```ts +import { useMutation } from '@tanstack/react-query' +import { useNavigate } from 'react-router' +import { apiFetch } from '@/lib/api' +import { useAuthStore, type AuthUser } from '@/store/authStore' + +interface LoginRequest { + email: string + password: string + tenant_slug?: string +} + +interface LoginResponse { + access_token: string + user: AuthUser +} + +export function useLogin() { + const { setAuth } = useAuthStore() + const navigate = useNavigate() + + return useMutation({ + mutationFn: async (data: LoginRequest) => { + return apiFetch('/auth/login', { + method: 'POST', + body: JSON.stringify(data), + }) + }, + onSuccess: (data) => { + setAuth(data.user, data.access_token) + if (data.user.role === 'super_admin') { + navigate('/admin', { replace: true }) + } else { + navigate('/app', { replace: true }) + } + }, + }) +} + +export function useLogout() { + const { clearAuth } = useAuthStore() + const navigate = useNavigate() + + return useMutation({ + mutationFn: async () => { + await apiFetch('/auth/logout', { method: 'POST' }) + }, + onSettled: () => { + clearAuth() + navigate('/login', { replace: true }) + }, + }) +} +``` + +Note: the login response needs `user` in the data envelope. Update `LoginHandler` in `backend/internal/auth/handler.go` to return user info alongside the token: + +In `LoginHandler`, replace the final `return c.JSON(...)` calls to include user info: + +For super_admin: +```go +return c.JSON(fiber.Map{"data": fiber.Map{ + "access_token": access, + "user": fiber.Map{ + "id": admin.ID, "email": admin.Email, + "name": admin.Email, "role": "super_admin", + }, +}, "error": nil}) +``` + +For tenant user: +```go +return c.JSON(fiber.Map{"data": fiber.Map{ + "access_token": access, + "user": fiber.Map{ + "id": user.ID, "email": user.Email, + "name": user.Name, "role": user.Role, + "tenantId": ten.ID, + }, +}, "error": nil}) +``` + +Also update `TestLogin_superAdmin_success` and `TestLogin_tenantUser_success` in `handler_test.go` to check for `user` field in response. + +- [ ] **Step 3: Replace LoginPage.tsx** + +Replace `frontend/src/pages/auth/LoginPage.tsx`: + +```tsx +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useLogin } from '@/hooks/useAuth' + +const schema = z.object({ + email: z.string().email('Email inválido'), + password: z.string().min(1, 'Password obrigatória'), + tenant_slug: z.string().optional(), +}) +type FormData = z.infer + +export default function LoginPage() { + const [showSlug, setShowSlug] = useState(false) + const { mutate: login, isPending, error } = useLogin() + + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + }) + + const onSubmit = (data: FormData) => { + login({ + email: data.email, + password: data.password, + tenant_slug: data.tenant_slug || undefined, + }) + } + + return ( +
+
+
+

TechXCar

+

Gestão de Oficina

+
+ +
+
+ + + {errors.email &&

{errors.email.message}

} +
+ +
+ + + {errors.password &&

{errors.password.message}

} +
+ + {showSlug && ( +
+ + +
+ )} + + {error && ( +

{error.message}

+ )} + + + + +
+
+
+ ) +} +``` + +- [ ] **Step 4: Build to verify no TypeScript errors** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/frontend +npm run build +``` + +Expected: build success, zero TypeScript errors. + +- [ ] **Step 5: Run frontend tests** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/frontend +npm run test:run +``` + +Expected: 4 PASS (authStore tests). + +- [ ] **Step 6: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add frontend/src/pages/auth/ frontend/src/hooks/ +git commit -m "feat: login page with React Hook Form + Zod validation and useLogin/useLogout hooks" +``` + +--- + +### Task 8: Frontend — super-admin tenants panel + +**Files:** +- Modify: `frontend/src/App.tsx` — add `/admin/tenants` route +- Modify: `frontend/src/pages/admin/DashboardPage.tsx` — add nav link to tenants +- Create: `frontend/src/pages/admin/TenantsPage.tsx` +- Create: `frontend/src/components/layout/AdminLayout.tsx` — add sidebar nav (replaces placeholder) + +**Interfaces:** +- Consumes: `apiFetch` from `@/lib/api`, TanStack Query, `Button`, `Input`, `Badge` from `@/components/ui/*` +- Produces: + - `GET /api/v1/admin/tenants` — fetched via TanStack Query `['admin', 'tenants']` + - `POST /api/v1/admin/invites` — mutation to generate platform invite + - `TenantsPage` — table of tenants + "Gerar Convite" button that shows the invite token + +- [ ] **Step 1: Add tenant types and API hooks** + +Create `frontend/src/lib/types.ts`: + +```ts +export interface Tenant { + id: string + slug: string + name: string + status: 'active' | 'suspended' | 'pending' + created_at: string +} + +export interface Invite { + id: string + token: string + tenant_id: string | null + expires_at: string + used_at: string | null + created_at: string +} +``` + +- [ ] **Step 2: Create TenantsPage.tsx** + +Create `frontend/src/pages/admin/TenantsPage.tsx`: + +```tsx +import { useState } from 'react' +import { useQuery, useMutation } from '@tanstack/react-query' +import { apiFetch } from '@/lib/api' +import { queryClient } from '@/lib/queryClient' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import type { Tenant, Invite } from '@/lib/types' + +function statusVariant(status: string) { + if (status === 'active') return 'success' + if (status === 'suspended') return 'destructive' + return 'secondary' +} + +export default function TenantsPage() { + const [inviteToken, setInviteToken] = useState(null) + + const { data: tenants = [], isLoading } = useQuery({ + queryKey: ['admin', 'tenants'], + queryFn: () => apiFetch('/admin/tenants'), + }) + + const generateInvite = useMutation({ + mutationFn: () => apiFetch('/admin/invites', { method: 'POST' }), + onSuccess: (invite) => setInviteToken(invite.token), + }) + + const inviteUrl = inviteToken + ? `${window.location.origin}/invite/${inviteToken}` + : null + + return ( +
+
+
+

Oficinas

+

{tenants.length} oficinas registadas

+
+ +
+ + {inviteUrl && ( +
+

Link de convite (válido 72h):

+
+ + {inviteUrl} + + +
+ +
+ )} + + {isLoading ? ( +

A carregar...

+ ) : tenants.length === 0 ? ( +

Nenhuma oficina registada.

+ ) : ( +
+ + + + + + + + + + + {tenants.map((t) => ( + + + + + + + ))} + +
NomeSlugEstadoCriada
{t.name}{t.slug} + {t.status} + + {new Intl.DateTimeFormat('pt-PT').format(new Date(t.created_at))} +
+
+ )} +
+ ) +} +``` + +- [ ] **Step 3: Update AdminLayout.tsx with navigation** + +Replace `frontend/src/components/layout/AdminLayout.tsx`: + +```tsx +import { Outlet, NavLink } from 'react-router' +import { useLogout } from '@/hooks/useAuth' + +export default function AdminLayout() { + const { mutate: logout } = useLogout() + + return ( +
+ +
+ +
+
+ ) +} +``` + +- [ ] **Step 4: Update App.tsx to add /admin/tenants route** + +In `frontend/src/App.tsx`, add the import and route: + +```tsx +import TenantsPage from '@/pages/admin/TenantsPage' +``` + +Inside the `/admin` route's `` children, add: + +```tsx +} /> +``` + +The `/admin` route block becomes: + +```tsx + + + + } +> + } /> + } /> + +``` + +- [ ] **Step 5: Build and run frontend tests** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/frontend +npm run build +npm run test:run +``` + +Expected: build success, 4 tests PASS. + +- [ ] **Step 6: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add frontend/src/ +git commit -m "feat: super-admin tenants panel with tenant list and platform invite generation" +``` + +--- + +### Task 9: Frontend — invite redemption page + +**Files:** +- Create: `frontend/src/pages/public/InviteRedeemPage.tsx` +- Modify: `frontend/src/App.tsx` — add `/invite/:token` public route + +**Interfaces:** +- Consumes: `apiFetch`, React Hook Form + Zod, `Button`, `Input`, `Label` +- Produces: + - `GET /api/v1/invites/:token` — validates invite on mount + - `POST /api/v1/invites/:token/redeem` — creates tenant + admin, auto-logs in, redirects to `/app` + - Public route at `/invite/:token` — no auth required + +- [ ] **Step 1: Create InviteRedeemPage.tsx** + +Create `frontend/src/pages/public/InviteRedeemPage.tsx`: + +```tsx +import { useParams, useNavigate } from 'react-router' +import { useQuery, useMutation } from '@tanstack/react-query' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { apiFetch } from '@/lib/api' +import { useAuthStore } from '@/store/authStore' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { Invite } from '@/lib/types' + +const schema = z.object({ + tenant_name: z.string().min(2, 'Nome obrigatório (mínimo 2 caracteres)'), + tenant_slug: z + .string() + .min(2, 'Slug obrigatório') + .regex(/^[a-z0-9-]+$/, 'Apenas letras minúsculas, números e hífens'), + admin_email: z.string().email('Email inválido'), + admin_password: z.string().min(8, 'Mínimo 8 caracteres'), + admin_name: z.string().min(2, 'Nome obrigatório'), +}) +type FormData = z.infer + +interface RedeemResponse { + access_token: string + tenant_slug: string +} + +export default function InviteRedeemPage() { + const { token } = useParams<{ token: string }>() + const navigate = useNavigate() + const { setAuth } = useAuthStore() + + const { data: invite, isLoading, error: inviteError } = useQuery({ + queryKey: ['invite', token], + queryFn: () => apiFetch(`/invites/${token}`), + retry: false, + }) + + const { register, handleSubmit, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + }) + + const redeem = useMutation({ + mutationFn: (data: FormData) => + apiFetch(`/invites/${token}/redeem`, { + method: 'POST', + body: JSON.stringify(data), + }), + onSuccess: (data) => { + setAuth( + { id: '', email: '', name: '', role: 'tenant_admin', tenantId: undefined }, + data.access_token + ) + navigate('/app', { replace: true }) + }, + }) + + if (isLoading) { + return ( +
+

A validar convite...

+
+ ) + } + + if (inviteError || !invite) { + return ( +
+
+

Convite inválido

+

Este convite não existe, expirou ou já foi utilizado.

+
+
+ ) + } + + return ( +
+
+
+

TechXCar

+

Criar conta da oficina

+
+ +
redeem.mutate(data))} + className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 space-y-4" + > +
+ + Dados da oficina + +
+ + + {errors.tenant_name &&

{errors.tenant_name.message}

} +
+
+ + +

Usado no URL — apenas letras minúsculas, números e hífens.

+ {errors.tenant_slug &&

{errors.tenant_slug.message}

} +
+
+ +
+ + Conta de administrador + +
+ + + {errors.admin_name &&

{errors.admin_name.message}

} +
+
+ + + {errors.admin_email &&

{errors.admin_email.message}

} +
+
+ + + {errors.admin_password &&

{errors.admin_password.message}

} +
+
+ + {redeem.error && ( +

{redeem.error.message}

+ )} + + +
+
+
+ ) +} +``` + +- [ ] **Step 2: Add public route to App.tsx** + +In `frontend/src/App.tsx`, add import: + +```tsx +import InviteRedeemPage from '@/pages/public/InviteRedeemPage' +``` + +Add route before the `` redirect: + +```tsx +} /> +``` + +- [ ] **Step 3: Create public directory** + +```bash +mkdir -p /var/home/lmilani/Documentos/IDE/techxcar/frontend/src/pages/public +``` + +- [ ] **Step 4: Build and run tests** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/frontend +npm run build +npm run test:run +``` + +Expected: build success, 4 tests PASS. + +- [ ] **Step 5: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add frontend/src/ +git commit -m "feat: public invite redemption page — creates tenant + admin account and auto-logs in" +``` + +--- + +## O que este plano entrega + +- **Backend:** JWT auth (login/refresh/logout), bcrypt password hashing, Redis rate limiting (10 req/min), refresh token storage in Redis, JWT + role middleware, tenant schema injection per request +- **Backend:** Tenant repository with full CRUD + invite system; super-admin endpoints behind role guard; public invite redemption that provisions a fresh PostgreSQL schema +- **Backend:** Optional `INITIAL_ADMIN_EMAIL`/`INITIAL_ADMIN_PASSWORD` to seed the first super_admin on startup +- **Frontend:** Real login page (React Hook Form + Zod, optional workspace slug for tenant users) +- **Frontend:** Super-admin panel with tenants table + platform invite generation with copyable link +- **Frontend:** Public invite redemption form that creates a new tenant workspace and auto-logs in the admin + +## Planos seguintes + +| Plano | Âmbito | +|---|---| +| **Plano 3** | Core Workshop: Clientes, Veículos, Catálogo, Ordens de Trabalho (CRUD completo + máquina de estados) | +| **Plano 4** | Faturação, Técnicos & Despesas: Faturas/Orçamentos, geração PDF com chromedp, gestão de staff | +| **Plano 5** | Notificações, Dashboard & Relatórios: worker Telegram/Email, KPIs, relatórios, exportação | diff --git a/docs/superpowers/plans/2026-06-20-plan3-core-workshop.md b/docs/superpowers/plans/2026-06-20-plan3-core-workshop.md new file mode 100644 index 0000000..7a82d48 --- /dev/null +++ b/docs/superpowers/plans/2026-06-20-plan3-core-workshop.md @@ -0,0 +1,2628 @@ +# Plan 3: Core Workshop Implementation + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement Clients, Vehicles, Catalog Items, and Work Orders (with state machine, line items, and staff hours) — the core workshop data layer and UI. + +**Architecture:** No new DB migrations — all tables already exist in `migrations/tenant/000001_create_tenant_schema.up.sql`. Backend packages follow the same pattern as `internal/tenant/`: package-level handler functions receiving dependencies as args, routes registered in `server.New()`. All `/app/*` routes use `RequireAuth + RequireRole + TenantMiddleware` middleware stack; handlers acquire the tenant-scoped connection via `auth.GetConn(c)`. + +**Tech Stack:** Go 1.23 + Fiber v2 + pgx/v5; React 19 + TypeScript + TanStack Query v5 + React Hook Form + Zod + shadcn/ui + +## Global Constraints + +- Tenant schema set per-request; always use `auth.GetConn(c)` — never the pool directly +- `tenantID` validated as `^[a-zA-Z0-9_-]{1,63}$` before interpolation (already done in `TenantMiddleware`) +- Response envelope: `{"data": ..., "error": null}` or `{"data": null, "error": "message"}` +- API base path: `/api/v1/` — all routes mounted there +- Roles: `tenant_admin`, `manager`, `technician` — all three can read; only `tenant_admin`/`manager` can write +- PT-PT strings for error messages (match existing pattern in `internal/tenant/handler.go`) +- `apiFetch` returns `response.data` directly (see `frontend/src/lib/api.ts`) +- No new DB migrations; use only existing columns + +--- + +## File Map + +**Backend — new files:** +- `backend/internal/client/handler.go` — HTTP handlers for clients + vehicles +- `backend/internal/client/repository.go` — DB queries for clients + vehicles +- `backend/internal/catalog/handler.go` — HTTP handlers for catalog items +- `backend/internal/catalog/repository.go` — DB queries for catalog items +- `backend/internal/workorder/handler.go` — HTTP handlers for work orders + state transitions + items + staff hours +- `backend/internal/workorder/repository.go` — DB queries for all work_order tables + +**Backend — modified:** +- `backend/internal/server/server.go` — wire new route groups + +**Frontend — new files:** +- `frontend/src/lib/types.ts` — extend with new domain types +- `frontend/src/pages/app/ClientsPage.tsx` — clients list + modal form +- `frontend/src/pages/app/VehiclesPage.tsx` — vehicles list + modal form (filtered by client) +- `frontend/src/pages/app/CatalogPage.tsx` — catalog items list + modal form +- `frontend/src/pages/app/WorkOrdersPage.tsx` — work orders list with status badges +- `frontend/src/pages/app/WorkOrderDetailPage.tsx` — detail view: items, staff hours, state machine buttons + +**Frontend — modified:** +- `frontend/src/components/layout/AppLayout.tsx` — full navigation sidebar +- `frontend/src/App.tsx` — add new routes + +--- + +### Task 1: Client Repository + Handlers + +**Files:** +- Create: `backend/internal/client/repository.go` +- Create: `backend/internal/client/handler.go` + +**Interfaces:** +- Produces: + - `client.RegisterRoutes(app *fiber.App, db *database.DB, cfg *config.Config)` + - GET `/api/v1/clients` → `[]Client` + - POST `/api/v1/clients` → `Client` (201) + - GET `/api/v1/clients/:id` → `Client` + - PUT `/api/v1/clients/:id` → `Client` + - DELETE `/api/v1/clients/:id` → 204 + - GET `/api/v1/clients/:id/vehicles` → `[]Vehicle` + - POST `/api/v1/clients/:id/vehicles` → `Vehicle` (201) + - PUT `/api/v1/vehicles/:id` → `Vehicle` + +- [ ] **Step 1: Write failing tests** + +Create `backend/internal/client/repository_test.go`: + +```go +package client_test + +import ( + "context" + "os" + "testing" + "time" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/techxcar/backend/internal/client" +) + +func getTestConn(t *testing.T) *pgxpool.Conn { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + pool, err := pgxpool.New(context.Background(), dsn) + require.NoError(t, err) + t.Cleanup(func() { pool.Close() }) + + conn, err := pool.Acquire(context.Background()) + require.NoError(t, err) + t.Cleanup(func() { conn.Release() }) + + _, err = conn.Exec(context.Background(), "SET search_path = tenant_test, public") + require.NoError(t, err) + return conn +} + +func TestClientCRUD(t *testing.T) { + conn := getTestConn(t) + ctx := context.Background() + + c, err := client.CreateClient(ctx, conn, "João Silva", "123456789", "912345678", "joao@example.com", "Rua A", "") + require.NoError(t, err) + assert.NotEmpty(t, c.ID) + assert.Equal(t, "João Silva", c.Name) + + list, err := client.ListClients(ctx, conn) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(list), 1) + + got, err := client.GetClient(ctx, conn, c.ID) + require.NoError(t, err) + assert.Equal(t, c.ID, got.ID) + + updated, err := client.UpdateClient(ctx, conn, c.ID, "João Santos", "987654321", "921000000", "joao2@example.com", "Rua B", "nota") + require.NoError(t, err) + assert.Equal(t, "João Santos", updated.Name) + + err = client.DeleteClient(ctx, conn, c.ID) + require.NoError(t, err) +} + +func TestVehicleCRUD(t *testing.T) { + conn := getTestConn(t) + ctx := context.Background() + + c, err := client.CreateClient(ctx, conn, "Test Client", "", "", "", "", "") + require.NoError(t, err) + + v, err := client.CreateVehicle(ctx, conn, c.ID, "AA-00-AA", "Toyota", "Yaris", 2020, "", 50000, "") + require.NoError(t, err) + assert.NotEmpty(t, v.ID) + assert.Equal(t, "AA-00-AA", v.Plate) + + list, err := client.ListVehiclesByClient(ctx, conn, c.ID) + require.NoError(t, err) + assert.Len(t, list, 1) + + updated, err := client.UpdateVehicle(ctx, conn, v.ID, c.ID, "BB-11-BB", "Toyota", "Yaris", 2021, "", 60000, "nota") + require.NoError(t, err) + assert.Equal(t, "BB-11-BB", updated.Plate) +} +``` + +- [ ] **Step 2: Run tests — verify they fail (compile error)** + +```bash +cd backend && go test ./internal/client/... 2>&1 | head -30 +``` +Expected: `cannot find package` + +- [ ] **Step 3: Write repository** + +Create `backend/internal/client/repository.go`: + +```go +package client + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type Client struct { + ID string `json:"id"` + Name string `json:"name"` + NIF string `json:"nif"` + Phone string `json:"phone"` + Email string `json:"email"` + Address string `json:"address"` + Notes string `json:"notes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type Vehicle struct { + ID string `json:"id"` + ClientID *string `json:"client_id"` + Plate string `json:"plate"` + Brand string `json:"brand"` + Model string `json:"model"` + Year *int `json:"year"` + VIN string `json:"vin"` + Mileage *int `json:"mileage"` + Notes string `json:"notes"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func ListClients(ctx context.Context, conn *pgxpool.Conn) ([]*Client, error) { + rows, err := conn.Query(ctx, ` + SELECT id, name, COALESCE(nif,''), COALESCE(phone,''), COALESCE(email,''), + COALESCE(address,''), COALESCE(notes,''), created_at, updated_at + FROM clients ORDER BY name`) + if err != nil { + return nil, err + } + defer rows.Close() + var list []*Client + for rows.Next() { + var c Client + if err := rows.Scan(&c.ID, &c.Name, &c.NIF, &c.Phone, &c.Email, + &c.Address, &c.Notes, &c.CreatedAt, &c.UpdatedAt); err != nil { + return nil, err + } + list = append(list, &c) + } + return list, rows.Err() +} + +func GetClient(ctx context.Context, conn *pgxpool.Conn, id string) (*Client, error) { + var c Client + err := conn.QueryRow(ctx, ` + SELECT id, name, COALESCE(nif,''), COALESCE(phone,''), COALESCE(email,''), + COALESCE(address,''), COALESCE(notes,''), created_at, updated_at + FROM clients WHERE id = $1`, id). + Scan(&c.ID, &c.Name, &c.NIF, &c.Phone, &c.Email, + &c.Address, &c.Notes, &c.CreatedAt, &c.UpdatedAt) + if err != nil { + return nil, err + } + return &c, nil +} + +func CreateClient(ctx context.Context, conn *pgxpool.Conn, name, nif, phone, email, address, notes string) (*Client, error) { + var c Client + err := conn.QueryRow(ctx, ` + INSERT INTO clients (name, nif, phone, email, address, notes) + VALUES ($1, NULLIF($2,''), NULLIF($3,''), NULLIF($4,''), NULLIF($5,''), NULLIF($6,'')) + RETURNING id, name, COALESCE(nif,''), COALESCE(phone,''), COALESCE(email,''), + COALESCE(address,''), COALESCE(notes,''), created_at, updated_at`, + name, nif, phone, email, address, notes). + Scan(&c.ID, &c.Name, &c.NIF, &c.Phone, &c.Email, + &c.Address, &c.Notes, &c.CreatedAt, &c.UpdatedAt) + return &c, err +} + +func UpdateClient(ctx context.Context, conn *pgxpool.Conn, id, name, nif, phone, email, address, notes string) (*Client, error) { + var c Client + err := conn.QueryRow(ctx, ` + UPDATE clients SET name=$2, nif=NULLIF($3,''), phone=NULLIF($4,''), email=NULLIF($5,''), + address=NULLIF($6,''), notes=NULLIF($7,''), updated_at=NOW() + WHERE id=$1 + RETURNING id, name, COALESCE(nif,''), COALESCE(phone,''), COALESCE(email,''), + COALESCE(address,''), COALESCE(notes,''), created_at, updated_at`, + id, name, nif, phone, email, address, notes). + Scan(&c.ID, &c.Name, &c.NIF, &c.Phone, &c.Email, + &c.Address, &c.Notes, &c.CreatedAt, &c.UpdatedAt) + return &c, err +} + +func DeleteClient(ctx context.Context, conn *pgxpool.Conn, id string) error { + _, err := conn.Exec(ctx, `DELETE FROM clients WHERE id = $1`, id) + return err +} + +func ListVehiclesByClient(ctx context.Context, conn *pgxpool.Conn, clientID string) ([]*Vehicle, error) { + rows, err := conn.Query(ctx, ` + SELECT id, client_id, plate, brand, model, year, COALESCE(vin,''), mileage, COALESCE(notes,''), created_at, updated_at + FROM vehicles WHERE client_id = $1 ORDER BY plate`, clientID) + if err != nil { + return nil, err + } + defer rows.Close() + var list []*Vehicle + for rows.Next() { + var v Vehicle + if err := rows.Scan(&v.ID, &v.ClientID, &v.Plate, &v.Brand, &v.Model, + &v.Year, &v.VIN, &v.Mileage, &v.Notes, &v.CreatedAt, &v.UpdatedAt); err != nil { + return nil, err + } + list = append(list, &v) + } + return list, rows.Err() +} + +func CreateVehicle(ctx context.Context, conn *pgxpool.Conn, clientID, plate, brand, model string, year int, vin string, mileage int, notes string) (*Vehicle, error) { + var v Vehicle + var yearPtr *int + if year != 0 { + yearPtr = &year + } + var mileagePtr *int + if mileage != 0 { + mileagePtr = &mileage + } + err := conn.QueryRow(ctx, ` + INSERT INTO vehicles (client_id, plate, brand, model, year, vin, mileage, notes) + VALUES (NULLIF($1,'')::uuid, $2, $3, $4, $5, NULLIF($6,''), $7, NULLIF($8,'')) + RETURNING id, client_id, plate, brand, model, year, COALESCE(vin,''), mileage, COALESCE(notes,''), created_at, updated_at`, + clientID, plate, brand, model, yearPtr, vin, mileagePtr, notes). + Scan(&v.ID, &v.ClientID, &v.Plate, &v.Brand, &v.Model, + &v.Year, &v.VIN, &v.Mileage, &v.Notes, &v.CreatedAt, &v.UpdatedAt) + return &v, err +} + +func UpdateVehicle(ctx context.Context, conn *pgxpool.Conn, id, clientID, plate, brand, model string, year int, vin string, mileage int, notes string) (*Vehicle, error) { + var v Vehicle + var yearPtr *int + if year != 0 { + yearPtr = &year + } + var mileagePtr *int + if mileage != 0 { + mileagePtr = &mileage + } + err := conn.QueryRow(ctx, ` + UPDATE vehicles SET client_id=NULLIF($2,'')::uuid, plate=$3, brand=$4, model=$5, + year=$6, vin=NULLIF($7,''), mileage=$8, notes=NULLIF($9,''), updated_at=NOW() + WHERE id=$1 + RETURNING id, client_id, plate, brand, model, year, COALESCE(vin,''), mileage, COALESCE(notes,''), created_at, updated_at`, + id, clientID, plate, brand, model, yearPtr, vin, mileagePtr, notes). + Scan(&v.ID, &v.ClientID, &v.Plate, &v.Brand, &v.Model, + &v.Year, &v.VIN, &v.Mileage, &v.Notes, &v.CreatedAt, &v.UpdatedAt) + return &v, err +} +``` + +- [ ] **Step 4: Write handlers** + +Create `backend/internal/client/handler.go`: + +```go +package client + +import ( + "github.com/gofiber/fiber/v2" + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/pkg/database" +) + +func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { + rw := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager", "technician"), + auth.TenantMiddleware(db), + } + write := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager"), + auth.TenantMiddleware(db), + } + + app.Get("/api/v1/clients", append(rw, listClientsH())...) + app.Post("/api/v1/clients", append(write, createClientH())...) + app.Get("/api/v1/clients/:id", append(rw, getClientH())...) + app.Put("/api/v1/clients/:id", append(write, updateClientH())...) + app.Delete("/api/v1/clients/:id", append(write, deleteClientH())...) + + app.Get("/api/v1/clients/:id/vehicles", append(rw, listVehiclesH())...) + app.Post("/api/v1/clients/:id/vehicles", append(write, createVehicleH())...) + app.Put("/api/v1/vehicles/:id", append(write, updateVehicleH())...) +} + +func listClientsH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + list, err := ListClients(c.Context(), conn) + if err != nil { + return fiber.NewError(500, "erro ao listar clientes") + } + if list == nil { + list = []*Client{} + } + return c.JSON(fiber.Map{"data": list, "error": nil}) + } +} + +func getClientH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + cl, err := GetClient(c.Context(), conn, c.Params("id")) + if err != nil { + return fiber.NewError(404, "cliente não encontrado") + } + return c.JSON(fiber.Map{"data": cl, "error": nil}) + } +} + +type clientBody struct { + Name string `json:"name"` + NIF string `json:"nif"` + Phone string `json:"phone"` + Email string `json:"email"` + Address string `json:"address"` + Notes string `json:"notes"` +} + +func createClientH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b clientBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + if b.Name == "" { + return fiber.NewError(400, "nome é obrigatório") + } + conn := auth.GetConn(c) + cl, err := CreateClient(c.Context(), conn, b.Name, b.NIF, b.Phone, b.Email, b.Address, b.Notes) + if err != nil { + return fiber.NewError(500, "erro ao criar cliente") + } + return c.Status(201).JSON(fiber.Map{"data": cl, "error": nil}) + } +} + +func updateClientH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b clientBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + if b.Name == "" { + return fiber.NewError(400, "nome é obrigatório") + } + conn := auth.GetConn(c) + cl, err := UpdateClient(c.Context(), conn, c.Params("id"), b.Name, b.NIF, b.Phone, b.Email, b.Address, b.Notes) + if err != nil { + return fiber.NewError(500, "erro ao actualizar cliente") + } + return c.JSON(fiber.Map{"data": cl, "error": nil}) + } +} + +func deleteClientH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + if err := DeleteClient(c.Context(), conn, c.Params("id")); err != nil { + return fiber.NewError(500, "erro ao eliminar cliente") + } + return c.SendStatus(204) + } +} + +func listVehiclesH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + list, err := ListVehiclesByClient(c.Context(), conn, c.Params("id")) + if err != nil { + return fiber.NewError(500, "erro ao listar veículos") + } + if list == nil { + list = []*Vehicle{} + } + return c.JSON(fiber.Map{"data": list, "error": nil}) + } +} + +type vehicleBody struct { + ClientID string `json:"client_id"` + Plate string `json:"plate"` + Brand string `json:"brand"` + Model string `json:"model"` + Year int `json:"year"` + VIN string `json:"vin"` + Mileage int `json:"mileage"` + Notes string `json:"notes"` +} + +func createVehicleH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b vehicleBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + if b.Plate == "" || b.Brand == "" || b.Model == "" { + return fiber.NewError(400, "matrícula, marca e modelo são obrigatórios") + } + conn := auth.GetConn(c) + v, err := CreateVehicle(c.Context(), conn, c.Params("id"), b.Plate, b.Brand, b.Model, b.Year, b.VIN, b.Mileage, b.Notes) + if err != nil { + return fiber.NewError(500, "erro ao criar veículo") + } + return c.Status(201).JSON(fiber.Map{"data": v, "error": nil}) + } +} + +func updateVehicleH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b vehicleBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + if b.Plate == "" || b.Brand == "" || b.Model == "" { + return fiber.NewError(400, "matrícula, marca e modelo são obrigatórios") + } + conn := auth.GetConn(c) + v, err := UpdateVehicle(c.Context(), conn, c.Params("id"), b.ClientID, b.Plate, b.Brand, b.Model, b.Year, b.VIN, b.Mileage, b.Notes) + if err != nil { + return fiber.NewError(500, "erro ao actualizar veículo") + } + return c.JSON(fiber.Map{"data": v, "error": nil}) + } +} +``` + +- [ ] **Step 5: Run tests** + +```bash +cd backend && go test ./internal/client/... -v 2>&1 +``` +Expected: SKIP (no TEST_DATABASE_URL) or PASS (with env set). No compile errors. + +- [ ] **Step 6: Compile check** + +```bash +cd backend && go build ./... 2>&1 +``` +Expected: no errors (client package not yet wired — that's Task 4) + +- [ ] **Step 7: Commit** + +```bash +git add backend/internal/client/ +git commit -m "feat: client + vehicle repository and HTTP handlers" +``` + +--- + +### Task 2: Catalog Repository + Handlers + +**Files:** +- Create: `backend/internal/catalog/repository.go` +- Create: `backend/internal/catalog/handler.go` + +**Interfaces:** +- Produces: + - `catalog.RegisterRoutes(app *fiber.App, db *database.DB, secret string)` + - GET `/api/v1/catalog` → `[]CatalogItem` + - POST `/api/v1/catalog` → `CatalogItem` (201) + - PUT `/api/v1/catalog/:id` → `CatalogItem` + - DELETE `/api/v1/catalog/:id` → 204 + +- [ ] **Step 1: Write failing test** + +Create `backend/internal/catalog/repository_test.go`: + +```go +package catalog_test + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/techxcar/backend/internal/catalog" +) + +func getTestConn(t *testing.T) *pgxpool.Conn { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + pool, err := pgxpool.New(context.Background(), dsn) + require.NoError(t, err) + t.Cleanup(func() { pool.Close() }) + conn, err := pool.Acquire(context.Background()) + require.NoError(t, err) + t.Cleanup(func() { conn.Release() }) + _, err = conn.Exec(context.Background(), "SET search_path = tenant_test, public") + require.NoError(t, err) + return conn +} + +func TestCatalogCRUD(t *testing.T) { + conn := getTestConn(t) + ctx := context.Background() + + item, err := catalog.CreateItem(ctx, conn, "MO-5W40", "Óleo Motor 5W40", "lubrificantes", "litro", 12.50) + require.NoError(t, err) + assert.NotEmpty(t, item.ID) + assert.Equal(t, "MO-5W40", item.Code) + + list, err := catalog.ListItems(ctx, conn) + require.NoError(t, err) + assert.GreaterOrEqual(t, len(list), 1) + + updated, err := catalog.UpdateItem(ctx, conn, item.ID, "MO-5W40", "Óleo Motor 5W40 Sintético", "lubrificantes", "litro", 15.00, true) + require.NoError(t, err) + assert.Equal(t, 15.00, updated.BasePrice) + + err = catalog.DeleteItem(ctx, conn, item.ID) + require.NoError(t, err) +} +``` + +- [ ] **Step 2: Run — expect compile error** + +```bash +cd backend && go test ./internal/catalog/... 2>&1 | head -10 +``` + +- [ ] **Step 3: Write repository** + +Create `backend/internal/catalog/repository.go`: + +```go +package catalog + +import ( + "context" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type CatalogItem struct { + ID string `json:"id"` + Code string `json:"code"` + Name string `json:"name"` + Category string `json:"category"` + Unit string `json:"unit"` + BasePrice float64 `json:"base_price"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +func ListItems(ctx context.Context, conn *pgxpool.Conn) ([]*CatalogItem, error) { + rows, err := conn.Query(ctx, ` + SELECT id, code, name, category, unit, base_price, active, created_at, updated_at + FROM catalog_items ORDER BY category, name`) + if err != nil { + return nil, err + } + defer rows.Close() + var list []*CatalogItem + for rows.Next() { + var i CatalogItem + if err := rows.Scan(&i.ID, &i.Code, &i.Name, &i.Category, &i.Unit, + &i.BasePrice, &i.Active, &i.CreatedAt, &i.UpdatedAt); err != nil { + return nil, err + } + list = append(list, &i) + } + return list, rows.Err() +} + +func CreateItem(ctx context.Context, conn *pgxpool.Conn, code, name, category, unit string, basePrice float64) (*CatalogItem, error) { + var i CatalogItem + err := conn.QueryRow(ctx, ` + INSERT INTO catalog_items (code, name, category, unit, base_price) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, code, name, category, unit, base_price, active, created_at, updated_at`, + code, name, category, unit, basePrice). + Scan(&i.ID, &i.Code, &i.Name, &i.Category, &i.Unit, &i.BasePrice, &i.Active, &i.CreatedAt, &i.UpdatedAt) + return &i, err +} + +func UpdateItem(ctx context.Context, conn *pgxpool.Conn, id, code, name, category, unit string, basePrice float64, active bool) (*CatalogItem, error) { + var i CatalogItem + err := conn.QueryRow(ctx, ` + UPDATE catalog_items SET code=$2, name=$3, category=$4, unit=$5, base_price=$6, active=$7, updated_at=NOW() + WHERE id=$1 + RETURNING id, code, name, category, unit, base_price, active, created_at, updated_at`, + id, code, name, category, unit, basePrice, active). + Scan(&i.ID, &i.Code, &i.Name, &i.Category, &i.Unit, &i.BasePrice, &i.Active, &i.CreatedAt, &i.UpdatedAt) + return &i, err +} + +func DeleteItem(ctx context.Context, conn *pgxpool.Conn, id string) error { + _, err := conn.Exec(ctx, `DELETE FROM catalog_items WHERE id = $1`, id) + return err +} +``` + +- [ ] **Step 4: Write handlers** + +Create `backend/internal/catalog/handler.go`: + +```go +package catalog + +import ( + "github.com/gofiber/fiber/v2" + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/pkg/database" +) + +func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { + ro := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager", "technician"), + auth.TenantMiddleware(db), + } + write := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager"), + auth.TenantMiddleware(db), + } + + app.Get("/api/v1/catalog", append(ro, listItemsH())...) + app.Post("/api/v1/catalog", append(write, createItemH())...) + app.Put("/api/v1/catalog/:id", append(write, updateItemH())...) + app.Delete("/api/v1/catalog/:id", append(write, deleteItemH())...) +} + +func listItemsH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + list, err := ListItems(c.Context(), conn) + if err != nil { + return fiber.NewError(500, "erro ao listar catálogo") + } + if list == nil { + list = []*CatalogItem{} + } + return c.JSON(fiber.Map{"data": list, "error": nil}) + } +} + +type itemBody struct { + Code string `json:"code"` + Name string `json:"name"` + Category string `json:"category"` + Unit string `json:"unit"` + BasePrice float64 `json:"base_price"` + Active bool `json:"active"` +} + +func createItemH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b itemBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + if b.Code == "" || b.Name == "" || b.Category == "" || b.Unit == "" { + return fiber.NewError(400, "código, nome, categoria e unidade são obrigatórios") + } + validUnits := map[string]bool{"un": true, "hora": true, "litro": true, "kg": true} + if !validUnits[b.Unit] { + return fiber.NewError(400, "unidade inválida (un, hora, litro, kg)") + } + conn := auth.GetConn(c) + item, err := CreateItem(c.Context(), conn, b.Code, b.Name, b.Category, b.Unit, b.BasePrice) + if err != nil { + return fiber.NewError(500, "erro ao criar item") + } + return c.Status(201).JSON(fiber.Map{"data": item, "error": nil}) + } +} + +func updateItemH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b itemBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + if b.Code == "" || b.Name == "" || b.Category == "" || b.Unit == "" { + return fiber.NewError(400, "código, nome, categoria e unidade são obrigatórios") + } + conn := auth.GetConn(c) + item, err := UpdateItem(c.Context(), conn, c.Params("id"), b.Code, b.Name, b.Category, b.Unit, b.BasePrice, b.Active) + if err != nil { + return fiber.NewError(500, "erro ao actualizar item") + } + return c.JSON(fiber.Map{"data": item, "error": nil}) + } +} + +func deleteItemH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + if err := DeleteItem(c.Context(), conn, c.Params("id")); err != nil { + return fiber.NewError(500, "erro ao eliminar item") + } + return c.SendStatus(204) + } +} +``` + +- [ ] **Step 5: Run tests** + +```bash +cd backend && go test ./internal/catalog/... -v 2>&1 +``` + +- [ ] **Step 6: Commit** + +```bash +git add backend/internal/catalog/ +git commit -m "feat: catalog item repository and HTTP handlers" +``` + +--- + +### Task 3: Work Order Repository + Handlers + +**Files:** +- Create: `backend/internal/workorder/repository.go` +- Create: `backend/internal/workorder/handler.go` + +**Interfaces:** +- Produces: + - `workorder.RegisterRoutes(app *fiber.App, db *database.DB, secret string)` + - GET `/api/v1/work-orders` → `[]WorkOrder` (supports `?status=` filter) + - POST `/api/v1/work-orders` → `WorkOrder` (201) + - GET `/api/v1/work-orders/:id` → `WorkOrderDetail` (includes items + staff hours) + - PUT `/api/v1/work-orders/:id` → `WorkOrder` + - POST `/api/v1/work-orders/:id/transition` → `WorkOrder` (state machine) + - POST `/api/v1/work-orders/:id/items` → `WOItem` (201) + - DELETE `/api/v1/work-orders/:id/items/:itemId` → 204 + - POST `/api/v1/work-orders/:id/staff-hours` → `WOStaffHours` (201) + - DELETE `/api/v1/work-orders/:id/staff-hours/:shId` → 204 + +**State machine allowed transitions:** +``` +open → in_progress, cancelled +in_progress → completed, cancelled +completed → invoiced, cancelled +invoiced → (none) +cancelled → (none) +``` + +- [ ] **Step 1: Write failing tests** + +Create `backend/internal/workorder/repository_test.go`: + +```go +package workorder_test + +import ( + "context" + "os" + "testing" + + "github.com/jackc/pgx/v5/pgxpool" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" + "github.com/techxcar/backend/internal/workorder" +) + +func getTestConn(t *testing.T) *pgxpool.Conn { + t.Helper() + dsn := os.Getenv("TEST_DATABASE_URL") + if dsn == "" { + t.Skip("TEST_DATABASE_URL not set") + } + pool, err := pgxpool.New(context.Background(), dsn) + require.NoError(t, err) + t.Cleanup(func() { pool.Close() }) + conn, err := pool.Acquire(context.Background()) + require.NoError(t, err) + t.Cleanup(func() { conn.Release() }) + _, err = conn.Exec(context.Background(), "SET search_path = tenant_test, public") + require.NoError(t, err) + return conn +} + +func TestWorkOrderCRUD(t *testing.T) { + conn := getTestConn(t) + ctx := context.Background() + + wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "") + require.NoError(t, err) + assert.NotEmpty(t, wo.ID) + assert.Equal(t, "open", wo.Status) + + list, err := workorder.ListWorkOrders(ctx, conn, "") + require.NoError(t, err) + assert.GreaterOrEqual(t, len(list), 1) + + detail, err := workorder.GetWorkOrderDetail(ctx, conn, wo.ID) + require.NoError(t, err) + assert.Equal(t, wo.ID, detail.ID) + assert.Empty(t, detail.Items) + assert.Empty(t, detail.StaffHours) +} + +func TestWorkOrderTransition(t *testing.T) { + conn := getTestConn(t) + ctx := context.Background() + + wo, err := workorder.CreateWorkOrder(ctx, conn, "", "", "", "") + require.NoError(t, err) + + wo2, err := workorder.TransitionStatus(ctx, conn, wo.ID, "in_progress", "") + require.NoError(t, err) + assert.Equal(t, "in_progress", wo2.Status) + + _, err = workorder.TransitionStatus(ctx, conn, wo.ID, "cancelled_invalid", "") + assert.Error(t, err, "invalid transition should error") +} + +func TestAllowedTransitions(t *testing.T) { + tests := []struct { + from string + to string + valid bool + }{ + {"open", "in_progress", true}, + {"open", "cancelled", true}, + {"open", "completed", false}, + {"in_progress", "completed", true}, + {"in_progress", "cancelled", true}, + {"in_progress", "open", false}, + {"completed", "invoiced", true}, + {"completed", "cancelled", true}, + {"invoiced", "cancelled", false}, + } + for _, tt := range tests { + err := workorder.ValidateTransition(tt.from, tt.to) + if tt.valid { + assert.NoError(t, err, "%s→%s should be valid", tt.from, tt.to) + } else { + assert.Error(t, err, "%s→%s should be invalid", tt.from, tt.to) + } + } +} +``` + +- [ ] **Step 2: Run — expect compile error** + +```bash +cd backend && go test ./internal/workorder/... 2>&1 | head -10 +``` + +- [ ] **Step 3: Write repository** + +Create `backend/internal/workorder/repository.go`: + +```go +package workorder + +import ( + "context" + "errors" + "time" + + "github.com/jackc/pgx/v5/pgxpool" +) + +type WorkOrder struct { + ID string `json:"id"` + Number int `json:"number"` + ClientID *string `json:"client_id"` + VehicleID *string `json:"vehicle_id"` + Status string `json:"status"` + InternalNotes string `json:"internal_notes"` + ClientNotes string `json:"client_notes"` + CreatedBy *string `json:"created_by"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` +} + +type WOItem struct { + ID string `json:"id"` + WorkOrderID string `json:"work_order_id"` + CatalogItemID *string `json:"catalog_item_id"` + Description string `json:"description"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + DiscountPct float64 `json:"discount_pct"` + Total float64 `json:"total"` +} + +type WOStaffHours struct { + ID string `json:"id"` + WorkOrderID string `json:"work_order_id"` + StaffID string `json:"staff_id"` + Hours float64 `json:"hours"` + CostPerHour float64 `json:"cost_per_hour"` + Total float64 `json:"total"` +} + +type WorkOrderDetail struct { + WorkOrder + Items []*WOItem `json:"items"` + StaffHours []*WOStaffHours `json:"staff_hours"` +} + +var allowedTransitions = map[string][]string{ + "open": {"in_progress", "cancelled"}, + "in_progress": {"completed", "cancelled"}, + "completed": {"invoiced", "cancelled"}, + "invoiced": {}, + "cancelled": {}, +} + +func ValidateTransition(from, to string) error { + nexts, ok := allowedTransitions[from] + if !ok { + return errors.New("estado desconhecido") + } + for _, n := range nexts { + if n == to { + return nil + } + } + return errors.New("transição inválida") +} + +func scanWO(row interface{ Scan(...any) error }) (*WorkOrder, error) { + var wo WorkOrder + return &wo, row.Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, + &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) +} + +func ListWorkOrders(ctx context.Context, conn *pgxpool.Conn, status string) ([]*WorkOrder, error) { + q := `SELECT id, number, client_id, vehicle_id, status, + COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at + FROM work_orders` + args := []any{} + if status != "" { + q += " WHERE status = $1" + args = append(args, status) + } + q += " ORDER BY created_at DESC" + rows, err := conn.Query(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + var list []*WorkOrder + for rows.Next() { + var wo WorkOrder + if err := rows.Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, + &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt); err != nil { + return nil, err + } + list = append(list, &wo) + } + return list, rows.Err() +} + +func CreateWorkOrder(ctx context.Context, conn *pgxpool.Conn, clientID, vehicleID, internalNotes, createdBy string) (*WorkOrder, error) { + var wo WorkOrder + err := conn.QueryRow(ctx, ` + INSERT INTO work_orders (client_id, vehicle_id, internal_notes, created_by) + VALUES (NULLIF($1,'')::uuid, NULLIF($2,'')::uuid, NULLIF($3,''), NULLIF($4,'')::uuid) + RETURNING id, number, client_id, vehicle_id, status, + COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`, + clientID, vehicleID, internalNotes, createdBy). + Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, + &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) + return &wo, err +} + +func UpdateWorkOrder(ctx context.Context, conn *pgxpool.Conn, id, clientID, vehicleID, internalNotes, clientNotes string) (*WorkOrder, error) { + var wo WorkOrder + err := conn.QueryRow(ctx, ` + UPDATE work_orders SET client_id=NULLIF($2,'')::uuid, vehicle_id=NULLIF($3,'')::uuid, + internal_notes=NULLIF($4,''), client_notes=NULLIF($5,''), updated_at=NOW() + WHERE id=$1 + RETURNING id, number, client_id, vehicle_id, status, + COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`, + id, clientID, vehicleID, internalNotes, clientNotes). + Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, + &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) + return &wo, err +} + +func TransitionStatus(ctx context.Context, conn *pgxpool.Conn, id, toStatus, changedBy string) (*WorkOrder, error) { + var fromStatus string + if err := conn.QueryRow(ctx, `SELECT status FROM work_orders WHERE id=$1`, id).Scan(&fromStatus); err != nil { + return nil, errors.New("ordem não encontrada") + } + if err := ValidateTransition(fromStatus, toStatus); err != nil { + return nil, err + } + var wo WorkOrder + err := conn.QueryRow(ctx, ` + UPDATE work_orders SET status=$2, updated_at=NOW() WHERE id=$1 + RETURNING id, number, client_id, vehicle_id, status, + COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at`, + id, toStatus). + Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, + &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) + if err != nil { + return nil, err + } + _, _ = conn.Exec(ctx, ` + INSERT INTO wo_status_log (work_order_id, from_status, to_status, changed_by) + VALUES ($1, $2, $3, NULLIF($4,'')::uuid)`, id, fromStatus, toStatus, changedBy) + return &wo, nil +} + +func GetWorkOrderDetail(ctx context.Context, conn *pgxpool.Conn, id string) (*WorkOrderDetail, error) { + var wo WorkOrder + err := conn.QueryRow(ctx, ` + SELECT id, number, client_id, vehicle_id, status, + COALESCE(internal_notes,''), COALESCE(client_notes,''), created_by, created_at, updated_at + FROM work_orders WHERE id=$1`, id). + Scan(&wo.ID, &wo.Number, &wo.ClientID, &wo.VehicleID, + &wo.Status, &wo.InternalNotes, &wo.ClientNotes, &wo.CreatedBy, &wo.CreatedAt, &wo.UpdatedAt) + if err != nil { + return nil, err + } + + detail := &WorkOrderDetail{WorkOrder: wo, Items: []*WOItem{}, StaffHours: []*WOStaffHours{}} + + rows, err := conn.Query(ctx, ` + SELECT id, work_order_id, catalog_item_id, description, qty, unit_price, discount_pct, total + FROM wo_items WHERE work_order_id=$1`, id) + if err != nil { + return nil, err + } + defer rows.Close() + for rows.Next() { + var i WOItem + if err := rows.Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, + &i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total); err != nil { + return nil, err + } + detail.Items = append(detail.Items, &i) + } + if err := rows.Err(); err != nil { + return nil, err + } + + shRows, err := conn.Query(ctx, ` + SELECT id, work_order_id, staff_id, hours, cost_per_hour, total + FROM wo_staff_hours WHERE work_order_id=$1`, id) + if err != nil { + return nil, err + } + defer shRows.Close() + for shRows.Next() { + var sh WOStaffHours + if err := shRows.Scan(&sh.ID, &sh.WorkOrderID, &sh.StaffID, &sh.Hours, &sh.CostPerHour, &sh.Total); err != nil { + return nil, err + } + detail.StaffHours = append(detail.StaffHours, &sh) + } + return detail, shRows.Err() +} + +func AddItem(ctx context.Context, conn *pgxpool.Conn, woID, catalogItemID, description string, qty, unitPrice, discountPct float64) (*WOItem, error) { + var i WOItem + err := conn.QueryRow(ctx, ` + INSERT INTO wo_items (work_order_id, catalog_item_id, description, qty, unit_price, discount_pct) + VALUES ($1, NULLIF($2,'')::uuid, $3, $4, $5, $6) + RETURNING id, work_order_id, catalog_item_id, description, qty, unit_price, discount_pct, total`, + woID, catalogItemID, description, qty, unitPrice, discountPct). + Scan(&i.ID, &i.WorkOrderID, &i.CatalogItemID, &i.Description, &i.Qty, &i.UnitPrice, &i.DiscountPct, &i.Total) + return &i, err +} + +func RemoveItem(ctx context.Context, conn *pgxpool.Conn, woID, itemID string) error { + _, err := conn.Exec(ctx, `DELETE FROM wo_items WHERE id=$1 AND work_order_id=$2`, itemID, woID) + return err +} + +func AddStaffHours(ctx context.Context, conn *pgxpool.Conn, woID, staffID string, hours, costPerHour float64) (*WOStaffHours, error) { + var sh WOStaffHours + err := conn.QueryRow(ctx, ` + INSERT INTO wo_staff_hours (work_order_id, staff_id, hours, cost_per_hour) + VALUES ($1, $2, $3, $4) + RETURNING id, work_order_id, staff_id, hours, cost_per_hour, total`, + woID, staffID, hours, costPerHour). + Scan(&sh.ID, &sh.WorkOrderID, &sh.StaffID, &sh.Hours, &sh.CostPerHour, &sh.Total) + return &sh, err +} + +func RemoveStaffHours(ctx context.Context, conn *pgxpool.Conn, woID, shID string) error { + _, err := conn.Exec(ctx, `DELETE FROM wo_staff_hours WHERE id=$1 AND work_order_id=$2`, shID, woID) + return err +} +``` + +- [ ] **Step 4: Write handlers** + +Create `backend/internal/workorder/handler.go`: + +```go +package workorder + +import ( + "github.com/gofiber/fiber/v2" + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/pkg/database" +) + +func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { + ro := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager", "technician"), + auth.TenantMiddleware(db), + } + write := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager"), + auth.TenantMiddleware(db), + } + + app.Get("/api/v1/work-orders", append(ro, listWOsH())...) + app.Post("/api/v1/work-orders", append(write, createWOH())...) + app.Get("/api/v1/work-orders/:id", append(ro, getWODetailH())...) + app.Put("/api/v1/work-orders/:id", append(write, updateWOH())...) + app.Post("/api/v1/work-orders/:id/transition", append(write, transitionWOH())...) + + app.Post("/api/v1/work-orders/:id/items", append(write, addItemH())...) + app.Delete("/api/v1/work-orders/:id/items/:itemId", append(write, removeItemH())...) + + app.Post("/api/v1/work-orders/:id/staff-hours", append(write, addStaffHoursH())...) + app.Delete("/api/v1/work-orders/:id/staff-hours/:shId", append(write, removeStaffHoursH())...) +} + +func listWOsH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + list, err := ListWorkOrders(c.Context(), conn, c.Query("status")) + if err != nil { + return fiber.NewError(500, "erro ao listar ordens") + } + if list == nil { + list = []*WorkOrder{} + } + return c.JSON(fiber.Map{"data": list, "error": nil}) + } +} + +type woBody struct { + ClientID string `json:"client_id"` + VehicleID string `json:"vehicle_id"` + InternalNotes string `json:"internal_notes"` + ClientNotes string `json:"client_notes"` +} + +func createWOH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b woBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + claims, _ := c.Locals("claims").(*auth.Claims) + createdBy := "" + if claims != nil { + createdBy = claims.UserID + } + conn := auth.GetConn(c) + wo, err := CreateWorkOrder(c.Context(), conn, b.ClientID, b.VehicleID, b.InternalNotes, createdBy) + if err != nil { + return fiber.NewError(500, "erro ao criar ordem") + } + return c.Status(201).JSON(fiber.Map{"data": wo, "error": nil}) + } +} + +func getWODetailH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + detail, err := GetWorkOrderDetail(c.Context(), conn, c.Params("id")) + if err != nil { + return fiber.NewError(404, "ordem não encontrada") + } + return c.JSON(fiber.Map{"data": detail, "error": nil}) + } +} + +func updateWOH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b woBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + conn := auth.GetConn(c) + wo, err := UpdateWorkOrder(c.Context(), conn, c.Params("id"), b.ClientID, b.VehicleID, b.InternalNotes, b.ClientNotes) + if err != nil { + return fiber.NewError(500, "erro ao actualizar ordem") + } + return c.JSON(fiber.Map{"data": wo, "error": nil}) + } +} + +func transitionWOH() fiber.Handler { + return func(c *fiber.Ctx) error { + var body struct { + Status string `json:"status"` + } + if err := c.BodyParser(&body); err != nil || body.Status == "" { + return fiber.NewError(400, "status é obrigatório") + } + claims, _ := c.Locals("claims").(*auth.Claims) + changedBy := "" + if claims != nil { + changedBy = claims.UserID + } + conn := auth.GetConn(c) + wo, err := TransitionStatus(c.Context(), conn, c.Params("id"), body.Status, changedBy) + if err != nil { + return fiber.NewError(400, err.Error()) + } + return c.JSON(fiber.Map{"data": wo, "error": nil}) + } +} + +type itemBody struct { + CatalogItemID string `json:"catalog_item_id"` + Description string `json:"description"` + Qty float64 `json:"qty"` + UnitPrice float64 `json:"unit_price"` + DiscountPct float64 `json:"discount_pct"` +} + +func addItemH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b itemBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + if b.Description == "" || b.Qty <= 0 || b.UnitPrice < 0 { + return fiber.NewError(400, "descrição, quantidade e preço unitário são obrigatórios") + } + conn := auth.GetConn(c) + item, err := AddItem(c.Context(), conn, c.Params("id"), b.CatalogItemID, b.Description, b.Qty, b.UnitPrice, b.DiscountPct) + if err != nil { + return fiber.NewError(500, "erro ao adicionar item") + } + return c.Status(201).JSON(fiber.Map{"data": item, "error": nil}) + } +} + +func removeItemH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + if err := RemoveItem(c.Context(), conn, c.Params("id"), c.Params("itemId")); err != nil { + return fiber.NewError(500, "erro ao remover item") + } + return c.SendStatus(204) + } +} + +type staffHoursBody struct { + StaffID string `json:"staff_id"` + Hours float64 `json:"hours"` + CostPerHour float64 `json:"cost_per_hour"` +} + +func addStaffHoursH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b staffHoursBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo inválido") + } + if b.StaffID == "" || b.Hours <= 0 { + return fiber.NewError(400, "funcionário e horas são obrigatórios") + } + conn := auth.GetConn(c) + sh, err := AddStaffHours(c.Context(), conn, c.Params("id"), b.StaffID, b.Hours, b.CostPerHour) + if err != nil { + return fiber.NewError(500, "erro ao adicionar horas") + } + return c.Status(201).JSON(fiber.Map{"data": sh, "error": nil}) + } +} + +func removeStaffHoursH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + if err := RemoveStaffHours(c.Context(), conn, c.Params("id"), c.Params("shId")); err != nil { + return fiber.NewError(500, "erro ao remover horas") + } + return c.SendStatus(204) + } +} +``` + +- [ ] **Step 5: Check auth.Claims has UserID field** + +```bash +grep -n "UserID\|Subject\|Sub " /var/home/lmilani/Documentos/IDE/techxcar/backend/internal/auth/jwt.go 2>/dev/null || grep -rn "UserID\|type Claims" backend/internal/auth/ +``` + +If `Claims` uses a different field name (e.g. `Subject`), replace `claims.UserID` with the correct field in `handler.go`. + +- [ ] **Step 6: Run tests** + +```bash +cd backend && go test ./internal/workorder/... -v 2>&1 +``` + +- [ ] **Step 7: Commit** + +```bash +git add backend/internal/workorder/ +git commit -m "feat: work order repository, state machine, and HTTP handlers" +``` + +--- + +### Task 4: Wire Routes in server.go + +**Files:** +- Modify: `backend/internal/server/server.go` + +**Interfaces:** +- Consumes: `client.RegisterRoutes`, `catalog.RegisterRoutes`, `workorder.RegisterRoutes` + +- [ ] **Step 1: Read current server.go** + +```bash +cat backend/internal/server/server.go +``` + +- [ ] **Step 2: Add imports and route wiring** + +In `backend/internal/server/server.go`, add imports for the new packages and call `RegisterRoutes` after the existing tenant routes. The `New` function already has `Deps` with `Config`, `DB`, and `Redis`. Example addition after `tenant.RegisterRoutes(...)`: + +```go +import ( + // existing imports... + "github.com/techxcar/backend/internal/client" + "github.com/techxcar/backend/internal/catalog" + "github.com/techxcar/backend/internal/workorder" +) + +// inside New(deps Deps), after tenant.RegisterRoutes call: +client.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) +catalog.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) +workorder.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) +``` + +- [ ] **Step 3: Compile check** + +```bash +cd backend && go build ./... 2>&1 +``` +Expected: no errors + +- [ ] **Step 4: Run all tests** + +```bash +cd backend && go test ./... 2>&1 +``` +Expected: all pass (or SKIP if no TEST env) + +- [ ] **Step 5: Smoke test against running server** + +With docker running (`docker compose up -d`), login and test an endpoint: + +```bash +TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \ + -H 'Content-Type: application/json' \ + -d '{"email":"admin@techxcar.com","password":"TechXCar2026!"}' | jq -r '.data.access_token') + +curl -s -H "Authorization: Bearer $TOKEN" http://localhost:8080/api/v1/clients | jq . +``` +Expected: `{"data":[],"error":null}` + +- [ ] **Step 6: Commit** + +```bash +git add backend/internal/server/server.go +git commit -m "feat: wire client, catalog, work-order routes" +``` + +--- + +### Task 5: Frontend Types + AppLayout Navigation + +**Files:** +- Modify: `frontend/src/lib/types.ts` +- Modify: `frontend/src/components/layout/AppLayout.tsx` + +**Interfaces:** +- Produces: typed TS interfaces used by all app pages; full sidebar nav + +- [ ] **Step 1: Extend types.ts** + +Replace `frontend/src/lib/types.ts` with: + +```typescript +export interface Tenant { + id: string + slug: string + name: string + status: 'active' | 'suspended' | 'pending' + created_at: string +} + +export interface Invite { + id: string + token: string + tenant_id: string | null + expires_at: string + used_at: string | null + created_at: string +} + +export interface Client { + id: string + name: string + nif: string + phone: string + email: string + address: string + notes: string + created_at: string + updated_at: string +} + +export interface Vehicle { + id: string + client_id: string | null + plate: string + brand: string + model: string + year: number | null + vin: string + mileage: number | null + notes: string + created_at: string + updated_at: string +} + +export interface CatalogItem { + id: string + code: string + name: string + category: string + unit: 'un' | 'hora' | 'litro' | 'kg' + base_price: number + active: boolean + created_at: string + updated_at: string +} + +export interface WorkOrder { + id: string + number: number + client_id: string | null + vehicle_id: string | null + status: 'open' | 'in_progress' | 'completed' | 'invoiced' | 'cancelled' + internal_notes: string + client_notes: string + created_by: string | null + created_at: string + updated_at: string +} + +export interface WOItem { + id: string + work_order_id: string + catalog_item_id: string | null + description: string + qty: number + unit_price: number + discount_pct: number + total: number +} + +export interface WOStaffHours { + id: string + work_order_id: string + staff_id: string + hours: number + cost_per_hour: number + total: number +} + +export interface WorkOrderDetail extends WorkOrder { + items: WOItem[] + staff_hours: WOStaffHours[] +} +``` + +- [ ] **Step 2: Update AppLayout with full navigation** + +Replace `frontend/src/components/layout/AppLayout.tsx`: + +```tsx +import { Outlet, NavLink } from 'react-router' +import { useLogout } from '@/hooks/useAuth' + +const nav = [ + { to: '/app', label: 'Dashboard', end: true }, + { to: '/app/work-orders', label: 'Ordens de Trabalho' }, + { to: '/app/clients', label: 'Clientes' }, + { to: '/app/catalog', label: 'Catálogo' }, +] + +export default function AppLayout() { + const { mutate: logout } = useLogout() + + return ( +
+ +
+ +
+
+ ) +} +``` + +- [ ] **Step 3: TypeScript check** + +```bash +cd frontend && npm run build 2>&1 | tail -20 +``` +Expected: no TypeScript errors (build may warn about missing pages — those come in Task 6-9) + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/lib/types.ts frontend/src/components/layout/AppLayout.tsx +git commit -m "feat: extend domain types and full AppLayout navigation" +``` + +--- + +### Task 6: Clients Page + +**Files:** +- Create: `frontend/src/pages/app/ClientsPage.tsx` +- Modify: `frontend/src/App.tsx` + +- [ ] **Step 1: Create ClientsPage** + +Create `frontend/src/pages/app/ClientsPage.tsx`: + +```tsx +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { apiFetch } from '@/lib/api' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { Client } from '@/lib/types' + +const schema = z.object({ + name: z.string().min(1, 'Nome obrigatório'), + nif: z.string().optional().default(''), + phone: z.string().optional().default(''), + email: z.string().email('Email inválido').optional().or(z.literal('')).default(''), + address: z.string().optional().default(''), + notes: z.string().optional().default(''), +}) +type FormData = z.infer + +export default function ClientsPage() { + const qc = useQueryClient() + const [editing, setEditing] = useState(null) + const [showForm, setShowForm] = useState(false) + + const { data: clients = [], isLoading } = useQuery({ + queryKey: ['clients'], + queryFn: () => apiFetch('/clients'), + }) + + const { register, handleSubmit, reset, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + }) + + const save = useMutation({ + mutationFn: (data: FormData) => + editing + ? apiFetch(`/clients/${editing.id}`, { method: 'PUT', body: JSON.stringify(data) }) + : apiFetch('/clients', { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['clients'] }) + setShowForm(false) + setEditing(null) + reset() + }, + }) + + const remove = useMutation({ + mutationFn: (id: string) => apiFetch(`/clients/${id}`, { method: 'DELETE' }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['clients'] }), + }) + + function openNew() { + setEditing(null) + reset({ name: '', nif: '', phone: '', email: '', address: '', notes: '' }) + setShowForm(true) + } + + function openEdit(c: Client) { + setEditing(c) + reset({ name: c.name, nif: c.nif, phone: c.phone, email: c.email, address: c.address, notes: c.notes }) + setShowForm(true) + } + + return ( +
+
+
+

Clientes

+

{clients.length} clientes

+
+ +
+ + {showForm && ( +
+

+ {editing ? 'Editar Cliente' : 'Novo Cliente'} +

+
save.mutate(d))} className="grid grid-cols-2 gap-4"> +
+ + + {errors.name &&

{errors.name.message}

} +
+
+ + +
+
+ + +
+
+ + + {errors.email &&

{errors.email.message}

} +
+
+ + +
+
+ + +
+ {save.error && ( +

{save.error.message}

+ )} +
+ + +
+
+
+ )} + + {isLoading ? ( +

A carregar...

+ ) : clients.length === 0 ? ( +

Nenhum cliente registado.

+ ) : ( +
+ + + + + + + + + + + + {clients.map((c) => ( + + + + + + + + ))} + +
NomeNIFTelefoneEmail
{c.name}{c.nif || '—'}{c.phone || '—'}{c.email || '—'} + + +
+
+ )} +
+ ) +} +``` + +- [ ] **Step 2: Add route to App.tsx** + +In `frontend/src/App.tsx`, add inside the `/app` route group: + +```tsx +import ClientsPage from '@/pages/app/ClientsPage' +// ... +} /> +``` + +- [ ] **Step 3: TypeScript check** + +```bash +cd frontend && npm run build 2>&1 | tail -20 +``` + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/pages/app/ClientsPage.tsx frontend/src/App.tsx +git commit -m "feat: clients list + create/edit/delete UI" +``` + +--- + +### Task 7: Catalog Page + +**Files:** +- Create: `frontend/src/pages/app/CatalogPage.tsx` +- Modify: `frontend/src/App.tsx` + +- [ ] **Step 1: Create CatalogPage** + +Create `frontend/src/pages/app/CatalogPage.tsx`: + +```tsx +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { apiFetch } from '@/lib/api' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { CatalogItem } from '@/lib/types' + +const UNITS = ['un', 'hora', 'litro', 'kg'] as const + +const schema = z.object({ + code: z.string().min(1, 'Código obrigatório'), + name: z.string().min(1, 'Nome obrigatório'), + category: z.string().min(1, 'Categoria obrigatória'), + unit: z.enum(UNITS), + base_price: z.coerce.number().min(0), + active: z.boolean().optional().default(true), +}) +type FormData = z.infer + +export default function CatalogPage() { + const qc = useQueryClient() + const [editing, setEditing] = useState(null) + const [showForm, setShowForm] = useState(false) + + const { data: items = [], isLoading } = useQuery({ + queryKey: ['catalog'], + queryFn: () => apiFetch('/catalog'), + }) + + const { register, handleSubmit, reset, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + }) + + const save = useMutation({ + mutationFn: (data: FormData) => + editing + ? apiFetch(`/catalog/${editing.id}`, { method: 'PUT', body: JSON.stringify(data) }) + : apiFetch('/catalog', { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['catalog'] }) + setShowForm(false) + setEditing(null) + reset() + }, + }) + + const remove = useMutation({ + mutationFn: (id: string) => apiFetch(`/catalog/${id}`, { method: 'DELETE' }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['catalog'] }), + }) + + function openNew() { + setEditing(null) + reset({ code: '', name: '', category: '', unit: 'un', base_price: 0, active: true }) + setShowForm(true) + } + + function openEdit(item: CatalogItem) { + setEditing(item) + reset({ code: item.code, name: item.name, category: item.category, unit: item.unit, base_price: item.base_price, active: item.active }) + setShowForm(true) + } + + return ( +
+
+
+

Catálogo

+

{items.length} itens

+
+ +
+ + {showForm && ( +
+

+ {editing ? 'Editar Item' : 'Novo Item'} +

+
save.mutate(d))} className="grid grid-cols-2 gap-4"> +
+ + + {errors.code &&

{errors.code.message}

} +
+
+ + + {errors.name &&

{errors.name.message}

} +
+
+ + + {errors.category &&

{errors.category.message}

} +
+
+ + +
+
+ + +
+
+ + +
+ {save.error && ( +

{save.error.message}

+ )} +
+ + +
+
+
+ )} + + {isLoading ? ( +

A carregar...

+ ) : items.length === 0 ? ( +

Nenhum item no catálogo.

+ ) : ( +
+ + + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + + ))} + +
CódigoNomeCategoriaUn.PreçoEstado
{item.code}{item.name}{item.category}{item.unit}{item.base_price.toFixed(2)} € + + {item.active ? 'Activo' : 'Inactivo'} + + + + +
+
+ )} +
+ ) +} +``` + +- [ ] **Step 2: Add route to App.tsx** + +```tsx +import CatalogPage from '@/pages/app/CatalogPage' +// inside /app route group: +} /> +``` + +- [ ] **Step 3: Build check + commit** + +```bash +cd frontend && npm run build 2>&1 | tail -10 +git add frontend/src/pages/app/CatalogPage.tsx frontend/src/App.tsx +git commit -m "feat: catalog items list + create/edit/delete UI" +``` + +--- + +### Task 8: Work Orders List Page + +**Files:** +- Create: `frontend/src/pages/app/WorkOrdersPage.tsx` +- Modify: `frontend/src/App.tsx` + +- [ ] **Step 1: Create WorkOrdersPage** + +Create `frontend/src/pages/app/WorkOrdersPage.tsx`: + +```tsx +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Link } from 'react-router' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { apiFetch } from '@/lib/api' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { WorkOrder, Client, Vehicle } from '@/lib/types' + +const STATUS_LABELS: Record = { + open: 'Aberta', + in_progress: 'Em Curso', + completed: 'Concluída', + invoiced: 'Faturada', + cancelled: 'Cancelada', +} + +const STATUS_VARIANT: Record = { + open: 'secondary', + in_progress: 'default', + completed: 'default', + invoiced: 'secondary', + cancelled: 'destructive', +} + +const schema = z.object({ + client_id: z.string().optional().default(''), + vehicle_id: z.string().optional().default(''), + internal_notes: z.string().optional().default(''), +}) +type FormData = z.infer + +export default function WorkOrdersPage() { + const qc = useQueryClient() + const [statusFilter, setStatusFilter] = useState('') + const [showForm, setShowForm] = useState(false) + + const { data: orders = [], isLoading } = useQuery({ + queryKey: ['work-orders', statusFilter], + queryFn: () => apiFetch(`/work-orders${statusFilter ? `?status=${statusFilter}` : ''}`), + }) + + const { data: clients = [] } = useQuery({ + queryKey: ['clients'], + queryFn: () => apiFetch('/clients'), + }) + + const { register, handleSubmit, watch, reset, formState: { errors } } = useForm({ + resolver: zodResolver(schema), + }) + + const selectedClientId = watch('client_id') + + const { data: vehicles = [] } = useQuery({ + queryKey: ['vehicles', selectedClientId], + queryFn: () => apiFetch(`/clients/${selectedClientId}/vehicles`), + enabled: !!selectedClientId, + }) + + const create = useMutation({ + mutationFn: (data: FormData) => + apiFetch('/work-orders', { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['work-orders'] }) + setShowForm(false) + reset() + }, + }) + + return ( +
+
+
+

Ordens de Trabalho

+

{orders.length} ordens

+
+ +
+ +
+ {['', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled'].map((s) => ( + + ))} +
+ + {showForm && ( +
+

Nova Ordem de Trabalho

+
create.mutate(d))} className="grid grid-cols-2 gap-4"> +
+ + +
+
+ + +
+
+ + +
+ {create.error && ( +

{create.error.message}

+ )} +
+ + +
+
+
+ )} + + {isLoading ? ( +

A carregar...

+ ) : orders.length === 0 ? ( +

Nenhuma ordem de trabalho.

+ ) : ( +
+ + + + + + + + + + + {orders.map((o) => ( + + + + + + + ))} + +
EstadoCriada
#{o.number} + + {STATUS_LABELS[o.status]} + + + {new Intl.DateTimeFormat('pt-PT').format(new Date(o.created_at))} + + + Ver detalhe → + +
+
+ )} +
+ ) +} +``` + +- [ ] **Step 2: Add routes to App.tsx** + +```tsx +import WorkOrdersPage from '@/pages/app/WorkOrdersPage' +// inside /app route group: +} /> +} /> +``` +(WorkOrderDetailPage is created in Task 9 — add its import too in that task) + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/pages/app/WorkOrdersPage.tsx frontend/src/App.tsx +git commit -m "feat: work orders list with status filter and create form" +``` + +--- + +### Task 9: Work Order Detail Page + +**Files:** +- Create: `frontend/src/pages/app/WorkOrderDetailPage.tsx` +- Modify: `frontend/src/App.tsx` (add import) + +- [ ] **Step 1: Create WorkOrderDetailPage** + +Create `frontend/src/pages/app/WorkOrderDetailPage.tsx`: + +```tsx +import { useState } from 'react' +import { useParams, Link } from 'react-router' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { apiFetch } from '@/lib/api' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { WorkOrderDetail, CatalogItem } from '@/lib/types' + +const STATUS_LABELS: Record = { + open: 'Aberta', + in_progress: 'Em Curso', + completed: 'Concluída', + invoiced: 'Faturada', + cancelled: 'Cancelada', +} + +const TRANSITIONS: Record = { + open: ['in_progress', 'cancelled'], + in_progress: ['completed', 'cancelled'], + completed: ['invoiced', 'cancelled'], + invoiced: [], + cancelled: [], +} + +const itemSchema = z.object({ + catalog_item_id: z.string().optional().default(''), + description: z.string().min(1, 'Descrição obrigatória'), + qty: z.coerce.number().positive('Quantidade deve ser positiva'), + unit_price: z.coerce.number().min(0), + discount_pct: z.coerce.number().min(0).max(100).optional().default(0), +}) +type ItemForm = z.infer + +export default function WorkOrderDetailPage() { + const { id } = useParams<{ id: string }>() + const qc = useQueryClient() + const [showAddItem, setShowAddItem] = useState(false) + + const { data: detail, isLoading } = useQuery({ + queryKey: ['work-order', id], + queryFn: () => apiFetch(`/work-orders/${id}`), + }) + + const { data: catalogItems = [] } = useQuery({ + queryKey: ['catalog'], + queryFn: () => apiFetch('/catalog'), + }) + + const transition = useMutation({ + mutationFn: (status: string) => + apiFetch(`/work-orders/${id}/transition`, { method: 'POST', body: JSON.stringify({ status }) }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }), + }) + + const { register, handleSubmit, watch, reset, setValue, formState: { errors } } = useForm({ + resolver: zodResolver(itemSchema), + }) + + const selectedCatalogId = watch('catalog_item_id') + + function onCatalogSelect(e: React.ChangeEvent) { + const itemId = e.target.value + setValue('catalog_item_id', itemId) + if (itemId) { + const found = catalogItems.find((c) => c.id === itemId) + if (found) { + setValue('description', found.name) + setValue('unit_price', found.base_price) + } + } + } + + const addItem = useMutation({ + mutationFn: (data: ItemForm) => + apiFetch(`/work-orders/${id}/items`, { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['work-order', id] }) + setShowAddItem(false) + reset() + }, + }) + + const removeItem = useMutation({ + mutationFn: (itemId: string) => + apiFetch(`/work-orders/${id}/items/${itemId}`, { method: 'DELETE' }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }), + }) + + if (isLoading) return

A carregar...

+ if (!detail) return

Ordem não encontrada.

+ + const total = detail.items.reduce((s, i) => s + i.total, 0) + const hoursTotal = detail.staff_hours.reduce((s, h) => s + h.total, 0) + const nextStates = TRANSITIONS[detail.status] ?? [] + + return ( +
+
+ + ← Ordens + + / +

Ordem #{detail.number}

+ + {STATUS_LABELS[detail.status]} + +
+ + {nextStates.length > 0 && ( +
+ Transição: + {nextStates.map((s) => ( + + ))} +
+ )} + +
+
+

Itens

+ {detail.status !== 'invoiced' && detail.status !== 'cancelled' && ( + + )} +
+ + {showAddItem && ( +
+
addItem.mutate(d))} className="grid grid-cols-3 gap-3"> +
+ + +
+
+ + + {errors.description &&

{errors.description.message}

} +
+
+ + + {errors.qty &&

{errors.qty.message}

} +
+
+ + +
+
+ + +
+ {addItem.error && ( +

{addItem.error.message}

+ )} +
+ + +
+
+
+ )} + + {detail.items.length === 0 ? ( +

Sem itens.

+ ) : ( +
+ + + + + + + + + + + + + {detail.items.map((item) => ( + + + + + + + + + ))} + + + + + + + {hoursTotal > 0 && ( + + + + + )} + + + + + +
DescriçãoQtd.Preço Unit.Desc.%Total
{item.description}{item.qty}{item.unit_price.toFixed(2)} €{item.discount_pct}%{item.total.toFixed(2)} € + {detail.status !== 'invoiced' && detail.status !== 'cancelled' && ( + + )} +
Subtotal itens{total.toFixed(2)} € +
Mão de obra{hoursTotal.toFixed(2)} € +
Total{(total + hoursTotal).toFixed(2)} € +
+
+ )} +
+ + {detail.staff_hours.length > 0 && ( +
+

Horas de Trabalho

+
+ + + + + + + + + + + {detail.staff_hours.map((sh) => ( + + + + + + + ))} + +
FuncionárioHoras€/horaTotal
{sh.staff_id}{sh.hours}{sh.cost_per_hour.toFixed(2)} €{sh.total.toFixed(2)} €
+
+
+ )} +
+ ) +} +``` + +- [ ] **Step 2: Add import to App.tsx** + +```tsx +import WorkOrderDetailPage from '@/pages/app/WorkOrderDetailPage' +``` +(The route `} />` was added in Task 8 Step 2) + +- [ ] **Step 3: Final build check** + +```bash +cd frontend && npm run build 2>&1 | tail -20 +``` +Expected: no TypeScript errors, build succeeds + +- [ ] **Step 4: Run frontend tests** + +```bash +cd frontend && npm run test:run 2>&1 +``` + +- [ ] **Step 5: Commit** + +```bash +git add frontend/src/pages/app/WorkOrderDetailPage.tsx frontend/src/App.tsx +git commit -m "feat: work order detail — items, staff hours, state machine transitions" +``` + +--- + +## Self-Review + +**Spec coverage check:** +- ✅ Clients CRUD — Task 1, 6 +- ✅ Vehicles CRUD (under client) — Task 1, 6 +- ✅ Catalog items CRUD — Task 2, 7 +- ✅ Work Orders: create, list, filter by status — Task 3, 8 +- ✅ Work Order state machine (open→in_progress→completed→invoiced, any→cancelled) — Task 3 (`ValidateTransition`, `TransitionStatus`), Task 9 +- ✅ Work Order line items: add, remove, computed total — Task 3 (`AddItem`, `RemoveItem`), Task 9 +- ✅ Staff hours: add, remove, computed total — Task 3 (`AddStaffHours`, `RemoveStaffHours`), Task 9 +- ✅ `wo_status_log` written on every transition — Task 3 (`TransitionStatus`) +- ✅ All `/app/*` routes behind `RequireAuth + RequireRole + TenantMiddleware` — Tasks 1, 2, 3 +- ✅ AppLayout navigation sidebar — Task 5 +- ✅ No new migrations — all tables exist in `000001_create_tenant_schema.up.sql` + +**Not in Plan 3 (deferred to Plan 4):** +- Staff management page +- Expenses CRUD +- Invoice/PDF generation +- Dashboard KPIs diff --git a/docs/superpowers/plans/2026-06-22-plan3-core-pages.md b/docs/superpowers/plans/2026-06-22-plan3-core-pages.md new file mode 100644 index 0000000..df277f1 --- /dev/null +++ b/docs/superpowers/plans/2026-06-22-plan3-core-pages.md @@ -0,0 +1,2219 @@ +# Plan 3: Core Workshop Pages — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build the 3 missing tenant-facing pages (Clients, Catalog, Work Orders) with domain hooks, modals, and full routing — making the TechXCar app fully operational for workshop staff. + +**Architecture:** Hooks-per-domain — `hooks/useClients.ts`, `hooks/useCatalog.ts`, `hooks/useWorkOrders.ts` wrap TanStack Query. Pages stay presentational. All modals share one `components/ui/dialog.tsx` Radix UI wrapper. + +**Tech Stack:** React 19, TypeScript 6, TanStack Query v5, Radix UI Dialog (already installed as `@radix-ui/react-dialog ^1.1.17`), lucide-react icons, Vitest + Testing Library. Forms use plain `useState` — no react-hook-form (consistent with existing codebase). + +## Global Constraints + +- All UI text in Portuguese (pt-PT) — follow existing pages +- Dark slate theme: root `bg-slate-950`, panels `bg-slate-900`, borders `border-slate-700`/`border-slate-800`, text `text-white`/`text-slate-400` +- Input overrides in dark contexts: add `className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500"` — the base Input is light-themed +- All API calls via `apiFetch` from `@/lib/api` — never raw fetch +- Query keys must be stable: `['clients']`, `['clients', id]`, `['clients', clientId, 'vehicles']`, `['catalog']`, `['work-orders']`, `['work-orders', id]` +- Mutations call `queryClient.invalidateQueries` on success using keys above +- Loading state: `

A carregar...

` +- Empty state: `

Nenhum registo.

` +- Error banner: `

{msg}

` +- After each task: run `npm run build` from `frontend/` — must exit 0 +- After tasks with tests: run `npm run test:run` from `frontend/` — must exit 0 +- Working directory for all npm commands: `frontend/` + +--- + +## File Map + +**Create:** +- `frontend/src/components/ui/dialog.tsx` +- `frontend/src/hooks/useClients.ts` +- `frontend/src/hooks/useClients.test.ts` +- `frontend/src/hooks/useCatalog.ts` +- `frontend/src/hooks/useCatalog.test.ts` +- `frontend/src/hooks/useWorkOrders.ts` +- `frontend/src/hooks/useWorkOrders.test.ts` +- `frontend/src/pages/app/clients/ClientsPage.tsx` +- `frontend/src/pages/app/clients/ClientDetailPage.tsx` +- `frontend/src/pages/app/catalog/CatalogPage.tsx` +- `frontend/src/pages/app/work-orders/WorkOrdersPage.tsx` +- `frontend/src/pages/app/work-orders/WorkOrderDetailPage.tsx` + +**Modify:** +- `frontend/src/App.tsx` — add 5 new routes + +--- + +### Task 1: Shared Dialog component + +**Files:** +- Create: `frontend/src/components/ui/dialog.tsx` + +**Interfaces:** +- Produces: `Dialog`, `DialogTrigger`, `DialogContent`, `DialogHeader`, `DialogTitle`, `DialogClose` — used by all pages in Tasks 3–9 + +- [ ] **Step 1: Create dialog.tsx** + +```tsx +// frontend/src/components/ui/dialog.tsx +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { X } from 'lucide-react' +import { cn } from '@/lib/utils' + +const Dialog = DialogPrimitive.Root +const DialogTrigger = DialogPrimitive.Trigger +const DialogPortal = DialogPrimitive.Portal +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Fechar + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = 'DialogHeader' + +const DialogTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +export { + Dialog, + DialogTrigger, + DialogPortal, + DialogOverlay, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, +} +``` + +- [ ] **Step 2: Build check** + +```bash +cd frontend && npm run build +``` +Expected: exit 0, no TypeScript errors. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/components/ui/dialog.tsx +git commit -m "feat: add shadcn Dialog component (Radix UI wrapper)" +``` + +--- + +### Task 2: useClients hook + +**Files:** +- Create: `frontend/src/hooks/useClients.ts` +- Create: `frontend/src/hooks/useClients.test.ts` + +**Interfaces:** +- Consumes: `apiFetch` from `@/lib/api`, `queryClient` from `@/lib/queryClient`, types `Client`, `Vehicle` from `@/lib/types` +- Produces: + - `useClients(): UseQueryResult` + - `useClient(id: string): UseQueryResult` + - `useCreateClient(): UseMutationResult` + - `useUpdateClient(): UseMutationResult` + - `useDeleteClient(): UseMutationResult` + - `useClientVehicles(clientId: string): UseQueryResult` + - `useCreateVehicle(clientId: string): UseMutationResult` + - `useUpdateVehicle(clientId: string): UseMutationResult` + +Where: +```ts +type ClientPayload = { name: string; nif: string; phone: string; email: string; address: string; notes: string } +type ClientUpdatePayload = ClientPayload & { id: string } +type VehiclePayload = { plate: string; brand: string; model: string; year: number; vin: string; mileage: number; notes: string } +type VehicleUpdatePayload = VehiclePayload & { id: string } +``` + +- [ ] **Step 1: Write the failing test** + +```ts +// frontend/src/hooks/useClients.test.ts +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import { useClients, useClientVehicles } from './useClients' + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })) +vi.mock('@/lib/queryClient', () => ({ queryClient: new QueryClient() })) + +import { apiFetch } from '@/lib/api' +const mockFetch = apiFetch as ReturnType + +function makeWrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children) + return Wrapper +} + +describe('useClients', () => { + beforeEach(() => vi.clearAllMocks()) + + it('calls GET /clients', async () => { + mockFetch.mockResolvedValue([]) + const { result } = renderHook(() => useClients(), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockFetch).toHaveBeenCalledWith('/clients') + }) +}) + +describe('useClientVehicles', () => { + beforeEach(() => vi.clearAllMocks()) + + it('calls GET /clients/:id/vehicles when clientId provided', async () => { + mockFetch.mockResolvedValue([]) + const { result } = renderHook(() => useClientVehicles('abc-123'), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockFetch).toHaveBeenCalledWith('/clients/abc-123/vehicles') + }) + + it('does not fetch when clientId is empty', () => { + const { result } = renderHook(() => useClientVehicles(''), { wrapper: makeWrapper() }) + expect(result.current.fetchStatus).toBe('idle') + }) +}) +``` + +- [ ] **Step 2: Run test — verify it fails** + +```bash +cd frontend && npm run test:run -- useClients +``` +Expected: FAIL — `useClients` not found. + +- [ ] **Step 3: Implement useClients.ts** + +```ts +// frontend/src/hooks/useClients.ts +import { useQuery, useMutation } from '@tanstack/react-query' +import { apiFetch } from '@/lib/api' +import { queryClient } from '@/lib/queryClient' +import type { Client, Vehicle } from '@/lib/types' + +export type ClientPayload = { + name: string; nif: string; phone: string; email: string; address: string; notes: string +} +export type VehiclePayload = { + plate: string; brand: string; model: string; year: number; vin: string; mileage: number; notes: string +} + +export function useClients() { + return useQuery({ + queryKey: ['clients'], + queryFn: () => apiFetch('/clients'), + }) +} + +export function useClient(id: string) { + return useQuery({ + queryKey: ['clients', id], + queryFn: () => apiFetch(`/clients/${id}`), + enabled: !!id, + }) +} + +export function useCreateClient() { + return useMutation({ + mutationFn: (data: ClientPayload) => + apiFetch('/clients', { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['clients'] }), + }) +} + +export function useUpdateClient() { + return useMutation({ + mutationFn: ({ id, ...data }: ClientPayload & { id: string }) => + apiFetch(`/clients/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + onSuccess: (_result, { id }) => { + queryClient.invalidateQueries({ queryKey: ['clients'] }) + queryClient.invalidateQueries({ queryKey: ['clients', id] }) + }, + }) +} + +export function useDeleteClient() { + return useMutation({ + mutationFn: (id: string) => apiFetch(`/clients/${id}`, { method: 'DELETE' }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['clients'] }), + }) +} + +export function useClientVehicles(clientId: string) { + return useQuery({ + queryKey: ['clients', clientId, 'vehicles'], + queryFn: () => apiFetch(`/clients/${clientId}/vehicles`), + enabled: !!clientId, + }) +} + +export function useCreateVehicle(clientId: string) { + return useMutation({ + mutationFn: (data: VehiclePayload) => + apiFetch(`/clients/${clientId}/vehicles`, { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'vehicles'] }), + }) +} + +export function useUpdateVehicle(clientId: string) { + return useMutation({ + mutationFn: ({ id, ...data }: VehiclePayload & { id: string }) => + apiFetch(`/vehicles/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'vehicles'] }), + }) +} +``` + +- [ ] **Step 4: Run tests — verify they pass** + +```bash +cd frontend && npm run test:run -- useClients +``` +Expected: 3 tests PASS. + +- [ ] **Step 5: Build check** + +```bash +cd frontend && npm run build +``` +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/hooks/useClients.ts frontend/src/hooks/useClients.test.ts +git commit -m "feat: useClients domain hook (clients + vehicles CRUD)" +``` + +--- + +### Task 3: ClientsPage + +**Files:** +- Create: `frontend/src/pages/app/clients/ClientsPage.tsx` + +**Interfaces:** +- Consumes: `useClients`, `useCreateClient`, `useUpdateClient` from `@/hooks/useClients`; `Dialog`, `DialogContent`, `DialogHeader`, `DialogTitle` from `@/components/ui/dialog`; `Button` from `@/components/ui/button`; `Input` from `@/components/ui/input`; `Label` from `@/components/ui/label`; `Link` from `react-router`; `Pencil` from `lucide-react` +- Produces: default export `ClientsPage` — rendered at `/app/clients` + +- [ ] **Step 1: Create ClientsPage.tsx** + +```tsx +// frontend/src/pages/app/clients/ClientsPage.tsx +import { useState } from 'react' +import { Link } from 'react-router' +import { Pencil } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useClients, useCreateClient, useUpdateClient, type ClientPayload } from '@/hooks/useClients' +import type { Client } from '@/lib/types' + +const emptyForm: ClientPayload = { name: '', nif: '', phone: '', email: '', address: '', notes: '' } + +export default function ClientsPage() { + const { data: clients = [], isLoading, error } = useClients() + const createClient = useCreateClient() + const updateClient = useUpdateClient() + + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [form, setForm] = useState(emptyForm) + + function openCreate() { + setEditing(null) + setForm(emptyForm) + setOpen(true) + } + + function openEdit(c: Client) { + setEditing(c) + setForm({ name: c.name, nif: c.nif, phone: c.phone, email: c.email, address: c.address, notes: c.notes }) + setOpen(true) + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!form.name.trim()) return + if (editing) { + updateClient.mutate({ id: editing.id, ...form }, { onSuccess: () => setOpen(false) }) + } else { + createClient.mutate(form, { onSuccess: () => setOpen(false) }) + } + } + + const field = (key: keyof ClientPayload) => (e: React.ChangeEvent) => + setForm((f) => ({ ...f, [key]: e.target.value })) + + return ( +
+
+
+

Clientes

+

{clients.length} clientes registados

+
+ +
+ + {error && ( +
+

{(error as Error).message}

+
+ )} + + {isLoading ? ( +

A carregar...

+ ) : clients.length === 0 ? ( +

Nenhum cliente registado.

+ ) : ( +
+ + + + {['Nome', 'NIF', 'Telefone', 'Email', 'Criado', ''].map((h) => ( + + ))} + + + + {clients.map((c) => ( + + + + + + + + + ))} + +
{h}
+ + {c.name} + + {c.nif || '—'}{c.phone || '—'}{c.email || '—'} + {new Intl.DateTimeFormat('pt-PT').format(new Date(c.created_at))} + + +
+
+ )} + + + + + {editing ? 'Editar Cliente' : 'Novo Cliente'} + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+
+ ) +} +``` + +- [ ] **Step 2: Build check** + +```bash +cd frontend && npm run build +``` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/pages/app/clients/ClientsPage.tsx +git commit -m "feat: ClientsPage — list + create/edit modal" +``` + +--- + +### Task 4: ClientDetailPage + +**Files:** +- Create: `frontend/src/pages/app/clients/ClientDetailPage.tsx` + +**Interfaces:** +- Consumes: `useClient`, `useUpdateClient`, `useClientVehicles`, `useCreateVehicle`, `useUpdateVehicle`, `type ClientPayload`, `type VehiclePayload` from `@/hooks/useClients`; `useParams`, `Link` from `react-router`; `Pencil`, `ArrowLeft` from `lucide-react` + +- [ ] **Step 1: Create ClientDetailPage.tsx** + +```tsx +// frontend/src/pages/app/clients/ClientDetailPage.tsx +import { useState } from 'react' +import { useParams, Link } from 'react-router' +import { ArrowLeft, Pencil } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { + useClient, useUpdateClient, + useClientVehicles, useCreateVehicle, useUpdateVehicle, + type ClientPayload, type VehiclePayload, +} from '@/hooks/useClients' +import type { Vehicle } from '@/lib/types' + +const emptyVehicle: VehiclePayload = { plate: '', brand: '', model: '', year: 0, vin: '', mileage: 0, notes: '' } + +export default function ClientDetailPage() { + const { id = '' } = useParams() + const { data: client, isLoading: loadingClient, error: clientError } = useClient(id) + const { data: vehicles = [], isLoading: loadingVehicles } = useClientVehicles(id) + const updateClient = useUpdateClient() + const createVehicle = useCreateVehicle(id) + const updateVehicle = useUpdateVehicle(id) + + const [editClientOpen, setEditClientOpen] = useState(false) + const [clientForm, setClientForm] = useState({ name: '', nif: '', phone: '', email: '', address: '', notes: '' }) + + const [vehicleOpen, setVehicleOpen] = useState(false) + const [editingVehicle, setEditingVehicle] = useState(null) + const [vehicleForm, setVehicleForm] = useState(emptyVehicle) + + function openEditClient() { + if (!client) return + setClientForm({ name: client.name, nif: client.nif, phone: client.phone, email: client.email, address: client.address, notes: client.notes }) + setEditClientOpen(true) + } + + function openAddVehicle() { + setEditingVehicle(null) + setVehicleForm(emptyVehicle) + setVehicleOpen(true) + } + + function openEditVehicle(v: Vehicle) { + setEditingVehicle(v) + setVehicleForm({ plate: v.plate, brand: v.brand, model: v.model, year: v.year ?? 0, vin: v.vin, mileage: v.mileage ?? 0, notes: v.notes }) + setVehicleOpen(true) + } + + function handleClientSubmit(e: React.FormEvent) { + e.preventDefault() + if (!clientForm.name.trim()) return + updateClient.mutate({ id, ...clientForm }, { onSuccess: () => setEditClientOpen(false) }) + } + + function handleVehicleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!vehicleForm.plate.trim() || !vehicleForm.brand.trim() || !vehicleForm.model.trim()) return + if (editingVehicle) { + updateVehicle.mutate({ id: editingVehicle.id, ...vehicleForm }, { onSuccess: () => setVehicleOpen(false) }) + } else { + createVehicle.mutate(vehicleForm, { onSuccess: () => setVehicleOpen(false) }) + } + } + + const vf = (key: keyof VehiclePayload) => (e: React.ChangeEvent) => + setVehicleForm((f) => ({ ...f, [key]: key === 'year' || key === 'mileage' ? Number(e.target.value) : e.target.value })) + + const cf = (key: keyof ClientPayload) => (e: React.ChangeEvent) => + setClientForm((f) => ({ ...f, [key]: e.target.value })) + + if (loadingClient) return

A carregar...

+ if (clientError || !client) return ( +
+ + Clientes + +

Cliente não encontrado.

+
+ ) + + return ( +
+ + Clientes + + + {/* Client header */} +
+
+

{client.name}

+ +
+
+ {[ + ['NIF', client.nif], + ['Telefone', client.phone], + ['Email', client.email], + ['Morada', client.address], + ].map(([label, value]) => ( +
+
{label}
+
{value || '—'}
+
+ ))} + {client.notes && ( +
+
Notas
+
{client.notes}
+
+ )} +
+
+ + {/* Vehicles section */} +
+
+

Veículos

+ +
+ {loadingVehicles ? ( +

A carregar...

+ ) : vehicles.length === 0 ? ( +

Nenhum veículo associado.

+ ) : ( +
+ + + + {['Matrícula', 'Marca', 'Modelo', 'Ano', 'Km', ''].map((h) => ( + + ))} + + + + {vehicles.map((v) => ( + + + + + + + + + ))} + +
{h}
{v.plate}{v.brand}{v.model}{v.year ?? '—'}{v.mileage != null ? `${v.mileage.toLocaleString('pt-PT')} km` : '—'} + +
+
+ )} +
+ + {/* Edit client modal */} + + + Editar Cliente +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+
+
+ + {/* Add/edit vehicle modal */} + + + + {editingVehicle ? 'Editar Veículo' : 'Adicionar Veículo'} + +
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+
+ ) +} +``` + +- [ ] **Step 2: Build check** + +```bash +cd frontend && npm run build +``` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/pages/app/clients/ClientDetailPage.tsx +git commit -m "feat: ClientDetailPage — client info + vehicles CRUD" +``` + +--- + +### Task 5: useCatalog hook + CatalogPage + +**Files:** +- Create: `frontend/src/hooks/useCatalog.ts` +- Create: `frontend/src/hooks/useCatalog.test.ts` +- Create: `frontend/src/pages/app/catalog/CatalogPage.tsx` + +**Interfaces:** +- Produces: + - `useCatalog(): UseQueryResult` + - `useCreateCatalogItem(): UseMutationResult` + - `useUpdateCatalogItem(): UseMutationResult` + - `useDeleteCatalogItem(): UseMutationResult` + +Where: +```ts +type CatalogPayload = { code: string; name: string; category: string; unit: 'un' | 'hora' | 'litro' | 'kg'; base_price: number; active: boolean } +type CatalogUpdatePayload = CatalogPayload & { id: string } +``` + +- [ ] **Step 1: Write failing test** + +```ts +// frontend/src/hooks/useCatalog.test.ts +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import { useCatalog } from './useCatalog' + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })) +vi.mock('@/lib/queryClient', () => ({ queryClient: new QueryClient() })) + +import { apiFetch } from '@/lib/api' +const mockFetch = apiFetch as ReturnType + +function makeWrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children) + return Wrapper +} + +describe('useCatalog', () => { + beforeEach(() => vi.clearAllMocks()) + + it('calls GET /catalog', async () => { + mockFetch.mockResolvedValue([]) + const { result } = renderHook(() => useCatalog(), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockFetch).toHaveBeenCalledWith('/catalog') + }) +}) +``` + +- [ ] **Step 2: Run test — verify it fails** + +```bash +cd frontend && npm run test:run -- useCatalog +``` +Expected: FAIL — `useCatalog` not found. + +- [ ] **Step 3: Implement useCatalog.ts** + +```ts +// frontend/src/hooks/useCatalog.ts +import { useQuery, useMutation } from '@tanstack/react-query' +import { apiFetch } from '@/lib/api' +import { queryClient } from '@/lib/queryClient' +import type { CatalogItem } from '@/lib/types' + +export type CatalogPayload = { + code: string + name: string + category: string + unit: 'un' | 'hora' | 'litro' | 'kg' + base_price: number + active: boolean +} + +export function useCatalog() { + return useQuery({ + queryKey: ['catalog'], + queryFn: () => apiFetch('/catalog'), + }) +} + +export function useCreateCatalogItem() { + return useMutation({ + mutationFn: (data: CatalogPayload) => + apiFetch('/catalog', { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['catalog'] }), + }) +} + +export function useUpdateCatalogItem() { + return useMutation({ + mutationFn: ({ id, ...data }: CatalogPayload & { id: string }) => + apiFetch(`/catalog/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['catalog'] }), + }) +} + +export function useDeleteCatalogItem() { + return useMutation({ + mutationFn: (id: string) => apiFetch(`/catalog/${id}`, { method: 'DELETE' }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['catalog'] }), + }) +} +``` + +- [ ] **Step 4: Run tests — verify they pass** + +```bash +cd frontend && npm run test:run -- useCatalog +``` +Expected: 1 test PASS. + +- [ ] **Step 5: Create CatalogPage.tsx** + +```tsx +// frontend/src/pages/app/catalog/CatalogPage.tsx +import { useState } from 'react' +import { Pencil, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Badge } from '@/components/ui/badge' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useCatalog, useCreateCatalogItem, useUpdateCatalogItem, useDeleteCatalogItem, type CatalogPayload } from '@/hooks/useCatalog' +import type { CatalogItem } from '@/lib/types' + +const UNITS: CatalogItem['unit'][] = ['un', 'hora', 'litro', 'kg'] +const emptyForm: CatalogPayload = { code: '', name: '', category: '', unit: 'un', base_price: 0, active: true } + +export default function CatalogPage() { + const { data: items = [], isLoading, error } = useCatalog() + const createItem = useCreateCatalogItem() + const updateItem = useUpdateCatalogItem() + const deleteItem = useDeleteCatalogItem() + + const [open, setOpen] = useState(false) + const [editing, setEditing] = useState(null) + const [form, setForm] = useState(emptyForm) + const [confirmDelete, setConfirmDelete] = useState(null) + + function openCreate() { + setEditing(null) + setForm(emptyForm) + setOpen(true) + } + + function openEdit(item: CatalogItem) { + setEditing(item) + setForm({ code: item.code, name: item.name, category: item.category, unit: item.unit, base_price: item.base_price, active: item.active }) + setOpen(true) + } + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + if (!form.code.trim() || !form.name.trim() || !form.category.trim()) return + if (editing) { + updateItem.mutate({ id: editing.id, ...form }, { onSuccess: () => setOpen(false) }) + } else { + createItem.mutate(form, { onSuccess: () => setOpen(false) }) + } + } + + function handleDelete(id: string) { + if (confirmDelete === id) { + deleteItem.mutate(id, { onSuccess: () => setConfirmDelete(null) }) + } else { + setConfirmDelete(id) + } + } + + const f = (key: keyof CatalogPayload) => (e: React.ChangeEvent) => + setForm((prev) => ({ + ...prev, + [key]: key === 'base_price' ? Number(e.target.value) + : key === 'active' ? (e.target as HTMLInputElement).checked + : e.target.value, + })) + + return ( +
+
+
+

Catálogo

+

{items.length} itens

+
+ +
+ + {error && ( +
+

{(error as Error).message}

+
+ )} + + {isLoading ? ( +

A carregar...

+ ) : items.length === 0 ? ( +

Nenhum item no catálogo.

+ ) : ( +
+ + + + {['Código', 'Nome', 'Categoria', 'Unidade', 'Preço Base', 'Estado', ''].map((h) => ( + + ))} + + + + {items.map((item) => ( + + + + + + + + + + ))} + +
{h}
{item.code}{item.name}{item.category}{item.unit} + {new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }).format(item.base_price)} + + + {item.active ? 'Activo' : 'Inactivo'} + + +
+ + +
+
+
+ )} + + + + + {editing ? 'Editar Item' : 'Novo Item'} + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+ + +
+
+
+
+
+ ) +} +``` + +- [ ] **Step 6: Build check** + +```bash +cd frontend && npm run build +``` +Expected: exit 0. + +- [ ] **Step 7: Commit** + +```bash +git add frontend/src/hooks/useCatalog.ts frontend/src/hooks/useCatalog.test.ts frontend/src/pages/app/catalog/CatalogPage.tsx +git commit -m "feat: useCatalog hook + CatalogPage — items CRUD with confirm-delete" +``` + +--- + +### Task 6: useWorkOrders hook + +**Files:** +- Create: `frontend/src/hooks/useWorkOrders.ts` +- Create: `frontend/src/hooks/useWorkOrders.test.ts` + +**Interfaces:** +- Produces: + - `useWorkOrders(status?: string): UseQueryResult` + - `useWorkOrder(id: string): UseQueryResult` + - `useCreateWorkOrder(): UseMutationResult` + - `useTransitionWorkOrder(): UseMutationResult` + - `useUpdateWorkOrder(): UseMutationResult` + - `useAddWOItem(): UseMutationResult` + - `useRemoveWOItem(): UseMutationResult` + - `useAddStaffHours(): UseMutationResult` + - `useRemoveStaffHours(): UseMutationResult` + +Where: +```ts +type WOPayload = { client_id: string; vehicle_id: string; internal_notes: string; client_notes: string } +type WOUpdatePayload = WOPayload & { id: string } +type WOItemPayload = { catalog_item_id: string; description: string; qty: number; unit_price: number; discount_pct: number } +type StaffHoursPayload = { staff_id: string; hours: number; cost_per_hour: number } +``` + +- [ ] **Step 1: Write failing test** + +```ts +// frontend/src/hooks/useWorkOrders.test.ts +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import { useWorkOrders, useWorkOrder } from './useWorkOrders' + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })) +vi.mock('@/lib/queryClient', () => ({ queryClient: new QueryClient() })) + +import { apiFetch } from '@/lib/api' +const mockFetch = apiFetch as ReturnType + +function makeWrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children) + return Wrapper +} + +describe('useWorkOrders', () => { + beforeEach(() => vi.clearAllMocks()) + + it('calls GET /work-orders without status filter', async () => { + mockFetch.mockResolvedValue([]) + const { result } = renderHook(() => useWorkOrders(), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockFetch).toHaveBeenCalledWith('/work-orders') + }) + + it('calls GET /work-orders?status=open when status provided', async () => { + mockFetch.mockResolvedValue([]) + const { result } = renderHook(() => useWorkOrders('open'), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockFetch).toHaveBeenCalledWith('/work-orders?status=open') + }) +}) + +describe('useWorkOrder', () => { + beforeEach(() => vi.clearAllMocks()) + + it('calls GET /work-orders/:id', async () => { + mockFetch.mockResolvedValue({ id: 'wo-1', items: [], staff_hours: [] }) + const { result } = renderHook(() => useWorkOrder('wo-1'), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockFetch).toHaveBeenCalledWith('/work-orders/wo-1') + }) + + it('does not fetch when id is empty', () => { + const { result } = renderHook(() => useWorkOrder(''), { wrapper: makeWrapper() }) + expect(result.current.fetchStatus).toBe('idle') + }) +}) +``` + +- [ ] **Step 2: Run test — verify it fails** + +```bash +cd frontend && npm run test:run -- useWorkOrders +``` +Expected: FAIL — `useWorkOrders` not found. + +- [ ] **Step 3: Implement useWorkOrders.ts** + +```ts +// frontend/src/hooks/useWorkOrders.ts +import { useQuery, useMutation } from '@tanstack/react-query' +import { apiFetch } from '@/lib/api' +import { queryClient } from '@/lib/queryClient' +import type { WorkOrder, WorkOrderDetail, WOItem, WOStaffHours } from '@/lib/types' + +export type WOPayload = { + client_id: string + vehicle_id: string + internal_notes: string + client_notes: string +} + +export type WOItemPayload = { + catalog_item_id: string + description: string + qty: number + unit_price: number + discount_pct: number +} + +export type StaffHoursPayload = { + staff_id: string + hours: number + cost_per_hour: number +} + +export function useWorkOrders(status?: string) { + const url = status ? `/work-orders?status=${status}` : '/work-orders' + return useQuery({ + queryKey: status ? ['work-orders', { status }] : ['work-orders'], + queryFn: () => apiFetch(url), + }) +} + +export function useWorkOrder(id: string) { + return useQuery({ + queryKey: ['work-orders', id], + queryFn: () => apiFetch(`/work-orders/${id}`), + enabled: !!id, + }) +} + +export function useCreateWorkOrder() { + return useMutation({ + mutationFn: (data: WOPayload) => + apiFetch('/work-orders', { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['work-orders'] }), + }) +} + +export function useUpdateWorkOrder() { + return useMutation({ + mutationFn: ({ id, ...data }: WOPayload & { id: string }) => + apiFetch(`/work-orders/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + onSuccess: (_r, { id }) => { + queryClient.invalidateQueries({ queryKey: ['work-orders'] }) + queryClient.invalidateQueries({ queryKey: ['work-orders', id] }) + }, + }) +} + +export function useTransitionWorkOrder() { + return useMutation({ + mutationFn: ({ id, status }: { id: string; status: string }) => + apiFetch(`/work-orders/${id}/transition`, { method: 'POST', body: JSON.stringify({ status }) }), + onSuccess: (_r, { id }) => { + queryClient.invalidateQueries({ queryKey: ['work-orders'] }) + queryClient.invalidateQueries({ queryKey: ['work-orders', id] }) + }, + }) +} + +export function useAddWOItem() { + return useMutation({ + mutationFn: ({ woId, ...data }: { woId: string } & WOItemPayload) => + apiFetch(`/work-orders/${woId}/items`, { method: 'POST', body: JSON.stringify(data) }), + onSuccess: (_r, { woId }) => + queryClient.invalidateQueries({ queryKey: ['work-orders', woId] }), + }) +} + +export function useRemoveWOItem() { + return useMutation({ + mutationFn: ({ woId, itemId }: { woId: string; itemId: string }) => + apiFetch(`/work-orders/${woId}/items/${itemId}`, { method: 'DELETE' }), + onSuccess: (_r, { woId }) => + queryClient.invalidateQueries({ queryKey: ['work-orders', woId] }), + }) +} + +export function useAddStaffHours() { + return useMutation({ + mutationFn: ({ woId, ...data }: { woId: string } & StaffHoursPayload) => + apiFetch(`/work-orders/${woId}/staff-hours`, { method: 'POST', body: JSON.stringify(data) }), + onSuccess: (_r, { woId }) => + queryClient.invalidateQueries({ queryKey: ['work-orders', woId] }), + }) +} + +export function useRemoveStaffHours() { + return useMutation({ + mutationFn: ({ woId, shId }: { woId: string; shId: string }) => + apiFetch(`/work-orders/${woId}/staff-hours/${shId}`, { method: 'DELETE' }), + onSuccess: (_r, { woId }) => + queryClient.invalidateQueries({ queryKey: ['work-orders', woId] }), + }) +} +``` + +- [ ] **Step 4: Run tests — verify they pass** + +```bash +cd frontend && npm run test:run -- useWorkOrders +``` +Expected: 4 tests PASS. + +- [ ] **Step 5: Build check** + +```bash +cd frontend && npm run build +``` +Expected: exit 0. + +- [ ] **Step 6: Commit** + +```bash +git add frontend/src/hooks/useWorkOrders.ts frontend/src/hooks/useWorkOrders.test.ts +git commit -m "feat: useWorkOrders domain hook (list, detail, transitions, items, staff hours)" +``` + +--- + +### Task 7: WorkOrdersPage + +**Files:** +- Create: `frontend/src/pages/app/work-orders/WorkOrdersPage.tsx` + +**Interfaces:** +- Consumes: `useWorkOrders`, `useCreateWorkOrder`, `type WOPayload` from `@/hooks/useWorkOrders`; `useClients`, `useClientVehicles` from `@/hooks/useClients`; `useNavigate` from `react-router` + +Status badge colours: `open`→slate, `in_progress`→blue, `completed`→green, `invoiced`→purple, `cancelled`→red. + +- [ ] **Step 1: Create WorkOrdersPage.tsx** + +```tsx +// frontend/src/pages/app/work-orders/WorkOrdersPage.tsx +import { useState } from 'react' +import { useNavigate } from 'react-router' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { useWorkOrders, useCreateWorkOrder, type WOPayload } from '@/hooks/useWorkOrders' +import { useClients, useClientVehicles } from '@/hooks/useClients' +import type { WorkOrder } from '@/lib/types' + +const STATUS_TABS = [ + { value: '', label: 'Todas' }, + { value: 'open', label: 'Abertas' }, + { value: 'in_progress', label: 'Em Progresso' }, + { value: 'completed', label: 'Concluídas' }, + { value: 'invoiced', label: 'Faturadas' }, + { value: 'cancelled', label: 'Canceladas' }, +] + +const STATUS_BADGE: Record = { + open: 'bg-slate-700 text-slate-300', + in_progress: 'bg-blue-900/50 text-blue-300 border border-blue-700', + completed: 'bg-green-900/50 text-green-300 border border-green-700', + invoiced: 'bg-purple-900/50 text-purple-300 border border-purple-700', + cancelled: 'bg-red-900/50 text-red-300 border border-red-700', +} + +const STATUS_LABEL: Record = { + open: 'Aberta', + in_progress: 'Em Progresso', + completed: 'Concluída', + invoiced: 'Faturada', + cancelled: 'Cancelada', +} + +const emptyForm: WOPayload = { client_id: '', vehicle_id: '', internal_notes: '', client_notes: '' } + +export default function WorkOrdersPage() { + const navigate = useNavigate() + const [statusFilter, setStatusFilter] = useState('') + const { data: orders = [], isLoading, error } = useWorkOrders(statusFilter || undefined) + const createWO = useCreateWorkOrder() + + const [open, setOpen] = useState(false) + const [form, setForm] = useState(emptyForm) + + const { data: clients = [] } = useClients() + const { data: vehicles = [] } = useClientVehicles(form.client_id) + + function handleSubmit(e: React.FormEvent) { + e.preventDefault() + createWO.mutate(form, { + onSuccess: (wo) => { + setOpen(false) + setForm(emptyForm) + navigate(`/app/work-orders/${wo.id}`) + }, + }) + } + + return ( +
+
+
+

Ordens de Trabalho

+

{orders.length} ordens

+
+ +
+ + {/* Status tabs */} +
+ {STATUS_TABS.map(({ value, label }) => ( + + ))} +
+ + {error && ( +
+

{(error as Error).message}

+
+ )} + + {isLoading ? ( +

A carregar...

+ ) : orders.length === 0 ? ( +

Nenhuma ordem de trabalho.

+ ) : ( +
+ + + + {['Nº OT', 'Estado', 'Notas Internas', 'Data', ''].map((h) => ( + + ))} + + + + {orders.map((wo) => ( + navigate(`/app/work-orders/${wo.id}`)} + className="border-t border-slate-700 hover:bg-slate-800/50 cursor-pointer" + > + + + + + + + ))} + +
{h}
#{wo.number} + + {STATUS_LABEL[wo.status]} + + {wo.internal_notes || '—'} + {new Intl.DateTimeFormat('pt-PT').format(new Date(wo.created_at))} + Ver →
+
+ )} + + + + Nova Ordem de Trabalho +
+
+ + +
+
+ + +
+
+ + setForm((f) => ({ ...f, internal_notes: e.target.value }))} + className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" + placeholder="Problema reportado, diagnóstico..." /> +
+
+ + setForm((f) => ({ ...f, client_notes: e.target.value }))} + className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" + placeholder="Mensagem para o cliente..." /> +
+
+ + +
+
+
+
+
+ ) +} +``` + +- [ ] **Step 2: Build check** + +```bash +cd frontend && npm run build +``` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/pages/app/work-orders/WorkOrdersPage.tsx +git commit -m "feat: WorkOrdersPage — list with status tabs + create modal" +``` + +--- + +### Task 8: WorkOrderDetailPage + +**Files:** +- Create: `frontend/src/pages/app/work-orders/WorkOrderDetailPage.tsx` + +**Interfaces:** +- Consumes: `useWorkOrder`, `useTransitionWorkOrder`, `useUpdateWorkOrder`, `useAddWOItem`, `useRemoveWOItem`, `useAddStaffHours`, `useRemoveStaffHours`, `type WOItemPayload`, `type StaffHoursPayload` from `@/hooks/useWorkOrders`; `useCatalog` from `@/hooks/useCatalog`; `useParams`, `Link` from `react-router` + +State machine transitions: +- `open` → `in_progress` (button: "Iniciar") +- `in_progress` → `completed` (button: "Concluir") +- `completed` → `invoiced` (button: "Faturar") +- Any state except `invoiced` → `cancelled` (button: "Cancelar OT", destructive style) + +Stepper steps in order: `open`, `in_progress`, `completed`, `invoiced` + +- [ ] **Step 1: Create WorkOrderDetailPage.tsx** + +```tsx +// frontend/src/pages/app/work-orders/WorkOrderDetailPage.tsx +import { useState } from 'react' +import { useParams, Link } from 'react-router' +import { ArrowLeft, Trash2 } from 'lucide-react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog' +import { + useWorkOrder, useTransitionWorkOrder, useUpdateWorkOrder, + useAddWOItem, useRemoveWOItem, useAddStaffHours, useRemoveStaffHours, + type WOItemPayload, type StaffHoursPayload, +} from '@/hooks/useWorkOrders' +import { useCatalog } from '@/hooks/useCatalog' +import type { WorkOrder } from '@/lib/types' + +const STEPS: WorkOrder['status'][] = ['open', 'in_progress', 'completed', 'invoiced'] +const STEP_LABEL: Record = { + open: 'Aberta', + in_progress: 'Em Progresso', + completed: 'Concluída', + invoiced: 'Faturada', + cancelled: 'Cancelada', +} +const NEXT_STATUS: Partial> = { + open: 'in_progress', + in_progress: 'completed', + completed: 'invoiced', +} +const NEXT_LABEL: Partial> = { + open: 'Iniciar', + in_progress: 'Concluir', + completed: 'Faturar', +} +const STATUS_BADGE: Record = { + open: 'bg-slate-700 text-slate-300', + in_progress: 'bg-blue-900/50 text-blue-300 border border-blue-700', + completed: 'bg-green-900/50 text-green-300 border border-green-700', + invoiced: 'bg-purple-900/50 text-purple-300 border border-purple-700', + cancelled: 'bg-red-900/50 text-red-300 border border-red-700', +} + +const emptyItem: WOItemPayload = { catalog_item_id: '', description: '', qty: 1, unit_price: 0, discount_pct: 0 } +const emptyHours: StaffHoursPayload = { staff_id: '', hours: 0, cost_per_hour: 0 } + +const fmt = new Intl.NumberFormat('pt-PT', { style: 'currency', currency: 'EUR' }) + +export default function WorkOrderDetailPage() { + const { id = '' } = useParams() + const { data: wo, isLoading, error } = useWorkOrder(id) + const { data: catalog = [] } = useCatalog() + const transition = useTransitionWorkOrder() + const updateWO = useUpdateWorkOrder() + const addItem = useAddWOItem() + const removeItem = useRemoveWOItem() + const addHours = useAddStaffHours() + const removeHours = useRemoveStaffHours() + + const [itemOpen, setItemOpen] = useState(false) + const [itemForm, setItemForm] = useState(emptyItem) + + const [hoursOpen, setHoursOpen] = useState(false) + const [hoursForm, setHoursForm] = useState(emptyHours) + + const [editNotesMode, setEditNotesMode] = useState(false) + const [notesForm, setNotesForm] = useState({ internal_notes: '', client_notes: '' }) + const [cancelConfirm, setCancelConfirm] = useState(false) + + function handleCatalogSelect(catalogId: string) { + const item = catalog.find((c) => c.id === catalogId) + if (item) { + setItemForm((f) => ({ ...f, catalog_item_id: catalogId, description: item.name, unit_price: item.base_price })) + } else { + setItemForm((f) => ({ ...f, catalog_item_id: catalogId })) + } + } + + function handleAddItem(e: React.FormEvent) { + e.preventDefault() + if (!itemForm.description.trim() || itemForm.qty <= 0) return + addItem.mutate({ woId: id, ...itemForm }, { onSuccess: () => { setItemOpen(false); setItemForm(emptyItem) } }) + } + + function handleAddHours(e: React.FormEvent) { + e.preventDefault() + if (!hoursForm.staff_id.trim() || hoursForm.hours <= 0) return + addHours.mutate({ woId: id, ...hoursForm }, { onSuccess: () => { setHoursOpen(false); setHoursForm(emptyHours) } }) + } + + function openEditNotes() { + if (!wo) return + setNotesForm({ internal_notes: wo.internal_notes, client_notes: wo.client_notes }) + setEditNotesMode(true) + } + + function handleSaveNotes(e: React.FormEvent) { + e.preventDefault() + if (!wo) return + updateWO.mutate( + { id, client_id: wo.client_id ?? '', vehicle_id: wo.vehicle_id ?? '', ...notesForm }, + { onSuccess: () => setEditNotesMode(false) } + ) + } + + if (isLoading) return

A carregar...

+ if (error || !wo) return ( +
+ + Ordens de Trabalho + +

Ordem não encontrada.

+
+ ) + + const nextStatus = NEXT_STATUS[wo.status] + const canCancel = wo.status !== 'invoiced' && wo.status !== 'cancelled' + const isClosed = wo.status === 'invoiced' || wo.status === 'cancelled' + + const subtotalItems = wo.items.reduce((s, i) => s + i.total, 0) + const subtotalHours = wo.staff_hours.reduce((s, h) => s + h.total, 0) + + return ( +
+ + Ordens de Trabalho + + +
+ {/* LEFT: info + transitions */} +
+
+
+
+

OT #{wo.number}

+ + {STEP_LABEL[wo.status]} + +
+

+ {new Intl.DateTimeFormat('pt-PT').format(new Date(wo.created_at))} +

+
+ + {/* Stepper */} + {wo.status !== 'cancelled' && ( +
+ {STEPS.map((step, i) => { + const stepIndex = STEPS.indexOf(wo.status as WorkOrder['status']) + const done = STEPS.indexOf(step) <= stepIndex + return ( +
+
+ {i < STEPS.length - 1 && null} +
+ ) + })} +
+ )} + + {/* Notes */} + {editNotesMode ? ( +
+
+ + setNotesForm((f) => ({ ...f, internal_notes: e.target.value }))} + className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" + /> +
+
+ + setNotesForm((f) => ({ ...f, client_notes: e.target.value }))} + className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" + /> +
+
+ + +
+
+ ) : ( +
+
+

Notas Internas

+

{wo.internal_notes || '—'}

+
+
+

Notas para o Cliente

+

{wo.client_notes || '—'}

+
+ {!isClosed && ( + + )} +
+ )} +
+ + {/* Transition buttons */} + {!isClosed && ( +
+ {nextStatus && ( + + )} + {canCancel && ( + + )} +
+ )} +
+ + {/* RIGHT: items + staff hours */} +
+ {/* Items */} +
+
+

Peças / Serviços

+ {!isClosed && } +
+ {wo.items.length === 0 ? ( +

Nenhum item adicionado.

+ ) : ( +
+ + + + {['Descrição', 'Qty', 'P. Unit.', 'Desc.', 'Total', ''].map((h) => ( + + ))} + + + + {wo.items.map((item) => ( + + + + + + + + + ))} + + + + + +
{h}
{item.description}{item.qty}{fmt.format(item.unit_price)}{item.discount_pct > 0 ? `${item.discount_pct}%` : '—'}{fmt.format(item.total)} + {!isClosed && ( + + )} +
Subtotal peças{fmt.format(subtotalItems)}
+
+ )} +
+ + {/* Staff hours */} +
+
+

Horas de Técnico

+ {!isClosed && } +
+ {wo.staff_hours.length === 0 ? ( +

Nenhuma hora registada.

+ ) : ( +
+ + + + {['Técnico', 'Horas', 'Custo/h', 'Total', ''].map((h) => ( + + ))} + + + + {wo.staff_hours.map((sh) => ( + + + + + + + + ))} + + + + + +
{h}
{sh.staff_id}{sh.hours}h{fmt.format(sh.cost_per_hour)}{fmt.format(sh.total)} + {!isClosed && ( + + )} +
Subtotal horas{fmt.format(subtotalHours)}
+
+ )} +
+ + {/* Grand total */} + {(wo.items.length > 0 || wo.staff_hours.length > 0) && ( +
+
+

Total Geral

+

{fmt.format(subtotalItems + subtotalHours)}

+
+
+ )} +
+
+ + {/* Add item modal */} + + + Adicionar Item +
+
+ + +
+
+ + setItemForm((f) => ({ ...f, description: e.target.value }))} + required + className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" + placeholder="Descrição do serviço ou peça" /> +
+
+
+ + setItemForm((f) => ({ ...f, qty: Number(e.target.value) }))} + required className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" /> +
+
+ + setItemForm((f) => ({ ...f, unit_price: Number(e.target.value) }))} + className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" /> +
+
+ + setItemForm((f) => ({ ...f, discount_pct: Number(e.target.value) }))} + className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" /> +
+
+
+ + +
+
+
+
+ + {/* Add staff hours modal */} + + + Adicionar Horas de Técnico +
+
+ + setHoursForm((f) => ({ ...f, staff_id: e.target.value }))} + required + className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" + placeholder="ID ou nome do técnico" /> +
+
+
+ + setHoursForm((f) => ({ ...f, hours: Number(e.target.value) }))} + required className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" /> +
+
+ + setHoursForm((f) => ({ ...f, cost_per_hour: Number(e.target.value) }))} + className="bg-slate-800 border-slate-600 text-white placeholder:text-slate-500" /> +
+
+
+ + +
+
+
+
+
+ ) +} +``` + +- [ ] **Step 2: Build check** + +```bash +cd frontend && npm run build +``` +Expected: exit 0. + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/pages/app/work-orders/WorkOrderDetailPage.tsx +git commit -m "feat: WorkOrderDetailPage — detail, state machine, items, staff hours, totals" +``` + +--- + +### Task 9: Wire routes + final verification + +**Files:** +- Modify: `frontend/src/App.tsx` + +**Interfaces:** +- Consumes: all 5 new page components from Tasks 3–8 + +- [ ] **Step 1: Update App.tsx** + +Replace the existing `/app` route block with: + +```tsx +// frontend/src/App.tsx +import { BrowserRouter, Routes, Route, Navigate } from 'react-router' +import { QueryClientProvider } from '@tanstack/react-query' +import { queryClient } from '@/lib/queryClient' +import { useAuthStore } from '@/store/authStore' +import type { UserRole } from '@/store/authStore' +import LoginPage from '@/pages/auth/LoginPage' +import AppDashboardPage from '@/pages/app/DashboardPage' +import AdminDashboardPage from '@/pages/admin/DashboardPage' +import TenantsPage from '@/pages/admin/TenantsPage' +import InviteRedeemPage from '@/pages/public/InviteRedeemPage' +import ClientsPage from '@/pages/app/clients/ClientsPage' +import ClientDetailPage from '@/pages/app/clients/ClientDetailPage' +import CatalogPage from '@/pages/app/catalog/CatalogPage' +import WorkOrdersPage from '@/pages/app/work-orders/WorkOrdersPage' +import WorkOrderDetailPage from '@/pages/app/work-orders/WorkOrderDetailPage' +import AppLayout from '@/components/layout/AppLayout' +import AdminLayout from '@/components/layout/AdminLayout' + +function RequireAuth({ + children, + allowedRoles, +}: { + children: React.ReactNode + allowedRoles: UserRole[] +}) { + const { isAuthenticated, user } = useAuthStore() + if (!isAuthenticated) return + if (user && !allowedRoles.includes(user.role)) return + return <>{children} +} + +export default function App() { + return ( + + + + } /> + } /> + + + + + } + > + } /> + } /> + + + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + + + + ) +} +``` + +- [ ] **Step 2: Full build + test run** + +```bash +cd frontend && npm run build && npm run test:run +``` +Expected: build exit 0, all tests PASS (authStore × 4 + useClients × 3 + useCatalog × 1 + useWorkOrders × 4 = 12 tests). + +- [ ] **Step 3: Rebuild Docker image + smoke test** + +```bash +cd /path/to/project && docker compose build frontend && docker compose up -d frontend +``` +Then verify: +```bash +curl -s http://localhost:3000 | grep -q 'TechXCar' && echo "Frontend OK" +curl -s -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@techxcar.com","password":"TechXCar2026!"}' | grep -q 'access_token' && echo "Auth OK" +``` +Expected: both lines print OK. + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/App.tsx +git commit -m "feat: wire client, catalog, and work-order routes into App router" +``` + +--- + +## Self-Review + +**Spec coverage check:** + +| Spec requirement | Task | +|---|---| +| `dialog.tsx` shared component | Task 1 | +| `useClients` hook (8 operations) | Task 2 | +| `ClientsPage` list + create/edit modal | Task 3 | +| `ClientDetailPage` + vehicles CRUD | Task 4 | +| `useCatalog` hook (4 operations) | Task 5 | +| `CatalogPage` with confirm-delete | Task 5 | +| `useWorkOrders` hook (8 operations) | Task 6 | +| `WorkOrdersPage` + status tabs + create modal | Task 7 | +| `WorkOrderDetailPage` + state machine + items + hours | Task 8 | +| Route wiring + final verification | Task 9 | + +**No placeholders, no TBDs.** + +**Type consistency:** All type names and function signatures are consistent across all tasks. `WOPayload`, `WOItemPayload`, `StaffHoursPayload`, `ClientPayload`, `VehiclePayload`, `CatalogPayload` defined in hooks and re-used in pages. diff --git a/docs/superpowers/plans/2026-06-29-super-admin-tenant-access.md b/docs/superpowers/plans/2026-06-29-super-admin-tenant-access.md new file mode 100644 index 0000000..f283a97 --- /dev/null +++ b/docs/superpowers/plans/2026-06-29-super-admin-tenant-access.md @@ -0,0 +1,633 @@ +# Super Admin Tenant Access — Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Fix TenantMiddleware schema name bug and add super-admin tenant impersonation with session restore. + +**Architecture:** One-line backend bug fix; new `POST /api/v1/admin/tenants/:id/access` endpoint issues a tenant-scoped JWT; frontend authStore grows `previousSession` + two actions; TenantsPage adds "Gerir" button; AppLayout renders amber banner when impersonating. + +**Tech Stack:** Go 1.25 + Fiber v2, pgxpool, golang-jwt v5, React 19 + TypeScript strict, Zustand v5, TanStack Query v5 + +## Global Constraints + +- Go module: `github.com/techxcar/backend` +- All user-facing error messages in Português de Portugal (pt-PT) +- API envelope: `{ "data": ..., "error": null }` +- JWT: access 15 min, HS256 +- Tenant schema name: `tenant_` + UUID with hyphens replaced by underscores +- Frontend alias: `@/` → `src/` +- No new npm packages + +--- + +## File Map + +| File | Action | Responsibility | +|---|---|---| +| `backend/internal/auth/middleware.go` | Modify | Fix schema name construction (line 58) | +| `backend/internal/tenant/handler.go` | Modify | Add `tenantAccessHandler` | +| `backend/internal/tenant/routes.go` | Modify | Register new endpoint | +| `frontend/src/store/authStore.ts` | Modify | Add `previousSession`, `impersonateTenant`, `restoreSession` | +| `frontend/src/pages/admin/TenantsPage.tsx` | Modify | Add "Gerir" button per row | +| `frontend/src/components/layout/AppLayout.tsx` | Modify | Add impersonation banner | + +--- + +### Task 1: Fix TenantMiddleware schema name + +**Files:** +- Modify: `backend/internal/auth/middleware.go` + +**Interfaces:** +- Consumes: `claims.TenantID` (UUID string with hyphens, e.g. `590b9965-9f7b-4427-b1c8-2b695610e7bd`) +- Produces: `search_path` set to `"tenant_590b9965_9f7b_4427_b1c8_2b695610e7bd", public` + +- [ ] **Step 1: Write failing test** + +Create `backend/internal/auth/middleware_test.go`: + +```go +package auth_test + +import ( + "net/http/httptest" + "testing" + + "github.com/gofiber/fiber/v2" + "github.com/stretchr/testify/assert" + "github.com/techxcar/backend/internal/auth" +) + +func TestRequireRole_allowsMatchingRole(t *testing.T) { + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("claims", &auth.Claims{Role: "tenant_admin"}) + return c.Next() + }) + app.Get("/test", auth.RequireRole("tenant_admin"), func(c *fiber.Ctx) error { + return c.SendStatus(200) + }) + req := httptest.NewRequest("GET", "/test", nil) + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) +} + +func TestRequireRole_rejectsMismatch(t *testing.T) { + app := fiber.New() + app.Use(func(c *fiber.Ctx) error { + c.Locals("claims", &auth.Claims{Role: "technician"}) + return c.Next() + }) + app.Get("/test", auth.RequireRole("super_admin"), func(c *fiber.Ctx) error { + return c.SendStatus(200) + }) + req := httptest.NewRequest("GET", "/test", nil) + resp, err := app.Test(req) + assert.NoError(t, err) + assert.Equal(t, 403, resp.StatusCode) +} +``` + +- [ ] **Step 2: Run test to verify it passes (baseline)** + +```bash +cd backend && go test ./internal/auth/... -v -run TestRequireRole +``` + +Expected: PASS (these test existing behavior, not the bug) + +- [ ] **Step 3: Apply the bug fix** + +In `backend/internal/auth/middleware.go`, line 58, replace: + +```go +schema := `"tenant_` + claims.TenantID + `"` +if _, err := conn.Exec(c.Context(), "SET search_path = "+schema+", public"); err != nil { +``` + +with: + +```go +schema := `"tenant_` + strings.ReplaceAll(claims.TenantID, "-", "_") + `"` +if _, err := conn.Exec(c.Context(), "SET search_path = "+schema+", public"); err != nil { +``` + +Verify `strings` is already imported (it is — `strings.SplitN` is used in `RequireAuth`). + +- [ ] **Step 4: Run full auth package tests** + +```bash +cd backend && go test ./internal/auth/... -v +``` + +Expected: all PASS + +- [ ] **Step 5: Smoke test tenant login via curl** + +```bash +TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@oficina.pt","password":"password123","tenant_slug":"oficina-demo"}' \ + | python3 -c "import sys,json; d=json.load(sys.stdin); print(d['data']['access_token'] if d['error'] is None else 'FAIL: '+d['error'])") +echo $TOKEN +``` + +Expected: JWT token string (not "FAIL:...") + +- [ ] **Step 6: Commit** + +```bash +git add backend/internal/auth/middleware.go backend/internal/auth/middleware_test.go +git commit -m "fix: TenantMiddleware schema name uses underscores to match provisioned schema" +``` + +--- + +### Task 2: Backend — tenant access endpoint + +**Files:** +- Modify: `backend/internal/tenant/handler.go` +- Modify: `backend/internal/tenant/routes.go` + +**Interfaces:** +- Consumes: `repo.GetTenantByID(ctx, id)` → `*Tenant`; `auth.GenerateAccessToken(userID, tenantID, role, secret)` → `(string, error)`; `auth.Claims` with `UserID`, `TenantID`, `Role` +- Produces: `POST /api/v1/admin/tenants/:id/access` → `{ "data": { "access_token": string, "tenant": { "id": string, "name": string, "slug": string } }, "error": null }` + +- [ ] **Step 1: Write failing handler test** + +Add to `backend/internal/tenant/handler_test.go` (find the existing test file and append): + +```go +func TestTenantAccessHandler(t *testing.T) { + secret := "test-secret-32-chars-minimum-len" + app := fiber.New(fiber.Config{ErrorHandler: func(c *fiber.Ctx, err error) error { + code := fiber.StatusInternalServerError + if e, ok := err.(*fiber.Error); ok { code = e.Code } + return c.Status(code).JSON(fiber.Map{"data": nil, "error": err.Error()}) + }}) + + tok, _ := auth.GenerateAccessToken("sa-1", "", "super_admin", secret) + app.Use(func(c *fiber.Ctx) error { + c.Locals("claims", &auth.Claims{UserID: "sa-1", TenantID: "", Role: "super_admin"}) + return c.Next() + }) + + cfg := &config.Config{JWTSecret: secret} + repo := &mockRepo{ + tenant: &Tenant{ID: "abc-123", Slug: "t1", Name: "Tenant 1", Status: "active"}, + } + app.Post("/admin/tenants/:id/access", + auth.RequireRole("super_admin"), + tenantAccessHandler(repo, cfg), + ) + + req := httptest.NewRequest("POST", "/admin/tenants/abc-123/access", nil) + req.Header.Set("Authorization", "Bearer "+tok) + resp, err := app.Test(req) + require.NoError(t, err) + assert.Equal(t, 200, resp.StatusCode) + + var body struct { + Data struct { + AccessToken string `json:"access_token"` + Tenant struct { + ID string `json:"id"` + Name string `json:"name"` + Slug string `json:"slug"` + } `json:"tenant"` + } `json:"data"` + Error *string `json:"error"` + } + require.NoError(t, json.NewDecoder(resp.Body).Decode(&body)) + assert.Nil(t, body.Error) + assert.NotEmpty(t, body.Data.AccessToken) + assert.Equal(t, "abc-123", body.Data.Tenant.ID) + assert.Equal(t, "Tenant 1", body.Data.Tenant.Name) +} +``` + +Note: check existing `handler_test.go` for how `mockRepo` is defined and extend it with a `tenant` field + `GetTenantByID` method if not already present. + +- [ ] **Step 2: Run test to verify it fails** + +```bash +cd backend && go test ./internal/tenant/... -v -run TestTenantAccessHandler +``` + +Expected: FAIL — `tenantAccessHandler undefined` + +- [ ] **Step 3: Implement the handler** + +Add to `backend/internal/tenant/handler.go`: + +```go +func tenantAccessHandler(repo *Repository, cfg *config.Config) fiber.Handler { + return func(c *fiber.Ctx) error { + claims, ok := c.Locals("claims").(*auth.Claims) + if !ok { + return fiber.NewError(401, "autenticação necessária") + } + + tenantID := c.Params("id") + ten, err := repo.GetTenantByID(c.Context(), tenantID) + if err != nil || ten == nil { + return fiber.NewError(404, "oficina não encontrada") + } + if ten.Status != "active" { + return fiber.NewError(404, "oficina não encontrada ou inativa") + } + + token, err := auth.GenerateAccessToken(claims.UserID, ten.ID, "tenant_admin", cfg.JWTSecret) + if err != nil { + return fiber.NewError(500, "erro ao gerar token") + } + + return c.JSON(fiber.Map{ + "data": fiber.Map{ + "access_token": token, + "tenant": fiber.Map{ + "id": ten.ID, + "name": ten.Name, + "slug": ten.Slug, + }, + }, + "error": nil, + }) + } +} +``` + +- [ ] **Step 4: Register the route** + +In `backend/internal/tenant/routes.go`, inside the `admin` group (after existing routes): + +```go +admin.Post("/tenants/:id/access", tenantAccessHandler(repo, cfg)) +``` + +The existing `admin` group already has `RequireAuth` + `RequireRole("super_admin")` — no extra middleware needed. + +- [ ] **Step 5: Run tests** + +```bash +cd backend && go test ./internal/tenant/... -v -run TestTenantAccess +``` + +Expected: PASS + +- [ ] **Step 6: Smoke test via curl** + +```bash +# Login as super admin +TOKEN=$(curl -s -X POST http://localhost:8080/api/v1/auth/login \ + -H "Content-Type: application/json" \ + -d '{"email":"admin@techxcar.com","password":"TechXCar2026!"}' \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['access_token'])") + +# Get tenant ID from list +TENANT_ID=$(curl -s http://localhost:8080/api/v1/admin/tenants \ + -H "Authorization: Bearer $TOKEN" \ + | python3 -c "import sys,json; print(json.load(sys.stdin)['data'][0]['id'])") + +# Request tenant-scoped token +curl -s -X POST http://localhost:8080/api/v1/admin/tenants/$TENANT_ID/access \ + -H "Authorization: Bearer $TOKEN" | python3 -m json.tool +``` + +Expected: `{ "data": { "access_token": "eyJ...", "tenant": { "id": "...", "name": "...", "slug": "..." } }, "error": null }` + +- [ ] **Step 7: Rebuild backend container** + +```bash +docker compose up --build -d backend +``` + +Wait for healthy, re-run smoke test against port 8080. + +- [ ] **Step 8: Commit** + +```bash +git add backend/internal/tenant/handler.go backend/internal/tenant/routes.go backend/internal/tenant/handler_test.go +git commit -m "feat: tenant access endpoint — super admin can get tenant-scoped JWT" +``` + +--- + +### Task 3: Frontend — authStore impersonation + +**Files:** +- Modify: `frontend/src/store/authStore.ts` + +**Interfaces:** +- Consumes: existing `AuthUser`, `accessToken` state +- Produces: + - `previousSession: { token: string; user: AuthUser } | null` (NOT persisted) + - `impersonateTenant(token: string, user: AuthUser): void` + - `restoreSession(): void` + +- [ ] **Step 1: Update authStore.ts** + +Replace the entire file content: + +```typescript +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +export type UserRole = 'super_admin' | 'tenant_admin' | 'manager' | 'technician' + +export interface AuthUser { + id: string + email: string + name: string + role: UserRole + tenantId?: string +} + +interface PreviousSession { + token: string + user: AuthUser +} + +interface AuthState { + user: AuthUser | null + accessToken: string | null + isAuthenticated: boolean + previousSession: PreviousSession | null + setAuth: (user: AuthUser, accessToken: string) => void + clearAuth: () => void + updateToken: (accessToken: string) => void + impersonateTenant: (token: string, user: AuthUser) => void + restoreSession: () => void +} + +export const useAuthStore = create()( + persist( + (set, get) => ({ + user: null, + accessToken: null, + isAuthenticated: false, + previousSession: null, + setAuth: (user, accessToken) => + set({ user, accessToken, isAuthenticated: true, previousSession: null }), + clearAuth: () => + set({ user: null, accessToken: null, isAuthenticated: false, previousSession: null }), + updateToken: (accessToken) => + set({ accessToken }), + impersonateTenant: (token, user) => { + const { accessToken, user: currentUser } = get() + set({ + accessToken: token, + user, + isAuthenticated: true, + previousSession: currentUser && accessToken + ? { token: accessToken, user: currentUser } + : null, + }) + }, + restoreSession: () => { + const { previousSession } = get() + if (!previousSession) return + set({ + accessToken: previousSession.token, + user: previousSession.user, + isAuthenticated: true, + previousSession: null, + }) + }, + }), + { + name: 'techxcar-auth', + partialize: (state) => ({ + user: state.user, + accessToken: state.accessToken, + isAuthenticated: state.isAuthenticated, + // previousSession intentionally excluded — impersonation does not survive refresh + }), + } + ) +) +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd frontend && npm run build 2>&1 | tail -10 +``` + +Expected: build succeeds (or only pre-existing errors, none from authStore) + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/store/authStore.ts +git commit -m "feat: authStore gains impersonateTenant and restoreSession for super-admin tenant access" +``` + +--- + +### Task 4: Frontend — TenantsPage "Gerir" button + +**Files:** +- Modify: `frontend/src/pages/admin/TenantsPage.tsx` + +**Interfaces:** +- Consumes: + - `POST /api/v1/admin/tenants/:id/access` → `{ access_token: string, tenant: { id, name, slug } }` + - `useAuthStore().impersonateTenant(token, user)` + - `useNavigate()` from `react-router` +- Produces: button per row that triggers impersonation and navigates to `/app` + +- [ ] **Step 1: Add the "Gerir" mutation and button** + +In `frontend/src/pages/admin/TenantsPage.tsx`: + +1. Add imports at top: +```typescript +import { useNavigate } from 'react-router' +import { useAuthStore } from '@/store/authStore' +import type { AuthUser } from '@/store/authStore' +``` + +2. Inside `TenantsPage` component, after existing mutations, add: +```typescript +const navigate = useNavigate() +const { impersonateTenant } = useAuthStore() + +const accessTenant = useMutation({ + mutationFn: (tenantId: string) => + apiFetch<{ access_token: string; tenant: { id: string; name: string; slug: string } }>( + `/admin/tenants/${tenantId}/access`, + { method: 'POST' } + ), + onSuccess: (data) => { + const tenantUser: AuthUser = { + id: data.tenant.id, + email: '', + name: data.tenant.name, + role: 'tenant_admin', + tenantId: data.tenant.id, + } + impersonateTenant(data.access_token, tenantUser) + queryClient.clear() + navigate('/app') + }, +}) +``` + +3. In the table ``, add a fifth column header `` in ``: +```tsx +Ações +``` + +4. In each `` inside the `.map()`, add a fifth `` after the "Criada" cell: +```tsx + + + +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd frontend && npm run build 2>&1 | tail -15 +``` + +Expected: build succeeds + +- [ ] **Step 3: Commit** + +```bash +git add frontend/src/pages/admin/TenantsPage.tsx +git commit -m "feat: TenantsPage — Gerir button triggers tenant impersonation" +``` + +--- + +### Task 5: Frontend — AppLayout impersonation banner + +**Files:** +- Modify: `frontend/src/components/layout/AppLayout.tsx` + +**Interfaces:** +- Consumes: `useAuthStore().previousSession`, `useAuthStore().restoreSession()`, `useNavigate()` +- Produces: amber banner at top when `previousSession !== null`; "Voltar ao painel" button restores session and navigates `/admin` + +- [ ] **Step 1: Add the banner to AppLayout** + +Replace `frontend/src/components/layout/AppLayout.tsx` with: + +```tsx +import { Outlet, NavLink, useNavigate } from 'react-router' +import { useLogout } from '@/hooks/useAuth' +import { useAuthStore } from '@/store/authStore' + +const nav = [ + { to: '/app', label: 'Dashboard', end: true }, + { to: '/app/work-orders', label: 'Ordens de Trabalho' }, + { to: '/app/clients', label: 'Clientes' }, + { to: '/app/catalog', label: 'Catálogo' }, +] + +export default function AppLayout() { + const { mutate: logout } = useLogout() + const { previousSession, restoreSession, user } = useAuthStore() + const navigate = useNavigate() + + function handleRestore() { + restoreSession() + navigate('/admin') + } + + return ( +
+ {previousSession && ( +
+ + TechXCar Admin — a gerir: {user?.name} + + +
+ )} +
+ +
+ +
+
+
+ ) +} +``` + +- [ ] **Step 2: Verify TypeScript compiles** + +```bash +cd frontend && npm run build 2>&1 | tail -10 +``` + +Expected: build succeeds + +- [ ] **Step 3: Rebuild frontend container and smoke test** + +```bash +docker compose up --build -d frontend +``` + +Then in browser: +1. Log in as `admin@techxcar.com` / `TechXCar2026!` +2. Go to `/admin/tenants` +3. Click "Gerir" on a tenant row +4. Verify: navigates to `/app`, amber banner shows "TechXCar Admin — a gerir: Oficina Demo" +5. Click "← Voltar ao painel" +6. Verify: back at `/admin`, super_admin session restored, no banner + +- [ ] **Step 4: Commit** + +```bash +git add frontend/src/components/layout/AppLayout.tsx +git commit -m "feat: AppLayout shows impersonation banner with session restore for super admin" +``` diff --git a/docs/superpowers/plans/2026-06-30-plan4-staff-expenses-invoices-settings.md b/docs/superpowers/plans/2026-06-30-plan4-staff-expenses-invoices-settings.md new file mode 100644 index 0000000..71b38f8 --- /dev/null +++ b/docs/superpowers/plans/2026-06-30-plan4-staff-expenses-invoices-settings.md @@ -0,0 +1,2164 @@ +# TechXCar — Plan 4: Staff, Despesas, Faturação & Settings + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Implement Staff (técnicos), Expenses (despesas), Tenant Settings, PDF generation, and Invoices/Quotes (faturação) — the remaining operational data layer and UI needed before reports. + +**Architecture:** Backend gains `internal/staff/`, `internal/expense/`, `internal/settings/`, `internal/invoice/` packages and `pkg/pdf/` — all following the established pattern (package-level functions receiving deps, routes registered in `server.New()`). PDF generation uses `github.com/go-pdf/fpdf` (pure Go, no Chrome dependency — pragmatic deviation from the spec's chromedp to avoid a ~150MB Chromium dependency in the Docker image; HTML-templated chromedp can be swapped in later). All `/app/*` routes use `RequireAuth + RequireRole + TenantMiddleware`; handlers call `auth.GetConn(c)` for the tenant-scoped connection. + +**Tech Stack:** Go 1.25 + Fiber v2 + pgx/v5 + `github.com/go-pdf/fpdf` v2; React 19 + TypeScript + TanStack Query v5 + React Hook Form + Zod + shadcn/ui + +## Global Constraints + +- All tables already exist in `migrations/tenant/000001_create_tenant_schema.up.sql` — no new migrations +- Response envelope: `{"data": ..., "error": null}` or `{"data": null, "error": "message"}` +- API base: `/api/v1/` +- Tenant-scoped queries always use `auth.GetConn(c)` — never the pool directly +- PT-PT strings for all user-facing error messages +- Roles: `tenant_admin`, `manager` can write; `technician` read-only +- `apiFetch` returns `response.data` directly (see `frontend/src/lib/api.ts`) +- PDFs stored at `/app/storage//inv_.pdf`; `/app/storage/` is a Docker volume + +--- + +## File Map + +**Backend — new files:** +- `backend/internal/staff/repository.go` — Staff CRUD queries +- `backend/internal/staff/handler.go` — HTTP handlers + route registration +- `backend/internal/expense/repository.go` — Expense CRUD queries +- `backend/internal/expense/handler.go` — HTTP handlers + route registration +- `backend/internal/settings/repository.go` — tenant_settings key-value CRUD +- `backend/internal/settings/handler.go` — HTTP handlers + route registration +- `backend/pkg/pdf/pdf.go` — `Generate(items []LineItem, meta DocMeta, outPath string) error` +- `backend/internal/invoice/repository.go` — Invoice CRUD queries +- `backend/internal/invoice/handler.go` — HTTP handlers (generate, list, download PDF) + +**Backend — modified:** +- `backend/internal/server/server.go` — wire 4 new route groups +- `backend/go.mod` + `backend/go.sum` — add `github.com/go-pdf/fpdf/v2` +- `backend/Dockerfile` — add `/app/storage` volume dir + chown + +**Frontend — new files:** +- `frontend/src/pages/app/StaffPage.tsx` +- `frontend/src/pages/app/ExpensesPage.tsx` +- `frontend/src/pages/app/SettingsPage.tsx` +- `frontend/src/pages/app/InvoicesPage.tsx` + +**Frontend — modified:** +- `frontend/src/components/layout/AppLayout.tsx` — add 4 nav items +- `frontend/src/App.tsx` — add 4 routes +- `frontend/src/lib/types.ts` — add Staff, Expense, TenantSettings, Invoice types + +--- + +### Task 1: Staff Repository + Handlers + +**Files:** +- Create: `backend/internal/staff/repository.go` +- Create: `backend/internal/staff/handler.go` + +**Interfaces:** +- Produces: + - `Staff{ID, UserID *string, Name, Email, Phone, Type, Active bool, HourlyRate float64, CreatedAt time.Time}` + - `ListStaff(ctx, conn) ([]*Staff, error)` + - `GetStaffByID(ctx, conn, id) (*Staff, error)` — nil if not found + - `CreateStaff(ctx, conn, name, email, phone, staffType string, hourlyRate float64) (*Staff, error)` + - `UpdateStaff(ctx, conn, id, name, email, phone, staffType string, hourlyRate float64, active bool) (*Staff, error)` — nil if not found + - `DeleteStaff(ctx, conn, id) error` + - `staff.RegisterRoutes(app *fiber.App, db *database.DB, secret string)` + - `GET /api/v1/staff` → `[]*Staff` (all roles) + - `POST /api/v1/staff` → `*Staff` 201 (admin/manager) + - `PUT /api/v1/staff/:id` → `*Staff` (admin/manager) + - `DELETE /api/v1/staff/:id` → 204 (admin/manager) + +- [ ] **Step 1: Create repository.go** + +Create `backend/internal/staff/repository.go`: + +```go +package staff + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Staff struct { + ID string `json:"id"` + UserID *string `json:"user_id"` + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + Type string `json:"type"` + HourlyRate float64 `json:"hourly_rate"` + Active bool `json:"active"` + CreatedAt time.Time `json:"created_at"` +} + +func ListStaff(ctx context.Context, conn *pgxpool.Conn) ([]*Staff, error) { + rows, err := conn.Query(ctx, + `SELECT id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type, + COALESCE(hourly_rate,0), active, created_at + FROM staff ORDER BY name`) + if err != nil { + return nil, fmt.Errorf("staff: list: %w", err) + } + defer rows.Close() + var list []*Staff + for rows.Next() { + var s Staff + if err := rows.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone, + &s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt); err != nil { + return nil, fmt.Errorf("staff: scan: %w", err) + } + list = append(list, &s) + } + return list, nil +} + +func GetStaffByID(ctx context.Context, conn *pgxpool.Conn, id string) (*Staff, error) { + row := conn.QueryRow(ctx, + `SELECT id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type, + COALESCE(hourly_rate,0), active, created_at + FROM staff WHERE id = $1`, id) + var s Staff + err := row.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone, + &s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("staff: get: %w", err) + } + return &s, nil +} + +func CreateStaff(ctx context.Context, conn *pgxpool.Conn, name, email, phone, staffType string, hourlyRate float64) (*Staff, error) { + row := conn.QueryRow(ctx, + `INSERT INTO staff (name, email, phone, type, hourly_rate) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type, + COALESCE(hourly_rate,0), active, created_at`, + name, email, phone, staffType, hourlyRate) + var s Staff + if err := row.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone, + &s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt); err != nil { + return nil, fmt.Errorf("staff: create: %w", err) + } + return &s, nil +} + +func UpdateStaff(ctx context.Context, conn *pgxpool.Conn, id, name, email, phone, staffType string, hourlyRate float64, active bool) (*Staff, error) { + row := conn.QueryRow(ctx, + `UPDATE staff SET name=$2, email=$3, phone=$4, type=$5, hourly_rate=$6, active=$7 + WHERE id=$1 + RETURNING id, user_id, name, COALESCE(email,''), COALESCE(phone,''), type, + COALESCE(hourly_rate,0), active, created_at`, + id, name, email, phone, staffType, hourlyRate, active) + var s Staff + err := row.Scan(&s.ID, &s.UserID, &s.Name, &s.Email, &s.Phone, + &s.Type, &s.HourlyRate, &s.Active, &s.CreatedAt) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("staff: update: %w", err) + } + return &s, nil +} + +func DeleteStaff(ctx context.Context, conn *pgxpool.Conn, id string) error { + tag, err := conn.Exec(ctx, `DELETE FROM staff WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("staff: delete: %w", err) + } + if tag.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} +``` + +- [ ] **Step 2: Create handler.go** + +Create `backend/internal/staff/handler.go`: + +```go +package staff + +import ( + "errors" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/pkg/database" +) + +func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { + ro := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager", "technician"), + auth.TenantMiddleware(db), + } + write := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager"), + auth.TenantMiddleware(db), + } + + app.Get("/api/v1/staff", append(ro, listStaffH())...) + app.Post("/api/v1/staff", append(write, createStaffH())...) + app.Put("/api/v1/staff/:id", append(write, updateStaffH())...) + app.Delete("/api/v1/staff/:id", append(write, deleteStaffH())...) +} + +func listStaffH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + list, err := ListStaff(c.Context(), conn) + if err != nil { + return fiber.NewError(500, "erro ao listar técnicos") + } + if list == nil { + list = []*Staff{} + } + return c.JSON(fiber.Map{"data": list, "error": nil}) + } +} + +type staffBody struct { + Name string `json:"name"` + Email string `json:"email"` + Phone string `json:"phone"` + Type string `json:"type"` + HourlyRate float64 `json:"hourly_rate"` + Active bool `json:"active"` +} + +func createStaffH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b staffBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo do pedido inválido") + } + if b.Name == "" { + return fiber.NewError(400, "nome obrigatório") + } + if b.Type != "internal" && b.Type != "external" { + return fiber.NewError(400, "tipo deve ser 'internal' ou 'external'") + } + conn := auth.GetConn(c) + s, err := CreateStaff(c.Context(), conn, b.Name, b.Email, b.Phone, b.Type, b.HourlyRate) + if err != nil { + return fiber.NewError(500, "erro ao criar técnico") + } + return c.Status(201).JSON(fiber.Map{"data": s, "error": nil}) + } +} + +func updateStaffH() fiber.Handler { + return func(c *fiber.Ctx) error { + id := c.Params("id") + var b staffBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo do pedido inválido") + } + if b.Name == "" { + return fiber.NewError(400, "nome obrigatório") + } + conn := auth.GetConn(c) + s, err := UpdateStaff(c.Context(), conn, id, b.Name, b.Email, b.Phone, b.Type, b.HourlyRate, b.Active) + if err != nil { + return fiber.NewError(500, "erro ao actualizar técnico") + } + if s == nil { + return fiber.NewError(404, "técnico não encontrado") + } + return c.JSON(fiber.Map{"data": s, "error": nil}) + } +} + +func deleteStaffH() fiber.Handler { + return func(c *fiber.Ctx) error { + id := c.Params("id") + conn := auth.GetConn(c) + err := DeleteStaff(c.Context(), conn, id) + if errors.Is(err, pgx.ErrNoRows) { + return fiber.NewError(404, "técnico não encontrado") + } + if err != nil { + return fiber.NewError(500, "erro ao eliminar técnico") + } + return c.SendStatus(204) + } +} +``` + +- [ ] **Step 3: Compile check** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +export PATH=$PATH:/var/home/lmilani/.local/go/bin +go build ./... +``` + +Expected: no output (success). + +- [ ] **Step 4: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add backend/internal/staff/ +git commit -m "feat: staff repository and HTTP handlers (list, create, update, delete)" +``` + +--- + +### Task 2: Expense Repository + Handlers + +**Files:** +- Create: `backend/internal/expense/repository.go` +- Create: `backend/internal/expense/handler.go` + +**Interfaces:** +- Produces: + - `Expense{ID, VehicleID *string, Type, Description string, Amount float64, Date time.Time, CreatedAt time.Time}` + - `ListExpenses(ctx, conn, typeFilter string) ([]*Expense, error)` — empty typeFilter = all + - `CreateExpense(ctx, conn, vehicleID *string, expType, description string, amount float64, date time.Time) (*Expense, error)` + - `DeleteExpense(ctx, conn, id string) error` + - `expense.RegisterRoutes(app, db, secret)` + - `GET /api/v1/expenses` → `[]*Expense` (query: `?type=fuel`) + - `POST /api/v1/expenses` → `*Expense` 201 + - `DELETE /api/v1/expenses/:id` → 204 + +- [ ] **Step 1: Create repository.go** + +Create `backend/internal/expense/repository.go`: + +```go +package expense + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Expense struct { + ID string `json:"id"` + VehicleID *string `json:"vehicle_id"` + Type string `json:"type"` + Amount float64 `json:"amount"` + Description string `json:"description"` + Date time.Time `json:"date"` + CreatedAt time.Time `json:"created_at"` +} + +func ListExpenses(ctx context.Context, conn *pgxpool.Conn, typeFilter string) ([]*Expense, error) { + q := `SELECT id, vehicle_id, type, amount, COALESCE(description,''), date, created_at + FROM expenses` + args := []any{} + if typeFilter != "" { + q += " WHERE type = $1" + args = append(args, typeFilter) + } + q += " ORDER BY date DESC, created_at DESC" + rows, err := conn.Query(ctx, q, args...) + if err != nil { + return nil, fmt.Errorf("expense: list: %w", err) + } + defer rows.Close() + var list []*Expense + for rows.Next() { + var e Expense + if err := rows.Scan(&e.ID, &e.VehicleID, &e.Type, &e.Amount, &e.Description, &e.Date, &e.CreatedAt); err != nil { + return nil, fmt.Errorf("expense: scan: %w", err) + } + list = append(list, &e) + } + return list, nil +} + +func CreateExpense(ctx context.Context, conn *pgxpool.Conn, vehicleID *string, expType, description string, amount float64, date time.Time) (*Expense, error) { + row := conn.QueryRow(ctx, + `INSERT INTO expenses (vehicle_id, type, amount, description, date) + VALUES ($1, $2, $3, $4, $5) + RETURNING id, vehicle_id, type, amount, COALESCE(description,''), date, created_at`, + vehicleID, expType, amount, description, date) + var e Expense + if err := row.Scan(&e.ID, &e.VehicleID, &e.Type, &e.Amount, &e.Description, &e.Date, &e.CreatedAt); err != nil { + return nil, fmt.Errorf("expense: create: %w", err) + } + return &e, nil +} + +func DeleteExpense(ctx context.Context, conn *pgxpool.Conn, id string) error { + tag, err := conn.Exec(ctx, `DELETE FROM expenses WHERE id = $1`, id) + if err != nil { + return fmt.Errorf("expense: delete: %w", err) + } + if tag.RowsAffected() == 0 { + return pgx.ErrNoRows + } + return nil +} +``` + +- [ ] **Step 2: Create handler.go** + +Create `backend/internal/expense/handler.go`: + +```go +package expense + +import ( + "errors" + "time" + + "github.com/gofiber/fiber/v2" + "github.com/jackc/pgx/v5" + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/pkg/database" +) + +var allowedTypes = map[string]bool{"fuel": true, "parts": true, "tools": true, "other": true} + +func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { + ro := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager", "technician"), + auth.TenantMiddleware(db), + } + write := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager"), + auth.TenantMiddleware(db), + } + + app.Get("/api/v1/expenses", append(ro, listExpensesH())...) + app.Post("/api/v1/expenses", append(write, createExpenseH())...) + app.Delete("/api/v1/expenses/:id", append(write, deleteExpenseH())...) +} + +func listExpensesH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + list, err := ListExpenses(c.Context(), conn, c.Query("type")) + if err != nil { + return fiber.NewError(500, "erro ao listar despesas") + } + if list == nil { + list = []*Expense{} + } + return c.JSON(fiber.Map{"data": list, "error": nil}) + } +} + +type expenseBody struct { + VehicleID string `json:"vehicle_id"` + Type string `json:"type"` + Amount float64 `json:"amount"` + Description string `json:"description"` + Date string `json:"date"` // YYYY-MM-DD +} + +func createExpenseH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b expenseBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo do pedido inválido") + } + if !allowedTypes[b.Type] { + return fiber.NewError(400, "tipo inválido: fuel, parts, tools, other") + } + if b.Amount <= 0 { + return fiber.NewError(400, "valor deve ser positivo") + } + if b.Date == "" { + return fiber.NewError(400, "data obrigatória") + } + date, err := time.Parse("2006-01-02", b.Date) + if err != nil { + return fiber.NewError(400, "data inválida (formato: YYYY-MM-DD)") + } + var vehicleID *string + if b.VehicleID != "" { + vehicleID = &b.VehicleID + } + conn := auth.GetConn(c) + e, err := CreateExpense(c.Context(), conn, vehicleID, b.Type, b.Description, b.Amount, date) + if err != nil { + return fiber.NewError(500, "erro ao registar despesa") + } + return c.Status(201).JSON(fiber.Map{"data": e, "error": nil}) + } +} + +func deleteExpenseH() fiber.Handler { + return func(c *fiber.Ctx) error { + id := c.Params("id") + conn := auth.GetConn(c) + err := DeleteExpense(c.Context(), conn, id) + if errors.Is(err, pgx.ErrNoRows) { + return fiber.NewError(404, "despesa não encontrada") + } + if err != nil { + return fiber.NewError(500, "erro ao eliminar despesa") + } + return c.SendStatus(204) + } +} +``` + +- [ ] **Step 3: Compile check** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +export PATH=$PATH:/var/home/lmilani/.local/go/bin +go build ./... +``` + +Expected: no output. + +- [ ] **Step 4: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add backend/internal/expense/ +git commit -m "feat: expense repository and HTTP handlers (list, create, delete)" +``` + +--- + +### Task 3: Settings Repository + Handlers + +**Files:** +- Create: `backend/internal/settings/repository.go` +- Create: `backend/internal/settings/handler.go` + +**Interfaces:** +- Produces: + - `AllowedKeys` — set of valid setting keys + - `GetSettings(ctx, conn) (map[string]string, error)` — returns all settings + - `SetSetting(ctx, conn, key, value string) error` + - `settings.RegisterRoutes(app, db, secret)` + - `GET /api/v1/settings` → `map[string]string` (admin/manager) + - `PUT /api/v1/settings` → `map[string]string` (admin only) — body: `{"key":"value",...}` + +Valid keys: `company_name`, `company_nif`, `company_address`, `company_iban`, `company_phone`, `company_email` + +- [ ] **Step 1: Create repository.go** + +Create `backend/internal/settings/repository.go`: + +```go +package settings + +import ( + "context" + "fmt" + + "github.com/jackc/pgx/v5/pgxpool" +) + +var AllowedKeys = map[string]bool{ + "company_name": true, + "company_nif": true, + "company_address": true, + "company_iban": true, + "company_phone": true, + "company_email": true, +} + +func GetSettings(ctx context.Context, conn *pgxpool.Conn) (map[string]string, error) { + rows, err := conn.Query(ctx, `SELECT key, value FROM tenant_settings`) + if err != nil { + return nil, fmt.Errorf("settings: get: %w", err) + } + defer rows.Close() + result := map[string]string{} + for rows.Next() { + var k, v string + if err := rows.Scan(&k, &v); err != nil { + return nil, fmt.Errorf("settings: scan: %w", err) + } + result[k] = v + } + return result, nil +} + +func SetSetting(ctx context.Context, conn *pgxpool.Conn, key, value string) error { + _, err := conn.Exec(ctx, + `INSERT INTO tenant_settings (key, value) VALUES ($1, $2) + ON CONFLICT (key) DO UPDATE SET value = EXCLUDED.value, updated_at = NOW()`, + key, value) + if err != nil { + return fmt.Errorf("settings: set %s: %w", key, err) + } + return nil +} +``` + +- [ ] **Step 2: Create handler.go** + +Create `backend/internal/settings/handler.go`: + +```go +package settings + +import ( + "github.com/gofiber/fiber/v2" + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/pkg/database" +) + +func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { + read := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager"), + auth.TenantMiddleware(db), + } + admin := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin"), + auth.TenantMiddleware(db), + } + + app.Get("/api/v1/settings", append(read, getSettingsH())...) + app.Put("/api/v1/settings", append(admin, updateSettingsH())...) +} + +func getSettingsH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + s, err := GetSettings(c.Context(), conn) + if err != nil { + return fiber.NewError(500, "erro ao obter definições") + } + return c.JSON(fiber.Map{"data": s, "error": nil}) + } +} + +func updateSettingsH() fiber.Handler { + return func(c *fiber.Ctx) error { + var body map[string]string + if err := c.BodyParser(&body); err != nil { + return fiber.NewError(400, "corpo do pedido inválido") + } + conn := auth.GetConn(c) + for k, v := range body { + if !AllowedKeys[k] { + return fiber.NewError(400, "chave inválida: "+k) + } + if err := SetSetting(c.Context(), conn, k, v); err != nil { + return fiber.NewError(500, "erro ao guardar definição: "+k) + } + } + s, err := GetSettings(c.Context(), conn) + if err != nil { + return fiber.NewError(500, "erro ao obter definições") + } + return c.JSON(fiber.Map{"data": s, "error": nil}) + } +} +``` + +- [ ] **Step 3: Compile check + commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +export PATH=$PATH:/var/home/lmilani/.local/go/bin +go build ./... +git add backend/internal/settings/ +git commit -m "feat: settings repository and HTTP handlers (get, update tenant settings)" +``` + +--- + +### Task 4: PDF Package + go.mod + +**Files:** +- Create: `backend/pkg/pdf/pdf.go` +- Modify: `backend/go.mod` (add `github.com/go-pdf/fpdf/v2`) + +**Interfaces:** +- Produces: + - `pdf.DocMeta{CompanyName, CompanyNIF, CompanyAddress, CompanyIBAN, CompanyPhone, CompanyEmail, DocType, DocNumber, IssuedAt, ClientName, ClientNIF, VehiclePlate string}` + - `pdf.LineItem{Description string, Qty, UnitPrice, DiscountPct, Total float64}` + - `pdf.Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string) error` + +- [ ] **Step 1: Add dependency** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +export PATH=$PATH:/var/home/lmilani/.local/go/bin +go get github.com/go-pdf/fpdf/v2 +``` + +Expected: `go.mod` and `go.sum` updated. + +- [ ] **Step 2: Create pdf.go** + +Create `backend/pkg/pdf/pdf.go`: + +```go +package pdf + +import ( + "fmt" + "os" + "path/filepath" + + "github.com/go-pdf/fpdf/v2" +) + +type DocMeta struct { + CompanyName string + CompanyNIF string + CompanyAddress string + CompanyIBAN string + CompanyPhone string + CompanyEmail string + DocType string // "Fatura" or "Orçamento" + DocNumber string // e.g. "F2024/001" + IssuedAt string // e.g. "30/06/2026" + ClientName string + ClientNIF string + VehiclePlate string +} + +type LineItem struct { + Description string + Qty float64 + UnitPrice float64 + DiscountPct float64 + Total float64 +} + +func Generate(meta DocMeta, items []LineItem, staffTotal float64, outPath string) error { + if err := os.MkdirAll(filepath.Dir(outPath), 0755); err != nil { + return fmt.Errorf("pdf: mkdir: %w", err) + } + + f := fpdf.New("P", "mm", "A4", "") + f.AddPage() + f.SetMargins(15, 15, 15) + + // Header: company info + f.SetFont("Helvetica", "B", 18) + f.CellFormat(120, 10, meta.CompanyName, "", 0, "L", false, 0, "") + f.SetFont("Helvetica", "B", 14) + f.CellFormat(60, 10, meta.DocType, "", 1, "R", false, 0, "") + + f.SetFont("Helvetica", "", 9) + if meta.CompanyNIF != "" { + f.CellFormat(120, 5, "NIF: "+meta.CompanyNIF, "", 0, "L", false, 0, "") + } else { + f.CellFormat(120, 5, "", "", 0, "L", false, 0, "") + } + f.SetFont("Helvetica", "", 11) + f.CellFormat(60, 5, meta.DocNumber, "", 1, "R", false, 0, "") + + f.SetFont("Helvetica", "", 9) + if meta.CompanyAddress != "" { + f.MultiCell(120, 5, meta.CompanyAddress, "", "L", false) + } + f.SetXY(f.GetX()+120, f.GetY()) + f.SetFont("Helvetica", "", 9) + f.CellFormat(60, 5, "Data: "+meta.IssuedAt, "", 1, "R", false, 0, "") + f.Ln(5) + + // Client info + if meta.ClientName != "" { + f.SetFont("Helvetica", "B", 9) + f.CellFormat(180, 5, "Cliente", "", 1, "L", false, 0, "") + f.SetFont("Helvetica", "", 9) + f.CellFormat(180, 5, meta.ClientName, "", 1, "L", false, 0, "") + if meta.ClientNIF != "" { + f.CellFormat(180, 5, "NIF: "+meta.ClientNIF, "", 1, "L", false, 0, "") + } + if meta.VehiclePlate != "" { + f.CellFormat(180, 5, "Matrícula: "+meta.VehiclePlate, "", 1, "L", false, 0, "") + } + f.Ln(5) + } + + // Table header + f.SetFillColor(50, 50, 50) + f.SetTextColor(255, 255, 255) + f.SetFont("Helvetica", "B", 9) + f.CellFormat(90, 7, "Descrição", "1", 0, "L", true, 0, "") + f.CellFormat(20, 7, "Qtd.", "1", 0, "C", true, 0, "") + f.CellFormat(25, 7, "Preço Unit.", "1", 0, "R", true, 0, "") + f.CellFormat(20, 7, "Desc.%", "1", 0, "C", true, 0, "") + f.CellFormat(25, 7, "Total", "1", 1, "R", true, 0, "") + + // Table rows + f.SetFillColor(245, 245, 245) + f.SetTextColor(0, 0, 0) + f.SetFont("Helvetica", "", 9) + fill := false + for _, item := range items { + f.CellFormat(90, 6, item.Description, "1", 0, "L", fill, 0, "") + f.CellFormat(20, 6, fmt.Sprintf("%.2f", item.Qty), "1", 0, "C", fill, 0, "") + f.CellFormat(25, 6, fmt.Sprintf("%.2f €", item.UnitPrice), "1", 0, "R", fill, 0, "") + f.CellFormat(20, 6, fmt.Sprintf("%.0f%%", item.DiscountPct), "1", 0, "C", fill, 0, "") + f.CellFormat(25, 6, fmt.Sprintf("%.2f €", item.Total), "1", 1, "R", fill, 0, "") + fill = !fill + } + + // Totals + var subtotal float64 + for _, item := range items { + subtotal += item.Total + } + grandTotal := subtotal + staffTotal + + f.Ln(3) + f.SetFont("Helvetica", "", 9) + if staffTotal > 0 { + f.CellFormat(155, 6, "Subtotal peças/serviços", "", 0, "R", false, 0, "") + f.CellFormat(25, 6, fmt.Sprintf("%.2f €", subtotal), "1", 1, "R", false, 0, "") + f.CellFormat(155, 6, "Mão de obra", "", 0, "R", false, 0, "") + f.CellFormat(25, 6, fmt.Sprintf("%.2f €", staffTotal), "1", 1, "R", false, 0, "") + } + f.SetFont("Helvetica", "B", 10) + f.CellFormat(155, 7, "TOTAL", "", 0, "R", false, 0, "") + f.CellFormat(25, 7, fmt.Sprintf("%.2f €", grandTotal), "1", 1, "R", false, 0, "") + + // Footer: IBAN + if meta.CompanyIBAN != "" { + f.Ln(8) + f.SetFont("Helvetica", "", 8) + f.CellFormat(180, 5, "IBAN: "+meta.CompanyIBAN, "", 1, "C", false, 0, "") + } + + return f.OutputFileAndClose(outPath) +} +``` + +- [ ] **Step 3: Compile check** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +export PATH=$PATH:/var/home/lmilani/.local/go/bin +go build ./... +``` + +Expected: no output. + +- [ ] **Step 4: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add backend/pkg/pdf/ backend/go.mod backend/go.sum +git commit -m "feat: PDF generation package using go-pdf/fpdf (pure Go, no Chrome dependency)" +``` + +--- + +### Task 5: Invoice Repository + Handlers + +**Files:** +- Create: `backend/internal/invoice/repository.go` +- Create: `backend/internal/invoice/handler.go` + +**Interfaces:** +- Consumes: `pkg/pdf.Generate`, `internal/settings.GetSettings`, `internal/workorder` types +- Produces: + - `Invoice{ID, WorkOrderID, Type, Number int, PDFPath string, IssuedAt time.Time, CreatedAt time.Time}` + - `ListInvoices(ctx, conn) ([]*Invoice, error)` + - `GetInvoice(ctx, conn, id) (*Invoice, error)` — nil if not found + - `CreateInvoice(ctx, conn, woID, docType string) (*Invoice, error)` — inserts row, returns with generated number + - `SetPDFPath(ctx, conn, id, path string) error` + - `invoice.RegisterRoutes(app, db, secret)` + - `GET /api/v1/invoices` → `[]*Invoice` + - `POST /api/v1/invoices` → `*Invoice` 201 — body: `{work_order_id, type: "quote"|"invoice"}`; generates PDF; if type=invoice transitions WO to invoiced + - `GET /api/v1/invoices/:id/pdf` → binary PDF stream (authenticated) + +- [ ] **Step 1: Create repository.go** + +Create `backend/internal/invoice/repository.go`: + +```go +package invoice + +import ( + "context" + "fmt" + "time" + + "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgxpool" +) + +type Invoice struct { + ID string `json:"id"` + WorkOrderID string `json:"work_order_id"` + Type string `json:"type"` + Number int `json:"number"` + PDFPath string `json:"pdf_path"` + IssuedAt time.Time `json:"issued_at"` + CreatedAt time.Time `json:"created_at"` +} + +func ListInvoices(ctx context.Context, conn *pgxpool.Conn) ([]*Invoice, error) { + rows, err := conn.Query(ctx, + `SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at + FROM invoices ORDER BY issued_at DESC`) + if err != nil { + return nil, fmt.Errorf("invoice: list: %w", err) + } + defer rows.Close() + var list []*Invoice + for rows.Next() { + var inv Invoice + if err := rows.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number, + &inv.PDFPath, &inv.IssuedAt, &inv.CreatedAt); err != nil { + return nil, fmt.Errorf("invoice: scan: %w", err) + } + list = append(list, &inv) + } + return list, nil +} + +func GetInvoice(ctx context.Context, conn *pgxpool.Conn, id string) (*Invoice, error) { + row := conn.QueryRow(ctx, + `SELECT id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at + FROM invoices WHERE id = $1`, id) + var inv Invoice + err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number, + &inv.PDFPath, &inv.IssuedAt, &inv.CreatedAt) + if err == pgx.ErrNoRows { + return nil, nil + } + if err != nil { + return nil, fmt.Errorf("invoice: get: %w", err) + } + return &inv, nil +} + +func CreateInvoice(ctx context.Context, conn *pgxpool.Conn, woID, docType string) (*Invoice, error) { + row := conn.QueryRow(ctx, + `INSERT INTO invoices (work_order_id, type, issued_at) + VALUES ($1, $2, NOW()) + RETURNING id, work_order_id, type, number, COALESCE(pdf_path,''), issued_at, created_at`, + woID, docType) + var inv Invoice + if err := row.Scan(&inv.ID, &inv.WorkOrderID, &inv.Type, &inv.Number, + &inv.PDFPath, &inv.IssuedAt, &inv.CreatedAt); err != nil { + return nil, fmt.Errorf("invoice: create: %w", err) + } + return &inv, nil +} + +func SetPDFPath(ctx context.Context, conn *pgxpool.Conn, id, path string) error { + _, err := conn.Exec(ctx, `UPDATE invoices SET pdf_path = $2 WHERE id = $1`, id, path) + if err != nil { + return fmt.Errorf("invoice: set pdf path: %w", err) + } + return nil +} +``` + +- [ ] **Step 2: Create handler.go** + +Create `backend/internal/invoice/handler.go`: + +```go +package invoice + +import ( + "fmt" + "os" + "path/filepath" + "strings" + + "github.com/gofiber/fiber/v2" + "github.com/techxcar/backend/internal/auth" + "github.com/techxcar/backend/internal/settings" + "github.com/techxcar/backend/internal/workorder" + "github.com/techxcar/backend/pkg/database" + "github.com/techxcar/backend/pkg/pdf" +) + +const storageRoot = "/app/storage" + +func RegisterRoutes(app *fiber.App, db *database.DB, secret string) { + ro := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager", "technician"), + auth.TenantMiddleware(db), + } + write := []fiber.Handler{ + auth.RequireAuth(secret), + auth.RequireRole("tenant_admin", "manager"), + auth.TenantMiddleware(db), + } + + app.Get("/api/v1/invoices", append(ro, listInvoicesH())...) + app.Post("/api/v1/invoices", append(write, createInvoiceH())...) + app.Get("/api/v1/invoices/:id/pdf", append(ro, downloadPDFH())...) +} + +func listInvoicesH() fiber.Handler { + return func(c *fiber.Ctx) error { + conn := auth.GetConn(c) + list, err := ListInvoices(c.Context(), conn) + if err != nil { + return fiber.NewError(500, "erro ao listar faturas") + } + if list == nil { + list = []*Invoice{} + } + return c.JSON(fiber.Map{"data": list, "error": nil}) + } +} + +type createBody struct { + WorkOrderID string `json:"work_order_id"` + Type string `json:"type"` // "quote" or "invoice" +} + +func createInvoiceH() fiber.Handler { + return func(c *fiber.Ctx) error { + var b createBody + if err := c.BodyParser(&b); err != nil { + return fiber.NewError(400, "corpo do pedido inválido") + } + if b.WorkOrderID == "" { + return fiber.NewError(400, "work_order_id obrigatório") + } + if b.Type != "quote" && b.Type != "invoice" { + return fiber.NewError(400, "tipo deve ser 'quote' ou 'invoice'") + } + + conn := auth.GetConn(c) + + // Fetch WO detail + detail, err := workorder.GetWorkOrderDetail(c.Context(), conn, b.WorkOrderID) + if err != nil { + return fiber.NewError(500, "erro ao obter ordem de trabalho") + } + if detail == nil { + return fiber.NewError(404, "ordem de trabalho não encontrada") + } + + // Fetch tenant settings + sett, err := settings.GetSettings(c.Context(), conn) + if err != nil { + return fiber.NewError(500, "erro ao obter definições") + } + + // Fetch client info if present + var clientName, clientNIF, vehiclePlate string + if detail.ClientID != nil { + row := conn.QueryRow(c.Context(), + `SELECT name, COALESCE(nif,'') FROM clients WHERE id = $1`, *detail.ClientID) + _ = row.Scan(&clientName, &clientNIF) + } + if detail.VehicleID != nil { + row := conn.QueryRow(c.Context(), + `SELECT plate FROM vehicles WHERE id = $1`, *detail.VehicleID) + _ = row.Scan(&vehiclePlate) + } + + // Create invoice record + inv, err := CreateInvoice(c.Context(), conn, b.WorkOrderID, b.Type) + if err != nil { + return fiber.NewError(500, "erro ao criar fatura") + } + + // Build PDF meta + docType := "Orçamento" + if b.Type == "invoice" { + docType = "Fatura" + } + prefix := "ORC" + if b.Type == "invoice" { + prefix = "FAT" + } + + // Derive tenant schema from search_path for storage directory + var searchPath string + row := conn.QueryRow(c.Context(), `SHOW search_path`) + _ = row.Scan(&searchPath) + // extract first segment (e.g. "tenant_abc123_def456_...") + tenantDir := strings.Split(strings.TrimSpace(searchPath), ",")[0] + tenantDir = strings.TrimSpace(tenantDir) + + outPath := filepath.Join(storageRoot, tenantDir, fmt.Sprintf("inv_%s.pdf", inv.ID)) + + meta := pdf.DocMeta{ + CompanyName: sett["company_name"], + CompanyNIF: sett["company_nif"], + CompanyAddress: sett["company_address"], + CompanyIBAN: sett["company_iban"], + CompanyPhone: sett["company_phone"], + CompanyEmail: sett["company_email"], + DocType: docType, + DocNumber: fmt.Sprintf("%s/%d/%04d", prefix, inv.IssuedAt.Year(), inv.Number), + IssuedAt: inv.IssuedAt.Format("02/01/2006"), + ClientName: clientName, + ClientNIF: clientNIF, + VehiclePlate: vehiclePlate, + } + + // Build line items + lineItems := make([]pdf.LineItem, len(detail.Items)) + for i, item := range detail.Items { + lineItems[i] = pdf.LineItem{ + Description: item.Description, + Qty: item.Qty, + UnitPrice: item.UnitPrice, + DiscountPct: item.DiscountPct, + Total: item.Total, + } + } + var staffTotal float64 + for _, sh := range detail.StaffHours { + staffTotal += sh.Total + } + + // Generate PDF + if err := pdf.Generate(meta, lineItems, staffTotal, outPath); err != nil { + return fiber.NewError(500, "erro ao gerar PDF") + } + + // Store path + if err := SetPDFPath(c.Context(), conn, inv.ID, outPath); err != nil { + return fiber.NewError(500, "erro ao registar caminho do PDF") + } + inv.PDFPath = outPath + + // If invoice (not quote), transition WO to invoiced + if b.Type == "invoice" { + if err := workorder.TransitionStatus(c.Context(), conn, b.WorkOrderID, detail.Status, "invoiced", ""); err != nil { + return fiber.NewError(500, "erro ao atualizar estado da ordem") + } + } + + return c.Status(201).JSON(fiber.Map{"data": inv, "error": nil}) + } +} + +func downloadPDFH() fiber.Handler { + return func(c *fiber.Ctx) error { + id := c.Params("id") + conn := auth.GetConn(c) + inv, err := GetInvoice(c.Context(), conn, id) + if err != nil { + return fiber.NewError(500, "erro interno") + } + if inv == nil { + return fiber.NewError(404, "fatura não encontrada") + } + if inv.PDFPath == "" { + return fiber.NewError(404, "PDF não disponível") + } + if _, err := os.Stat(inv.PDFPath); os.IsNotExist(err) { + return fiber.NewError(404, "ficheiro PDF não encontrado") + } + c.Set("Content-Disposition", fmt.Sprintf(`attachment; filename="invoice_%d.pdf"`, inv.Number)) + return c.SendFile(inv.PDFPath) + } +} +``` + +Note: `handler.go` calls `workorder.GetWorkOrderDetail` and `workorder.TransitionStatus` — these functions must be exported from the `workorder` package. Check if they are already exported. If `GetWorkOrderDetail` is unexported (lowercase), rename it. + +- [ ] **Step 3: Verify workorder exports** + +Check `backend/internal/workorder/repository.go` for `GetWorkOrderDetail` and `TransitionStatus`. If unexported, rename them: + +```bash +grep -n "func getWorkOrderDetail\|func transitionStatus\|func GetWorkOrderDetail\|func TransitionStatus" \ + /var/home/lmilani/Documentos/IDE/techxcar/backend/internal/workorder/repository.go \ + /var/home/lmilani/Documentos/IDE/techxcar/backend/internal/workorder/handler.go +``` + +If the functions are in `handler.go` as unexported closures (not top-level named functions), extract them to `repository.go` as exported functions. The typical pattern in this codebase puts DB logic in `repository.go`. Adjust as needed to ensure `GetWorkOrderDetail(ctx, conn, id) (*WorkOrderDetail, error)` and `TransitionStatus(ctx, conn, id, from, to, changedBy string) error` are exported and callable from `invoice/handler.go`. + +- [ ] **Step 4: Compile check** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +export PATH=$PATH:/var/home/lmilani/.local/go/bin +go build ./... +``` + +Fix any compilation errors (missing exports, import cycles). There should be no import cycle since `invoice` imports `workorder` and `settings`, not the other way around. + +- [ ] **Step 5: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add backend/internal/invoice/ backend/internal/workorder/ +git commit -m "feat: invoice repository and HTTP handlers (generate PDF, list, download)" +``` + +--- + +### Task 6: Wire Routes + Dockerfile Storage Dir + +**Files:** +- Modify: `backend/internal/server/server.go` +- Modify: `backend/Dockerfile` + +- [ ] **Step 1: Update server.go** + +In `backend/internal/server/server.go`, add imports and wire 4 new packages: + +```go +import ( + // existing imports... + "github.com/techxcar/backend/internal/expense" + "github.com/techxcar/backend/internal/invoice" + "github.com/techxcar/backend/internal/settings" + "github.com/techxcar/backend/internal/staff" +) +``` + +Inside `if deps.DB != nil && deps.Redis != nil && deps.Config != nil {` block, after the existing route registrations: + +```go + staff.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) + expense.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) + settings.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) + invoice.RegisterRoutes(app, deps.DB, deps.Config.JWTSecret) +``` + +- [ ] **Step 2: Update Dockerfile to create storage volume dir** + +In `backend/Dockerfile`, add the storage directory creation before the `USER app` line: + +```dockerfile +RUN mkdir -p /app/storage && chown -R app:app /app +``` + +The full updated Dockerfile: + +```dockerfile +FROM golang:1.25-alpine AS builder +WORKDIR /app +COPY go.mod go.sum ./ +RUN go mod download +COPY . . +RUN CGO_ENABLED=0 GOOS=linux go build -o server ./cmd/server + +FROM alpine:3.19 +RUN apk --no-cache add ca-certificates tzdata +RUN addgroup -S app && adduser -S app -G app +WORKDIR /app +RUN mkdir -p /app/storage && chown -R app:app /app +COPY --from=builder /app/server . +COPY --from=builder /app/migrations ./migrations +USER app +EXPOSE 8080 +CMD ["./server"] +``` + +- [ ] **Step 3: Full backend build + compile check** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/backend +export PATH=$PATH:/var/home/lmilani/.local/go/bin +go build ./... +go test ./... 2>&1 | grep -v "^---" | grep -v "^===" +``` + +Expected: build success; tests pass or skip (no TEST_DATABASE_URL). + +- [ ] **Step 4: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add backend/internal/server/server.go backend/Dockerfile +git commit -m "feat: wire staff, expense, settings, invoice routes; add /app/storage dir" +``` + +--- + +### Task 7: Frontend — Types + AppLayout Nav + +**Files:** +- Modify: `frontend/src/lib/types.ts` +- Modify: `frontend/src/components/layout/AppLayout.tsx` + +- [ ] **Step 1: Add types to types.ts** + +Append to `frontend/src/lib/types.ts`: + +```ts +export interface Staff { + id: string + user_id: string | null + name: string + email: string + phone: string + type: 'internal' | 'external' + hourly_rate: number + active: boolean + created_at: string +} + +export interface Expense { + id: string + vehicle_id: string | null + type: 'fuel' | 'parts' | 'tools' | 'other' + amount: number + description: string + date: string + created_at: string +} + +export type TenantSettings = Record + +export interface Invoice { + id: string + work_order_id: string + type: 'quote' | 'invoice' + number: number + pdf_path: string + issued_at: string + created_at: string +} +``` + +- [ ] **Step 2: Update AppLayout nav** + +In `frontend/src/components/layout/AppLayout.tsx`, update the `nav` array: + +```tsx +const nav = [ + { to: '/app', label: 'Dashboard', end: true }, + { to: '/app/work-orders', label: 'Ordens de Trabalho' }, + { to: '/app/clients', label: 'Clientes' }, + { to: '/app/catalog', label: 'Catálogo' }, + { to: '/app/staff', label: 'Técnicos' }, + { to: '/app/expenses', label: 'Despesas' }, + { to: '/app/invoices', label: 'Faturação' }, + { to: '/app/settings', label: 'Definições' }, +] +``` + +- [ ] **Step 3: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add frontend/src/lib/types.ts frontend/src/components/layout/AppLayout.tsx +git commit -m "feat: add Staff, Expense, TenantSettings, Invoice types and nav items" +``` + +--- + +### Task 8: Frontend — StaffPage + +**Files:** +- Create: `frontend/src/pages/app/StaffPage.tsx` +- Modify: `frontend/src/App.tsx` + +- [ ] **Step 1: Create StaffPage.tsx** + +Create `frontend/src/pages/app/StaffPage.tsx`: + +```tsx +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient, type Resolver } from '@tanstack/react-query' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { apiFetch } from '@/lib/api' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { Staff } from '@/lib/types' + +const STAFF_TYPES = ['internal', 'external'] as const + +const schema = z.object({ + name: z.string().min(1, 'Nome obrigatório'), + email: z.string(), + phone: z.string(), + type: z.enum(STAFF_TYPES), + hourly_rate: z.coerce.number().min(0), + active: z.boolean(), +}) +type FormData = z.infer + +const emptyStaff: FormData = { name: '', email: '', phone: '', type: 'internal', hourly_rate: 0, active: true } + +export default function StaffPage() { + const qc = useQueryClient() + const [editing, setEditing] = useState(null) + const [showForm, setShowForm] = useState(false) + + const { data: staff = [], isLoading } = useQuery({ + queryKey: ['staff'], + queryFn: () => apiFetch('/staff'), + }) + + const { register, handleSubmit, reset, formState: { errors } } = useForm({ + resolver: zodResolver(schema) as import('react-hook-form').Resolver, + defaultValues: emptyStaff, + }) + + const save = useMutation({ + mutationFn: (data: FormData) => + editing + ? apiFetch(`/staff/${editing.id}`, { method: 'PUT', body: JSON.stringify(data) }) + : apiFetch('/staff', { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['staff'] }) + setShowForm(false) + setEditing(null) + reset(emptyStaff) + }, + }) + + const remove = useMutation({ + mutationFn: (id: string) => apiFetch(`/staff/${id}`, { method: 'DELETE' }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['staff'] }), + }) + + function openNew() { + setEditing(null) + reset(emptyStaff) + setShowForm(true) + } + + function openEdit(s: Staff) { + setEditing(s) + reset({ name: s.name, email: s.email, phone: s.phone, type: s.type, hourly_rate: s.hourly_rate, active: s.active }) + setShowForm(true) + } + + return ( +
+
+
+

Técnicos

+

{staff.length} técnicos

+
+ +
+ + {showForm && ( +
+

+ {editing ? 'Editar Técnico' : 'Novo Técnico'} +

+
save.mutate(d))} className="grid grid-cols-2 gap-4"> +
+ + + {errors.name &&

{errors.name.message}

} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {save.error &&

{save.error.message}

} +
+ + +
+
+
+ )} + + {isLoading ? ( +

A carregar...

+ ) : staff.length === 0 ? ( +

Nenhum técnico registado.

+ ) : ( +
+ + + + + + + + + + + + + {staff.map((s) => ( + + + + + + + + + ))} + +
NomeTipoEmail€/horaEstado
{s.name}{s.type === 'internal' ? 'Interno' : 'Externo'}{s.email || '—'}{s.hourly_rate.toFixed(2)} € + + {s.active ? 'Activo' : 'Inactivo'} + + + + +
+
+ )} +
+ ) +} +``` + +- [ ] **Step 2: Add route to App.tsx** + +In `frontend/src/App.tsx`, add import: +```tsx +import StaffPage from '@/pages/app/StaffPage' +``` + +Inside the `/app` route group, add: +```tsx +} /> +``` + +- [ ] **Step 3: Build check** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/frontend +npm run build 2>&1 | tail -10 +``` + +Expected: build success, zero TypeScript errors. + +- [ ] **Step 4: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add frontend/src/pages/app/StaffPage.tsx frontend/src/App.tsx +git commit -m "feat: staff page — list, create/edit/delete technicians" +``` + +--- + +### Task 9: Frontend — ExpensesPage + +**Files:** +- Create: `frontend/src/pages/app/ExpensesPage.tsx` +- Modify: `frontend/src/App.tsx` + +- [ ] **Step 1: Create ExpensesPage.tsx** + +Create `frontend/src/pages/app/ExpensesPage.tsx`: + +```tsx +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useForm, type Resolver } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { apiFetch } from '@/lib/api' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { Expense } from '@/lib/types' + +const EXPENSE_TYPES = ['fuel', 'parts', 'tools', 'other'] as const +const TYPE_LABELS: Record = { + fuel: 'Combustível', parts: 'Peças', tools: 'Ferramentas', other: 'Outros', +} + +const schema = z.object({ + type: z.enum(EXPENSE_TYPES), + amount: z.coerce.number().positive('Valor deve ser positivo'), + description: z.string(), + date: z.string().min(1, 'Data obrigatória'), + vehicle_id: z.string(), +}) +type FormData = z.infer + +const today = new Date().toISOString().split('T')[0] +const emptyExpense: FormData = { type: 'fuel', amount: 0, description: '', date: today, vehicle_id: '' } + +export default function ExpensesPage() { + const qc = useQueryClient() + const [typeFilter, setTypeFilter] = useState('') + const [showForm, setShowForm] = useState(false) + + const { data: expenses = [], isLoading } = useQuery({ + queryKey: ['expenses', typeFilter], + queryFn: () => apiFetch(`/expenses${typeFilter ? `?type=${typeFilter}` : ''}`), + }) + + const { register, handleSubmit, reset, formState: { errors } } = useForm({ + resolver: zodResolver(schema) as Resolver, + defaultValues: emptyExpense, + }) + + const create = useMutation({ + mutationFn: (data: FormData) => + apiFetch('/expenses', { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['expenses'] }) + setShowForm(false) + reset(emptyExpense) + }, + }) + + const remove = useMutation({ + mutationFn: (id: string) => apiFetch(`/expenses/${id}`, { method: 'DELETE' }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['expenses'] }), + }) + + const total = expenses.reduce((s, e) => s + e.amount, 0) + + return ( +
+
+
+

Despesas

+

+ {expenses.length} registos — total: {total.toFixed(2)} € +

+
+ +
+ +
+ {['', ...EXPENSE_TYPES].map((t) => ( + + ))} +
+ + {showForm && ( +
+

Nova Despesa

+
create.mutate(d))} className="grid grid-cols-2 gap-4"> +
+ + +
+
+ + + {errors.amount &&

{errors.amount.message}

} +
+
+ + + {errors.date &&

{errors.date.message}

} +
+
+ + +
+ {create.error &&

{create.error.message}

} +
+ + +
+
+
+ )} + + {isLoading ? ( +

A carregar...

+ ) : expenses.length === 0 ? ( +

Nenhuma despesa registada.

+ ) : ( +
+ + + + + + + + + + + + {expenses.map((e) => ( + + + + + + + + ))} + +
DataTipoDescriçãoValor
+ {new Intl.DateTimeFormat('pt-PT').format(new Date(e.date))} + + {TYPE_LABELS[e.type]} + {e.description || '—'}{e.amount.toFixed(2)} € + +
+
+ )} +
+ ) +} +``` + +- [ ] **Step 2: Add route to App.tsx** + +```tsx +import ExpensesPage from '@/pages/app/ExpensesPage' +// inside /app group: +} /> +``` + +- [ ] **Step 3: Build check + commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/frontend && npm run build 2>&1 | tail -5 +cd /var/home/lmilani/Documentos/IDE/techxcar +git add frontend/src/pages/app/ExpensesPage.tsx frontend/src/App.tsx +git commit -m "feat: expenses page — list with type filter, create, delete" +``` + +--- + +### Task 10: Frontend — SettingsPage + +**Files:** +- Create: `frontend/src/pages/app/SettingsPage.tsx` +- Modify: `frontend/src/App.tsx` + +- [ ] **Step 1: Create SettingsPage.tsx** + +Create `frontend/src/pages/app/SettingsPage.tsx`: + +```tsx +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useForm } from 'react-hook-form' +import { useEffect } from 'react' +import { apiFetch } from '@/lib/api' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { TenantSettings } from '@/lib/types' + +type FormData = { + company_name: string + company_nif: string + company_address: string + company_iban: string + company_phone: string + company_email: string +} + +const defaultSettings: FormData = { + company_name: '', + company_nif: '', + company_address: '', + company_iban: '', + company_phone: '', + company_email: '', +} + +export default function SettingsPage() { + const qc = useQueryClient() + + const { data: settings, isLoading } = useQuery({ + queryKey: ['settings'], + queryFn: () => apiFetch('/settings'), + }) + + const { register, handleSubmit, reset } = useForm({ + defaultValues: defaultSettings, + }) + + useEffect(() => { + if (settings) { + reset({ + company_name: settings['company_name'] ?? '', + company_nif: settings['company_nif'] ?? '', + company_address: settings['company_address'] ?? '', + company_iban: settings['company_iban'] ?? '', + company_phone: settings['company_phone'] ?? '', + company_email: settings['company_email'] ?? '', + }) + } + }, [settings, reset]) + + const save = useMutation({ + mutationFn: (data: FormData) => + apiFetch('/settings', { method: 'PUT', body: JSON.stringify(data) }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }), + }) + + if (isLoading) return

A carregar...

+ + return ( +
+
+

Definições

+

Dados da oficina utilizados nos documentos PDF

+
+ +
save.mutate(d))} + className="bg-slate-800 rounded-lg border border-slate-700 p-6 space-y-4" + > +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + {save.error && ( +

{save.error.message}

+ )} + {save.isSuccess && ( +

Definições guardadas.

+ )} + +
+ +
+
+
+ ) +} +``` + +- [ ] **Step 2: Add route to App.tsx** + +```tsx +import SettingsPage from '@/pages/app/SettingsPage' +// inside /app group: +} /> +``` + +- [ ] **Step 3: Build check + commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/frontend && npm run build 2>&1 | tail -5 +cd /var/home/lmilani/Documentos/IDE/techxcar +git add frontend/src/pages/app/SettingsPage.tsx frontend/src/App.tsx +git commit -m "feat: settings page — tenant company data for PDF generation" +``` + +--- + +### Task 11: Frontend — InvoicesPage + +**Files:** +- Create: `frontend/src/pages/app/InvoicesPage.tsx` +- Modify: `frontend/src/App.tsx` + +- [ ] **Step 1: Create InvoicesPage.tsx** + +Create `frontend/src/pages/app/InvoicesPage.tsx`: + +```tsx +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { apiFetch } from '@/lib/api' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import type { Invoice, WorkOrder } from '@/lib/types' + +const TYPE_LABELS: Record = { quote: 'Orçamento', invoice: 'Fatura' } + +export default function InvoicesPage() { + const qc = useQueryClient() + const [showGenerate, setShowGenerate] = useState(false) + const [selectedWO, setSelectedWO] = useState('') + const [docType, setDocType] = useState<'quote' | 'invoice'>('quote') + + const { data: invoices = [], isLoading } = useQuery({ + queryKey: ['invoices'], + queryFn: () => apiFetch('/invoices'), + }) + + const { data: workOrders = [] } = useQuery({ + queryKey: ['work-orders-for-invoice'], + queryFn: () => apiFetch('/work-orders'), + enabled: showGenerate, + }) + + // Filter: only OTs that haven't been invoiced yet (or completed for invoice) + const eligibleWOs = workOrders.filter((wo) => + wo.status === 'open' || wo.status === 'in_progress' || wo.status === 'completed' + ) + + const generate = useMutation({ + mutationFn: () => + apiFetch('/invoices', { + method: 'POST', + body: JSON.stringify({ work_order_id: selectedWO, type: docType }), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['invoices'] }) + qc.invalidateQueries({ queryKey: ['work-orders'] }) + setShowGenerate(false) + setSelectedWO('') + }, + }) + + function handleDownload(inv: Invoice) { + window.open(`/api/v1/invoices/${inv.id}/pdf`, '_blank') + } + + return ( +
+
+
+

Faturação

+

{invoices.length} documentos

+
+ +
+ + {showGenerate && ( +
+

Gerar Documento

+
+
+ + +
+
+ + +
+
+ {generate.error && ( +

{generate.error.message}

+ )} +
+ + +
+
+ )} + + {isLoading ? ( +

A carregar...

+ ) : invoices.length === 0 ? ( +

Nenhum documento gerado.

+ ) : ( +
+ + + + + + + + + + + + {invoices.map((inv) => ( + + + + + + + + ))} + +
TipoOTEmitida
#{inv.number} + + {TYPE_LABELS[inv.type]} + + + {inv.work_order_id.slice(0, 8)}… + + {new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))} + + +
+
+ )} +
+ ) +} +``` + +- [ ] **Step 2: Add route to App.tsx** + +```tsx +import InvoicesPage from '@/pages/app/InvoicesPage' +// inside /app group: +} /> +``` + +- [ ] **Step 3: Full build + tests** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar/frontend +npm run build 2>&1 | tail -10 +npm run test:run +``` + +Expected: build success, 10 tests pass. + +- [ ] **Step 4: Commit** + +```bash +cd /var/home/lmilani/Documentos/IDE/techxcar +git add frontend/src/pages/app/InvoicesPage.tsx frontend/src/App.tsx +git commit -m "feat: invoices page — generate quote/invoice PDF from work order, list, download" +``` + +--- + +## Self-Review + +**Spec coverage:** +- ✅ Técnicos: internal/external, hourly_rate, active/inactive, CRUD — Tasks 1, 8 +- ✅ Despesas: tipo (fuel/parts/tools/other), valor, data, filtro — Tasks 2, 9 +- ✅ tenant_settings: company_name, NIF, address, IBAN, phone, email — Tasks 3, 10 +- ✅ PDF gerado em Go com go-pdf/fpdf (pure Go, pragmatic MVP — chromedp deferred) — Task 4 +- ✅ Fatura + Orçamento: gerados a partir de OT, numeração sequencial, PDF guardado — Tasks 5, 11 +- ✅ Fatura transiciona OT para "invoiced" — Task 5 +- ✅ PDF endpoint autenticado `/api/v1/invoices/:id/pdf` — Task 5 +- ✅ `/app/storage` volume dir criado no Dockerfile — Task 6 + +**Not in Plan 4 (deferred to Plan 5):** +- Dashboard KPIs (receita mês, OTs por estado) +- Relatórios com filtro de período +- Exportação CSV +- Notificações Telegram/Email +- Platform settings (SMTP, Telegram) — only tenant settings in this plan diff --git a/docs/superpowers/specs/2026-06-16-techxcar-design.md b/docs/superpowers/specs/2026-06-16-techxcar-design.md new file mode 100644 index 0000000..de532b4 --- /dev/null +++ b/docs/superpowers/specs/2026-06-16-techxcar-design.md @@ -0,0 +1,296 @@ +# TechXCar — Design Spec +**Data:** 2026-06-16 +**Versão:** 1.0 + +--- + +## 1. Visão Geral + +**TechXCar** é um SaaS multi-tenant de gestão de oficina automóvel. Cada oficina tem o seu espaço isolado com clientes, veículos, ordens de trabalho, faturação, técnicos e relatórios. A plataforma é gerida por um super-admin que cria e convida oficinas manualmente. + +**Inspiração:** LubeLogger, mas focado na gestão operacional da oficina (não no diário do proprietário do veículo). + +--- + +## 2. Stack Técnica + +| Camada | Tecnologia | +|---|---| +| Frontend | React 19 + TypeScript + Vite | +| Backend | Go + Fiber (framework HTTP) | +| Base de dados | PostgreSQL 16 | +| Cache / Filas | Redis 7 | +| Auth | JWT (access 15min + refresh 30d, httpOnly cookie) | +| Passwords | bcrypt cost 12 | +| PDF | Geração server-side em Go (html/template → PDF) | +| Notificações | Telegram Bot API + SMTP | +| Frontend UI | Tailwind CSS 4 + shadcn/ui | +| Estado cliente | TanStack Query + Zustand | +| Formulários | React Hook Form + Zod | +| Roteamento | React Router v7 | +| Migrations | golang-migrate | +| Deploy | Docker Compose → Coolify | + +--- + +## 3. Arquitectura de Deploy + +### Serviços Docker +``` +techxcar/ +├── frontend/ # React 19 + TypeScript + Vite (servido via Nginx) +├── backend/ # Go + Fiber +├── docker-compose.yml # Desenvolvimento +└── docker-compose.prod.yml # Produção (Coolify) +``` + +Quatro serviços: +- `frontend` — Nginx a servir o build Vite na porta 80 +- `backend` — Binário Go na porta 8080 +- `postgres` — PostgreSQL 16 +- `redis` — Redis 7 + +### Configuração em dois níveis +1. **`.env`** — valores de arranque imutáveis em runtime: `DATABASE_URL`, `REDIS_URL`, `JWT_SECRET`, `PORT`, `APP_ENV`. Requerem reinício do container para alterar. +2. **`platform_settings` / `tenant_settings` (DB)** — configuração dinâmica gerida pela Web UI sem redeploy: SMTP, Telegram Bot Token, templates de notificação, limites, dados de faturação. Cached em Redis por 5 minutos. + +### Coolify +- Secrets geridos pelo Coolify (injectados como variáveis de ambiente) +- HTTPS automático via Coolify (Let's Encrypt) +- Health checks em `/api/v1/health` + +--- + +## 4. Multi-tenancy + +### Estratégia: Schema-per-tenant no PostgreSQL +- Schema `public`: tabelas globais (`tenants`, `invites`, `super_admins`, `platform_settings`) +- Schema `tenant_`: dados isolados de cada oficina +- Middleware no backend define `SET search_path = tenant_` por request, com base no JWT + +### Onboarding de oficinas +**Via convite:** Super-admin gera token assinado com expiração configurável. Oficina clica no link, preenche nome, NIF, email, password → schema PostgreSQL provisionado automaticamente via migrations. + +**Via criação manual:** Super-admin cria a oficina no painel `/admin`, define credenciais iniciais, envia email de boas-vindas. + +--- + +## 5. Autenticação & Autorização + +### Níveis de acesso +``` +super_admin — gestão da plataforma, todos os tenants +tenant_admin — dono/gestor principal da oficina +manager — gestor operacional, sem acesso a settings financeiras +technician — técnico interno, acesso a OTs atribuídas +external_technician — sem login; apenas para atribuição e registo de custos +``` + +### Fluxo JWT +- `POST /api/v1/auth/login` → devolve `access_token` (body) + `refresh_token` (httpOnly cookie) +- `POST /api/v1/auth/refresh` → renova access token usando refresh token do cookie +- `POST /api/v1/auth/logout` → invalida refresh token +- Rate limiting: 10 tentativas/min por IP nas rotas de auth (Redis) + +--- + +## 6. Módulos de Negócio + +### 6.1 Clientes & Veículos +- **Cliente**: nome, NIF, telefone, email, morada, notas +- **Veículo**: pertence a um cliente (opcional), matrícula, marca, modelo, ano, VIN, km actual +- Um cliente pode ter N veículos +- OT pode ser criada sem cliente nem veículo (cliente temporário) + +### 6.2 Ordens de Trabalho (OT) + +**Estados e transições:** +``` +Aberta → Em Progresso → Concluída → Faturada + ↓ + Cancelada (de qualquer estado excepto Faturada) +``` + +**Conteúdo de uma OT:** +- Cliente + Veículo (ambos opcionais) +- Items: serviços/peças do catálogo (qty, preço unitário, desconto por item) +- Técnico(s) atribuído(s) + registo de horas (horas × custo/hora) +- Notas internas (não visíveis ao cliente) +- Notas para o cliente (aparecem no PDF) +- Log de estados: timestamp + utilizador responsável por cada transição +- Total calculado: soma de items + mão de obra + +### 6.3 Catálogo de Serviços & Peças +- Item: código, nome, categoria, unidade (un/hora/litro/kg), preço base, activo/inactivo +- Categorias livres (criadas pela oficina) +- Ao adicionar a OT: preço copiado do catálogo mas editável por OT +- Pesquisa por código ou nome no momento de adição à OT + +### 6.4 Faturação & Orçamentos +- **Orçamento**: gerado a partir de OT em estado "Aberta", PDF com logo e dados da oficina +- **Fatura**: gerada quando OT passa a "Faturada", numeração sequencial por oficina +- PDFs gerados server-side em Go com **`chromedp`** (render HTML → PDF via Chrome headless); armazenados localmente (volume Docker) ou S3-compatible (configurável nas Settings) +- Dados da oficina para PDF: logo, nome, NIF, morada, IBAN — configuráveis nas Settings da Web UI + +### 6.5 Técnicos (Staff) +- **Interno**: tem conta no sistema (role `technician`), aparece na atribuição de OTs +- **Externo**: registo simplificado (nome, contacto, custo/hora), sem login +- Registo de horas por OT: horas trabalhadas × custo/hora = custo de mão de obra +- Listagem com estado activo/inactivo + +### 6.6 Combustíveis & Despesas da Oficina +- Registo: data, tipo (combustível / peças / ferramentas / outros), valor, descrição, veículo (opcional) +- Filtros por período e tipo +- Totais agregados no dashboard e relatórios + +### 6.7 Notificações ao Cliente +- **Telegram**: token do bot configurável nas Settings; envia mensagem quando OT muda para "Concluída" ou "Faturada" +- **Email**: SMTP configurável nas Settings; template HTML editável na Web UI +- Configurável por oficina: quais eventos disparam notificação e qual canal +- Envio assíncrono: o handler HTTP publica um job numa lista Redis; uma goroutine dedicada consome a lista e envia as notificações (não bloqueia o request HTTP) + +### 6.8 Dashboard & Relatórios +**Dashboard (tempo real):** +- Receita do mês actual vs mês anterior +- Nº de OTs por estado (cards) +- OTs abertas recentes (tabela) +- Serviços mais realizados (top 5) + +**Relatórios (com filtro de período):** +- Receita por período (dia/semana/mês) +- OTs por estado e por técnico +- Despesas vs Receita +- Exportação em PDF e CSV + +--- + +## 7. Estrutura do Backend Go + +``` +backend/ +├── cmd/server/main.go +├── internal/ +│ ├── auth/ # JWT, middleware, bcrypt +│ ├── tenant/ # Gestão tenants, schema provisioning +│ ├── workorder/ # Ordens de trabalho +│ ├── client/ # Clientes & veículos +│ ├── catalog/ # Serviços & peças +│ ├── invoice/ # Faturação, geração PDF +│ ├── staff/ # Técnicos +│ ├── expense/ # Combustíveis & despesas +│ ├── notification/ # Telegram + Email (worker assíncrono) +│ ├── report/ # Relatórios & exportações +│ └── settings/ # Settings dinâmicas (DB + cache Redis) +├── pkg/ +│ ├── database/ # Pool PostgreSQL, migrations +│ ├── redis/ # Cliente Redis +│ └── pdf/ # Geração PDF +└── migrations/ + ├── public/ # Schema público + └── tenant/ # Schema tenant (aplicado no provisionamento) +``` + +### API +- Base: `/api/v1/` +- Autenticação: Bearer token no header `Authorization` +- Tenant resolvido via claim `tenant_id` no JWT +- Respostas: `{ "data": ..., "error": null, "meta": { "page": ..., "total": ... } }` +- Paginação cursor-based + +--- + +## 8. Estrutura do Frontend React + +``` +frontend/ +├── src/ +│ ├── pages/ +│ │ ├── auth/ # Login +│ │ ├── admin/ # Super-admin (dashboard, tenants, settings) +│ │ └── app/ # Área da oficina +│ │ ├── dashboard/ +│ │ ├── work-orders/ +│ │ ├── clients/ +│ │ ├── vehicles/ +│ │ ├── catalog/ +│ │ ├── staff/ +│ │ ├── expenses/ +│ │ ├── reports/ +│ │ └── settings/ +│ ├── components/ +│ │ ├── ui/ # shadcn/ui base components +│ │ └── shared/ # Componentes reutilizáveis da app +│ ├── hooks/ # Custom hooks (useAuth, useTenant, etc.) +│ ├── lib/ +│ │ ├── api.ts # Cliente HTTP (fetch + interceptors) +│ │ └── queryClient.ts # TanStack Query config +│ ├── store/ # Zustand stores +│ └── i18n/ # Estrutura i18n (PT por defeito, EN preparado) +``` + +### Rotas protegidas +- `/login` — público +- `/admin/*` — requer role `super_admin` +- `/app/*` — requer role `tenant_admin`, `manager` ou `technician` +- Guards no React Router v7 redireccionam para `/login` se JWT expirado + +--- + +## 9. Base de Dados — Schemas + +### Schema `public` +```sql +tenants (id uuid PK, slug text UNIQUE, name text, status, plan, created_at) +invites (id uuid PK, tenant_id uuid FK, token text UNIQUE, expires_at, used_at nullable) +super_admins (id uuid PK, email text UNIQUE, password_hash text, created_at) +platform_settings (key text PK, value text, updated_at) +``` + +### Schema `tenant_` (por oficina) +```sql +users (id uuid PK, email, password_hash, role, name, active) +clients (id uuid PK, name, nif, phone, email, address, notes, created_at) +vehicles (id uuid PK, client_id uuid nullable FK, plate, brand, model, year, vin, mileage, notes) +work_orders (id uuid PK, number serial, client_id nullable, vehicle_id nullable, status, internal_notes, client_notes, created_by, created_at, updated_at) +wo_items (id uuid PK, work_order_id FK, catalog_item_id nullable FK, description, qty, unit_price, discount_pct, total) +wo_staff_hours (id uuid PK, work_order_id FK, staff_id FK, hours, cost_per_hour, total) +wo_status_log (id uuid PK, work_order_id FK, from_status, to_status, changed_by FK, changed_at) +catalog_items (id uuid PK, code, name, category, unit, base_price, active) +invoices (id uuid PK, work_order_id FK, type: quote|invoice, number serial, pdf_path, issued_at) +staff (id uuid PK, user_id nullable FK, name, email, phone, type: internal|external, hourly_rate, active) +expenses (id uuid PK, vehicle_id nullable FK, type, amount, description, date) +tenant_settings (key text PK, value text, updated_at) +``` + +--- + +## 10. Internacionalização (i18n) + +- Estrutura i18n no frontend desde o início (ficheiros JSON por locale em `src/i18n/`) +- Idioma padrão e único no MVP: **Português de Portugal (pt-PT)** +- Inglês (en) preparado na estrutura mas não traduzido no MVP +- Datas, moeda e números formatados com `Intl` API (locale `pt-PT`, moeda `EUR`) + +--- + +## 11. Segurança + +- HTTPS obrigatório em produção (Coolify / Let's Encrypt) +- JWT com expiração curta + refresh token rotativo em httpOnly cookie +- Passwords com bcrypt cost 12 +- Rate limiting em todas as rotas de auth (Redis) +- Row-level isolation via PostgreSQL `search_path` por tenant +- Headers de segurança: CSP, HSTS, X-Frame-Options (middleware Fiber) +- Inputs validados com Zod (frontend) e struct validation em Go (backend) +- Ficheiros PDF armazenados fora do webroot, servidos via endpoint autenticado + +--- + +## 12. Fora de Âmbito (MVP) + +- Billing automático / Stripe +- App mobile nativa +- Integração com sistemas de diagnóstico OBD +- Portal self-service para clientes da oficina +- Multi-idioma completo (EN traduzido) +- Backups automáticos (responsabilidade do Coolify/infra) diff --git a/docs/superpowers/specs/2026-06-22-plan3-core-pages-design.md b/docs/superpowers/specs/2026-06-22-plan3-core-pages-design.md new file mode 100644 index 0000000..5a60ca1 --- /dev/null +++ b/docs/superpowers/specs/2026-06-22-plan3-core-pages-design.md @@ -0,0 +1,175 @@ +# TechXCar — Plan 3: Core Workshop Pages + +**Date:** 2026-06-22 +**Status:** Approved + +## Scope + +Build the 3 missing tenant-facing pages: Clients, Catalog, Work Orders. Backend has all 31 endpoints ready. Frontend needs hooks, pages, modals, and routing wired up. + +--- + +## Architecture + +Approach: **hooks-per-domain + pages**. Each domain has a dedicated hook file in `frontend/src/hooks/` that wraps TanStack Query. Pages consume hooks and stay presentational. + +### New files + +``` +frontend/src/ +├── hooks/ +│ ├── useClients.ts +│ ├── useCatalog.ts +│ └── useWorkOrders.ts +├── components/ +│ └── ui/ +│ └── dialog.tsx ← shadcn Dialog (shared by all modals) +└── pages/app/ + ├── clients/ + │ ├── ClientsPage.tsx + │ └── ClientDetailPage.tsx + ├── catalog/ + │ └── CatalogPage.tsx + └── work-orders/ + ├── WorkOrdersPage.tsx + └── WorkOrderDetailPage.tsx +``` + +### New routes (added to `App.tsx`) + +``` +/app/clients → ClientsPage +/app/clients/:id → ClientDetailPage +/app/catalog → CatalogPage +/app/work-orders → WorkOrdersPage +/app/work-orders/:id → WorkOrderDetailPage +``` + +--- + +## Domain: Clients + +### `useClients.ts` + +| Hook | Method | Endpoint | +|---|---|---| +| `useClients()` | GET | `/clients` | +| `useClient(id)` | GET | `/clients/:id` | +| `useCreateClient()` | POST | `/clients` | +| `useUpdateClient()` | PUT | `/clients/:id` | +| `useDeleteClient()` | DELETE | `/clients/:id` | +| `useClientVehicles(clientId)` | GET | `/clients/:id/vehicles` | +| `useCreateVehicle(clientId)` | POST | `/clients/:id/vehicles` | +| `useUpdateVehicle()` | PUT | `/vehicles/:id` | + +All mutations call `queryClient.invalidateQueries` on success. + +### `ClientsPage` + +- Table columns: Nome, NIF, Telefone, Email, Data criação +- "Novo Cliente" button → modal (create) +- Edit icon per row → same modal prefilled +- Modal fields: nome (required), NIF, telefone, email, morada, notas + +### `ClientDetailPage` + +- Header: client name + fields (NIF, phone, email, address, notes) + Edit button +- "Veículos" section below: table with matrícula, marca, modelo, ano +- "Adicionar Veículo" → modal: matrícula (required), marca (required), modelo (required), ano, VIN, quilómetros, notas +- Edit icon per vehicle row → same modal prefilled +- Back link → `/app/clients` + +--- + +## Domain: Catalog + +### `useCatalog.ts` + +| Hook | Method | Endpoint | +|---|---|---| +| `useCatalog()` | GET | `/catalog` | +| `useCreateCatalogItem()` | POST | `/catalog` | +| `useUpdateCatalogItem()` | PUT | `/catalog/:id` | +| `useDeleteCatalogItem()` | DELETE | `/catalog/:id` | + +### `CatalogPage` + +- Table columns: Código, Nome, Categoria, Unidade, Preço Base, Estado (badge) +- "Novo Item" button → modal (create) +- Edit icon per row → modal prefilled +- Delete: button toggles to "Confirmar?" before calling DELETE +- Modal fields: código, nome (required), categoria, unidade (select: un/hora/litro/kg), preço base (number), activo (checkbox) + +--- + +## Domain: Work Orders + +### `useWorkOrders.ts` + +| Hook | Method | Endpoint | +|---|---|---| +| `useWorkOrders(status?)` | GET | `/work-orders?status=` | +| `useWorkOrder(id)` | GET | `/work-orders/:id` | +| `useCreateWorkOrder()` | POST | `/work-orders` | +| `useTransitionWorkOrder()` | POST | `/work-orders/:id/transition` | +| `useAddWOItem()` | POST | `/work-orders/:id/items` | +| `useRemoveWOItem()` | DELETE | `/work-orders/:id/items/:itemId` | +| `useAddStaffHours()` | POST | `/work-orders/:id/staff-hours` | +| `useRemoveStaffHours()` | DELETE | `/work-orders/:id/staff-hours/:shId` | + +### `WorkOrdersPage` + +- Table columns: Nº OT, Cliente, Veículo, Estado (badge colorido), Data +- Status filter: tabs (Todas / Abertas / Em Progresso / Concluídas / Faturadas / Canceladas) +- "Nova OT" → modal: cliente (searchable dropdown from `/clients`), veículo (filtered by client), notas internas, notas cliente +- Click row → navigate to `/app/work-orders/:id` + +### `WorkOrderDetailPage` + +Two-column layout: + +**Left column — info + transitions:** +- OT number, status badge, client name, vehicle plate + brand/model +- Internal notes, client notes (editable inline via PUT) +- State stepper: `Aberta → Em Progresso → Concluída → Faturada` +- Transition buttons: advance to next state, or "Cancelar OT" (any state except invoiced) +- Back link → `/app/work-orders` + +**Right column — items + hours:** +- "Peças / Serviços" table: descrição, qty, preço unitário, desconto %, total + - "Adicionar Item" → modal: select catalog item (searchable), qty, unit price (prefilled from catalog), discount % + - Remove icon per row (DELETE) +- "Horas de Técnico" table: técnico (staff_id), horas, custo/hora, total + - "Adicionar Horas" → modal: staff_id (text for now — staff module in Plan 4), horas, custo/hora + - Remove icon per row (DELETE) +- Totals row at bottom: subtotal peças + subtotal horas + total geral + +### State badge colours + +| Status | Colour | +|---|---| +| open | slate | +| in_progress | blue | +| completed | green | +| invoiced | purple | +| cancelled | red | + +--- + +## Shared components + +- **`dialog.tsx`** — shadcn Dialog, used by all create/edit modals. Install via shadcn CLI or copy pattern from existing ui components. +- All pages follow existing dark slate theme: `bg-slate-950` root, `bg-slate-900` panels, `border-slate-700/800`, white text. +- Loading states: `

A carregar...

` +- Empty states: `

Nenhum registo.

` +- Error states: show `ApiError.message` in a red banner. + +--- + +## Out of scope (Plan 4+) + +- Staff management (staff_id is free text for now) +- PDF invoice generation +- Notifications (Telegram / email) +- Dashboard statistics +- Billing / invoicing module diff --git a/docs/superpowers/specs/2026-06-29-super-admin-tenant-access.md b/docs/superpowers/specs/2026-06-29-super-admin-tenant-access.md new file mode 100644 index 0000000..73a572d --- /dev/null +++ b/docs/superpowers/specs/2026-06-29-super-admin-tenant-access.md @@ -0,0 +1,115 @@ +# Super Admin — Tenant Access Design + +**Date:** 2026-06-29 +**Status:** Approved +**Scope:** Bug fix (TenantMiddleware schema name) + feature (super admin impersonation with session restore) + +--- + +## Problem + +1. **Bug:** `TenantMiddleware` constructs schema name as `"tenant_"` but schemas are named `tenant_`. All tenant user requests fail with SET search_path error. Independent of the feature — must always be fixed. + +2. **Feature gap:** Super admin can list and create tenants but cannot access or manage data within individual tenant workspaces. + +--- + +## Solution + +### Bug Fix — TenantMiddleware + +Replace raw UUID concatenation with normalized schema name (hyphens → underscores): + +```go +// Before +schema := `"tenant_` + claims.TenantID + `"` + +// After +schema := `"tenant_` + strings.ReplaceAll(claims.TenantID, "-", "_") + `"` +``` + +### Feature — Tenant Impersonation with Session Restore + +**Flow:** +1. Super admin clicks "Gerir" on any tenant row in TenantsPage +2. Frontend calls `POST /api/v1/admin/tenants/:id/access` +3. Backend validates tenant (exists + active), generates a 15-min access token with `tenantID=` and `role="tenant_admin"` +4. Frontend saves current session to `previousSession` in authStore, sets new token+user +5. Navigates to `/app` — super admin now sees the tenant workspace +6. AppLayout shows a banner: `[TechXCar Admin] A gerir: [← Voltar ao painel]` +7. Clicking "Voltar" restores the previous session and navigates to `/admin` + +--- + +## Backend + +### New endpoint + +`POST /api/v1/admin/tenants/:id/access` +- Auth: `RequireAuth` + `RequireRole("super_admin")` +- Validates: tenant ID format, tenant exists, tenant status = "active" +- Returns: `{ "data": { "access_token": "...", "tenant": { "id", "name", "slug" } }, "error": null }` +- Token: standard 15-min access token, `userID = super_admin_id`, `tenantID = tenant.ID`, `role = "tenant_admin"` +- No new refresh token — the impersonation session is access-token only + +### Route registration + +Added to `tenant.RegisterRoutes` under the existing `admin` group: +``` +POST /api/v1/admin/tenants/:id/access +``` + +--- + +## Frontend + +### authStore changes + +New fields: +```typescript +previousSession?: { token: string; user: AuthUser } +``` + +New actions: +- `impersonateTenant(token, user)` — saves current `{ token, user }` to `previousSession`, sets new token+user +- `restoreSession()` — restores `previousSession` into token+user, clears `previousSession` + +`previousSession` is NOT persisted to localStorage (impersonation does not survive page refresh — intentional). + +### TenantsPage + +Each tenant row gains a "Gerir" button (secondary/outline variant). + +On click: +1. POST `/admin/tenants/:id/access` +2. On success: `impersonateTenant(data.access_token, { ...data.tenant, role: 'tenant_admin' })` +3. Invalidate TanStack Query cache (avoid stale super-admin-scoped data) +4. Navigate to `/app` + +### AppLayout + +When `authStore.previousSession` is defined, render a fixed banner at the top: + +``` +[TechXCar Admin] A gerir: [← Voltar ao painel] +``` + +- Background: amber/yellow to visually distinguish from normal tenant UI +- "Voltar" button: calls `restoreSession()`, navigates to `/admin` +- Banner height: ~40px; main content padding-top adjusts accordingly + +--- + +## Error Handling + +- Tenant not found or not active → 404 "oficina não encontrada ou inativa" +- `impersonateTenant` failure (network) → show error toast, do not change session +- Restoring session never fails (purely client-side state) + +--- + +## Out of Scope + +- Refresh token for impersonation sessions (15-min window is intentional) +- Audit log of impersonation events (future) +- Super admin creating/editing data within tenant as themselves (they appear as tenant_admin) diff --git a/frontend/.gitignore b/frontend/.gitignore new file mode 100644 index 0000000..a547bf3 --- /dev/null +++ b/frontend/.gitignore @@ -0,0 +1,24 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +dist +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..9b52fb1 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,16 @@ +FROM node:20-alpine AS builder +WORKDIR /app +COPY package*.json ./ +RUN npm ci +COPY . . +RUN npm run build + +FROM nginxinc/nginx-unprivileged:alpine +COPY --from=builder /app/dist /usr/share/nginx/html +COPY nginx.conf /etc/nginx/conf.d/default.conf +COPY docker-entrypoint.sh /docker-entrypoint.sh +USER root +RUN chmod +x /docker-entrypoint.sh +USER nginx +EXPOSE 8080 +ENTRYPOINT ["/docker-entrypoint.sh"] diff --git a/frontend/README.md b/frontend/README.md new file mode 100644 index 0000000..7dbf7eb --- /dev/null +++ b/frontend/README.md @@ -0,0 +1,73 @@ +# React + TypeScript + Vite + +This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules. + +Currently, two official plugins are available: + +- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react) uses [Oxc](https://oxc.rs) +- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react-swc) uses [SWC](https://swc.rs/) + +## React Compiler + +The React Compiler is not enabled on this template because of its impact on dev & build performances. To add it, see [this documentation](https://react.dev/learn/react-compiler/installation). + +## Expanding the ESLint configuration + +If you are developing a production application, we recommend updating the configuration to enable type-aware lint rules: + +```js +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + + // Remove tseslint.configs.recommended and replace with this + tseslint.configs.recommendedTypeChecked, + // Alternatively, use this for stricter rules + tseslint.configs.strictTypeChecked, + // Optionally, add this for stylistic rules + tseslint.configs.stylisticTypeChecked, + + // Other configs... + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` + +You can also install [eslint-plugin-react-x](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-x) and [eslint-plugin-react-dom](https://github.com/Rel1cx/eslint-react/tree/main/packages/plugins/eslint-plugin-react-dom) for React-specific lint rules: + +```js +// eslint.config.js +import reactX from 'eslint-plugin-react-x' +import reactDom from 'eslint-plugin-react-dom' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + // Other configs... + // Enable lint rules for React + reactX.configs['recommended-typescript'], + // Enable lint rules for React DOM + reactDom.configs.recommended, + ], + languageOptions: { + parserOptions: { + project: ['./tsconfig.node.json', './tsconfig.app.json'], + tsconfigRootDir: import.meta.dirname, + }, + // other options... + }, + }, +]) +``` diff --git a/frontend/docker-entrypoint.sh b/frontend/docker-entrypoint.sh new file mode 100644 index 0000000..b14829e --- /dev/null +++ b/frontend/docker-entrypoint.sh @@ -0,0 +1,9 @@ +#!/bin/sh +set -e + +NAMESERVER=$(awk '/^nameserver/{print $2; exit}' /etc/resolv.conf) +NAMESERVER=${NAMESERVER:-127.0.0.11} + +sed -i "s/__RESOLVER__/$NAMESERVER/" /etc/nginx/conf.d/default.conf + +exec nginx -g "daemon off;" diff --git a/frontend/eslint.config.js b/frontend/eslint.config.js new file mode 100644 index 0000000..ef614d2 --- /dev/null +++ b/frontend/eslint.config.js @@ -0,0 +1,22 @@ +import js from '@eslint/js' +import globals from 'globals' +import reactHooks from 'eslint-plugin-react-hooks' +import reactRefresh from 'eslint-plugin-react-refresh' +import tseslint from 'typescript-eslint' +import { defineConfig, globalIgnores } from 'eslint/config' + +export default defineConfig([ + globalIgnores(['dist']), + { + files: ['**/*.{ts,tsx}'], + extends: [ + js.configs.recommended, + tseslint.configs.recommended, + reactHooks.configs.flat.recommended, + reactRefresh.configs.vite, + ], + languageOptions: { + globals: globals.browser, + }, + }, +]) diff --git a/frontend/index.html b/frontend/index.html new file mode 100644 index 0000000..0fca6f0 --- /dev/null +++ b/frontend/index.html @@ -0,0 +1,13 @@ + + + + + + + frontend + + +
+ + + diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..9e8e020 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,28 @@ +server { + listen 8080; + root /usr/share/nginx/html; + index index.html; + + gzip on; + gzip_types text/plain text/css application/json application/javascript text/xml application/xml application/xml+rss text/javascript; + + add_header X-Frame-Options "SAMEORIGIN"; + add_header X-Content-Type-Options "nosniff"; + + resolver __RESOLVER__ valid=10s ipv6=off; + + location /api/ { + set $backend_upstream http://backend:8080; + proxy_pass $backend_upstream; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + proxy_read_timeout 30s; + proxy_connect_timeout 5s; + } + + location / { + try_files $uri $uri/ /index.html; + } +} diff --git a/frontend/package-lock.json b/frontend/package-lock.json new file mode 100644 index 0000000..f1931ea --- /dev/null +++ b/frontend/package-lock.json @@ -0,0 +1,5532 @@ +{ + "name": "frontend", + "version": "0.0.0", + "lockfileVersion": 3, + "requires": true, + "packages": { + "": { + "name": "frontend", + "version": "0.0.0", + "dependencies": { + "@hookform/resolvers": "^5.4.0", + "@radix-ui/react-dialog": "^1.1.17", + "@radix-ui/react-dropdown-menu": "^2.1.18", + "@radix-ui/react-label": "^2.1.10", + "@radix-ui/react-select": "^2.3.1", + "@radix-ui/react-separator": "^1.1.10", + "@radix-ui/react-slot": "^1.3.0", + "@radix-ui/react-tooltip": "^1.2.10", + "@tanstack/react-query": "^5.101.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.21.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-hook-form": "^7.79.0", + "react-router": "^7.18.0", + "tailwind-merge": "^3.6.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.3.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.13.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.9", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "jsdom": "^29.1.1", + "tailwindcss": "^4.3.1", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12", + "vitest": "^4.1.9" + } + }, + "node_modules/@adobe/css-tools": { + "version": "4.5.0", + "resolved": "https://registry.npmjs.org/@adobe/css-tools/-/css-tools-4.5.0.tgz", + "integrity": "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@asamuzakjp/css-color": { + "version": "5.1.11", + "resolved": "https://registry.npmjs.org/@asamuzakjp/css-color/-/css-color-5.1.11.tgz", + "integrity": "sha512-KVw6qIiCTUQhByfTd78h2yD1/00waTmm9uy/R7Ck/ctUyAPj+AEDLkQIdJW0T8+qGgj3j5bpNKK7Q3G+LedJWg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@csstools/css-calc": "^3.2.0", + "@csstools/css-color-parser": "^4.1.0", + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/dom-selector": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/dom-selector/-/dom-selector-7.1.1.tgz", + "integrity": "sha512-67RZDnYRc8H/8MLDgQCDE//zoqVFwajkepHZgmXrbwybzXOEwOWGPYGmALYl9J2DOLfFPPs6kKCqmbzV895hTQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/generational-cache": "^1.0.1", + "@asamuzakjp/nwsapi": "^2.3.9", + "bidi-js": "^1.0.3", + "css-tree": "^3.2.1", + "is-potential-custom-element-name": "^1.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/generational-cache": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@asamuzakjp/generational-cache/-/generational-cache-1.0.1.tgz", + "integrity": "sha512-wajfB8KqzMCN2KGNFdLkReeHncd0AslUSrvHVvvYWuU8ghncRJoA50kT3zP9MVL0+9g4/67H+cdvBskj9THPzg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/@asamuzakjp/nwsapi": { + "version": "2.3.9", + "resolved": "https://registry.npmjs.org/@asamuzakjp/nwsapi/-/nwsapi-2.3.9.tgz", + "integrity": "sha512-n8GuYSrI9bF7FFZ/SjhwevlHc8xaVlb/7HmHelnc/PZXBD2ZR49NnN9sMMuDdEGPeeRQ5d0hqlSlEpgCX3Wl0Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/@babel/code-frame": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-validator-identifier": "^7.29.7", + "js-tokens": "^4.0.0", + "picocolors": "^1.1.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/compat-data": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/core": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/remapping": "^2.3.5", + "convert-source-map": "^2.0.0", + "debug": "^4.1.0", + "gensync": "^1.0.0-beta.2", + "json5": "^2.2.3", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/babel" + } + }, + "node_modules/@babel/generator": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.7.tgz", + "integrity": "sha512-DkXD5OJQaAQIdZ1bt3UZdEnHAn9Imd3IVBdX03UFe+ony9Ojw5pzr9YVKGDY1jt+Gcn/FnGkNf8r+Vj5NOJWtQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7", + "@jridgewell/gen-mapping": "^0.3.12", + "@jridgewell/trace-mapping": "^0.3.28", + "jsesc": "^3.0.2" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-compilation-targets": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", + "browserslist": "^4.24.0", + "lru-cache": "^5.1.1", + "semver": "^6.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-globals": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-imports": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-module-transforms": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + }, + "peerDependencies": { + "@babel/core": "^7.0.0" + } + }, + "node_modules/@babel/helper-string-parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.7.tgz", + "integrity": "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-identifier": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helper-validator-option": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/helpers": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.7.tgz", + "integrity": "sha512-1k2lAGRMfHTcwuNYcCNUmaUffmQv8KWMfh2iJUUeRlwlwH4FdNG7mfPI10NPfLHJFThE4Tyr4mv7kTNZOiPuBg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/parser": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.7.tgz", + "integrity": "sha512-hnORnjP/1P/zFEndoeX+n+t1RwWRJiJpM/jO7FW32Kn9r5+sJB2JWOdYo4L6k78j15eCwY3Gm/7364B1EMwtNg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/types": "^7.29.7" + }, + "bin": { + "parser": "bin/babel-parser.js" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@babel/runtime": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.29.7.tgz", + "integrity": "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/template": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/traverse": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.7.tgz", + "integrity": "sha512-EhlfNQtZ+NK22w5BM61ciuiq1m58ed33Wr1Xan//ZRTy6hgjnwyCffRYwzsGXdASJSUJ1guZILsErh1eQcl+zw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.7", + "debug": "^4.3.1" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@babel/types": { + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.7.tgz", + "integrity": "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/helper-string-parser": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7" + }, + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/@bcoe/v8-coverage": { + "version": "1.0.2", + "resolved": "https://registry.npmjs.org/@bcoe/v8-coverage/-/v8-coverage-1.0.2.tgz", + "integrity": "sha512-6zABk/ECA/QYSCQ1NGiVwwbQerUCZ+TQbp64Q3AgmfNvurHH0j8TtXa1qbShXA6qqkpAj4V5W8pP6mLe1mcMqA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/@bramus/specificity": { + "version": "2.4.2", + "resolved": "https://registry.npmjs.org/@bramus/specificity/-/specificity-2.4.2.tgz", + "integrity": "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw==", + "dev": true, + "license": "MIT", + "dependencies": { + "css-tree": "^3.0.0" + }, + "bin": { + "specificity": "bin/cli.js" + } + }, + "node_modules/@csstools/color-helpers": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@csstools/color-helpers/-/color-helpers-6.0.2.tgz", + "integrity": "sha512-LMGQLS9EuADloEFkcTBR3BwV/CGHV7zyDxVRtVDTwdI2Ca4it0CCVTT9wCkxSgokjE5Ho41hEPgb8OEUwoXr6Q==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@csstools/css-calc": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/@csstools/css-calc/-/css-calc-3.2.1.tgz", + "integrity": "sha512-DtdHlgXh5ZkA43cwBcAm+huzgJiwx3ZTWVjBs94kwz2xKqSimDA3lBgCjphYgwgVUMWatSM0pDd8TILB1yrVVg==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-color-parser": { + "version": "4.1.7", + "resolved": "https://registry.npmjs.org/@csstools/css-color-parser/-/css-color-parser-4.1.7.tgz", + "integrity": "sha512-CmjJFQTFQx/U/xNJhSjCQ0ilpesPmNQ8+eOUeM/+kDOVW33qsIjeOXc27vrQDdWVkf83ZSWwtg7kXSUvKDJ8cQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "dependencies": { + "@csstools/color-helpers": "^6.0.2", + "@csstools/css-calc": "^3.2.1" + }, + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-parser-algorithms": "^4.0.0", + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-parser-algorithms": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-parser-algorithms/-/css-parser-algorithms-4.0.0.tgz", + "integrity": "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + }, + "peerDependencies": { + "@csstools/css-tokenizer": "^4.0.0" + } + }, + "node_modules/@csstools/css-syntax-patches-for-csstree": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@csstools/css-syntax-patches-for-csstree/-/css-syntax-patches-for-csstree-1.1.5.tgz", + "integrity": "sha512-oNjBvzLq2GPZtJphCjLqXow/cHySHSgtxvKZb7OqSZ/xHgw6NWNhfad+6AB9cLeVm6eA9d/qMll3JdEHjy6M+A==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT-0", + "peerDependencies": { + "css-tree": "^3.2.1" + }, + "peerDependenciesMeta": { + "css-tree": { + "optional": true + } + } + }, + "node_modules/@csstools/css-tokenizer": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/@csstools/css-tokenizer/-/css-tokenizer-4.0.0.tgz", + "integrity": "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/csstools" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/csstools" + } + ], + "license": "MIT", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/@emnapi/core": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/core/-/core-1.10.0.tgz", + "integrity": "sha512-yq6OkJ4p82CAfPl0u9mQebQHKPJkY7WrIuk205cTYnYe+k2Z8YBh11FrbRG/H6ihirqcacOgl2BIO8oyMQLeXw==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/wasi-threads": "1.2.1", + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/runtime": { + "version": "1.10.0", + "resolved": "https://registry.npmjs.org/@emnapi/runtime/-/runtime-1.10.0.tgz", + "integrity": "sha512-ewvYlk86xUoGI0zQRNq/mC+16R1QeDlKQy21Ki3oSYXNgLb45GV1P6A0M+/s6nyCuNDqe5VpaY84BzXGwVbwFA==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@emnapi/wasi-threads": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@emnapi/wasi-threads/-/wasi-threads-1.2.1.tgz", + "integrity": "sha512-uTII7OYF+/Mes/MrcIOYp5yOtSMLBWSIoLPpcgwipoiKbli6k322tcoFsxoIIxPDqW01SQGAgko4EzZi2BNv2w==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@eslint-community/eslint-utils": { + "version": "4.9.1", + "resolved": "https://registry.npmjs.org/@eslint-community/eslint-utils/-/eslint-utils-4.9.1.tgz", + "integrity": "sha512-phrYmNiYppR7znFEdqgfWHXR6NCkZEK7hwWDHZUjit/2/U0r6XvkDl0SYnoM51Hq7FhCGdLDT6zxCCOY1hexsQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "eslint-visitor-keys": "^3.4.3" + }, + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + }, + "peerDependencies": { + "eslint": "^6.0.0 || ^7.0.0 || >=8.0.0" + } + }, + "node_modules/@eslint-community/eslint-utils/node_modules/eslint-visitor-keys": { + "version": "3.4.3", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-3.4.3.tgz", + "integrity": "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^12.22.0 || ^14.17.0 || >=16.0.0" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/@eslint-community/regexpp": { + "version": "4.12.2", + "resolved": "https://registry.npmjs.org/@eslint-community/regexpp/-/regexpp-4.12.2.tgz", + "integrity": "sha512-EriSTlt5OC9/7SXkRSCAhfSxxoSUgBm33OH+IkwbdpgoqsSsUg7y3uh+IICI/Qg4BBWr3U2i39RpmycbxMq4ew==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^12.0.0 || ^14.0.0 || >=16.0.0" + } + }, + "node_modules/@eslint/config-array": { + "version": "0.23.5", + "resolved": "https://registry.npmjs.org/@eslint/config-array/-/config-array-0.23.5.tgz", + "integrity": "sha512-Y3kKLvC1dvTOT+oGlqNQ1XLqK6D1HU2YXPc52NmAlJZbMMWDzGYXMiPRJ8TYD39muD/OTjlZmNJ4ib7dvSrMBA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/object-schema": "^3.0.5", + "debug": "^4.3.1", + "minimatch": "^10.2.4" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/config-helpers": { + "version": "0.6.0", + "resolved": "https://registry.npmjs.org/@eslint/config-helpers/-/config-helpers-0.6.0.tgz", + "integrity": "sha512-ii6Bw9jJ2zi2cWA2Z+9/QZ/+3DX6kwaV5Q986D/CdP3Lap3w/pgQZ373FV7byY/i7L4IRH/G43I5dz1ClsCbpA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/core": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/@eslint/core/-/core-1.2.1.tgz", + "integrity": "sha512-MwcE1P+AZ4C6DWlpin/OmOA54mmIZ/+xZuJiQd4SyB29oAJjN30UW9wkKNptW2ctp4cEsvhlLY/CsQ1uoHDloQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@types/json-schema": "^7.0.15" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/js": { + "version": "10.0.1", + "resolved": "https://registry.npmjs.org/@eslint/js/-/js-10.0.1.tgz", + "integrity": "sha512-zeR9k5pd4gxjZ0abRoIaxdc7I3nDktoXZk2qOv9gCNWx3mVwEn32VRhyLaRsDiJjTs0xq/T8mfPtyuXu7GWBcA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "eslint": "^10.0.0" + }, + "peerDependenciesMeta": { + "eslint": { + "optional": true + } + } + }, + "node_modules/@eslint/object-schema": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@eslint/object-schema/-/object-schema-3.0.5.tgz", + "integrity": "sha512-vqTaUEgxzm+YDSdElad6PiRoX4t8VGDjCtt05zn4nU810UIx/uNEV7/lZJ6KwFThKZOzOxzXy48da+No7HZaMw==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@eslint/plugin-kit": { + "version": "0.7.2", + "resolved": "https://registry.npmjs.org/@eslint/plugin-kit/-/plugin-kit-0.7.2.tgz", + "integrity": "sha512-+CNAzxglkrpNf/kKywqQfk74QjtceuOE7Qm+AF8miRvPF/wmmK5+OJOgVh3AVTT3RP2mH3+FOaxlE5v72owk0A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@eslint/core": "^1.2.1", + "levn": "^0.4.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + } + }, + "node_modules/@exodus/bytes": { + "version": "1.15.1", + "resolved": "https://registry.npmjs.org/@exodus/bytes/-/bytes-1.15.1.tgz", + "integrity": "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + }, + "peerDependencies": { + "@noble/hashes": "^1.8.0 || ^2.0.0" + }, + "peerDependenciesMeta": { + "@noble/hashes": { + "optional": true + } + } + }, + "node_modules/@floating-ui/core": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/core/-/core-1.7.5.tgz", + "integrity": "sha512-1Ih4WTWyw0+lKyFMcBHGbb5U5FtuHJuujoyyr5zTaWS5EYMeT6Jb2AuDeftsCsEuchO+mM2ij5+q9crhydzLhQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/dom": { + "version": "1.7.6", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.6.tgz", + "integrity": "sha512-9gZSAI5XM36880PPMm//9dfiEngYoC6Am2izES1FF406YFsjvyBMmeJ2g4SAju3xWwtuynNRFL2s9hgxpLI5SQ==", + "license": "MIT", + "dependencies": { + "@floating-ui/core": "^1.7.5", + "@floating-ui/utils": "^0.2.11" + } + }, + "node_modules/@floating-ui/react-dom": { + "version": "2.1.8", + "resolved": "https://registry.npmjs.org/@floating-ui/react-dom/-/react-dom-2.1.8.tgz", + "integrity": "sha512-cC52bHwM/n/CxS87FH0yWdngEZrjdtLW/qVruo68qg+prK7ZQ4YGdut2GyDVpoGeAYe/h899rVeOVm6Oi40k2A==", + "license": "MIT", + "dependencies": { + "@floating-ui/dom": "^1.7.6" + }, + "peerDependencies": { + "react": ">=16.8.0", + "react-dom": ">=16.8.0" + } + }, + "node_modules/@floating-ui/utils": { + "version": "0.2.11", + "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.11.tgz", + "integrity": "sha512-RiB/yIh78pcIxl6lLMG0CgBXAZ2Y0eVHqMPYugu+9U0AeT6YBeiJpf7lbdJNIugFP5SIjwNRgo4DhR1Qxi26Gg==", + "license": "MIT" + }, + "node_modules/@hookform/resolvers": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/@hookform/resolvers/-/resolvers-5.4.0.tgz", + "integrity": "sha512-EIsqr/t/qbinPIhGjMdtvutIN1Kk4uwbROE9/UQ93CAVGR7GkA7Y92+fX80OzXi/OB67jVFYwKGO1WzkxmkFZw==", + "license": "MIT", + "dependencies": { + "@standard-schema/utils": "^0.3.0" + }, + "peerDependencies": { + "react-hook-form": "^7.55.0" + } + }, + "node_modules/@humanfs/core": { + "version": "0.19.2", + "resolved": "https://registry.npmjs.org/@humanfs/core/-/core-0.19.2.tgz", + "integrity": "sha512-UhXNm+CFMWcbChXywFwkmhqjs3PRCmcSa/hfBgLIb7oQ5HNb1wS0icWsGtSAUNgefHeI+eBrA8I1fxmbHsGdvA==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/types": "^0.15.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/node": { + "version": "0.16.8", + "resolved": "https://registry.npmjs.org/@humanfs/node/-/node-0.16.8.tgz", + "integrity": "sha512-gE1eQNZ3R++kTzFUpdGlpmy8kDZD/MLyHqDwqjkVQI0JMdI1D51sy1H958PNXYkM2rAac7e5/CnIKZrHtPh3BQ==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "@humanfs/core": "^0.19.2", + "@humanfs/types": "^0.15.0", + "@humanwhocodes/retry": "^0.4.0" + }, + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanfs/types": { + "version": "0.15.0", + "resolved": "https://registry.npmjs.org/@humanfs/types/-/types-0.15.0.tgz", + "integrity": "sha512-ZZ1w0aoQkwuUuC7Yf+7sdeaNfqQiiLcSRbfI08oAxqLtpXQr9AIVX7Ay7HLDuiLYAaFPu8oBYNq/QIi9URHJ3Q==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18.0" + } + }, + "node_modules/@humanwhocodes/module-importer": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@humanwhocodes/module-importer/-/module-importer-1.0.1.tgz", + "integrity": "sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.22" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@humanwhocodes/retry": { + "version": "0.4.3", + "resolved": "https://registry.npmjs.org/@humanwhocodes/retry/-/retry-0.4.3.tgz", + "integrity": "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18.18" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/nzakas" + } + }, + "node_modules/@jridgewell/gen-mapping": { + "version": "0.3.13", + "resolved": "https://registry.npmjs.org/@jridgewell/gen-mapping/-/gen-mapping-0.3.13.tgz", + "integrity": "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.0", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/remapping": { + "version": "2.3.5", + "resolved": "https://registry.npmjs.org/@jridgewell/remapping/-/remapping-2.3.5.tgz", + "integrity": "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/gen-mapping": "^0.3.5", + "@jridgewell/trace-mapping": "^0.3.24" + } + }, + "node_modules/@jridgewell/resolve-uri": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/@jridgewell/resolve-uri/-/resolve-uri-3.1.2.tgz", + "integrity": "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/@jridgewell/sourcemap-codec": { + "version": "1.5.5", + "resolved": "https://registry.npmjs.org/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.5.5.tgz", + "integrity": "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og==", + "dev": true, + "license": "MIT" + }, + "node_modules/@jridgewell/trace-mapping": { + "version": "0.3.31", + "resolved": "https://registry.npmjs.org/@jridgewell/trace-mapping/-/trace-mapping-0.3.31.tgz", + "integrity": "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/resolve-uri": "^3.1.0", + "@jridgewell/sourcemap-codec": "^1.4.14" + } + }, + "node_modules/@napi-rs/wasm-runtime": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", + "integrity": "sha512-AWPoBRJ9tsnVhor4sjO7rkni+7p+2IAEFj6cx06UgP10jkQHqay/36uRV/bFkgrh18D9vb4cr8Q0Pthskgzy+Q==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@tybys/wasm-util": "^0.10.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "peerDependencies": { + "@emnapi/core": "^1.7.1", + "@emnapi/runtime": "^1.7.1" + } + }, + "node_modules/@oxc-project/types": { + "version": "0.133.0", + "resolved": "https://registry.npmjs.org/@oxc-project/types/-/types-0.133.0.tgz", + "integrity": "sha512-KzkdCd6Uxqnf6l3HOw1xfatAlUURA0g14cvBYFyJ5SaNOQbOUvBr9PKArcPcrNIeRsBdgcUzOGrhKveVpvOIGA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/Boshen" + } + }, + "node_modules/@radix-ui/number": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/number/-/number-1.1.2.tgz", + "integrity": "sha512-ceTwaxc4I5IOi97DgCotl3pqiyRGvffcc0oOsE2dQYaJOFIDsDt4VWG6xEbg1QePv9QWausCEIppud/tJ1wNig==", + "license": "MIT" + }, + "node_modules/@radix-ui/primitive": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/primitive/-/primitive-1.1.4.tgz", + "integrity": "sha512-7AdCK9PQyiljKoBDbN8OuctCbd/esdwZPQ8RtOE3SsyQtUpiPb+ND75q0jEhC1m1ecBI0MFNeLJvwIh9iKHRcQ==", + "license": "MIT" + }, + "node_modules/@radix-ui/react-arrow": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-arrow/-/react-arrow-1.1.10.tgz", + "integrity": "sha512-j2VTDz1vgCsmuG0k5lBfOcM8n5JPFqZBcMryasFjHYMhwxYL5SRUV5lMSUpRdNtw3D/Sv8pzJtrlAgkssYSsQQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-collection": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-collection/-/react-collection-1.1.10.tgz", + "integrity": "sha512-IVVz4EvBcKjrzKgof714qDnz/SzQAkLA2Emh5edlHbgcE6fNd3Un6CJLlaYcnm8N4JmAtzQgse4dOKxcD2yc9g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-compose-refs": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-compose-refs/-/react-compose-refs-1.1.3.tgz", + "integrity": "sha512-rYOP8OMnuuPMQF1uhPVlGNcCDlkokKqGFE3JcxFViIkAXP7EvFWUliJAstrapypaBLJNHbZL6jGhbVDGTwmVhA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-context": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-context/-/react-context-1.1.4.tgz", + "integrity": "sha512-QwH4PO5urrbO+FaGd5Aglg+YJgWTyyuZ3g/6mKvsqraLkglDdckw9JafgL5McL5VEJ6EPNduPaT3ZE9BttDAqg==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dialog": { + "version": "1.1.17", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dialog/-/react-dialog-1.1.17.tgz", + "integrity": "sha512-TDTYmpdq8dI2+Xgvgj9AJ8Ghqq+Eph/TRVEdaFQPDItIY+6QSkU7MJMeevw1568Yw/2Ijz8BTphPSP2XejKphw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-direction": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-direction/-/react-direction-1.1.2.tgz", + "integrity": "sha512-C3vFhbyi4SW3PmbAi6Awpu4OzJtd0MxGurvSsYtr7p7nM8RNB3VAF3CUmnp2j50knpkrRcB7+ycVXzgLgF6yNA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dismissable-layer": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dismissable-layer/-/react-dismissable-layer-1.1.13.tgz", + "integrity": "sha512-2v+zNAWWe0ySxgC0D0yeXMPQ23xZVgXZTerTz+JKlmdRj6gfTqmCcR29jb6d290DezXPGgruHWDX/vYUebtErg==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-escape-keydown": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-dropdown-menu": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-dropdown-menu/-/react-dropdown-menu-2.1.18.tgz", + "integrity": "sha512-PZGV82gFk0WltDRI//SsG28ZIjlo9ANTmoNYg0jLNzXXiDsAy5PkOOYQaVD1pPxY6t7gxffb1QMD6qaUvsBZdw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-menu": "2.1.18", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-guards": { + "version": "1.1.4", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-guards/-/react-focus-guards-1.1.4.tgz", + "integrity": "sha512-cot/aB/mOm0IYVYTTmQcEEK1M48lZWi8FlYe5nDPQQ8NYZUlXEFgncJ9p2Kzer3RKSrY7cTTpEMLZKNo9QoP5Q==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-focus-scope": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-focus-scope/-/react-focus-scope-1.1.10.tgz", + "integrity": "sha512-Fas/lXQqhVvqwAb64s5RFeHiHYElZ6SUQbZaNd6EkfhP/Al7wTIQ9WIR4QVX475tlu5yFCEdDcJH6/UwsZjMWw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-id": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-id/-/react-id-1.1.2.tgz", + "integrity": "sha512-orBC88futVpqCmhX1p4cvquNHsELQ+w+vBJnuj3ftETI5bJb0bZn3Tqu3SWN2IOcPycTnMGnhwoermvISt72sA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-label": { + "version": "2.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-label/-/react-label-2.1.10.tgz", + "integrity": "sha512-ib0zvq2ZsAqKm5tRnqGJn3vOxSgIts5ToxsXT0q1S/GfLD1Zj7UOEnkw8u2w6sRmn47djpQWuSU1DCL1R29/yw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-menu": { + "version": "2.1.18", + "resolved": "https://registry.npmjs.org/@radix-ui/react-menu/-/react-menu-2.1.18.tgz", + "integrity": "sha512-lj8Rxjtn6zJq1oSbE/uDtAwCbB9BnxgHD+8MwJMuTh6u1dPamYhW9iuELr/Z8d0D/UysFblYYHeBPwi7T4k0YQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-roving-focus": "1.1.13", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-popper": { + "version": "1.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-popper/-/react-popper-1.3.1.tgz", + "integrity": "sha512-bhnq/0DEPTi2lsOD3J5rTL65qUKHbKbhqHsmN9TMiclSXpipi651ooUKPPp6G5lF/WiHBdn1s0Wuqsn+myVAvw==", + "license": "MIT", + "dependencies": { + "@floating-ui/react-dom": "^2.0.0", + "@radix-ui/react-arrow": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-rect": "1.1.2", + "@radix-ui/react-use-size": "1.1.2", + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-portal": { + "version": "1.1.12", + "resolved": "https://registry.npmjs.org/@radix-ui/react-portal/-/react-portal-1.1.12.tgz", + "integrity": "sha512-m309havGzsjLHHaIX50G5PlvRs3xkgPCsGk/5PTvYm8D5q33yG0J7w/712PTOhid7NTaFETtnSXjngHQavvhVw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-presence": { + "version": "1.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-presence/-/react-presence-1.1.6.tgz", + "integrity": "sha512-zdTk4PlUO0E18HnZ3wYbW0KkJJxWCdiNYp6g6X1PtONFhxVkg01vliTJAmwIszU6mHiyBOoW9P0rAugl5/hULQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-primitive": { + "version": "2.1.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-primitive/-/react-primitive-2.1.6.tgz", + "integrity": "sha512-wetd0QI77DbvrPpTAvH1SqOxsYF2wZe5TNxqwOd5Ty4XDpV3dpV0s8K/1MGMJBeY5o7lg8ub5VIt1Ub+yVen6g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-slot": "1.3.0" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-roving-focus": { + "version": "1.1.13", + "resolved": "https://registry.npmjs.org/@radix-ui/react-roving-focus/-/react-roving-focus-1.1.13.tgz", + "integrity": "sha512-9gkwneI0guf8JDmrFxPjJF6Ozzgioyw+/lonYNCwefS9ZHA05er0BVHiXr+LbWGHxUfczvMY6G1oiZZi1VzjRw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-select": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/@radix-ui/react-select/-/react-select-2.3.1.tgz", + "integrity": "sha512-w6eDvY78LE9ZUiNnXCA1QVK8RYN7k9galFv09kjVydJqBAgHd7Y9A6h0UJ/6DCZNGZMZrB2ohcSW1Bo9d8+wWA==", + "license": "MIT", + "dependencies": { + "@radix-ui/number": "1.1.2", + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-collection": "1.1.10", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-direction": "1.1.2", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-focus-guards": "1.1.4", + "@radix-ui/react-focus-scope": "1.1.10", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-callback-ref": "1.1.2", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-use-layout-effect": "1.1.2", + "@radix-ui/react-use-previous": "1.1.2", + "@radix-ui/react-visually-hidden": "1.2.6", + "aria-hidden": "^1.2.4", + "react-remove-scroll": "^2.7.2" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-separator": { + "version": "1.1.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-separator/-/react-separator-1.1.10.tgz", + "integrity": "sha512-Y6K6jLQCVfCnTL2MEtGxDLffkhNfEfHsEg3Wa8JU+IWdn3EWbLXd3OuOfQRN7p/W/cUce1WyTk3QeuAoDBzN9g==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-slot": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@radix-ui/react-slot/-/react-slot-1.3.0.tgz", + "integrity": "sha512-MojKku4U/miO8Av4Dkb+ctMAQx7JmY96LmtDQlAarCRtd7rN52QCSzBF+XAvr5S6coSVj9HEPBgHAHKEJVk/WA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-compose-refs": "1.1.3" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-tooltip": { + "version": "1.2.10", + "resolved": "https://registry.npmjs.org/@radix-ui/react-tooltip/-/react-tooltip-1.2.10.tgz", + "integrity": "sha512-NlNe8D0dWEpVfXFli90IO6X07Josx/b1iu98tDnx9Xv0HT4wLIL+m2VOheMHhK7qbp2HoTBqALEFzGyZs/levw==", + "license": "MIT", + "dependencies": { + "@radix-ui/primitive": "1.1.4", + "@radix-ui/react-compose-refs": "1.1.3", + "@radix-ui/react-context": "1.1.4", + "@radix-ui/react-dismissable-layer": "1.1.13", + "@radix-ui/react-id": "1.1.2", + "@radix-ui/react-popper": "1.3.1", + "@radix-ui/react-portal": "1.1.12", + "@radix-ui/react-presence": "1.1.6", + "@radix-ui/react-primitive": "2.1.6", + "@radix-ui/react-slot": "1.3.0", + "@radix-ui/react-use-controllable-state": "1.2.3", + "@radix-ui/react-visually-hidden": "1.2.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-callback-ref": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-callback-ref/-/react-use-callback-ref-1.1.2.tgz", + "integrity": "sha512-xCso9j1/u8sEgP1RNHjFrXJLApL8LiqOkI1R4ywuN00rxWdYg4oQXuwKLS3i0j5NWLromUD27/4nlxj2UFVvIw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-controllable-state": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-controllable-state/-/react-use-controllable-state-1.2.3.tgz", + "integrity": "sha512-PLzC90MS+ReootmjC597dvopoelpZ8Q61HJkDXZSExitIq7PL55vHNnesAHwguHK0aPfBnpdNzQtv1uliaqQrA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-effect-event": "0.0.3", + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-effect-event": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-effect-event/-/react-use-effect-event-0.0.3.tgz", + "integrity": "sha512-6c8ZqvPTWILEKnyVkP53EGRCcpnJiKTC21sS/6R1GF5xKyHJJWQEPfkqlcgUkdRQivd6tb23abUwe4ngWmY0JA==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-escape-keydown": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-escape-keydown/-/react-use-escape-keydown-1.1.2.tgz", + "integrity": "sha512-2uVLvLjgO7NZCWw01/FdqRwmA42J0BcjPMUCA+koFEOAb+zjqIP7SiFz/7zWPrKnVmSqr76Omq2ALyCuX4dhLw==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-callback-ref": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-layout-effect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-layout-effect/-/react-use-layout-effect-1.1.2.tgz", + "integrity": "sha512-jrBWOxZITuGcnjRCM2t2U5ZPkCLxD+Ym6DjfssS5haTj2iiak/DOb64JeN6OdLfLgptb6/e2kKR+ZuTrGoZTPA==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-previous": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-previous/-/react-use-previous-1.1.2.tgz", + "integrity": "sha512-IGBQPtRFdhN6MQ8dbegVmBq1LVZluya3F1jWY+puIcQC3MHctRwTDSBWCkL/3ZcnMJLTMJ++Z+ktmvg0F89iCw==", + "license": "MIT", + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-rect/-/react-use-rect-1.1.2.tgz", + "integrity": "sha512-d8a+bBY/FxikNPlgJJoaBHZX+zKVbWHYJGTLnLvveQgFSTntkGdEKv3JDtHrMS0DNYpllz2nRsTLGLKYttbpmw==", + "license": "MIT", + "dependencies": { + "@radix-ui/rect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-use-size": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/react-use-size/-/react-use-size-1.1.2.tgz", + "integrity": "sha512-giWQp+4mxjBPt4KZ0MmyuykFNWfbDxKt4x+fPkRYmgRFJSbCZFzUglvMb/Kjn38tm10YP4ufiQZDx3zna4LU6w==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-use-layout-effect": "1.1.2" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/@radix-ui/react-visually-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/@radix-ui/react-visually-hidden/-/react-visually-hidden-1.2.6.tgz", + "integrity": "sha512-jCE0WljWifTI4niIMCll06kGpsJTAPiZVU9H4WR1N6qW7At9ystHbN7dDB+we2xH535roFHj7qKS+RGj0FMDWQ==", + "license": "MIT", + "dependencies": { + "@radix-ui/react-primitive": "2.1.6" + }, + "peerDependencies": { + "@types/react": "*", + "@types/react-dom": "*", + "react": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc", + "react-dom": "^16.8 || ^17.0 || ^18.0 || ^19.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@radix-ui/rect": { + "version": "1.1.2", + "resolved": "https://registry.npmjs.org/@radix-ui/rect/-/rect-1.1.2.tgz", + "integrity": "sha512-xnXE7wG13PI+cxieVssYXlQJuYVRhH9NBoxt3KNwzghDIA69GMm7d4wXRouHIYjE+KvS6U/MsMO73NdS2MH9ZA==", + "license": "MIT" + }, + "node_modules/@rolldown/binding-android-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.3.tgz", + "integrity": "sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-arm64/-/binding-darwin-arm64-1.0.3.tgz", + "integrity": "sha512-PcAhP+ynjURNyy8SKGl5DQP94aGuB/7JrXJb/t7P+hanXvQVMWzUvRRhBAcg/lNRadBhoUPqSoP4xw5tR/KBEA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-darwin-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-darwin-x64/-/binding-darwin-x64-1.0.3.tgz", + "integrity": "sha512-9YpfeUvSE2RS7wysJ81uOZkXJz7f7Q55H2Gvp3VEw/EsahqDtrphrZ0EwDLK5vvKOzaCrBsjF8JmnMLcUt78Gg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-freebsd-x64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-freebsd-x64/-/binding-freebsd-x64-1.0.3.tgz", + "integrity": "sha512-yB1IlAsSNHncV6SCTL27/MVGR5htvQsoGxIv5KMGXALp+Ll1wYsn+x98M9MW7qa+NdSbvrrY7ANI4wLJ0n1e6g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm-gnueabihf": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm-gnueabihf/-/binding-linux-arm-gnueabihf-1.0.3.tgz", + "integrity": "sha512-Yi30IVAAfLUCy2MseFjbB1jAMDl1VMCAas5StnYp8da9+CKvMd2H2cbEjWcw5NPaPqzvYkVIaF1nNUG+b7u/sw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-gnu/-/binding-linux-arm64-gnu-1.0.3.tgz", + "integrity": "sha512-jsO7R8To+AdlYgUmN5sHSCZbfhtMBkO0WUx8iORQnPcMMdgr7qM2DQmMwgabs3GhNztdmoKkMKQFHD6DTMCIQw==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-arm64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-arm64-musl/-/binding-linux-arm64-musl-1.0.3.tgz", + "integrity": "sha512-VWkUHwWriDciit80wleYwKILoR/KMvxh/IdwS/paX+ZgpuRpCrKLUdadJbc0NpBEiyhpYawsJ73j9aCvOH+f7Q==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-ppc64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-ppc64-gnu/-/binding-linux-ppc64-gnu-1.0.3.tgz", + "integrity": "sha512-5f1laC0SlIR0yDbFCd8acUhvJIag6N3zC5P7oUPN6wX0aOma+uKJ0wBDH5aq7I1PVI2ttTlhJwzwRIBnLiSGEg==", + "cpu": [ + "ppc64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-s390x-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-s390x-gnu/-/binding-linux-s390x-gnu-1.0.3.tgz", + "integrity": "sha512-Iq4ko0r4XsgbrF/LunNgHtAGLRRVE2kXonAXQ/MV0mC6jQpMOhW1SvtZja2EhC/kd05++bP78dsqBeIQyYJ6Yg==", + "cpu": [ + "s390x" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-gnu": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-gnu/-/binding-linux-x64-gnu-1.0.3.tgz", + "integrity": "sha512-B8m6tD5+/N5FeNQFbKlLA/2yVq9ycQP1SeedyEYYKWBNR3ZQbkvIUcNnDNM03lO1l5F2roiiFJGgvoLLyZXtSg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-linux-x64-musl": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-linux-x64-musl/-/binding-linux-x64-musl-1.0.3.tgz", + "integrity": "sha512-pSdpdUJHkuCxun9LE7jvgUB9qsRgaiyNNCX7m/AvHTcq67AiT/Yhoxvw5zPfhrM8k/BfP8ce/hMOpthKDpEUow==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-openharmony-arm64": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-openharmony-arm64/-/binding-openharmony-arm64-1.0.3.tgz", + "integrity": "sha512-OXXS3RKJgX2uLwM+gYyuH5omcH8fL1LJs96pZGgtetVCahON57+d4SJHzTgZiOjxgGkSnpXpOsWuPDGAKAigEg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "openharmony" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-wasm32-wasi": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-wasm32-wasi/-/binding-wasm32-wasi-1.0.3.tgz", + "integrity": "sha512-JTtb8BWFynicNSoPrehsCzBtOKjZ6jhMiPFEmOiuXg1Fl8dn2KHQob+GuPSGR0dryQa1PQJbzjF3dqO/whhjLg==", + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "1.10.0", + "@emnapi/runtime": "1.10.0", + "@napi-rs/wasm-runtime": "^1.1.4" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-arm64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-arm64-msvc/-/binding-win32-arm64-msvc-1.0.3.tgz", + "integrity": "sha512-gEdFFEN70A/jxb2svrWsN3aDL7OUtmvlOy+6fa2jxG8K0wQ1ZbdeLGnidov6Yu5/733dI5ySfzFlQ/cb0bSz1g==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/binding-win32-x64-msvc": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/@rolldown/binding-win32-x64-msvc/-/binding-win32-x64-msvc-1.0.3.tgz", + "integrity": "sha512-eXB7CHuaQdqmJcc3koCNtNPmT/bj2gc999kUFgBxG8Ac0NdgXc4rkCHhqrgrhN3zddvvvrgzj1e90SuSfmyIXA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": "^20.19.0 || >=22.12.0" + } + }, + "node_modules/@rolldown/pluginutils": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/@rolldown/pluginutils/-/pluginutils-1.0.1.tgz", + "integrity": "sha512-2j9bGt5Jh8hj+vPtgzPtl72j0yRxHAyumoo6TNfAjsLB04UtpSvPbPcDcBMxz7n+9CYB0c1GxQFxYRg2jimqGw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, + "node_modules/@tailwindcss/node": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/node/-/node-4.3.1.tgz", + "integrity": "sha512-6NDaqRoAMSXD1mr/RXu0HBvNE9a2n5tHPsxu9XHLws8o4Twes5rBM2205SUUiJ9goAtadrN6xTGX0UDEwp/N4A==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/remapping": "^2.3.5", + "enhanced-resolve": "5.21.6", + "jiti": "^2.7.0", + "lightningcss": "1.32.0", + "magic-string": "^0.30.21", + "source-map-js": "^1.2.1", + "tailwindcss": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide/-/oxide-4.3.1.tgz", + "integrity": "sha512-yVPyo8RNkabVr3O2EhHEE0Rewu7YKzc1DhIqfL46LKveFrmu9XbDazNOJY7/GRuvw1h6u3utWnR29H/p5JPlgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 20" + }, + "optionalDependencies": { + "@tailwindcss/oxide-android-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-arm64": "4.3.1", + "@tailwindcss/oxide-darwin-x64": "4.3.1", + "@tailwindcss/oxide-freebsd-x64": "4.3.1", + "@tailwindcss/oxide-linux-arm-gnueabihf": "4.3.1", + "@tailwindcss/oxide-linux-arm64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-arm64-musl": "4.3.1", + "@tailwindcss/oxide-linux-x64-gnu": "4.3.1", + "@tailwindcss/oxide-linux-x64-musl": "4.3.1", + "@tailwindcss/oxide-wasm32-wasi": "4.3.1", + "@tailwindcss/oxide-win32-arm64-msvc": "4.3.1", + "@tailwindcss/oxide-win32-x64-msvc": "4.3.1" + } + }, + "node_modules/@tailwindcss/oxide-android-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-android-arm64/-/oxide-android-arm64-4.3.1.tgz", + "integrity": "sha512-SVlyf61g374l5cHyg8x9kf5xmLcOaxvOTsbsqDnSsDJaKOEFZ7GCvi84VAVGpxojYOs1+3K6M0UjXfqPU8vmOQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-arm64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-arm64/-/oxide-darwin-arm64-4.3.1.tgz", + "integrity": "sha512-hVnWLwv+e/l7c4WKyVtHVrIPvYdqWHjRB3MDIqARynzFtnQg85kmQEFCbV9Ja0VVx4xXTIiDWY60Y7iz/iNoDA==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-darwin-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-darwin-x64/-/oxide-darwin-x64-4.3.1.tgz", + "integrity": "sha512-Cf7abu0WVgbhU7ANgPUnSAvm7nCvMweusHb8FnaHlLfv/Caq4GYaEZg7ZImzzmjx4lIAfuS8q+eLIS7A7IzxIg==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-freebsd-x64": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-freebsd-x64/-/oxide-freebsd-x64-4.3.1.tgz", + "integrity": "sha512-ZZqzX2Y+GXtXXfqSfpJhDm60OoZfvLHLCgm+J7NVqgHHJjG/m9ugZI77RwTsVd4fnBJuCFP6Ae6kTJb71UdS8g==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm-gnueabihf": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm-gnueabihf/-/oxide-linux-arm-gnueabihf-4.3.1.tgz", + "integrity": "sha512-/Ah/xik0LaMYfv9DZ0S/t4pBlBNYOcqtRwusjgovHkvT8ixueWCLyJjsaF5kQIckjb4IT8Q6K6p/iPmZMixYgg==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-gnu/-/oxide-linux-arm64-gnu-4.3.1.tgz", + "integrity": "sha512-gqdFoVJlw444GvpnheZLHmvTzSxI/cOUUh2KSNejQjTcYkW062SVD+En0rUgD+QV91bz1XGIGtt1HJd48xUGbQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-arm64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-arm64-musl/-/oxide-linux-arm64-musl-4.3.1.tgz", + "integrity": "sha512-Bwv9KwOvE0VKa86xPFif9b9c3Y1NxOV1P0gLti/IYaWEsQYZXDlxfGEtA8mdDZ7SG3wyNXAWYT5SIn3giL57oA==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-gnu": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-gnu/-/oxide-linux-x64-gnu-4.3.1.tgz", + "integrity": "sha512-Ymi8O8T15HYQdOUWUtTI6ldN0neHP85FC+Qz32xTcZ7iJXtem/x8ITev0o1e9e5rkqj4lONZfTRLvkmin1+tKg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-linux-x64-musl": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-linux-x64-musl/-/oxide-linux-x64-musl-4.3.1.tgz", + "integrity": "sha512-M+P/91qJ6uILLw4k2G93GMDRAXj61SMvFQYt39AqvUqYgExXpLL5aepfns7sj4HiAQeolirQF9E0lzRvdf4zPQ==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-wasm32-wasi": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-wasm32-wasi/-/oxide-wasm32-wasi-4.3.1.tgz", + "integrity": "sha512-zsM8uOeqvVGHsAXsJxsT28ttosFahLJKCLOTUBqRAtKnVgGSRitds9T432QiT8b77Yga7JIBkulIRRlJPtYhRA==", + "bundleDependencies": [ + "@napi-rs/wasm-runtime", + "@emnapi/core", + "@emnapi/runtime", + "@tybys/wasm-util", + "@emnapi/wasi-threads", + "tslib" + ], + "cpu": [ + "wasm32" + ], + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "@emnapi/core": "^1.10.0", + "@emnapi/runtime": "^1.10.0", + "@emnapi/wasi-threads": "^1.2.1", + "@napi-rs/wasm-runtime": "^1.1.4", + "@tybys/wasm-util": "^0.10.2", + "tslib": "^2.8.1" + }, + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/@tailwindcss/oxide-win32-arm64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-arm64-msvc/-/oxide-win32-arm64-msvc-4.3.1.tgz", + "integrity": "sha512-aiNvSq9BsVk8V513lDKlrCFAgf8qBMPZTpgEhInL+NwQqs97mYmupVMrPrgBBSL8Pv/0zXu9MrMF9rMun1ZeNg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/oxide-win32-x64-msvc": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/oxide-win32-x64-msvc/-/oxide-win32-x64-msvc-4.3.1.tgz", + "integrity": "sha512-xDEyu1rg290472FEGaKHnzyDyh5QH+AlWvsU5hMoMtPpzmKlRI0jaYKCgSHDYtaQWZOYbMaduSyCwFwY4n1HmA==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 20" + } + }, + "node_modules/@tailwindcss/vite": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@tailwindcss/vite/-/vite-4.3.1.tgz", + "integrity": "sha512-hItDHuIIlEV61R+faXu66s1K36aTurO/Qw0e45Vskz57gXl9pWOT6eg3zmcEui6CZXddbN7zd41bwmvag4JGwQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@tailwindcss/node": "4.3.1", + "@tailwindcss/oxide": "4.3.1", + "tailwindcss": "4.3.1" + }, + "peerDependencies": { + "vite": "^5.2.0 || ^6 || ^7 || ^8" + } + }, + "node_modules/@tanstack/query-core": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/query-core/-/query-core-5.101.0.tgz", + "integrity": "sha512-cQetA74EB+seWySv1TTKr828TnP0u39m6LykwDXIo84SNortpDkp30TMEjkqtYCNP9c40uT/iwl6MLiufEt0Ow==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + } + }, + "node_modules/@tanstack/react-query": { + "version": "5.101.0", + "resolved": "https://registry.npmjs.org/@tanstack/react-query/-/react-query-5.101.0.tgz", + "integrity": "sha512-rLlJXSpkqfizLWgkR5+eLeIk0MvTx/meEIR7LRjxic+qxiQP8zVjq7BqQkiCMNLQBlLfuOLqqr6KO5GtrDlmSg==", + "license": "MIT", + "dependencies": { + "@tanstack/query-core": "5.101.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/tannerlinsley" + }, + "peerDependencies": { + "react": "^18 || ^19" + } + }, + "node_modules/@testing-library/dom": { + "version": "10.4.1", + "resolved": "https://registry.npmjs.org/@testing-library/dom/-/dom-10.4.1.tgz", + "integrity": "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "@babel/code-frame": "^7.10.4", + "@babel/runtime": "^7.12.5", + "@types/aria-query": "^5.0.1", + "aria-query": "5.3.0", + "dom-accessibility-api": "^0.5.9", + "lz-string": "^1.5.0", + "picocolors": "1.1.1", + "pretty-format": "^27.0.2" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/@testing-library/jest-dom": { + "version": "6.9.1", + "resolved": "https://registry.npmjs.org/@testing-library/jest-dom/-/jest-dom-6.9.1.tgz", + "integrity": "sha512-zIcONa+hVtVSSep9UT3jZ5rizo2BsxgyDYU7WFD5eICBE7no3881HGeb/QkGfsJs6JTkY1aQhT7rIPC7e+0nnA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@adobe/css-tools": "^4.4.0", + "aria-query": "^5.0.0", + "css.escape": "^1.5.1", + "dom-accessibility-api": "^0.6.3", + "picocolors": "^1.1.1", + "redent": "^3.0.0" + }, + "engines": { + "node": ">=14", + "npm": ">=6", + "yarn": ">=1" + } + }, + "node_modules/@testing-library/jest-dom/node_modules/dom-accessibility-api": { + "version": "0.6.3", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.6.3.tgz", + "integrity": "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==", + "dev": true, + "license": "MIT" + }, + "node_modules/@testing-library/react": { + "version": "16.3.2", + "resolved": "https://registry.npmjs.org/@testing-library/react/-/react-16.3.2.tgz", + "integrity": "sha512-XU5/SytQM+ykqMnAnvB2umaJNIOsLF3PVv//1Ew4CTcpz0/BRyy/af40qqrt7SjKpDdT1saBMc42CUok5gaw+g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/runtime": "^7.12.5" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "@testing-library/dom": "^10.0.0", + "@types/react": "^18.0.0 || ^19.0.0", + "@types/react-dom": "^18.0.0 || ^19.0.0", + "react": "^18.0.0 || ^19.0.0", + "react-dom": "^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "@types/react-dom": { + "optional": true + } + } + }, + "node_modules/@testing-library/user-event": { + "version": "14.6.1", + "resolved": "https://registry.npmjs.org/@testing-library/user-event/-/user-event-14.6.1.tgz", + "integrity": "sha512-vq7fv0rnt+QTXgPxr5Hjc210p6YKq2kmdziLgnsZGgLJ9e6VAShx1pACLuRjd/AS/sr7phAR58OIIpf0LlmQNw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12", + "npm": ">=6" + }, + "peerDependencies": { + "@testing-library/dom": ">=7.21.4" + } + }, + "node_modules/@tybys/wasm-util": { + "version": "0.10.2", + "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.2.tgz", + "integrity": "sha512-RoBvJ2X0wuKlWFIjrwffGw1IqZHKQqzIchKaadZZfnNpsAYp2mM0h36JtPCjNDAHGgYez/15uMBpfGwchhiMgg==", + "dev": true, + "license": "MIT", + "optional": true, + "dependencies": { + "tslib": "^2.4.0" + } + }, + "node_modules/@types/aria-query": { + "version": "5.0.4", + "resolved": "https://registry.npmjs.org/@types/aria-query/-/aria-query-5.0.4.tgz", + "integrity": "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/@types/chai": { + "version": "5.2.3", + "resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz", + "integrity": "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/deep-eql": "*", + "assertion-error": "^2.0.1" + } + }, + "node_modules/@types/deep-eql": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/@types/deep-eql/-/deep-eql-4.0.2.tgz", + "integrity": "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/esrecurse": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/@types/esrecurse/-/esrecurse-4.3.1.tgz", + "integrity": "sha512-xJBAbDifo5hpffDBuHl0Y8ywswbiAp/Wi7Y/GtAgSlZyIABppyurxVueOPE8LUQOxdlgi6Zqce7uoEpqNTeiUw==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/estree": { + "version": "1.0.9", + "resolved": "https://registry.npmjs.org/@types/estree/-/estree-1.0.9.tgz", + "integrity": "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/json-schema": { + "version": "7.0.15", + "resolved": "https://registry.npmjs.org/@types/json-schema/-/json-schema-7.0.15.tgz", + "integrity": "sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==", + "dev": true, + "license": "MIT" + }, + "node_modules/@types/node": { + "version": "24.13.2", + "resolved": "https://registry.npmjs.org/@types/node/-/node-24.13.2.tgz", + "integrity": "sha512-fRa09kZTgu8o71KFcDjUFuc7F+dEbZYZmkI0mg5YBTRs0yMKjYHsq/c0urDKeDb+D5qVgXOdFcuu+DZPKOITwA==", + "dev": true, + "license": "MIT", + "dependencies": { + "undici-types": "~7.18.0" + } + }, + "node_modules/@types/react": { + "version": "19.2.17", + "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.17.tgz", + "integrity": "sha512-MXfmqaVPEVgkBT/aY0aGCkRWWtByiYQXo3xdQ8r5RzuFrPiRn8Gar2tQdXSUQ2GKV3bkXckek89V8wQBY2Q/Aw==", + "devOptional": true, + "license": "MIT", + "dependencies": { + "csstype": "^3.2.2" + } + }, + "node_modules/@types/react-dom": { + "version": "19.2.3", + "resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-19.2.3.tgz", + "integrity": "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ==", + "devOptional": true, + "license": "MIT", + "peerDependencies": { + "@types/react": "^19.2.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.61.1.tgz", + "integrity": "sha512-ZPlVl3PB3et/59Ne0fv/sci6ZXz4T4Hp4nTJ56i/Y0gR89ARb+KphojTq6j+56E5PIezmOIOOWyY+aWQFd+IkQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/regexpp": "^4.12.2", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/type-utils": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "ignore": "^7.0.5", + "natural-compare": "^1.4.0", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "@typescript-eslint/parser": "^8.61.1", + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/eslint-plugin/node_modules/ignore": { + "version": "7.0.5", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-7.0.5.tgz", + "integrity": "sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/@typescript-eslint/parser": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/parser/-/parser-8.61.1.tgz", + "integrity": "sha512-PJ5vePq5/ognBbrIcoC5+SHO5dfpeLPzP9FpLkzWrguoYQEeeSjlJpVwOpo1JRSTEi7dRcwNy4h4dzV70PqHcg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/project-service": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/project-service/-/project-service-8.61.1.tgz", + "integrity": "sha512-PrC4JYGmR241lYnfhmKGTXkFqv8+ymbTFgSAY0fVXpY82/QkMw5TZPl+vGzuDDU2QYJk9fIDOBTntF+yDv9LEA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/tsconfig-utils": "^8.61.1", + "@typescript-eslint/types": "^8.61.1", + "debug": "^4.4.3" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/scope-manager": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/scope-manager/-/scope-manager-8.61.1.tgz", + "integrity": "sha512-L2bdIeoQS8FlKAvONAr20w6OcLXeB+qiDKbAooS9A0Ben+iSIkBef0FxqwKWYqt5sa0i4KJtxVyVmhMylKzF5w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/tsconfig-utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/tsconfig-utils/-/tsconfig-utils-8.61.1.tgz", + "integrity": "sha512-UN/H4di+OO7EWx2ovME+8t31YO+KVnK0RRKEHR3kOt21/Ay8BOq3M1OMvWs5vNiqcFCYGYoxK3MXPZzmMUE+yg==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/type-utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/type-utils/-/type-utils-8.61.1.tgz", + "integrity": "sha512-GYRicKmVK0C4fsKgaACaknOUAq9Oa2kwsjnpFhFcS/5p4Ht5IP9OVLbgIgcK4SRk92nVHFluurg1lumD9dBcLw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1", + "debug": "^4.4.3", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/types": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/types/-/types-8.61.1.tgz", + "integrity": "sha512-G+CRlPqLv7Bz1IZVs03x5K59F1veqL0EJUROAdGhKsEq8qOiRiZbI+HUojPq5l0fEGOKModD9br6lObhB8zkoA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@typescript-eslint/typescript-estree": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/typescript-estree/-/typescript-estree-8.61.1.tgz", + "integrity": "sha512-u+oQD3BqYWPc8YV9Zab4vaJElJuwOLPRc10Jm1o/qS+6Qwen14HCWwx0Seo4LnSn2wxea2Ik8DxPt2/FHmuhrg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/project-service": "8.61.1", + "@typescript-eslint/tsconfig-utils": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/visitor-keys": "8.61.1", + "debug": "^4.4.3", + "minimatch": "^10.2.2", + "semver": "^7.7.3", + "tinyglobby": "^0.2.15", + "ts-api-utils": "^2.5.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/typescript-estree/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/@typescript-eslint/utils": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/utils/-/utils-8.61.1.tgz", + "integrity": "sha512-1+P/3Dj6jvtybE1q0HQ6yBt/gq+oKJyLdEv4HdnqasaEXRSYCAsD59mXEVQnM/ULNdQxbX77tdG4jPRjIS6knA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@eslint-community/eslint-utils": "^4.9.1", + "@typescript-eslint/scope-manager": "8.61.1", + "@typescript-eslint/types": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/@typescript-eslint/visitor-keys": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/@typescript-eslint/visitor-keys/-/visitor-keys-8.61.1.tgz", + "integrity": "sha512-6fJ9MHWtK14C1DSkiMlHUSOmrVebL7150xZJBlJiL62jjhIA4JmOq6flwBgDxIdBKKdoiZRel+dfPD5MLfny3w==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/types": "8.61.1", + "eslint-visitor-keys": "^5.0.0" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + } + }, + "node_modules/@vitejs/plugin-react": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/@vitejs/plugin-react/-/plugin-react-6.0.2.tgz", + "integrity": "sha512-DlSMqo4WhThw4vB8Mpn0Woe9J+Jfq1geJ61AKW0QEgLzGMNwtIMdxbDUzLxcun8W7NbJO0e2Jg/Nxm3cCSVzzg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@rolldown/pluginutils": "^1.0.0" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "peerDependencies": { + "@rolldown/plugin-babel": "^0.1.7 || ^0.2.0", + "babel-plugin-react-compiler": "^1.0.0", + "vite": "^8.0.0" + }, + "peerDependenciesMeta": { + "@rolldown/plugin-babel": { + "optional": true + }, + "babel-plugin-react-compiler": { + "optional": true + } + } + }, + "node_modules/@vitest/coverage-v8": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/coverage-v8/-/coverage-v8-4.1.9.tgz", + "integrity": "sha512-G9/lgqibheLVBDRuya45EbsEXTYcWoSG+TLg7i2axuzx0Eq62eXn+aWXyaVdV5vKvFSWd6ywcX8hA7la9Pvu8g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bcoe/v8-coverage": "^1.0.2", + "@vitest/utils": "4.1.9", + "ast-v8-to-istanbul": "^1.0.0", + "istanbul-lib-coverage": "^3.2.2", + "istanbul-lib-report": "^3.0.1", + "istanbul-reports": "^3.2.0", + "magicast": "^0.5.2", + "obug": "^2.1.1", + "std-env": "^4.0.0-rc.1", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@vitest/browser": "4.1.9", + "vitest": "4.1.9" + }, + "peerDependenciesMeta": { + "@vitest/browser": { + "optional": true + } + } + }, + "node_modules/@vitest/expect": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.9.tgz", + "integrity": "sha512-vl/rYsUKcBr3SnQn166+XR5ZQcgMx3DQhFWdfli/cWpLnLUmbxZvyrJZotLFUryib+LtArYMSTJ5RbQ57ZqrlA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.1.0", + "@types/chai": "^5.2.2", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/mocker": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.9.tgz", + "integrity": "sha512-EVkXzBjrPGM+cK8/ANWgBrkUCfJfb38/EfTSO8h7pWvKkyPkpWxvR7BkD2MyItMF62C97zAEoqdpUixwR/e+Rw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/spy": "4.1.9", + "estree-walker": "^3.0.3", + "magic-string": "^0.30.21" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "msw": "^2.4.9", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "msw": { + "optional": true + }, + "vite": { + "optional": true + } + } + }, + "node_modules/@vitest/pretty-format": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.9.tgz", + "integrity": "sha512-s0iufns3iIFitdgm+YR7g1whCAaGtXz459VS9/PqyKDEEFgYIhsHOQmXgIgDuYCt7DeQmiZT0Qe2OA2p4ZPu5A==", + "dev": true, + "license": "MIT", + "dependencies": { + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/runner": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.9.tgz", + "integrity": "sha512-KXLMDtc7oe70+3mJfGrPUWPesswH+3sTxAMAMl8DG7I8IUQT4XW718dY5ID3vPUcmlu27CcKfY4P3h3I29SLJg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/utils": "4.1.9", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/snapshot": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.9.tgz", + "integrity": "sha512-Jc7RKGNBo8Z28WYIm0Niej4xdSPByRf6mU58VpHQkd6Zh05rlnA+twjbK5HyeIGHxrzsc3mJgS43uM0CZKzaIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "@vitest/utils": "4.1.9", + "magic-string": "^0.30.21", + "pathe": "^2.0.3" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/spy": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.9.tgz", + "integrity": "sha512-fHpsS6mIi+PiEW+vcRVOMkX1oSaPKne3VOclSFICPcGOmfKgXPU5iAah+wcNcj2xPrCCmfq99IDGf+EojhhvhA==", + "dev": true, + "license": "MIT", + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/@vitest/utils": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.9.tgz", + "integrity": "sha512-A51o8ymO5PpqlWNnBP9ZHPXDIpuMtTLlGSjN7la4US+LJzoUMyhwjA5QXlm39JexgwHKW4Xjs8Z2d3dLCXOeuA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/pretty-format": "4.1.9", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + } + }, + "node_modules/acorn": { + "version": "8.17.0", + "resolved": "https://registry.npmjs.org/acorn/-/acorn-8.17.0.tgz", + "integrity": "sha512-xRQbDb9BnwDafYNn6Vwl839DYVjqXYb1XVGtWAZ1kcDc6iwAL4hg3B1dZlRiuENFeO2H53gFG3in621AdERVAg==", + "dev": true, + "license": "MIT", + "bin": { + "acorn": "bin/acorn" + }, + "engines": { + "node": ">=0.4.0" + } + }, + "node_modules/acorn-jsx": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz", + "integrity": "sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" + } + }, + "node_modules/ajv": { + "version": "6.15.0", + "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.15.0.tgz", + "integrity": "sha512-fgFx7Hfoq60ytK2c7DhnF8jIvzYgOMxfugjLOSMHjLIPgenqa7S7oaagATUq99mV6IYvN2tRmC0wnTYX6iPbMw==", + "dev": true, + "license": "MIT", + "dependencies": { + "fast-deep-equal": "^3.1.1", + "fast-json-stable-stringify": "^2.0.0", + "json-schema-traverse": "^0.4.1", + "uri-js": "^4.2.2" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/epoberezkin" + } + }, + "node_modules/ansi-regex": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-5.0.1.tgz", + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=8" + } + }, + "node_modules/ansi-styles": { + "version": "5.2.0", + "resolved": "https://registry.npmjs.org/ansi-styles/-/ansi-styles-5.2.0.tgz", + "integrity": "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==", + "dev": true, + "license": "MIT", + "peer": true, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/chalk/ansi-styles?sponsor=1" + } + }, + "node_modules/aria-hidden": { + "version": "1.2.6", + "resolved": "https://registry.npmjs.org/aria-hidden/-/aria-hidden-1.2.6.tgz", + "integrity": "sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/aria-query": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/aria-query/-/aria-query-5.3.0.tgz", + "integrity": "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==", + "dev": true, + "license": "Apache-2.0", + "dependencies": { + "dequal": "^2.0.3" + } + }, + "node_modules/assertion-error": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/assertion-error/-/assertion-error-2.0.1.tgz", + "integrity": "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + } + }, + "node_modules/ast-v8-to-istanbul": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/ast-v8-to-istanbul/-/ast-v8-to-istanbul-1.0.4.tgz", + "integrity": "sha512-0bC0/4bTSrnwdhU3IsZDwEdojvuPrSg59OYZfKsLRtJZ0u8VBx9DebfqqG8bRdCC0I7vjgxmPi41P0lpkhJHtA==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/trace-mapping": "^0.3.31", + "estree-walker": "^3.0.3", + "js-tokens": "^10.0.0" + } + }, + "node_modules/ast-v8-to-istanbul/node_modules/js-tokens": { + "version": "10.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-10.0.0.tgz", + "integrity": "sha512-lM/UBzQmfJRo9ABXbPWemivdCW8V2G8FHaHdypQaIy523snUjog0W71ayWXTjiR+ixeMyVHN2XcpnTd/liPg/Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/balanced-match": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/balanced-match/-/balanced-match-4.0.4.tgz", + "integrity": "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA==", + "dev": true, + "license": "MIT", + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/baseline-browser-mapping": { + "version": "2.10.38", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.38.tgz", + "integrity": "sha512-31/02mVB4yuQU6adKk5SlY6m+mxDwUq5KZkyYgnLrrKl7TEm1+3PyDtDBz2kOv/wxZz41GHsvV1A/u6RmiyBvw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "baseline-browser-mapping": "dist/cli.cjs" + }, + "engines": { + "node": ">=6.0.0" + } + }, + "node_modules/bidi-js": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/bidi-js/-/bidi-js-1.0.3.tgz", + "integrity": "sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==", + "dev": true, + "license": "MIT", + "dependencies": { + "require-from-string": "^2.0.2" + } + }, + "node_modules/brace-expansion": { + "version": "5.0.6", + "resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-5.0.6.tgz", + "integrity": "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g==", + "dev": true, + "license": "MIT", + "dependencies": { + "balanced-match": "^4.0.2" + }, + "engines": { + "node": "18 || 20 || >=22" + } + }, + "node_modules/browserslist": { + "version": "4.28.2", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.2.tgz", + "integrity": "sha512-48xSriZYYg+8qXna9kwqjIVzuQxi+KYWp2+5nCYnYKPTr0LvD89Jqk2Or5ogxz0NUMfIjhh2lIUX/LyX9B4oIg==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "baseline-browser-mapping": "^2.10.12", + "caniuse-lite": "^1.0.30001782", + "electron-to-chromium": "^1.5.328", + "node-releases": "^2.0.36", + "update-browserslist-db": "^1.2.3" + }, + "bin": { + "browserslist": "cli.js" + }, + "engines": { + "node": "^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7" + } + }, + "node_modules/caniuse-lite": { + "version": "1.0.30001799", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001799.tgz", + "integrity": "sha512-hG1bReV+OUU+MOqK4t/ZWI0tZOyz3rqS9XuhOUz1cIcbwBKjOyJEJuw9ER5JuNyqxNk8u/JUVbGibBOL1yrjFw==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/caniuse-lite" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "CC-BY-4.0" + }, + "node_modules/chai": { + "version": "6.2.2", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", + "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/class-variance-authority": { + "version": "0.7.1", + "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", + "integrity": "sha512-Ka+9Trutv7G8M6WT6SeiRWz792K5qEqIGEGzXKhAE6xOWAY6pPH8U+9IY3oCMv6kqTmLsv7Xh/2w2RigkePMsg==", + "license": "Apache-2.0", + "dependencies": { + "clsx": "^2.1.1" + }, + "funding": { + "url": "https://polar.sh/cva" + } + }, + "node_modules/clsx": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz", + "integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/convert-source-map": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/convert-source-map/-/convert-source-map-2.0.0.tgz", + "integrity": "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==", + "dev": true, + "license": "MIT" + }, + "node_modules/cookie": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/cookie/-/cookie-1.1.1.tgz", + "integrity": "sha512-ei8Aos7ja0weRpFzJnEA9UHJ/7XQmqglbRwnf2ATjcB9Wq874VKH9kfjjirM6UhU2/E5fFYadylyhFldcqSidQ==", + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/express" + } + }, + "node_modules/cross-spawn": { + "version": "7.0.6", + "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz", + "integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==", + "dev": true, + "license": "MIT", + "dependencies": { + "path-key": "^3.1.0", + "shebang-command": "^2.0.0", + "which": "^2.0.1" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/css-tree": { + "version": "3.2.1", + "resolved": "https://registry.npmjs.org/css-tree/-/css-tree-3.2.1.tgz", + "integrity": "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA==", + "dev": true, + "license": "MIT", + "dependencies": { + "mdn-data": "2.27.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12.20.0 || ^14.13.0 || >=15.0.0" + } + }, + "node_modules/css.escape": { + "version": "1.5.1", + "resolved": "https://registry.npmjs.org/css.escape/-/css.escape-1.5.1.tgz", + "integrity": "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==", + "dev": true, + "license": "MIT" + }, + "node_modules/csstype": { + "version": "3.2.3", + "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", + "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", + "devOptional": true, + "license": "MIT" + }, + "node_modules/data-urls": { + "version": "7.0.0", + "resolved": "https://registry.npmjs.org/data-urls/-/data-urls-7.0.0.tgz", + "integrity": "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA==", + "dev": true, + "license": "MIT", + "dependencies": { + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/debug": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/debug/-/debug-4.4.3.tgz", + "integrity": "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA==", + "dev": true, + "license": "MIT", + "dependencies": { + "ms": "^2.1.3" + }, + "engines": { + "node": ">=6.0" + }, + "peerDependenciesMeta": { + "supports-color": { + "optional": true + } + } + }, + "node_modules/decimal.js": { + "version": "10.6.0", + "resolved": "https://registry.npmjs.org/decimal.js/-/decimal.js-10.6.0.tgz", + "integrity": "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg==", + "dev": true, + "license": "MIT" + }, + "node_modules/deep-is": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz", + "integrity": "sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/dequal": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/dequal/-/dequal-2.0.3.tgz", + "integrity": "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/detect-libc": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", + "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=8" + } + }, + "node_modules/detect-node-es": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/detect-node-es/-/detect-node-es-1.1.0.tgz", + "integrity": "sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==", + "license": "MIT" + }, + "node_modules/dom-accessibility-api": { + "version": "0.5.16", + "resolved": "https://registry.npmjs.org/dom-accessibility-api/-/dom-accessibility-api-0.5.16.tgz", + "integrity": "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/electron-to-chromium": { + "version": "1.5.375", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.375.tgz", + "integrity": "sha512-ZWP5eB4BVPW/ZYo9252hQZHZ5XavtsTgpbhcmMmRwymavC5AsLWQWBPaKMeNd2LW0KGby5HPXvj7+sr4ta5j/Q==", + "dev": true, + "license": "ISC" + }, + "node_modules/enhanced-resolve": { + "version": "5.21.6", + "resolved": "https://registry.npmjs.org/enhanced-resolve/-/enhanced-resolve-5.21.6.tgz", + "integrity": "sha512-aNnGCvbJ/RIyWo1IuhNdVjnNF+EjH9wpzpNHt+ci/m9He9LJvUN8wrCcXjp9cWsGNAuvSpVFTx/vraAFQ8qGjQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "graceful-fs": "^4.2.4", + "tapable": "^2.3.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, + "node_modules/es-module-lexer": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.1.0.tgz", + "integrity": "sha512-n27zTYMjYu1aj4MjCWzSP7G9r75utsaoc8m61weK+W8JMBGGQybd43GstCXZ3WNmSFtGT9wi59qQTW6mhTR5LQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/escalade": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz", + "integrity": "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/escape-string-regexp": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/escape-string-regexp/-/escape-string-regexp-4.0.0.tgz", + "integrity": "sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/eslint": { + "version": "10.5.0", + "resolved": "https://registry.npmjs.org/eslint/-/eslint-10.5.0.tgz", + "integrity": "sha512-1y+7C+vi12bUK1IpZeaV3gsH9fHLBmPvYmPx42pvT/E9yG0IC8g3PUZZgp0+JLJl7ZDK0flc2gc+Aw9dpCvIsQ==", + "dev": true, + "license": "MIT", + "workspaces": [ + "packages/*" + ], + "dependencies": { + "@eslint-community/eslint-utils": "^4.8.0", + "@eslint-community/regexpp": "^4.12.2", + "@eslint/config-array": "^0.23.5", + "@eslint/config-helpers": "^0.6.0", + "@eslint/core": "^1.2.1", + "@eslint/plugin-kit": "^0.7.2", + "@humanfs/node": "^0.16.6", + "@humanwhocodes/module-importer": "^1.0.1", + "@humanwhocodes/retry": "^0.4.2", + "@types/estree": "^1.0.6", + "ajv": "^6.14.0", + "cross-spawn": "^7.0.6", + "debug": "^4.3.2", + "escape-string-regexp": "^4.0.0", + "eslint-scope": "^9.1.2", + "eslint-visitor-keys": "^5.0.1", + "espree": "^11.2.0", + "esquery": "^1.7.0", + "esutils": "^2.0.2", + "fast-deep-equal": "^3.1.3", + "file-entry-cache": "^8.0.0", + "find-up": "^5.0.0", + "glob-parent": "^6.0.2", + "ignore": "^5.2.0", + "imurmurhash": "^0.1.4", + "is-glob": "^4.0.0", + "json-stable-stringify-without-jsonify": "^1.0.1", + "minimatch": "^10.2.4", + "natural-compare": "^1.4.0", + "optionator": "^0.9.3" + }, + "bin": { + "eslint": "bin/eslint.js" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://eslint.org/donate" + }, + "peerDependencies": { + "jiti": "*" + }, + "peerDependenciesMeta": { + "jiti": { + "optional": true + } + } + }, + "node_modules/eslint-plugin-react-hooks": { + "version": "7.1.1", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-hooks/-/eslint-plugin-react-hooks-7.1.1.tgz", + "integrity": "sha512-f2I7Gw6JbvCexzIInuSbZpfdQ44D7iqdWX01FKLvrPgqxoE7oMj8clOfto8U6vYiz4yd5oKu39rRSVOe1zRu0g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/core": "^7.24.4", + "@babel/parser": "^7.24.4", + "hermes-parser": "^0.25.1", + "zod": "^3.25.0 || ^4.0.0", + "zod-validation-error": "^3.5.0 || ^4.0.0" + }, + "engines": { + "node": ">=18" + }, + "peerDependencies": { + "eslint": "^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 || ^9.0.0 || ^10.0.0" + } + }, + "node_modules/eslint-plugin-react-refresh": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/eslint-plugin-react-refresh/-/eslint-plugin-react-refresh-0.5.3.tgz", + "integrity": "sha512-5EMmLCV98Pi4o/f/3DP/v/tNqLHMIc9I8LKClNDWhZ9JTho89/kQcitCXQBMG7sAfVRK0Ie3T2EDOzp1YXYiVA==", + "dev": true, + "license": "MIT", + "peerDependencies": { + "eslint": "^9 || ^10" + } + }, + "node_modules/eslint-scope": { + "version": "9.1.2", + "resolved": "https://registry.npmjs.org/eslint-scope/-/eslint-scope-9.1.2.tgz", + "integrity": "sha512-xS90H51cKw0jltxmvmHy2Iai1LIqrfbw57b79w/J7MfvDfkIkFZ+kj6zC3BjtUwh150HsSSdxXZcsuv72miDFQ==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "@types/esrecurse": "^4.3.1", + "@types/estree": "^1.0.8", + "esrecurse": "^4.3.0", + "estraverse": "^5.2.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/eslint-visitor-keys": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/eslint-visitor-keys/-/eslint-visitor-keys-5.0.1.tgz", + "integrity": "sha512-tD40eHxA35h0PEIZNeIjkHoDR4YjjJp34biM0mDvplBe//mB+IHCqHDGV7pxF+7MklTvighcCPPZC7ynWyjdTA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/espree": { + "version": "11.2.0", + "resolved": "https://registry.npmjs.org/espree/-/espree-11.2.0.tgz", + "integrity": "sha512-7p3DrVEIopW1B1avAGLuCSh1jubc01H2JHc8B4qqGblmg5gI9yumBgACjWo4JlIc04ufug4xJ3SQI8HkS/Rgzw==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "acorn": "^8.16.0", + "acorn-jsx": "^5.3.2", + "eslint-visitor-keys": "^5.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24" + }, + "funding": { + "url": "https://opencollective.com/eslint" + } + }, + "node_modules/esquery": { + "version": "1.7.0", + "resolved": "https://registry.npmjs.org/esquery/-/esquery-1.7.0.tgz", + "integrity": "sha512-Ap6G0WQwcU/LHsvLwON1fAQX9Zp0A2Y6Y/cJBl9r/JbW90Zyg4/zbG6zzKa2OTALELarYHmKu0GhpM5EO+7T0g==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "estraverse": "^5.1.0" + }, + "engines": { + "node": ">=0.10" + } + }, + "node_modules/esrecurse": { + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/esrecurse/-/esrecurse-4.3.0.tgz", + "integrity": "sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "estraverse": "^5.2.0" + }, + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estraverse": { + "version": "5.3.0", + "resolved": "https://registry.npmjs.org/estraverse/-/estraverse-5.3.0.tgz", + "integrity": "sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=4.0" + } + }, + "node_modules/estree-walker": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/estree-walker/-/estree-walker-3.0.3.tgz", + "integrity": "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/estree": "^1.0.0" + } + }, + "node_modules/esutils": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/esutils/-/esutils-2.0.3.tgz", + "integrity": "sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/expect-type": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/expect-type/-/expect-type-1.3.0.tgz", + "integrity": "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=12.0.0" + } + }, + "node_modules/fast-deep-equal": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", + "integrity": "sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-json-stable-stringify": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fast-json-stable-stringify/-/fast-json-stable-stringify-2.1.0.tgz", + "integrity": "sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fast-levenshtein": { + "version": "2.0.6", + "resolved": "https://registry.npmjs.org/fast-levenshtein/-/fast-levenshtein-2.0.6.tgz", + "integrity": "sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==", + "dev": true, + "license": "MIT" + }, + "node_modules/fdir": { + "version": "6.5.0", + "resolved": "https://registry.npmjs.org/fdir/-/fdir-6.5.0.tgz", + "integrity": "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12.0.0" + }, + "peerDependencies": { + "picomatch": "^3 || ^4" + }, + "peerDependenciesMeta": { + "picomatch": { + "optional": true + } + } + }, + "node_modules/file-entry-cache": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-8.0.0.tgz", + "integrity": "sha512-XXTUwCvisa5oacNGRP9SfNtYBNAMi+RPwBFmblZEF7N7swHYQS6/Zfk7SRwx4D5j3CH211YNRco1DEMNVfZCnQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "flat-cache": "^4.0.0" + }, + "engines": { + "node": ">=16.0.0" + } + }, + "node_modules/find-up": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/find-up/-/find-up-5.0.0.tgz", + "integrity": "sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==", + "dev": true, + "license": "MIT", + "dependencies": { + "locate-path": "^6.0.0", + "path-exists": "^4.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/flat-cache": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/flat-cache/-/flat-cache-4.0.1.tgz", + "integrity": "sha512-f7ccFPK3SXFHpx15UIGyRJ/FJQctuKZ0zVuN3frBo4HnK3cay9VEW0R6yPYFHC0AgqhukPzKjq22t5DmAyqGyw==", + "dev": true, + "license": "MIT", + "dependencies": { + "flatted": "^3.2.9", + "keyv": "^4.5.4" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/flatted": { + "version": "3.4.2", + "resolved": "https://registry.npmjs.org/flatted/-/flatted-3.4.2.tgz", + "integrity": "sha512-PjDse7RzhcPkIJwy5t7KPWQSZ9cAbzQXcafsetQoD7sOJRQlGikNbx7yZp2OotDnJyrDcbyRq3Ttb18iYOqkxA==", + "dev": true, + "license": "ISC" + }, + "node_modules/fsevents": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz", + "integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==", + "dev": true, + "hasInstallScript": true, + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": "^8.16.0 || ^10.6.0 || >=11.0.0" + } + }, + "node_modules/gensync": { + "version": "1.0.0-beta.2", + "resolved": "https://registry.npmjs.org/gensync/-/gensync-1.0.0-beta.2.tgz", + "integrity": "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6.9.0" + } + }, + "node_modules/get-nonce": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/get-nonce/-/get-nonce-1.0.1.tgz", + "integrity": "sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==", + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/glob-parent": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-6.0.2.tgz", + "integrity": "sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==", + "dev": true, + "license": "ISC", + "dependencies": { + "is-glob": "^4.0.3" + }, + "engines": { + "node": ">=10.13.0" + } + }, + "node_modules/globals": { + "version": "17.6.0", + "resolved": "https://registry.npmjs.org/globals/-/globals-17.6.0.tgz", + "integrity": "sha512-sepffkT8stwnIYbsMBpoCHJuJM5l98FUF2AnE07hfvE0m/qp3R586hw4jF4uadbhvg1ooIdzuu7CsfD2jzCaNA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/graceful-fs": { + "version": "4.2.11", + "resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz", + "integrity": "sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==", + "dev": true, + "license": "ISC" + }, + "node_modules/has-flag": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/has-flag/-/has-flag-4.0.0.tgz", + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/hermes-estree": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-estree/-/hermes-estree-0.25.1.tgz", + "integrity": "sha512-0wUoCcLp+5Ev5pDW2OriHC2MJCbwLwuRx+gAqMTOkGKJJiBCLjtrvy4PWUGn6MIVefecRpzoOZ/UV6iGdOr+Cw==", + "dev": true, + "license": "MIT" + }, + "node_modules/hermes-parser": { + "version": "0.25.1", + "resolved": "https://registry.npmjs.org/hermes-parser/-/hermes-parser-0.25.1.tgz", + "integrity": "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA==", + "dev": true, + "license": "MIT", + "dependencies": { + "hermes-estree": "0.25.1" + } + }, + "node_modules/html-encoding-sniffer": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/html-encoding-sniffer/-/html-encoding-sniffer-6.0.0.tgz", + "integrity": "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.6.0" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/html-escaper": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/html-escaper/-/html-escaper-2.0.2.tgz", + "integrity": "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==", + "dev": true, + "license": "MIT" + }, + "node_modules/ignore": { + "version": "5.3.2", + "resolved": "https://registry.npmjs.org/ignore/-/ignore-5.3.2.tgz", + "integrity": "sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 4" + } + }, + "node_modules/imurmurhash": { + "version": "0.1.4", + "resolved": "https://registry.npmjs.org/imurmurhash/-/imurmurhash-0.1.4.tgz", + "integrity": "sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.8.19" + } + }, + "node_modules/indent-string": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/indent-string/-/indent-string-4.0.0.tgz", + "integrity": "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/is-extglob": { + "version": "2.1.1", + "resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz", + "integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-glob": { + "version": "4.0.3", + "resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz", + "integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==", + "dev": true, + "license": "MIT", + "dependencies": { + "is-extglob": "^2.1.1" + }, + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/is-potential-custom-element-name": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/is-potential-custom-element-name/-/is-potential-custom-element-name-1.0.1.tgz", + "integrity": "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/isexe": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/isexe/-/isexe-2.0.0.tgz", + "integrity": "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==", + "dev": true, + "license": "ISC" + }, + "node_modules/istanbul-lib-coverage": { + "version": "3.2.2", + "resolved": "https://registry.npmjs.org/istanbul-lib-coverage/-/istanbul-lib-coverage-3.2.2.tgz", + "integrity": "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=8" + } + }, + "node_modules/istanbul-lib-report": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/istanbul-lib-report/-/istanbul-lib-report-3.0.1.tgz", + "integrity": "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "istanbul-lib-coverage": "^3.0.0", + "make-dir": "^4.0.0", + "supports-color": "^7.1.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/istanbul-reports": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/istanbul-reports/-/istanbul-reports-3.2.0.tgz", + "integrity": "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "html-escaper": "^2.0.0", + "istanbul-lib-report": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/jiti": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/jiti/-/jiti-2.7.0.tgz", + "integrity": "sha512-AC/7JofJvZGrrneWNaEnJeOLUx+JlGt7tNa0wZiRPT4MY1wmfKjt2+6O2p2uz2+skll8OZZmJMNqeke7kKbNgQ==", + "dev": true, + "license": "MIT", + "bin": { + "jiti": "lib/jiti-cli.mjs" + } + }, + "node_modules/js-tokens": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/js-tokens/-/js-tokens-4.0.0.tgz", + "integrity": "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/jsdom": { + "version": "29.1.1", + "resolved": "https://registry.npmjs.org/jsdom/-/jsdom-29.1.1.tgz", + "integrity": "sha512-ECi4Fi2f7BdJtUKTflYRTiaMxIB0O6zfR1fX0GXpUrf6flp8QIYn1UT20YQqdSOfk2dfkCwS8LAFoJDEppNK5Q==", + "dev": true, + "license": "MIT", + "dependencies": { + "@asamuzakjp/css-color": "^5.1.11", + "@asamuzakjp/dom-selector": "^7.1.1", + "@bramus/specificity": "^2.4.2", + "@csstools/css-syntax-patches-for-csstree": "^1.1.3", + "@exodus/bytes": "^1.15.0", + "css-tree": "^3.2.1", + "data-urls": "^7.0.0", + "decimal.js": "^10.6.0", + "html-encoding-sniffer": "^6.0.0", + "is-potential-custom-element-name": "^1.0.1", + "lru-cache": "^11.3.5", + "parse5": "^8.0.1", + "saxes": "^6.0.0", + "symbol-tree": "^3.2.4", + "tough-cookie": "^6.0.1", + "undici": "^7.25.0", + "w3c-xmlserializer": "^5.0.0", + "webidl-conversions": "^8.0.1", + "whatwg-mimetype": "^5.0.0", + "whatwg-url": "^16.0.1", + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": "^20.19.0 || ^22.13.0 || >=24.0.0" + }, + "peerDependencies": { + "canvas": "^3.0.0" + }, + "peerDependenciesMeta": { + "canvas": { + "optional": true + } + } + }, + "node_modules/jsdom/node_modules/lru-cache": { + "version": "11.5.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-11.5.1.tgz", + "integrity": "sha512-RPimw/7aMdv2oqRrxKwvZXcPfwBrn/JZ2xYcY9Hus/6LaS3VOAKVWKWgNLCFSiOm1ESXinjsDlidVU7JlnCN2A==", + "dev": true, + "license": "BlueOak-1.0.0", + "engines": { + "node": "20 || >=22" + } + }, + "node_modules/jsesc": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/jsesc/-/jsesc-3.1.0.tgz", + "integrity": "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA==", + "dev": true, + "license": "MIT", + "bin": { + "jsesc": "bin/jsesc" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/json-buffer": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/json-buffer/-/json-buffer-3.0.1.tgz", + "integrity": "sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-schema-traverse": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/json-schema-traverse/-/json-schema-traverse-0.4.1.tgz", + "integrity": "sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==", + "dev": true, + "license": "MIT" + }, + "node_modules/json-stable-stringify-without-jsonify": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/json-stable-stringify-without-jsonify/-/json-stable-stringify-without-jsonify-1.0.1.tgz", + "integrity": "sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==", + "dev": true, + "license": "MIT" + }, + "node_modules/json5": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz", + "integrity": "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==", + "dev": true, + "license": "MIT", + "bin": { + "json5": "lib/cli.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/keyv": { + "version": "4.5.4", + "resolved": "https://registry.npmjs.org/keyv/-/keyv-4.5.4.tgz", + "integrity": "sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==", + "dev": true, + "license": "MIT", + "dependencies": { + "json-buffer": "3.0.1" + } + }, + "node_modules/levn": { + "version": "0.4.1", + "resolved": "https://registry.npmjs.org/levn/-/levn-0.4.1.tgz", + "integrity": "sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1", + "type-check": "~0.4.0" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/lightningcss": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss/-/lightningcss-1.32.0.tgz", + "integrity": "sha512-NXYBzinNrblfraPGyrbPoD19C1h9lfI/1mzgWYvXUTe414Gz/X1FD2XBZSZM7rRTrMA8JL3OtAaGifrIKhQ5yQ==", + "dev": true, + "license": "MPL-2.0", + "dependencies": { + "detect-libc": "^2.0.3" + }, + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + }, + "optionalDependencies": { + "lightningcss-android-arm64": "1.32.0", + "lightningcss-darwin-arm64": "1.32.0", + "lightningcss-darwin-x64": "1.32.0", + "lightningcss-freebsd-x64": "1.32.0", + "lightningcss-linux-arm-gnueabihf": "1.32.0", + "lightningcss-linux-arm64-gnu": "1.32.0", + "lightningcss-linux-arm64-musl": "1.32.0", + "lightningcss-linux-x64-gnu": "1.32.0", + "lightningcss-linux-x64-musl": "1.32.0", + "lightningcss-win32-arm64-msvc": "1.32.0", + "lightningcss-win32-x64-msvc": "1.32.0" + } + }, + "node_modules/lightningcss-android-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-android-arm64/-/lightningcss-android-arm64-1.32.0.tgz", + "integrity": "sha512-YK7/ClTt4kAK0vo6w3X+Pnm0D2cf2vPHbhOXdoNti1Ga0al1P4TBZhwjATvjNwLEBCnKvjJc2jQgHXH0NEwlAg==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-arm64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-arm64/-/lightningcss-darwin-arm64-1.32.0.tgz", + "integrity": "sha512-RzeG9Ju5bag2Bv1/lwlVJvBE3q6TtXskdZLLCyfg5pt+HLz9BqlICO7LZM7VHNTTn/5PRhHFBSjk5lc4cmscPQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-darwin-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-darwin-x64/-/lightningcss-darwin-x64-1.32.0.tgz", + "integrity": "sha512-U+QsBp2m/s2wqpUYT/6wnlagdZbtZdndSmut/NJqlCcMLTWp5muCrID+K5UJ6jqD2BFshejCYXniPDbNh73V8w==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-freebsd-x64": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-freebsd-x64/-/lightningcss-freebsd-x64-1.32.0.tgz", + "integrity": "sha512-JCTigedEksZk3tHTTthnMdVfGf61Fky8Ji2E4YjUTEQX14xiy/lTzXnu1vwiZe3bYe0q+SpsSH/CTeDXK6WHig==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm-gnueabihf": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm-gnueabihf/-/lightningcss-linux-arm-gnueabihf-1.32.0.tgz", + "integrity": "sha512-x6rnnpRa2GL0zQOkt6rts3YDPzduLpWvwAF6EMhXFVZXD4tPrBkEFqzGowzCsIWsPjqSK+tyNEODUBXeeVHSkw==", + "cpu": [ + "arm" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-gnu/-/lightningcss-linux-arm64-gnu-1.32.0.tgz", + "integrity": "sha512-0nnMyoyOLRJXfbMOilaSRcLH3Jw5z9HDNGfT/gwCPgaDjnx0i8w7vBzFLFR1f6CMLKF8gVbebmkUN3fa/kQJpQ==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-arm64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-arm64-musl/-/lightningcss-linux-arm64-musl-1.32.0.tgz", + "integrity": "sha512-UpQkoenr4UJEzgVIYpI80lDFvRmPVg6oqboNHfoH4CQIfNA+HOrZ7Mo7KZP02dC6LjghPQJeBsvXhJod/wnIBg==", + "cpu": [ + "arm64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-gnu": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-gnu/-/lightningcss-linux-x64-gnu-1.32.0.tgz", + "integrity": "sha512-V7Qr52IhZmdKPVr+Vtw8o+WLsQJYCTd8loIfpDaMRWGUZfBOYEJeyJIkqGIDMZPwPx24pUMfwSxxI8phr/MbOA==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "glibc" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-linux-x64-musl": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-linux-x64-musl/-/lightningcss-linux-x64-musl-1.32.0.tgz", + "integrity": "sha512-bYcLp+Vb0awsiXg/80uCRezCYHNg1/l3mt0gzHnWV9XP1W5sKa5/TCdGWaR/zBM2PeF/HbsQv/j2URNOiVuxWg==", + "cpu": [ + "x64" + ], + "dev": true, + "libc": [ + "musl" + ], + "license": "MPL-2.0", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-arm64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-arm64-msvc/-/lightningcss-win32-arm64-msvc-1.32.0.tgz", + "integrity": "sha512-8SbC8BR40pS6baCM8sbtYDSwEVQd4JlFTOlaD3gWGHfThTcABnNDBda6eTZeqbofalIJhFx0qKzgHJmcPTnGdw==", + "cpu": [ + "arm64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/lightningcss-win32-x64-msvc": { + "version": "1.32.0", + "resolved": "https://registry.npmjs.org/lightningcss-win32-x64-msvc/-/lightningcss-win32-x64-msvc-1.32.0.tgz", + "integrity": "sha512-Amq9B/SoZYdDi1kFrojnoqPLxYhQ4Wo5XiL8EVJrVsB8ARoC1PWW6VGtT0WKCemjy8aC+louJnjS7U18x3b06Q==", + "cpu": [ + "x64" + ], + "dev": true, + "license": "MPL-2.0", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 12.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/parcel" + } + }, + "node_modules/locate-path": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/locate-path/-/locate-path-6.0.0.tgz", + "integrity": "sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-locate": "^5.0.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/lru-cache": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/lru-cache/-/lru-cache-5.1.1.tgz", + "integrity": "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==", + "dev": true, + "license": "ISC", + "dependencies": { + "yallist": "^3.0.2" + } + }, + "node_modules/lucide-react": { + "version": "1.21.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-1.21.0.tgz", + "integrity": "sha512-reEZMXq8Qdd5jg5XYkQ5TR1fB/GiQ7ih4vcrthYDtgjSDwh0i6/YLiGjsWsIwgN49gpAnd4J2elSNzncMEEUUQ==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, + "node_modules/lz-string": { + "version": "1.5.0", + "resolved": "https://registry.npmjs.org/lz-string/-/lz-string-1.5.0.tgz", + "integrity": "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==", + "dev": true, + "license": "MIT", + "peer": true, + "bin": { + "lz-string": "bin/bin.js" + } + }, + "node_modules/magic-string": { + "version": "0.30.21", + "resolved": "https://registry.npmjs.org/magic-string/-/magic-string-0.30.21.tgz", + "integrity": "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@jridgewell/sourcemap-codec": "^1.5.5" + } + }, + "node_modules/magicast": { + "version": "0.5.3", + "resolved": "https://registry.npmjs.org/magicast/-/magicast-0.5.3.tgz", + "integrity": "sha512-pVKE4UdSQ7DvHzivsCIFx2BJn1mHG6KsyrFcaxFx6tONdneEuThrDx0Cj3AMg58KyN4pzYT+LHOotxDQDjNvkw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@babel/parser": "^7.29.3", + "@babel/types": "^7.29.0", + "source-map-js": "^1.2.1" + } + }, + "node_modules/make-dir": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz", + "integrity": "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==", + "dev": true, + "license": "MIT", + "dependencies": { + "semver": "^7.5.3" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "7.8.4", + "resolved": "https://registry.npmjs.org/semver/-/semver-7.8.4.tgz", + "integrity": "sha512-rUCObTnP32Q08R2uuIrt7r9PlEonuTmtuXYcW6s5kjdlj3xbnwe+21yXptAUYcMAABLkYYTtnmzb3w3EDZfueA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/mdn-data": { + "version": "2.27.1", + "resolved": "https://registry.npmjs.org/mdn-data/-/mdn-data-2.27.1.tgz", + "integrity": "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ==", + "dev": true, + "license": "CC0-1.0" + }, + "node_modules/min-indent": { + "version": "1.0.1", + "resolved": "https://registry.npmjs.org/min-indent/-/min-indent-1.0.1.tgz", + "integrity": "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=4" + } + }, + "node_modules/minimatch": { + "version": "10.2.5", + "resolved": "https://registry.npmjs.org/minimatch/-/minimatch-10.2.5.tgz", + "integrity": "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg==", + "dev": true, + "license": "BlueOak-1.0.0", + "dependencies": { + "brace-expansion": "^5.0.5" + }, + "engines": { + "node": "18 || 20 || >=22" + }, + "funding": { + "url": "https://github.com/sponsors/isaacs" + } + }, + "node_modules/ms": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz", + "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==", + "dev": true, + "license": "MIT" + }, + "node_modules/nanoid": { + "version": "3.3.12", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", + "integrity": "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ==", + "dev": true, + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "bin": { + "nanoid": "bin/nanoid.cjs" + }, + "engines": { + "node": "^10 || ^12 || ^13.7 || ^14 || >=15.0.1" + } + }, + "node_modules/natural-compare": { + "version": "1.4.0", + "resolved": "https://registry.npmjs.org/natural-compare/-/natural-compare-1.4.0.tgz", + "integrity": "sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==", + "dev": true, + "license": "MIT" + }, + "node_modules/node-releases": { + "version": "2.0.48", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.48.tgz", + "integrity": "sha512-1uz8041X6LoI6ZSdZacM9lVY28vuzDlSKitnpbSNK0RfKoIJkX29NBPVEFXhnuSuEOA9Ww0xnPJ+ILWbGAv8DA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/obug": { + "version": "2.1.3", + "resolved": "https://registry.npmjs.org/obug/-/obug-2.1.3.tgz", + "integrity": "sha512-9miFgM2OFba7hB+pRgvtV84pYTBaoTHohvmIgiRt6dRIzbwEOIaNaP+dIlGs2fNFoB0SeISs0Jz5WFVRid6Xyg==", + "dev": true, + "funding": [ + "https://github.com/sponsors/sxzz", + "https://opencollective.com/debug" + ], + "license": "MIT", + "engines": { + "node": ">=12.20.0" + } + }, + "node_modules/optionator": { + "version": "0.9.4", + "resolved": "https://registry.npmjs.org/optionator/-/optionator-0.9.4.tgz", + "integrity": "sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==", + "dev": true, + "license": "MIT", + "dependencies": { + "deep-is": "^0.1.3", + "fast-levenshtein": "^2.0.6", + "levn": "^0.4.1", + "prelude-ls": "^1.2.1", + "type-check": "^0.4.0", + "word-wrap": "^1.2.5" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/p-limit": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz", + "integrity": "sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "yocto-queue": "^0.1.0" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/p-locate": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/p-locate/-/p-locate-5.0.0.tgz", + "integrity": "sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==", + "dev": true, + "license": "MIT", + "dependencies": { + "p-limit": "^3.0.2" + }, + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/parse5": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/parse5/-/parse5-8.0.1.tgz", + "integrity": "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw==", + "dev": true, + "license": "MIT", + "dependencies": { + "entities": "^8.0.0" + }, + "funding": { + "url": "https://github.com/inikulin/parse5?sponsor=1" + } + }, + "node_modules/path-exists": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/path-exists/-/path-exists-4.0.0.tgz", + "integrity": "sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/path-key": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/path-key/-/path-key-3.1.1.tgz", + "integrity": "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/pathe": { + "version": "2.0.3", + "resolved": "https://registry.npmjs.org/pathe/-/pathe-2.0.3.tgz", + "integrity": "sha512-WUjGcAqP1gQacoQe+OBJsFA7Ld4DyXuUIjZ5cc75cLHvJ7dtNsTugphxIADwspS+AraAUePCKrSVtPLFj/F88w==", + "dev": true, + "license": "MIT" + }, + "node_modules/picocolors": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", + "integrity": "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA==", + "dev": true, + "license": "ISC" + }, + "node_modules/picomatch": { + "version": "4.0.4", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.4.tgz", + "integrity": "sha512-QP88BAKvMam/3NxH6vj2o21R6MjxZUAd6nlwAS/pnGvN9IVLocLHxGYIzFhg6fUQ+5th6P4dv4eW9jX3DSIj7A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=12" + }, + "funding": { + "url": "https://github.com/sponsors/jonschlinkert" + } + }, + "node_modules/postcss": { + "version": "8.5.15", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.15.tgz", + "integrity": "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/postcss/" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/postcss" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "nanoid": "^3.3.12", + "picocolors": "^1.1.1", + "source-map-js": "^1.2.1" + }, + "engines": { + "node": "^10 || ^12 || >=14" + } + }, + "node_modules/prelude-ls": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/prelude-ls/-/prelude-ls-1.2.1.tgz", + "integrity": "sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/pretty-format": { + "version": "27.5.1", + "resolved": "https://registry.npmjs.org/pretty-format/-/pretty-format-27.5.1.tgz", + "integrity": "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==", + "dev": true, + "license": "MIT", + "peer": true, + "dependencies": { + "ansi-regex": "^5.0.1", + "ansi-styles": "^5.0.0", + "react-is": "^17.0.1" + }, + "engines": { + "node": "^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0" + } + }, + "node_modules/punycode": { + "version": "2.3.1", + "resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz", + "integrity": "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + } + }, + "node_modules/react": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react/-/react-19.2.7.tgz", + "integrity": "sha512-HNe9WslTbXmFK8o8cmwgAeJFSBvt1bPdHCVKtaaV+WlAN36mpT4hcRpwbf3fY56ar2oIXzsBpOAiIRHAdY0OlQ==", + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/react-dom": { + "version": "19.2.7", + "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.7.tgz", + "integrity": "sha512-t0BRVXvbiE/o20Hfw669rLbMCDWtYZLvmJigy2f0MxsXF+71pxhR3xOkspmsO8h3ZlNzyibAmtCa3l4lYKk6gQ==", + "license": "MIT", + "dependencies": { + "scheduler": "^0.27.0" + }, + "peerDependencies": { + "react": "^19.2.7" + } + }, + "node_modules/react-hook-form": { + "version": "7.79.0", + "resolved": "https://registry.npmjs.org/react-hook-form/-/react-hook-form-7.79.0.tgz", + "integrity": "sha512-mhYp/MTmXvzYX6AJcJVko0rktoIhhmRnEouObj4wF5i/tCttgJvnp1+9wRkpITZjDTqpo4IOSJqu0dBlPlV/Lw==", + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/react-hook-form" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17 || ^18 || ^19" + } + }, + "node_modules/react-is": { + "version": "17.0.2", + "resolved": "https://registry.npmjs.org/react-is/-/react-is-17.0.2.tgz", + "integrity": "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==", + "dev": true, + "license": "MIT", + "peer": true + }, + "node_modules/react-remove-scroll": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/react-remove-scroll/-/react-remove-scroll-2.7.2.tgz", + "integrity": "sha512-Iqb9NjCCTt6Hf+vOdNIZGdTiH1QSqr27H/Ek9sv/a97gfueI/5h1s3yRi1nngzMUaOOToin5dI1dXKdXiF+u0Q==", + "license": "MIT", + "dependencies": { + "react-remove-scroll-bar": "^2.3.7", + "react-style-singleton": "^2.2.3", + "tslib": "^2.1.0", + "use-callback-ref": "^1.3.3", + "use-sidecar": "^1.1.3" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-remove-scroll-bar": { + "version": "2.3.8", + "resolved": "https://registry.npmjs.org/react-remove-scroll-bar/-/react-remove-scroll-bar-2.3.8.tgz", + "integrity": "sha512-9r+yi9+mgU33AKcj6IbT9oRCO78WriSj6t/cF8DWBZJ9aOGPOTEDvdUDz1FwKim7QXWwmHqtdHnRJfhAxEG46Q==", + "license": "MIT", + "dependencies": { + "react-style-singleton": "^2.2.2", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/react-router": { + "version": "7.18.0", + "resolved": "https://registry.npmjs.org/react-router/-/react-router-7.18.0.tgz", + "integrity": "sha512-pTTGt8J+ji1NOmYnjzT+bAJy/1zD+Jp4ziO6cL7T3ZLvXKtusO7BpFqlRXitqpcPVqllsIXFHRMt+2/k3Xn6HQ==", + "license": "MIT", + "dependencies": { + "cookie": "^1.0.1", + "set-cookie-parser": "^2.6.0" + }, + "engines": { + "node": ">=20.0.0" + }, + "peerDependencies": { + "react": ">=18", + "react-dom": ">=18" + }, + "peerDependenciesMeta": { + "react-dom": { + "optional": true + } + } + }, + "node_modules/react-style-singleton": { + "version": "2.2.3", + "resolved": "https://registry.npmjs.org/react-style-singleton/-/react-style-singleton-2.2.3.tgz", + "integrity": "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ==", + "license": "MIT", + "dependencies": { + "get-nonce": "^1.0.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/redent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/redent/-/redent-3.0.0.tgz", + "integrity": "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==", + "dev": true, + "license": "MIT", + "dependencies": { + "indent-string": "^4.0.0", + "strip-indent": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/require-from-string": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/require-from-string/-/require-from-string-2.0.2.tgz", + "integrity": "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/rolldown": { + "version": "1.0.3", + "resolved": "https://registry.npmjs.org/rolldown/-/rolldown-1.0.3.tgz", + "integrity": "sha512-i00lAJ2ks1BYr7rjNjKC7BcqAS7nVfiT3QX1SI5aY+AFHblCmaUf9OE9dbdzDvW6dJxbi2ZCZiy9v3CcwOiX3g==", + "dev": true, + "license": "MIT", + "dependencies": { + "@oxc-project/types": "=0.133.0", + "@rolldown/pluginutils": "^1.0.0" + }, + "bin": { + "rolldown": "bin/cli.mjs" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "optionalDependencies": { + "@rolldown/binding-android-arm64": "1.0.3", + "@rolldown/binding-darwin-arm64": "1.0.3", + "@rolldown/binding-darwin-x64": "1.0.3", + "@rolldown/binding-freebsd-x64": "1.0.3", + "@rolldown/binding-linux-arm-gnueabihf": "1.0.3", + "@rolldown/binding-linux-arm64-gnu": "1.0.3", + "@rolldown/binding-linux-arm64-musl": "1.0.3", + "@rolldown/binding-linux-ppc64-gnu": "1.0.3", + "@rolldown/binding-linux-s390x-gnu": "1.0.3", + "@rolldown/binding-linux-x64-gnu": "1.0.3", + "@rolldown/binding-linux-x64-musl": "1.0.3", + "@rolldown/binding-openharmony-arm64": "1.0.3", + "@rolldown/binding-wasm32-wasi": "1.0.3", + "@rolldown/binding-win32-arm64-msvc": "1.0.3", + "@rolldown/binding-win32-x64-msvc": "1.0.3" + } + }, + "node_modules/saxes": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/saxes/-/saxes-6.0.0.tgz", + "integrity": "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA==", + "dev": true, + "license": "ISC", + "dependencies": { + "xmlchars": "^2.2.0" + }, + "engines": { + "node": ">=v12.22.7" + } + }, + "node_modules/scheduler": { + "version": "0.27.0", + "resolved": "https://registry.npmjs.org/scheduler/-/scheduler-0.27.0.tgz", + "integrity": "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q==", + "license": "MIT" + }, + "node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "dev": true, + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, + "node_modules/set-cookie-parser": { + "version": "2.7.2", + "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", + "integrity": "sha512-oeM1lpU/UvhTxw+g3cIfxXHyJRc/uidd3yK1P242gzHds0udQBYzs3y8j4gCCW+ZJ7ad0yctld8RYO+bdurlvw==", + "license": "MIT" + }, + "node_modules/shebang-command": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/shebang-command/-/shebang-command-2.0.0.tgz", + "integrity": "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==", + "dev": true, + "license": "MIT", + "dependencies": { + "shebang-regex": "^3.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/shebang-regex": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/shebang-regex/-/shebang-regex-3.0.0.tgz", + "integrity": "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=8" + } + }, + "node_modules/siginfo": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/siginfo/-/siginfo-2.0.0.tgz", + "integrity": "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==", + "dev": true, + "license": "ISC" + }, + "node_modules/source-map-js": { + "version": "1.2.1", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", + "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "dev": true, + "license": "BSD-3-Clause", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/stackback": { + "version": "0.0.2", + "resolved": "https://registry.npmjs.org/stackback/-/stackback-0.0.2.tgz", + "integrity": "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==", + "dev": true, + "license": "MIT" + }, + "node_modules/std-env": { + "version": "4.1.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.1.0.tgz", + "integrity": "sha512-Rq7ybcX2RuC55r9oaPVEW7/xu3tj8u4GeBYHBWCychFtzMIr86A7e3PPEBPT37sHStKX3+TiX/Fr/ACmJLVlLQ==", + "dev": true, + "license": "MIT" + }, + "node_modules/strip-indent": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/strip-indent/-/strip-indent-3.0.0.tgz", + "integrity": "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "min-indent": "^1.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/supports-color": { + "version": "7.2.0", + "resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.2.0.tgz", + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dev": true, + "license": "MIT", + "dependencies": { + "has-flag": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/symbol-tree": { + "version": "3.2.4", + "resolved": "https://registry.npmjs.org/symbol-tree/-/symbol-tree-3.2.4.tgz", + "integrity": "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tailwind-merge": { + "version": "3.6.0", + "resolved": "https://registry.npmjs.org/tailwind-merge/-/tailwind-merge-3.6.0.tgz", + "integrity": "sha512-uxL7qAVQriqRQPAyK3pj66VqskWqoZ37PW94jwOTwNfq/z9oyu1V+eqrZqtR2+fCiXdYOZe/Modt8GtvqNzu+w==", + "license": "MIT", + "funding": { + "type": "github", + "url": "https://github.com/sponsors/dcastil" + } + }, + "node_modules/tailwindcss": { + "version": "4.3.1", + "resolved": "https://registry.npmjs.org/tailwindcss/-/tailwindcss-4.3.1.tgz", + "integrity": "sha512-hk+TB1m+K8CYNrP6rjQaq/Y+4Zylwpa87mLYBKCunwnnQ9p+fHb7kmSfGqyEJoxF/O6CDyABWVFEafNSYKll+Q==", + "dev": true, + "license": "MIT" + }, + "node_modules/tapable": { + "version": "2.3.3", + "resolved": "https://registry.npmjs.org/tapable/-/tapable-2.3.3.tgz", + "integrity": "sha512-uxc/zpqFg6x7C8vOE7lh6Lbda8eEL9zmVm/PLeTPBRhh1xCgdWaQ+J1CUieGpIfm2HdtsUpRv+HshiasBMcc6A==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=6" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/webpack" + } + }, + "node_modules/tinybench": { + "version": "2.9.0", + "resolved": "https://registry.npmjs.org/tinybench/-/tinybench-2.9.0.tgz", + "integrity": "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==", + "dev": true, + "license": "MIT" + }, + "node_modules/tinyexec": { + "version": "1.2.4", + "resolved": "https://registry.npmjs.org/tinyexec/-/tinyexec-1.2.4.tgz", + "integrity": "sha512-SHf/r48b7vOrjve9PxJo3MN5v5yuyjHvdUcrQffT3WXMUfnGmHDVbC4k3sHJaJTgZCwpUplIaAo5ANtMyp3YHg==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18" + } + }, + "node_modules/tinyglobby": { + "version": "0.2.17", + "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.17.tgz", + "integrity": "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==", + "dev": true, + "license": "MIT", + "dependencies": { + "fdir": "^6.5.0", + "picomatch": "^4.0.4" + }, + "engines": { + "node": ">=12.0.0" + }, + "funding": { + "url": "https://github.com/sponsors/SuperchupuDev" + } + }, + "node_modules/tinyrainbow": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.1.0.tgz", + "integrity": "sha512-Bf+ILmBgretUrdJxzXM0SgXLZ3XfiaUuOj/IKQHuTXip+05Xn+uyEYdVg0kYDipTBcLrCVyUzAPz7QmArb0mmw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=14.0.0" + } + }, + "node_modules/tldts": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tldts/-/tldts-7.4.3.tgz", + "integrity": "sha512-A3BDQBeeukYPzB4QdQ1DtdlUmp4x2OCH8n5UVhEWbyANxNep8GavottKzd1xYKFJKjUgMyPT7EzOfnBO55s8Sg==", + "dev": true, + "license": "MIT", + "dependencies": { + "tldts-core": "^7.4.3" + }, + "bin": { + "tldts": "bin/cli.js" + } + }, + "node_modules/tldts-core": { + "version": "7.4.3", + "resolved": "https://registry.npmjs.org/tldts-core/-/tldts-core-7.4.3.tgz", + "integrity": "sha512-27ep5H9PzdBrNd5OFM/j3WCU8F3kPwM9D0BOaOf7uYfxMJfyr0K5Tjj69Gri+sZlh2WXd5buIm47NuPF29CDiw==", + "dev": true, + "license": "MIT" + }, + "node_modules/tough-cookie": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/tough-cookie/-/tough-cookie-6.0.1.tgz", + "integrity": "sha512-LktZQb3IeoUWB9lqR5EWTHgW/VTITCXg4D21M+lvybRVdylLrRMnqaIONLVb5mav8vM19m44HIcGq4qASeu2Qw==", + "dev": true, + "license": "BSD-3-Clause", + "dependencies": { + "tldts": "^7.0.5" + }, + "engines": { + "node": ">=16" + } + }, + "node_modules/tr46": { + "version": "6.0.0", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-6.0.0.tgz", + "integrity": "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw==", + "dev": true, + "license": "MIT", + "dependencies": { + "punycode": "^2.3.1" + }, + "engines": { + "node": ">=20" + } + }, + "node_modules/ts-api-utils": { + "version": "2.5.0", + "resolved": "https://registry.npmjs.org/ts-api-utils/-/ts-api-utils-2.5.0.tgz", + "integrity": "sha512-OJ/ibxhPlqrMM0UiNHJ/0CKQkoKF243/AEmplt3qpRgkW8VG7IfOS41h7V8TjITqdByHzrjcS/2si+y4lIh8NA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.12" + }, + "peerDependencies": { + "typescript": ">=4.8.4" + } + }, + "node_modules/tslib": { + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/tslib/-/tslib-2.8.1.tgz", + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==", + "license": "0BSD" + }, + "node_modules/type-check": { + "version": "0.4.0", + "resolved": "https://registry.npmjs.org/type-check/-/type-check-0.4.0.tgz", + "integrity": "sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==", + "dev": true, + "license": "MIT", + "dependencies": { + "prelude-ls": "^1.2.1" + }, + "engines": { + "node": ">= 0.8.0" + } + }, + "node_modules/typescript": { + "version": "6.0.3", + "resolved": "https://registry.npmjs.org/typescript/-/typescript-6.0.3.tgz", + "integrity": "sha512-y2TvuxSZPDyQakkFRPZHKFm+KKVqIisdg9/CZwm9ftvKXLP8NRWj38/ODjNbr43SsoXqNuAisEf1GdCxqWcdBw==", + "dev": true, + "license": "Apache-2.0", + "bin": { + "tsc": "bin/tsc", + "tsserver": "bin/tsserver" + }, + "engines": { + "node": ">=14.17" + } + }, + "node_modules/typescript-eslint": { + "version": "8.61.1", + "resolved": "https://registry.npmjs.org/typescript-eslint/-/typescript-eslint-8.61.1.tgz", + "integrity": "sha512-V7PayAfJokV3pEHgN7/v03D1SpujhRfQtYLbLIiBfDDncdg4PAiRBfoS4cnCANK4jmAPncczi59QO3afiXUlNw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@typescript-eslint/eslint-plugin": "8.61.1", + "@typescript-eslint/parser": "8.61.1", + "@typescript-eslint/typescript-estree": "8.61.1", + "@typescript-eslint/utils": "8.61.1" + }, + "engines": { + "node": "^18.18.0 || ^20.9.0 || >=21.1.0" + }, + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/typescript-eslint" + }, + "peerDependencies": { + "eslint": "^8.57.0 || ^9.0.0 || ^10.0.0", + "typescript": ">=4.8.4 <6.1.0" + } + }, + "node_modules/undici": { + "version": "7.28.0", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.28.0.tgz", + "integrity": "sha512-cRZYrTDwWznlnRiPjggAGxZXanty6M8RV1ff8Wm4LWXBp7/IG8v5DnOm74DtUBp9OONpK75YlPnIjQqX0dBDtA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20.18.1" + } + }, + "node_modules/undici-types": { + "version": "7.18.2", + "resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.18.2.tgz", + "integrity": "sha512-AsuCzffGHJybSaRrmr5eHr81mwJU3kjw6M+uprWvCXiNeN9SOGwQ3Jn8jb8m3Z6izVgknn1R0FTCEAP2QrLY/w==", + "dev": true, + "license": "MIT" + }, + "node_modules/update-browserslist-db": { + "version": "1.2.3", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", + "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "dev": true, + "funding": [ + { + "type": "opencollective", + "url": "https://opencollective.com/browserslist" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/browserslist" + }, + { + "type": "github", + "url": "https://github.com/sponsors/ai" + } + ], + "license": "MIT", + "dependencies": { + "escalade": "^3.2.0", + "picocolors": "^1.1.1" + }, + "bin": { + "update-browserslist-db": "cli.js" + }, + "peerDependencies": { + "browserslist": ">= 4.21.0" + } + }, + "node_modules/uri-js": { + "version": "4.4.1", + "resolved": "https://registry.npmjs.org/uri-js/-/uri-js-4.4.1.tgz", + "integrity": "sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==", + "dev": true, + "license": "BSD-2-Clause", + "dependencies": { + "punycode": "^2.1.0" + } + }, + "node_modules/use-callback-ref": { + "version": "1.3.3", + "resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz", + "integrity": "sha512-jQL3lRnocaFtu3V00JToYz/4QkNWswxijDaCVNZRiRTO3HQDLsdu1ZtmIUvV4yPp+rvWm5j0y0TG/S61cuijTg==", + "license": "MIT", + "dependencies": { + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/use-sidecar": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/use-sidecar/-/use-sidecar-1.1.3.tgz", + "integrity": "sha512-Fedw0aZvkhynoPYlA5WXrMCAMm+nSWdZt6lzJQ7Ok8S6Q+VsHmHpRWndVRJ8Be0ZbkfPc5LRYH+5XrzXcEeLRQ==", + "license": "MIT", + "dependencies": { + "detect-node-es": "^1.1.0", + "tslib": "^2.0.0" + }, + "engines": { + "node": ">=10" + }, + "peerDependencies": { + "@types/react": "*", + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/vite": { + "version": "8.0.16", + "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.16.tgz", + "integrity": "sha512-h9bXPmJichP5fLmVQo3PyaGSDE2n3aPuomeAlVRm0JLmt4rY6zmPKd59HYI4LNW8oTK7tlTsuC7l/m7awx9Jcw==", + "dev": true, + "license": "MIT", + "dependencies": { + "lightningcss": "^1.32.0", + "picomatch": "^4.0.4", + "postcss": "^8.5.15", + "rolldown": "1.0.3", + "tinyglobby": "^0.2.17" + }, + "bin": { + "vite": "bin/vite.js" + }, + "engines": { + "node": "^20.19.0 || >=22.12.0" + }, + "funding": { + "url": "https://github.com/vitejs/vite?sponsor=1" + }, + "optionalDependencies": { + "fsevents": "~2.3.3" + }, + "peerDependencies": { + "@types/node": "^20.19.0 || >=22.12.0", + "@vitejs/devtools": "^0.1.18", + "esbuild": "^0.27.0 || ^0.28.0", + "jiti": ">=1.21.0", + "less": "^4.0.0", + "sass": "^1.70.0", + "sass-embedded": "^1.70.0", + "stylus": ">=0.54.8", + "sugarss": "^5.0.0", + "terser": "^5.16.0", + "tsx": "^4.8.1", + "yaml": "^2.4.2" + }, + "peerDependenciesMeta": { + "@types/node": { + "optional": true + }, + "@vitejs/devtools": { + "optional": true + }, + "esbuild": { + "optional": true + }, + "jiti": { + "optional": true + }, + "less": { + "optional": true + }, + "sass": { + "optional": true + }, + "sass-embedded": { + "optional": true + }, + "stylus": { + "optional": true + }, + "sugarss": { + "optional": true + }, + "terser": { + "optional": true + }, + "tsx": { + "optional": true + }, + "yaml": { + "optional": true + } + } + }, + "node_modules/vitest": { + "version": "4.1.9", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.9.tgz", + "integrity": "sha512-nE3/LEyc0z87uHYLZebqCUOaJr2hdtuPp7BQ4BosVFnfltxgAvMG08NyrSGlPpOUWvR27c5flSmYFTNr78L9GQ==", + "dev": true, + "license": "MIT", + "dependencies": { + "@vitest/expect": "4.1.9", + "@vitest/mocker": "4.1.9", + "@vitest/pretty-format": "4.1.9", + "@vitest/runner": "4.1.9", + "@vitest/snapshot": "4.1.9", + "@vitest/spy": "4.1.9", + "@vitest/utils": "4.1.9", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", + "magic-string": "^0.30.21", + "obug": "^2.1.1", + "pathe": "^2.0.3", + "picomatch": "^4.0.3", + "std-env": "^4.0.0-rc.1", + "tinybench": "^2.9.0", + "tinyexec": "^1.0.2", + "tinyglobby": "^0.2.15", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", + "why-is-node-running": "^2.3.0" + }, + "bin": { + "vitest": "vitest.mjs" + }, + "engines": { + "node": "^20.0.0 || ^22.0.0 || >=24.0.0" + }, + "funding": { + "url": "https://opencollective.com/vitest" + }, + "peerDependencies": { + "@edge-runtime/vm": "*", + "@opentelemetry/api": "^1.9.0", + "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", + "@vitest/browser-playwright": "4.1.9", + "@vitest/browser-preview": "4.1.9", + "@vitest/browser-webdriverio": "4.1.9", + "@vitest/coverage-istanbul": "4.1.9", + "@vitest/coverage-v8": "4.1.9", + "@vitest/ui": "4.1.9", + "happy-dom": "*", + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" + }, + "peerDependenciesMeta": { + "@edge-runtime/vm": { + "optional": true + }, + "@opentelemetry/api": { + "optional": true + }, + "@types/node": { + "optional": true + }, + "@vitest/browser-playwright": { + "optional": true + }, + "@vitest/browser-preview": { + "optional": true + }, + "@vitest/browser-webdriverio": { + "optional": true + }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, + "@vitest/ui": { + "optional": true + }, + "happy-dom": { + "optional": true + }, + "jsdom": { + "optional": true + }, + "vite": { + "optional": false + } + } + }, + "node_modules/w3c-xmlserializer": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/w3c-xmlserializer/-/w3c-xmlserializer-5.0.0.tgz", + "integrity": "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA==", + "dev": true, + "license": "MIT", + "dependencies": { + "xml-name-validator": "^5.0.0" + }, + "engines": { + "node": ">=18" + } + }, + "node_modules/webidl-conversions": { + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-8.0.1.tgz", + "integrity": "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ==", + "dev": true, + "license": "BSD-2-Clause", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-mimetype": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-mimetype/-/whatwg-mimetype-5.0.0.tgz", + "integrity": "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=20" + } + }, + "node_modules/whatwg-url": { + "version": "16.0.1", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-16.0.1.tgz", + "integrity": "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw==", + "dev": true, + "license": "MIT", + "dependencies": { + "@exodus/bytes": "^1.11.0", + "tr46": "^6.0.0", + "webidl-conversions": "^8.0.1" + }, + "engines": { + "node": "^20.19.0 || ^22.12.0 || >=24.0.0" + } + }, + "node_modules/which": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", + "integrity": "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==", + "dev": true, + "license": "ISC", + "dependencies": { + "isexe": "^2.0.0" + }, + "bin": { + "node-which": "bin/node-which" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/why-is-node-running": { + "version": "2.3.0", + "resolved": "https://registry.npmjs.org/why-is-node-running/-/why-is-node-running-2.3.0.tgz", + "integrity": "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==", + "dev": true, + "license": "MIT", + "dependencies": { + "siginfo": "^2.0.0", + "stackback": "0.0.2" + }, + "bin": { + "why-is-node-running": "cli.js" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/word-wrap": { + "version": "1.2.5", + "resolved": "https://registry.npmjs.org/word-wrap/-/word-wrap-1.2.5.tgz", + "integrity": "sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=0.10.0" + } + }, + "node_modules/xml-name-validator": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/xml-name-validator/-/xml-name-validator-5.0.0.tgz", + "integrity": "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg==", + "dev": true, + "license": "Apache-2.0", + "engines": { + "node": ">=18" + } + }, + "node_modules/xmlchars": { + "version": "2.2.0", + "resolved": "https://registry.npmjs.org/xmlchars/-/xmlchars-2.2.0.tgz", + "integrity": "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw==", + "dev": true, + "license": "MIT" + }, + "node_modules/yallist": { + "version": "3.1.1", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-3.1.1.tgz", + "integrity": "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==", + "dev": true, + "license": "ISC" + }, + "node_modules/yocto-queue": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", + "integrity": "sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=10" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/zod": { + "version": "4.4.3", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.4.3.tgz", + "integrity": "sha512-ytENFjIJFl2UwYglde2jchW2Hwm4GJFLDiSXWdTrJQBIN9Fcyp7n4DhxJEiWNAJMV1/BqWfW/kkg71UDcHJyTQ==", + "license": "MIT", + "funding": { + "url": "https://github.com/sponsors/colinhacks" + } + }, + "node_modules/zod-validation-error": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/zod-validation-error/-/zod-validation-error-4.0.2.tgz", + "integrity": "sha512-Q6/nZLe6jxuU80qb/4uJ4t5v2VEZ44lzQjPDhYJNztRQ4wyWc6VF3D3Kb/fAuPetZQnhS3hnajCf9CsWesghLQ==", + "dev": true, + "license": "MIT", + "engines": { + "node": ">=18.0.0" + }, + "peerDependencies": { + "zod": "^3.25.0 || ^4.0.0" + } + }, + "node_modules/zustand": { + "version": "5.0.14", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-5.0.14.tgz", + "integrity": "sha512-/8tAspM5LMPr28b3fwLYrtdj77ECpfZviaP75CMTnwO8ISyaE4GDIG/9rDDYq/cH9D2Xw2A2RXglLInmVBQB/g==", + "license": "MIT", + "engines": { + "node": ">=12.20.0" + }, + "peerDependencies": { + "@types/react": ">=18.0.0", + "immer": ">=9.0.6", + "react": ">=18.0.0", + "use-sync-external-store": ">=1.2.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + }, + "use-sync-external-store": { + "optional": true + } + } + } + } +} diff --git a/frontend/package.json b/frontend/package.json new file mode 100644 index 0000000..05c1894 --- /dev/null +++ b/frontend/package.json @@ -0,0 +1,58 @@ +{ + "name": "frontend", + "private": true, + "version": "0.0.0", + "type": "module", + "scripts": { + "dev": "vite", + "build": "tsc -b && vite build", + "lint": "eslint .", + "preview": "vite preview", + "test": "vitest", + "test:run": "vitest run", + "test:coverage": "vitest run --coverage" + }, + "dependencies": { + "@hookform/resolvers": "^5.4.0", + "@radix-ui/react-dialog": "^1.1.17", + "@radix-ui/react-dropdown-menu": "^2.1.18", + "@radix-ui/react-label": "^2.1.10", + "@radix-ui/react-select": "^2.3.1", + "@radix-ui/react-separator": "^1.1.10", + "@radix-ui/react-slot": "^1.3.0", + "@radix-ui/react-tooltip": "^1.2.10", + "@tanstack/react-query": "^5.101.0", + "class-variance-authority": "^0.7.1", + "clsx": "^2.1.1", + "lucide-react": "^1.21.0", + "react": "^19.2.6", + "react-dom": "^19.2.6", + "react-hook-form": "^7.79.0", + "react-router": "^7.18.0", + "tailwind-merge": "^3.6.0", + "zod": "^4.4.3", + "zustand": "^5.0.14" + }, + "devDependencies": { + "@eslint/js": "^10.0.1", + "@tailwindcss/vite": "^4.3.1", + "@testing-library/jest-dom": "^6.9.1", + "@testing-library/react": "^16.3.2", + "@testing-library/user-event": "^14.6.1", + "@types/node": "^24.13.2", + "@types/react": "^19.2.14", + "@types/react-dom": "^19.2.3", + "@vitejs/plugin-react": "^6.0.1", + "@vitest/coverage-v8": "^4.1.9", + "eslint": "^10.3.0", + "eslint-plugin-react-hooks": "^7.1.1", + "eslint-plugin-react-refresh": "^0.5.2", + "globals": "^17.6.0", + "jsdom": "^29.1.1", + "tailwindcss": "^4.3.1", + "typescript": "~6.0.2", + "typescript-eslint": "^8.59.2", + "vite": "^8.0.12", + "vitest": "^4.1.9" + } +} diff --git a/frontend/public/favicon.svg b/frontend/public/favicon.svg new file mode 100644 index 0000000..6893eb1 --- /dev/null +++ b/frontend/public/favicon.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/icons.svg b/frontend/public/icons.svg new file mode 100644 index 0000000..e952219 --- /dev/null +++ b/frontend/public/icons.svg @@ -0,0 +1,24 @@ + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/frontend/src/App.css b/frontend/src/App.css new file mode 100644 index 0000000..f90339d --- /dev/null +++ b/frontend/src/App.css @@ -0,0 +1,184 @@ +.counter { + font-size: 16px; + padding: 5px 10px; + border-radius: 5px; + color: var(--accent); + background: var(--accent-bg); + border: 2px solid transparent; + transition: border-color 0.3s; + margin-bottom: 24px; + + &:hover { + border-color: var(--accent-border); + } + &:focus-visible { + outline: 2px solid var(--accent); + outline-offset: 2px; + } +} + +.hero { + position: relative; + + .base, + .framework, + .vite { + inset-inline: 0; + margin: 0 auto; + } + + .base { + width: 170px; + position: relative; + z-index: 0; + } + + .framework, + .vite { + position: absolute; + } + + .framework { + z-index: 1; + top: 34px; + height: 28px; + transform: perspective(2000px) rotateZ(300deg) rotateX(44deg) rotateY(39deg) + scale(1.4); + } + + .vite { + z-index: 0; + top: 107px; + height: 26px; + width: auto; + transform: perspective(2000px) rotateZ(300deg) rotateX(40deg) rotateY(39deg) + scale(0.8); + } +} + +#center { + display: flex; + flex-direction: column; + gap: 25px; + place-content: center; + place-items: center; + flex-grow: 1; + + @media (max-width: 1024px) { + padding: 32px 20px 24px; + gap: 18px; + } +} + +#next-steps { + display: flex; + border-top: 1px solid var(--border); + text-align: left; + + & > div { + flex: 1 1 0; + padding: 32px; + @media (max-width: 1024px) { + padding: 24px 20px; + } + } + + .icon { + margin-bottom: 16px; + width: 22px; + height: 22px; + } + + @media (max-width: 1024px) { + flex-direction: column; + text-align: center; + } +} + +#docs { + border-right: 1px solid var(--border); + + @media (max-width: 1024px) { + border-right: none; + border-bottom: 1px solid var(--border); + } +} + +#next-steps ul { + list-style: none; + padding: 0; + display: flex; + gap: 8px; + margin: 32px 0 0; + + .logo { + height: 18px; + } + + a { + color: var(--text-h); + font-size: 16px; + border-radius: 6px; + background: var(--social-bg); + display: flex; + padding: 6px 12px; + align-items: center; + gap: 8px; + text-decoration: none; + transition: box-shadow 0.3s; + + &:hover { + box-shadow: var(--shadow); + } + .button-icon { + height: 18px; + width: 18px; + } + } + + @media (max-width: 1024px) { + margin-top: 20px; + flex-wrap: wrap; + justify-content: center; + + li { + flex: 1 1 calc(50% - 8px); + } + + a { + width: 100%; + justify-content: center; + box-sizing: border-box; + } + } +} + +#spacer { + height: 88px; + border-top: 1px solid var(--border); + @media (max-width: 1024px) { + height: 48px; + } +} + +.ticks { + position: relative; + width: 100%; + + &::before, + &::after { + content: ''; + position: absolute; + top: -4.5px; + border: 5px solid transparent; + } + + &::before { + left: 0; + border-left-color: var(--border); + } + &::after { + right: 0; + border-right-color: var(--border); + } +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx new file mode 100644 index 0000000..3f35b84 --- /dev/null +++ b/frontend/src/App.tsx @@ -0,0 +1,85 @@ +import { BrowserRouter, Routes, Route, Navigate, useLocation } from 'react-router' +import { QueryClientProvider } from '@tanstack/react-query' +import { queryClient } from '@/lib/queryClient' +import { useAuthStore } from '@/store/authStore' +import type { UserRole } from '@/store/authStore' +import LoginPage from '@/pages/auth/LoginPage' +import AppDashboardPage from '@/pages/app/DashboardPage' +import ClientsPage from '@/pages/app/ClientsPage' +import CatalogPage from '@/pages/app/CatalogPage' +import WorkOrdersPage from '@/pages/app/WorkOrdersPage' +import WorkOrderDetailPage from '@/pages/app/WorkOrderDetailPage' +import StaffPage from '@/pages/app/StaffPage' +import ExpensesPage from '@/pages/app/ExpensesPage' +import SettingsPage from '@/pages/app/SettingsPage' +import InvoicesPage from '@/pages/app/InvoicesPage' +import AdminDashboardPage from '@/pages/admin/DashboardPage' +import TenantsPage from '@/pages/admin/TenantsPage' +import InviteRedeemPage from '@/pages/public/InviteRedeemPage' +import AppLayout from '@/components/layout/AppLayout' +import AdminLayout from '@/components/layout/AdminLayout' + +function RequireAuth({ + children, + allowedRoles, +}: { + children: React.ReactNode + allowedRoles: UserRole[] +}) { + const { isAuthenticated, user } = useAuthStore() + const location = useLocation() + if (!isAuthenticated) return + // Allow super_admin carrying an impersonation payload through to /app — + // AppLayout applies the role switch on mount via useLayoutEffect. + const hasImpersonation = !!location.state?.impersonation + if (user && !allowedRoles.includes(user.role) && !hasImpersonation) { + return + } + return <>{children} +} + +export default function App() { + return ( + + + + } /> + } /> + + + + + } + > + } /> + } /> + + + + + + } + > + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + } /> + + + } /> + + + + ) +} diff --git a/frontend/src/assets/hero.png b/frontend/src/assets/hero.png new file mode 100644 index 0000000..02251f4 Binary files /dev/null and b/frontend/src/assets/hero.png differ diff --git a/frontend/src/assets/react.svg b/frontend/src/assets/react.svg new file mode 100644 index 0000000..6c87de9 --- /dev/null +++ b/frontend/src/assets/react.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/assets/vite.svg b/frontend/src/assets/vite.svg new file mode 100644 index 0000000..5101b67 --- /dev/null +++ b/frontend/src/assets/vite.svg @@ -0,0 +1 @@ +Vite diff --git a/frontend/src/components/layout/AdminLayout.tsx b/frontend/src/components/layout/AdminLayout.tsx new file mode 100644 index 0000000..fb499c1 --- /dev/null +++ b/frontend/src/components/layout/AdminLayout.tsx @@ -0,0 +1,55 @@ +import { Outlet, NavLink } from 'react-router' +import { useLogout } from '@/hooks/useAuth' + +export default function AdminLayout() { + const { mutate: logout } = useLogout() + + return ( +
+ +
+ +
+
+ ) +} diff --git a/frontend/src/components/layout/AppLayout.tsx b/frontend/src/components/layout/AppLayout.tsx new file mode 100644 index 0000000..f363611 --- /dev/null +++ b/frontend/src/components/layout/AppLayout.tsx @@ -0,0 +1,93 @@ +import { useLayoutEffect } from 'react' +import { Outlet, NavLink, useNavigate, useLocation } from 'react-router' +import { useLogout } from '@/hooks/useAuth' +import { useAuthStore } from '@/store/authStore' +import { queryClient } from '@/lib/queryClient' + +const nav = [ + { to: '/app', label: 'Dashboard', end: true }, + { to: '/app/work-orders', label: 'Ordens de Trabalho' }, + { to: '/app/clients', label: 'Clientes' }, + { to: '/app/catalog', label: 'Catálogo' }, + { to: '/app/staff', label: 'Técnicos' }, + { to: '/app/expenses', label: 'Despesas' }, + { to: '/app/invoices', label: 'Faturação' }, + { to: '/app/settings', label: 'Definições' }, +] + +export default function AppLayout() { + const { mutate: logout } = useLogout() + const { previousSession, restoreSession, user, impersonateTenant } = useAuthStore() + const navigate = useNavigate() + const location = useLocation() + + useLayoutEffect(() => { + const imp = location.state?.impersonation + if (imp) { + impersonateTenant(imp.token, imp.user) + queryClient.clear() + // Remove impersonation payload from history state so back/forward doesn't re-apply it + navigate(location.pathname, { replace: true, state: {} }) + } + }, []) // eslint-disable-line react-hooks/exhaustive-deps + + function handleRestore() { + restoreSession() + navigate('/admin') + } + + return ( +
+ {previousSession && ( +
+ + TechXCar Admin — a gerir: {user?.name} + + +
+ )} +
+ +
+ +
+
+
+ ) +} diff --git a/frontend/src/components/ui/badge.tsx b/frontend/src/components/ui/badge.tsx new file mode 100644 index 0000000..5ffc3fc --- /dev/null +++ b/frontend/src/components/ui/badge.tsx @@ -0,0 +1,32 @@ +import * as React from 'react' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/utils' + +const badgeVariants = cva( + 'inline-flex items-center rounded-full px-2.5 py-0.5 text-xs font-semibold transition-colors', + { + variants: { + variant: { + default: 'bg-blue-100 text-blue-800', + secondary: 'bg-gray-100 text-gray-800', + destructive: 'bg-red-100 text-red-800', + success: 'bg-green-100 text-green-800', + warning: 'bg-yellow-100 text-yellow-800', + outline: 'border border-gray-300 text-gray-700', + }, + }, + defaultVariants: { + variant: 'default', + }, + } +) + +export interface BadgeProps + extends React.HTMLAttributes, + VariantProps {} + +function Badge({ className, variant, ...props }: BadgeProps) { + return
+} + +export { Badge, badgeVariants } diff --git a/frontend/src/components/ui/button.tsx b/frontend/src/components/ui/button.tsx new file mode 100644 index 0000000..33b7f57 --- /dev/null +++ b/frontend/src/components/ui/button.tsx @@ -0,0 +1,52 @@ +import * as React from 'react' +import { Slot } from '@radix-ui/react-slot' +import { cva, type VariantProps } from 'class-variance-authority' +import { cn } from '@/lib/utils' + +const buttonVariants = cva( + 'inline-flex items-center justify-center gap-2 whitespace-nowrap rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50', + { + variants: { + variant: { + default: 'bg-blue-600 text-white shadow hover:bg-blue-700', + destructive: 'bg-red-600 text-white shadow-sm hover:bg-red-700', + outline: 'border border-gray-300 bg-white shadow-sm hover:bg-gray-50 text-gray-900', + secondary: 'bg-gray-100 text-gray-900 shadow-sm hover:bg-gray-200', + ghost: 'hover:bg-gray-100 text-gray-700', + link: 'text-blue-600 underline-offset-4 hover:underline', + }, + size: { + default: 'h-9 px-4 py-2', + sm: 'h-8 rounded-md px-3 text-xs', + lg: 'h-10 rounded-md px-8', + icon: 'h-9 w-9', + }, + }, + defaultVariants: { + variant: 'default', + size: 'default', + }, + } +) + +export interface ButtonProps + extends React.ButtonHTMLAttributes, + VariantProps { + asChild?: boolean +} + +const Button = React.forwardRef( + ({ className, variant, size, asChild = false, ...props }, ref) => { + const Comp = asChild ? Slot : 'button' + return ( + + ) + } +) +Button.displayName = 'Button' + +export { Button, buttonVariants } diff --git a/frontend/src/components/ui/dialog.tsx b/frontend/src/components/ui/dialog.tsx new file mode 100644 index 0000000..21f67c1 --- /dev/null +++ b/frontend/src/components/ui/dialog.tsx @@ -0,0 +1,76 @@ +import * as React from 'react' +import * as DialogPrimitive from '@radix-ui/react-dialog' +import { X } from 'lucide-react' +import { cn } from '@/lib/utils' + +const Dialog = DialogPrimitive.Root +const DialogTrigger = DialogPrimitive.Trigger +const DialogPortal = DialogPrimitive.Portal +const DialogClose = DialogPrimitive.Close + +const DialogOverlay = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogOverlay.displayName = DialogPrimitive.Overlay.displayName + +const DialogContent = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, children, ...props }, ref) => ( + + + + {children} + + + Fechar + + + +)) +DialogContent.displayName = DialogPrimitive.Content.displayName + +const DialogHeader = ({ className, ...props }: React.HTMLAttributes) => ( +
+) +DialogHeader.displayName = 'DialogHeader' + +const DialogTitle = React.forwardRef< + React.ComponentRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +DialogTitle.displayName = DialogPrimitive.Title.displayName + +export { + Dialog, + DialogTrigger, + DialogPortal, + DialogOverlay, + DialogClose, + DialogContent, + DialogHeader, + DialogTitle, +} diff --git a/frontend/src/components/ui/input.tsx b/frontend/src/components/ui/input.tsx new file mode 100644 index 0000000..c075034 --- /dev/null +++ b/frontend/src/components/ui/input.tsx @@ -0,0 +1,23 @@ +import * as React from 'react' +import { cn } from '@/lib/utils' + +export interface InputProps extends React.InputHTMLAttributes {} + +const Input = React.forwardRef( + ({ className, type, ...props }, ref) => { + return ( + + ) + } +) +Input.displayName = 'Input' + +export { Input } diff --git a/frontend/src/components/ui/label.tsx b/frontend/src/components/ui/label.tsx new file mode 100644 index 0000000..54b2db1 --- /dev/null +++ b/frontend/src/components/ui/label.tsx @@ -0,0 +1,17 @@ +import * as React from 'react' +import * as LabelPrimitive from '@radix-ui/react-label' +import { cn } from '@/lib/utils' + +const Label = React.forwardRef< + React.ElementRef, + React.ComponentPropsWithoutRef +>(({ className, ...props }, ref) => ( + +)) +Label.displayName = LabelPrimitive.Root.displayName + +export { Label } diff --git a/frontend/src/hooks/useAuth.ts b/frontend/src/hooks/useAuth.ts new file mode 100644 index 0000000..1d38eb2 --- /dev/null +++ b/frontend/src/hooks/useAuth.ts @@ -0,0 +1,49 @@ +import { useMutation } from '@tanstack/react-query' +import { useNavigate } from 'react-router' +import { apiFetch } from '@/lib/api' +import { useAuthStore, type AuthUser } from '@/store/authStore' + +interface LoginRequest { + email: string + password: string + tenant_slug?: string +} + +interface LoginResponse { + access_token: string + user: AuthUser +} + +export function useLogin() { + const { setAuth } = useAuthStore() + const navigate = useNavigate() + + return useMutation({ + mutationFn: (data: LoginRequest) => + apiFetch('/auth/login', { + method: 'POST', + body: JSON.stringify(data), + }), + onSuccess: (data) => { + setAuth(data.user, data.access_token) + if (data.user.role === 'super_admin') { + navigate('/admin', { replace: true }) + } else { + navigate('/app', { replace: true }) + } + }, + }) +} + +export function useLogout() { + const { clearAuth } = useAuthStore() + const navigate = useNavigate() + + return useMutation({ + mutationFn: () => apiFetch('/auth/logout', { method: 'POST' }), + onSettled: () => { + clearAuth() + navigate('/login', { replace: true }) + }, + }) +} diff --git a/frontend/src/hooks/useClients.test.ts b/frontend/src/hooks/useClients.test.ts new file mode 100644 index 0000000..4a80439 --- /dev/null +++ b/frontend/src/hooks/useClients.test.ts @@ -0,0 +1,45 @@ +import { vi, describe, it, expect, beforeEach } from 'vitest' +import { renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import React from 'react' +import { useClients, useClientVehicles } from './useClients' + +vi.mock('@/lib/api', () => ({ apiFetch: vi.fn() })) +vi.mock('@/lib/queryClient', () => ({ queryClient: new QueryClient() })) + +import { apiFetch } from '@/lib/api' +const mockFetch = apiFetch as ReturnType + +function makeWrapper() { + const qc = new QueryClient({ defaultOptions: { queries: { retry: false } } }) + const Wrapper = ({ children }: { children: React.ReactNode }) => + React.createElement(QueryClientProvider, { client: qc }, children) + return Wrapper +} + +describe('useClients', () => { + beforeEach(() => vi.clearAllMocks()) + + it('calls GET /clients', async () => { + mockFetch.mockResolvedValue([]) + const { result } = renderHook(() => useClients(), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockFetch).toHaveBeenCalledWith('/clients') + }) +}) + +describe('useClientVehicles', () => { + beforeEach(() => vi.clearAllMocks()) + + it('calls GET /clients/:id/vehicles when clientId provided', async () => { + mockFetch.mockResolvedValue([]) + const { result } = renderHook(() => useClientVehicles('abc-123'), { wrapper: makeWrapper() }) + await waitFor(() => expect(result.current.isSuccess).toBe(true)) + expect(mockFetch).toHaveBeenCalledWith('/clients/abc-123/vehicles') + }) + + it('does not fetch when clientId is empty', () => { + const { result } = renderHook(() => useClientVehicles(''), { wrapper: makeWrapper() }) + expect(result.current.fetchStatus).toBe('idle') + }) +}) diff --git a/frontend/src/hooks/useClients.ts b/frontend/src/hooks/useClients.ts new file mode 100644 index 0000000..bda7742 --- /dev/null +++ b/frontend/src/hooks/useClients.ts @@ -0,0 +1,80 @@ +import { useQuery, useMutation } from '@tanstack/react-query' +import { apiFetch } from '@/lib/api' +import { queryClient } from '@/lib/queryClient' +import type { Client, Vehicle } from '@/lib/types' + +export type ClientPayload = { + name: string; nif: string; phone: string; email: string; address: string; notes: string +} +export type ClientUpdatePayload = ClientPayload & { id: string } +export type VehiclePayload = { + plate: string; brand: string; model: string; year: number; vin: string; mileage: number; notes: string +} +export type VehicleUpdatePayload = VehiclePayload & { id: string } + +export function useClients() { + return useQuery({ + queryKey: ['clients'], + queryFn: () => apiFetch('/clients'), + }) +} + +export function useClient(id: string) { + return useQuery({ + queryKey: ['clients', id], + queryFn: () => apiFetch(`/clients/${id}`), + enabled: !!id, + }) +} + +export function useCreateClient() { + return useMutation({ + mutationFn: (data: ClientPayload) => + apiFetch('/clients', { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['clients'] }), + }) +} + +export function useUpdateClient() { + return useMutation({ + mutationFn: ({ id, ...data }: ClientUpdatePayload) => + apiFetch(`/clients/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + onSuccess: (_result, { id }) => { + queryClient.invalidateQueries({ queryKey: ['clients'] }) + queryClient.invalidateQueries({ queryKey: ['clients', id] }) + }, + }) +} + +export function useDeleteClient() { + return useMutation({ + mutationFn: (id: string) => apiFetch(`/clients/${id}`, { method: 'DELETE' }), + onSuccess: () => queryClient.invalidateQueries({ queryKey: ['clients'] }), + }) +} + +export function useClientVehicles(clientId: string) { + return useQuery({ + queryKey: ['clients', clientId, 'vehicles'], + queryFn: () => apiFetch(`/clients/${clientId}/vehicles`), + enabled: !!clientId, + }) +} + +export function useCreateVehicle(clientId: string) { + return useMutation({ + mutationFn: (data: VehiclePayload) => + apiFetch(`/clients/${clientId}/vehicles`, { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'vehicles'] }), + }) +} + +export function useUpdateVehicle(clientId: string) { + return useMutation({ + mutationFn: ({ id, ...data }: VehicleUpdatePayload) => + apiFetch(`/vehicles/${id}`, { method: 'PUT', body: JSON.stringify(data) }), + onSuccess: () => + queryClient.invalidateQueries({ queryKey: ['clients', clientId, 'vehicles'] }), + }) +} diff --git a/frontend/src/index.css b/frontend/src/index.css new file mode 100644 index 0000000..b523929 --- /dev/null +++ b/frontend/src/index.css @@ -0,0 +1,5 @@ +@import "tailwindcss"; + +:root { + --radius: 0.5rem; +} diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts new file mode 100644 index 0000000..bbc8900 --- /dev/null +++ b/frontend/src/lib/api.ts @@ -0,0 +1,77 @@ +import { useAuthStore } from '@/store/authStore' + +const BASE_URL = '/api/v1' + +export class ApiError extends Error { + constructor( + public status: number, + message: string + ) { + super(message) + this.name = 'ApiError' + } +} + +async function refreshAccessToken(): Promise { + try { + const res = await fetch(`${BASE_URL}/auth/refresh`, { + method: 'POST', + credentials: 'include', + }) + if (!res.ok) return null + const data = await res.json() + useAuthStore.getState().updateToken(data.data.access_token) + return data.data.access_token + } catch { + return null + } +} + +export async function apiFetch( + path: string, + options: RequestInit = {} +): Promise { + const { accessToken, clearAuth } = useAuthStore.getState() + + const headers: Record = { + 'Content-Type': 'application/json', + ...(options.headers as Record), + } + + if (accessToken) { + headers['Authorization'] = `Bearer ${accessToken}` + } + + let res = await fetch(`${BASE_URL}${path}`, { + ...options, + headers, + credentials: 'include', + }) + + if (res.status === 401 && accessToken) { + const newToken = await refreshAccessToken() + if (newToken) { + headers['Authorization'] = `Bearer ${newToken}` + res = await fetch(`${BASE_URL}${path}`, { + ...options, + headers, + credentials: 'include', + }) + } else { + clearAuth() + throw new ApiError(401, 'Sessão expirada. Por favor inicie sessão novamente.') + } + } + + if (res.status === 204) { + return undefined as T + } + + const json = await res.json() + + if (!res.ok) { + throw new ApiError(res.status, json.error ?? 'Erro desconhecido') + } + + return json.data as T +} diff --git a/frontend/src/lib/queryClient.ts b/frontend/src/lib/queryClient.ts new file mode 100644 index 0000000..f7cf388 --- /dev/null +++ b/frontend/src/lib/queryClient.ts @@ -0,0 +1,17 @@ +import { QueryClient } from '@tanstack/react-query' +import { ApiError } from './api' + +export const queryClient = new QueryClient({ + defaultOptions: { + queries: { + staleTime: 1000 * 60 * 5, + retry: (failureCount, error) => { + if (error instanceof ApiError && error.status < 500) return false + return failureCount < 2 + }, + }, + mutations: { + retry: false, + }, + }, +}) diff --git a/frontend/src/lib/types.ts b/frontend/src/lib/types.ts new file mode 100644 index 0000000..f2b4737 --- /dev/null +++ b/frontend/src/lib/types.ts @@ -0,0 +1,127 @@ +export interface Tenant { + id: string + slug: string + name: string + status: 'active' | 'suspended' | 'pending' + created_at: string +} + +export interface Invite { + id: string + token: string + tenant_id: string | null + expires_at: string + used_at: string | null + created_at: string +} + +export interface Client { + id: string + name: string + nif: string + phone: string + email: string + address: string + notes: string + created_at: string + updated_at: string +} + +export interface Vehicle { + id: string + client_id: string | null + plate: string + brand: string + model: string + year: number | null + vin: string + mileage: number | null + fuel_type: string + notes: string + created_at: string + updated_at: string +} + +export interface CatalogItem { + id: string + code: string + name: string + category: string + unit: 'un' | 'hora' | 'litro' | 'kg' + base_price: number + active: boolean + created_at: string + updated_at: string +} + +export interface WorkOrder { + id: string + number: number + client_id: string | null + vehicle_id: string | null + status: 'open' | 'in_progress' | 'completed' | 'invoiced' | 'cancelled' + internal_notes: string + client_notes: string + created_by: string | null + created_at: string + updated_at: string +} + +export interface WOItem { + id: string + work_order_id: string + catalog_item_id: string | null + description: string + qty: number + unit_price: number + discount_pct: number + total: number +} + +export interface WOStaffHours { + id: string + work_order_id: string + staff_id: string + hours: number + cost_per_hour: number + total: number +} + +export interface WorkOrderDetail extends WorkOrder { + items: WOItem[] + staff_hours: WOStaffHours[] +} + +export interface Staff { + id: string + user_id: string | null + name: string + email: string + phone: string + type: 'internal' | 'external' + hourly_rate: number + active: boolean + created_at: string +} + +export interface Expense { + id: string + vehicle_id: string | null + type: 'fuel' | 'parts' | 'tools' | 'other' + amount: number + description: string + date: string + created_at: string +} + +export type TenantSettings = Record + +export interface Invoice { + id: string + work_order_id: string + type: 'quote' | 'invoice' + number: number + pdf_path: string + issued_at: string + created_at: string +} diff --git a/frontend/src/lib/utils.ts b/frontend/src/lib/utils.ts new file mode 100644 index 0000000..fed2fe9 --- /dev/null +++ b/frontend/src/lib/utils.ts @@ -0,0 +1,6 @@ +import { clsx, type ClassValue } from 'clsx' +import { twMerge } from 'tailwind-merge' + +export function cn(...inputs: ClassValue[]) { + return twMerge(clsx(inputs)) +} diff --git a/frontend/src/main.tsx b/frontend/src/main.tsx new file mode 100644 index 0000000..bef5202 --- /dev/null +++ b/frontend/src/main.tsx @@ -0,0 +1,10 @@ +import { StrictMode } from 'react' +import { createRoot } from 'react-dom/client' +import './index.css' +import App from './App.tsx' + +createRoot(document.getElementById('root')!).render( + + + , +) diff --git a/frontend/src/pages/admin/DashboardPage.tsx b/frontend/src/pages/admin/DashboardPage.tsx new file mode 100644 index 0000000..f1ca1e4 --- /dev/null +++ b/frontend/src/pages/admin/DashboardPage.tsx @@ -0,0 +1,8 @@ +export default function AdminDashboardPage() { + return ( +
+

Painel de Administração

+

Gestão da plataforma — implementado no Plano 2

+
+ ) +} diff --git a/frontend/src/pages/admin/TenantsPage.tsx b/frontend/src/pages/admin/TenantsPage.tsx new file mode 100644 index 0000000..bd2e192 --- /dev/null +++ b/frontend/src/pages/admin/TenantsPage.tsx @@ -0,0 +1,259 @@ +import { useState } from 'react' +import { useNavigate } from 'react-router' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { apiFetch } from '@/lib/api' +import type { AuthUser } from '@/store/authStore' +import { Button } from '@/components/ui/button' +import { Badge } from '@/components/ui/badge' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { Tenant, Invite } from '@/lib/types' + +function toSlug(name: string) { + return name + .toLowerCase() + .normalize('NFD') + .replace(/[̀-ͯ]/g, '') + .replace(/[^a-z0-9]+/g, '-') + .replace(/^-|-$/g, '') +} + +export default function TenantsPage() { + const navigate = useNavigate() + const queryClient = useQueryClient() + + const [inviteToken, setInviteToken] = useState(null) + const [showCreate, setShowCreate] = useState(false) + const [createForm, setCreateForm] = useState({ + name: '', slug: '', admin_name: '', admin_email: '', admin_password: '', + }) + const [createError, setCreateError] = useState('') + + const { data: tenants = [], isLoading } = useQuery({ + queryKey: ['admin', 'tenants'], + queryFn: () => apiFetch('/admin/tenants'), + }) + + const generateInvite = useMutation({ + mutationFn: () => apiFetch('/admin/invites', { method: 'POST' }), + onSuccess: (invite) => { + setInviteToken(invite.token) + queryClient.invalidateQueries({ queryKey: ['admin', 'tenants'] }) + }, + }) + + const accessTenant = useMutation({ + mutationFn: (tenantId: string) => + apiFetch<{ access_token: string; tenant: { id: string; name: string; slug: string } }>( + `/admin/tenants/${tenantId}/access`, + { method: 'POST' } + ), + onSuccess: (data) => { + // Navigate first — auth state change happens in AppLayout after mount. + // If impersonateTenant were called here, the admin RequireAuth would + // see role='tenant_admin', reject it, and redirect to /login before + // navigate('/app') takes effect. + navigate('/app', { + state: { + impersonation: { + token: data.access_token, + user: { + id: data.tenant.id, + email: '', + name: data.tenant.name, + role: 'tenant_admin' as const, + tenantId: data.tenant.id, + } satisfies AuthUser, + }, + }, + }) + }, + onError: (err: Error) => { + console.error('Erro ao aceder à oficina:', err.message) + }, + }) + + const createTenant = useMutation({ + mutationFn: (body: typeof createForm) => + apiFetch('/admin/tenants', { method: 'POST', body: JSON.stringify(body) }), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['admin', 'tenants'] }) + setShowCreate(false) + setCreateForm({ name: '', slug: '', admin_name: '', admin_email: '', admin_password: '' }) + setCreateError('') + }, + onError: (err: Error) => setCreateError(err.message), + }) + + const inviteUrl = inviteToken ? `${window.location.origin}/invite/${inviteToken}` : null + + return ( +
+
+
+

Oficinas

+

{tenants.length} oficinas registadas

+
+
+ + +
+
+ + {showCreate && ( +
+

Nova Oficina

+
{ + e.preventDefault() + setCreateError('') + createTenant.mutate(createForm) + }} + className="grid grid-cols-2 gap-4" + > +
+ + setCreateForm(f => ({ + ...f, name: e.target.value, slug: toSlug(e.target.value), + }))} + className="bg-slate-900 border-slate-600 text-white" + required + /> +
+
+ + setCreateForm(f => ({ ...f, slug: e.target.value }))} + className="bg-slate-900 border-slate-600 text-white font-mono text-sm" + required + /> +
+
+ + setCreateForm(f => ({ ...f, admin_name: e.target.value }))} + className="bg-slate-900 border-slate-600 text-white" + required + /> +
+
+ + setCreateForm(f => ({ ...f, admin_email: e.target.value }))} + className="bg-slate-900 border-slate-600 text-white" + required + /> +
+
+ + setCreateForm(f => ({ ...f, admin_password: e.target.value }))} + className="bg-slate-900 border-slate-600 text-white" + required + /> +
+ {createError &&

{createError}

} +
+ + +
+
+
+ )} + + {inviteUrl && ( +
+

Link de convite (válido 72h):

+
+ + {inviteUrl} + + +
+ +
+ )} + + {isLoading ? ( +

A carregar...

+ ) : tenants.length === 0 ? ( +

Nenhuma oficina registada.

+ ) : ( +
+ + + + + + + + + + + + {tenants.map((t) => ( + + + + + + + + ))} + +
NomeSlugEstadoCriadaAções
{t.name}{t.slug} + + {t.status} + + + {new Intl.DateTimeFormat('pt-PT').format(new Date(t.created_at))} + + +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/app/CatalogPage.tsx b/frontend/src/pages/app/CatalogPage.tsx new file mode 100644 index 0000000..410fadf --- /dev/null +++ b/frontend/src/pages/app/CatalogPage.tsx @@ -0,0 +1,217 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useForm, type Resolver } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { apiFetch } from '@/lib/api' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { CatalogItem } from '@/lib/types' + +const UNITS = ['un', 'hora', 'litro', 'kg'] as const + +const CATEGORIES = [ + { value: 'mao_de_obra', label: 'Mão de Obra' }, + { value: 'diagnostico', label: 'Diagnóstico' }, + { value: 'manutencao', label: 'Manutenção' }, + { value: 'pecas_mecanicas', label: 'Peças Mecânicas' }, + { value: 'pneus_jantes', label: 'Pneus / Jantes' }, + { value: 'eletrica', label: 'Elétrica / Eletrónica' }, + { value: 'fluidos', label: 'Fluidos / Óleos' }, + { value: 'ar_condicionado', label: 'Ar Condicionado' }, + { value: 'carrocaria', label: 'Carroçaria / Pintura' }, + { value: 'consumiveis', label: 'Consumíveis' }, + { value: 'acessorios', label: 'Acessórios' }, + { value: 'outro', label: 'Outro' }, +] as const + +const CATEGORY_LABEL: Record = Object.fromEntries( + CATEGORIES.map(c => [c.value, c.label]) +) + +const UNITS_LABEL: Record = { + un: 'un', hora: 'hora', litro: 'L', kg: 'kg', +} + +const schema = z.object({ + code: z.string().min(1, 'Código obrigatório'), + name: z.string().min(1, 'Nome obrigatório'), + category: z.string().min(1, 'Categoria obrigatória'), + unit: z.enum(UNITS), + base_price: z.coerce.number().min(0), + active: z.boolean(), +}) +type FormData = z.infer + +const emptyItem: FormData = { code: '', name: '', category: 'mao_de_obra', unit: 'un', base_price: 0, active: true } + +export default function CatalogPage() { + const qc = useQueryClient() + const [editing, setEditing] = useState(null) + const [showForm, setShowForm] = useState(false) + + const { data: items = [], isLoading } = useQuery({ + queryKey: ['catalog'], + queryFn: () => apiFetch('/catalog'), + }) + + const { register, handleSubmit, reset, formState: { errors } } = useForm({ + resolver: zodResolver(schema) as Resolver, + defaultValues: emptyItem, + }) + + const save = useMutation({ + mutationFn: (data: FormData) => + editing + ? apiFetch(`/catalog/${editing.id}`, { method: 'PUT', body: JSON.stringify(data) }) + : apiFetch('/catalog', { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['catalog'] }) + setShowForm(false) + setEditing(null) + reset() + }, + }) + + const remove = useMutation({ + mutationFn: (id: string) => apiFetch(`/catalog/${id}`, { method: 'DELETE' }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['catalog'] }), + }) + + function openNew() { + setEditing(null) + reset(emptyItem) + setShowForm(true) + } + + function openEdit(item: CatalogItem) { + setEditing(item) + reset({ code: item.code, name: item.name, category: item.category, unit: item.unit, base_price: item.base_price, active: item.active }) + setShowForm(true) + } + + return ( +
+
+
+

Catálogo

+

{items.length} itens

+
+ +
+ + {showForm && ( +
+

+ {editing ? 'Editar Item' : 'Novo Item'} +

+
save.mutate(d))} className="grid grid-cols-2 gap-4"> +
+ + + {errors.code &&

{errors.code.message}

} +
+
+ + + {errors.name &&

{errors.name.message}

} +
+
+ + + {errors.category &&

{errors.category.message}

} +
+
+ + +
+
+ + +
+
+ + +
+ {save.error && ( +

{save.error.message}

+ )} +
+ + +
+
+
+ )} + + {isLoading ? ( +

A carregar...

+ ) : items.length === 0 ? ( +

Nenhum item no catálogo.

+ ) : ( +
+ + + + + + + + + + + + + + {items.map((item) => ( + + + + + + + + + + ))} + +
CódigoNomeCategoriaUn.PreçoEstado
{item.code}{item.name}{CATEGORY_LABEL[item.category] ?? item.category}{UNITS_LABEL[item.unit] ?? item.unit}{item.base_price.toFixed(2)} € + + {item.active ? 'Activo' : 'Inactivo'} + + + + +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/app/ClientsPage.tsx b/frontend/src/pages/app/ClientsPage.tsx new file mode 100644 index 0000000..25571fa --- /dev/null +++ b/frontend/src/pages/app/ClientsPage.tsx @@ -0,0 +1,393 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useForm, type Resolver } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { apiFetch } from '@/lib/api' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { Client, Vehicle } from '@/lib/types' + +// ─── Client form ───────────────────────────────────────────────────────────── + +const clientSchema = z.object({ + name: z.string().min(1, 'Nome obrigatório'), + nif: z.string(), + phone: z.string(), + email: z.union([z.string().email('Email inválido'), z.literal('')]), + address: z.string(), + notes: z.string(), +}) +type ClientForm = z.infer +const emptyClient: ClientForm = { name: '', nif: '', phone: '', email: '', address: '', notes: '' } + +// ─── Vehicle form ───────────────────────────────────────────────────────────── + +const FUEL_TYPES = [ + { value: '', label: '— Combustível —' }, + { value: 'gasoline', label: 'Gasolina' }, + { value: 'diesel', label: 'Diesel' }, + { value: 'electric', label: 'Eléctrico' }, + { value: 'hybrid', label: 'Híbrido' }, + { value: 'hybrid_plug', label: 'Híbrido Plug-in' }, + { value: 'lpg', label: 'GPL' }, + { value: 'hydrogen', label: 'Hidrogénio' }, + { value: 'bifuel', label: 'Bi-Fuel' }, +] + +const FUEL_LABEL: Record = Object.fromEntries( + FUEL_TYPES.filter(f => f.value).map(f => [f.value, f.label]) +) + +const vehicleSchema = z.object({ + plate: z.string().min(1, 'Matrícula obrigatória'), + brand: z.string(), + model: z.string(), + year: z.coerce.number().int().min(1900).max(2100).or(z.literal(0)), + vin: z.string(), + fuel_type: z.string(), + mileage: z.coerce.number().int().min(0).or(z.literal(0)), + notes: z.string(), +}) +type VehicleForm = z.infer +const emptyVehicle: VehicleForm = { plate: '', brand: '', model: '', year: 0, vin: '', fuel_type: '', mileage: 0, notes: '' } + +// ─── ClientVehicles sub-component ──────────────────────────────────────────── + +function ClientVehicles({ clientId }: { clientId: string }) { + const qc = useQueryClient() + const [editingVehicle, setEditingVehicle] = useState(null) + const [showVehicleForm, setShowVehicleForm] = useState(false) + + const { data: vehicles = [], isLoading } = useQuery({ + queryKey: ['vehicles', clientId], + queryFn: () => apiFetch(`/clients/${clientId}/vehicles`), + }) + + const { register, handleSubmit, reset, formState: { errors } } = useForm({ + resolver: zodResolver(vehicleSchema) as Resolver, + defaultValues: emptyVehicle, + }) + + const saveVehicle = useMutation({ + mutationFn: (data: VehicleForm) => { + const body = { + ...data, + year: data.year || null, + mileage: data.mileage || null, + } + return editingVehicle + ? apiFetch(`/vehicles/${editingVehicle.id}`, { method: 'PUT', body: JSON.stringify(body) }) + : apiFetch(`/clients/${clientId}/vehicles`, { method: 'POST', body: JSON.stringify(body) }) + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['vehicles', clientId] }) + setShowVehicleForm(false) + setEditingVehicle(null) + reset(emptyVehicle) + }, + }) + + function openNewVehicle() { + setEditingVehicle(null) + reset(emptyVehicle) + setShowVehicleForm(true) + } + + function openEditVehicle(v: Vehicle) { + setEditingVehicle(v) + reset({ + plate: v.plate, + brand: v.brand, + model: v.model, + year: v.year ?? 0, + vin: v.vin, + fuel_type: v.fuel_type, + mileage: v.mileage ?? 0, + notes: v.notes, + }) + setShowVehicleForm(true) + } + + return ( + + +
+ + Viaturas {!isLoading && `(${vehicles.length})`} + + +
+ + {showVehicleForm && ( +
saveVehicle.mutate(d))} + className="grid grid-cols-3 gap-3 mb-4 p-4 bg-slate-800 rounded-lg border border-slate-600" + > +
+ + + {errors.plate &&

{errors.plate.message}

} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {saveVehicle.error && ( +

{(saveVehicle.error as Error).message}

+ )} +
+ + +
+
+ )} + + {isLoading ? ( +

A carregar...

+ ) : vehicles.length === 0 && !showVehicleForm ? ( +

Sem viaturas. Clique em + Viatura para adicionar.

+ ) : vehicles.length > 0 ? ( + + + + + + + + + + + + + {vehicles.map((v) => ( + + + + + + + + + ))} + +
MatrículaMarca / ModeloAnoCombustívelKm
{v.plate} + {[v.brand, v.model].filter(Boolean).join(' ') || '—'} + {v.year ?? '—'}{FUEL_LABEL[v.fuel_type] ?? '—'} + {v.mileage != null ? v.mileage.toLocaleString('pt-PT') + ' km' : '—'} + + +
+ ) : null} + + + ) +} + +// ─── Main page ──────────────────────────────────────────────────────────────── + +export default function ClientsPage() { + const qc = useQueryClient() + const [editing, setEditing] = useState(null) + const [showForm, setShowForm] = useState(false) + const [expandedId, setExpandedId] = useState(null) + + const { data: clients = [], isLoading } = useQuery({ + queryKey: ['clients'], + queryFn: () => apiFetch('/clients'), + }) + + const { register, handleSubmit, reset, formState: { errors } } = useForm({ + resolver: zodResolver(clientSchema), + defaultValues: emptyClient, + }) + + const save = useMutation({ + mutationFn: (data: ClientForm) => + editing + ? apiFetch(`/clients/${editing.id}`, { method: 'PUT', body: JSON.stringify(data) }) + : apiFetch('/clients', { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['clients'] }) + setShowForm(false) + setEditing(null) + reset(emptyClient) + }, + }) + + const remove = useMutation({ + mutationFn: (id: string) => apiFetch(`/clients/${id}`, { method: 'DELETE' }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['clients'] }), + }) + + function openNew() { + setEditing(null) + reset(emptyClient) + setShowForm(true) + } + + function openEdit(c: Client) { + setEditing(c) + reset({ name: c.name, nif: c.nif, phone: c.phone, email: c.email, address: c.address, notes: c.notes }) + setShowForm(true) + } + + function toggleVehicles(clientId: string) { + setExpandedId(prev => prev === clientId ? null : clientId) + } + + return ( +
+
+
+

Clientes

+

{clients.length} clientes

+
+ +
+ + {showForm && ( +
+

+ {editing ? 'Editar Cliente' : 'Novo Cliente'} +

+
save.mutate(d))} className="grid grid-cols-2 gap-4"> +
+ + + {errors.name &&

{errors.name.message}

} +
+
+ + +
+
+ + +
+
+ + + {errors.email &&

{errors.email.message}

} +
+
+ + +
+
+ + +
+ {save.error && ( +

{(save.error as Error).message}

+ )} +
+ + +
+
+
+ )} + + {isLoading ? ( +

A carregar...

+ ) : clients.length === 0 ? ( +

Nenhum cliente registado.

+ ) : ( +
+ + + + + + + + + + + + {clients.map((c) => ( + <> + + + + + + + + {expandedId === c.id && } + + ))} + +
NomeNIFTelefoneEmail
{c.name}{c.nif || '—'}{c.phone || '—'}{c.email || '—'} + + + +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/app/DashboardPage.tsx b/frontend/src/pages/app/DashboardPage.tsx new file mode 100644 index 0000000..f4b9990 --- /dev/null +++ b/frontend/src/pages/app/DashboardPage.tsx @@ -0,0 +1,8 @@ +export default function DashboardPage() { + return ( +
+

Dashboard

+

Bem-vindo ao TechXCar — implementado no Plano 5

+
+ ) +} diff --git a/frontend/src/pages/app/ExpensesPage.tsx b/frontend/src/pages/app/ExpensesPage.tsx new file mode 100644 index 0000000..da2c4f0 --- /dev/null +++ b/frontend/src/pages/app/ExpensesPage.tsx @@ -0,0 +1,178 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useForm, type Resolver } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { apiFetch } from '@/lib/api' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { Expense } from '@/lib/types' + +const EXPENSE_TYPES = ['fuel', 'parts', 'tools', 'other'] as const +const TYPE_LABELS: Record = { + fuel: 'Combustível', parts: 'Peças', tools: 'Ferramentas', other: 'Outros', +} + +const schema = z.object({ + type: z.enum(EXPENSE_TYPES), + amount: z.coerce.number().positive('Valor deve ser positivo'), + description: z.string(), + date: z.string().min(1, 'Data obrigatória'), + vehicle_id: z.string(), +}) +type FormData = z.infer + +const today = new Date().toISOString().split('T')[0] +const emptyExpense: FormData = { type: 'fuel', amount: 0, description: '', date: today, vehicle_id: '' } + +export default function ExpensesPage() { + const qc = useQueryClient() + const [typeFilter, setTypeFilter] = useState('') + const [showForm, setShowForm] = useState(false) + + const { data: expenses = [], isLoading } = useQuery({ + queryKey: ['expenses', typeFilter], + queryFn: () => apiFetch(`/expenses${typeFilter ? `?type=${typeFilter}` : ''}`), + }) + + const { register, handleSubmit, reset, formState: { errors } } = useForm({ + resolver: zodResolver(schema) as Resolver, + defaultValues: emptyExpense, + }) + + const create = useMutation({ + mutationFn: (data: FormData) => + apiFetch('/expenses', { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['expenses'] }) + setShowForm(false) + reset(emptyExpense) + }, + }) + + const remove = useMutation({ + mutationFn: (id: string) => apiFetch(`/expenses/${id}`, { method: 'DELETE' }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['expenses'] }), + }) + + const total = expenses.reduce((s, e) => s + e.amount, 0) + + return ( +
+
+
+

Despesas

+

+ {expenses.length} registos — total: {total.toFixed(2)} € +

+
+ +
+ +
+ {(['', ...EXPENSE_TYPES] as string[]).map((t) => ( + + ))} +
+ + {showForm && ( +
+

Nova Despesa

+
create.mutate(d))} className="grid grid-cols-2 gap-4"> +
+ + +
+
+ + + {errors.amount &&

{errors.amount.message}

} +
+
+ + + {errors.date &&

{errors.date.message}

} +
+
+ + +
+ {create.error &&

{(create.error as Error).message}

} +
+ + +
+
+
+ )} + + {isLoading ? ( +

A carregar...

+ ) : expenses.length === 0 ? ( +

Nenhuma despesa registada.

+ ) : ( +
+ + + + + + + + + + + + {expenses.map((e) => ( + + + + + + + + ))} + +
DataTipoDescriçãoValor
+ {new Intl.DateTimeFormat('pt-PT').format(new Date(e.date))} + + {TYPE_LABELS[e.type]} + {e.description || '—'}{e.amount.toFixed(2)} € + +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/app/InvoicesPage.tsx b/frontend/src/pages/app/InvoicesPage.tsx new file mode 100644 index 0000000..6afb867 --- /dev/null +++ b/frontend/src/pages/app/InvoicesPage.tsx @@ -0,0 +1,151 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { apiFetch } from '@/lib/api' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import type { Invoice, WorkOrder } from '@/lib/types' + +const TYPE_LABELS: Record = { quote: 'Orçamento', invoice: 'Fatura' } + +export default function InvoicesPage() { + const qc = useQueryClient() + const [showGenerate, setShowGenerate] = useState(false) + const [selectedWO, setSelectedWO] = useState('') + const [docType, setDocType] = useState<'quote' | 'invoice'>('quote') + + const { data: invoices = [], isLoading } = useQuery({ + queryKey: ['invoices'], + queryFn: () => apiFetch('/invoices'), + }) + + const { data: workOrders = [] } = useQuery({ + queryKey: ['work-orders-for-invoice'], + queryFn: () => apiFetch('/work-orders'), + enabled: showGenerate, + }) + + const eligibleWOs = workOrders.filter((wo) => + wo.status === 'open' || wo.status === 'in_progress' || wo.status === 'completed' + ) + + const generate = useMutation({ + mutationFn: () => + apiFetch('/invoices', { + method: 'POST', + body: JSON.stringify({ work_order_id: selectedWO, type: docType }), + }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['invoices'] }) + qc.invalidateQueries({ queryKey: ['work-orders'] }) + setShowGenerate(false) + setSelectedWO('') + }, + }) + + return ( +
+
+
+

Faturação

+

{invoices.length} documentos

+
+ +
+ + {showGenerate && ( +
+

Gerar Documento

+
+
+ + +
+
+ + +
+
+ {generate.error && ( +

{(generate.error as Error).message}

+ )} +
+ + +
+
+ )} + + {isLoading ? ( +

A carregar...

+ ) : invoices.length === 0 ? ( +

Nenhum documento gerado.

+ ) : ( +
+ + + + + + + + + + + + {invoices.map((inv) => ( + + + + + + + + ))} + +
TipoOTEmitida
#{inv.number} + + {TYPE_LABELS[inv.type]} + + + {inv.work_order_id.slice(0, 8)}… + + {new Intl.DateTimeFormat('pt-PT').format(new Date(inv.issued_at))} + + +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/app/SettingsPage.tsx b/frontend/src/pages/app/SettingsPage.tsx new file mode 100644 index 0000000..54903de --- /dev/null +++ b/frontend/src/pages/app/SettingsPage.tsx @@ -0,0 +1,120 @@ +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useForm } from 'react-hook-form' +import { useEffect } from 'react' +import { apiFetch } from '@/lib/api' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { TenantSettings } from '@/lib/types' + +type FormData = { + company_name: string + company_nif: string + company_address: string + company_iban: string + company_phone: string + company_email: string +} + +const defaultValues: FormData = { + company_name: '', + company_nif: '', + company_address: '', + company_iban: '', + company_phone: '', + company_email: '', +} + +export default function SettingsPage() { + const qc = useQueryClient() + + const { data: settings, isLoading } = useQuery({ + queryKey: ['settings'], + queryFn: () => apiFetch('/settings'), + }) + + const { register, handleSubmit, reset } = useForm({ defaultValues }) + + useEffect(() => { + if (settings) { + reset({ + company_name: settings['company_name'] ?? '', + company_nif: settings['company_nif'] ?? '', + company_address: settings['company_address'] ?? '', + company_iban: settings['company_iban'] ?? '', + company_phone: settings['company_phone'] ?? '', + company_email: settings['company_email'] ?? '', + }) + } + }, [settings, reset]) + + const save = useMutation({ + mutationFn: (data: FormData) => + apiFetch('/settings', { method: 'PUT', body: JSON.stringify(data) }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['settings'] }), + }) + + if (isLoading) return

A carregar...

+ + return ( +
+
+

Definições

+

Dados da oficina utilizados nos documentos PDF

+
+ +
save.mutate(d))} + className="bg-slate-800 rounded-lg border border-slate-700 p-6 space-y-4" + > +
+ + +
+
+
+ + +
+
+ + +
+
+
+ + +
+
+
+ + +
+
+ + +
+
+ + {save.error && ( +

{(save.error as Error).message}

+ )} + {save.isSuccess && ( +

Definições guardadas.

+ )} + +
+ +
+
+
+ ) +} diff --git a/frontend/src/pages/app/StaffPage.tsx b/frontend/src/pages/app/StaffPage.tsx new file mode 100644 index 0000000..42bab8f --- /dev/null +++ b/frontend/src/pages/app/StaffPage.tsx @@ -0,0 +1,181 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useForm, type Resolver } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { apiFetch } from '@/lib/api' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { Staff } from '@/lib/types' + +const STAFF_TYPES = ['internal', 'external'] as const + +const schema = z.object({ + name: z.string().min(1, 'Nome obrigatório'), + email: z.string(), + phone: z.string(), + type: z.enum(STAFF_TYPES), + hourly_rate: z.coerce.number().min(0), + active: z.boolean(), +}) +type FormData = z.infer + +const emptyStaff: FormData = { name: '', email: '', phone: '', type: 'internal', hourly_rate: 0, active: true } + +export default function StaffPage() { + const qc = useQueryClient() + const [editing, setEditing] = useState(null) + const [showForm, setShowForm] = useState(false) + + const { data: staff = [], isLoading } = useQuery({ + queryKey: ['staff'], + queryFn: () => apiFetch('/staff'), + }) + + const { register, handleSubmit, reset, formState: { errors } } = useForm({ + resolver: zodResolver(schema) as Resolver, + defaultValues: emptyStaff, + }) + + const save = useMutation({ + mutationFn: (data: FormData) => + editing + ? apiFetch(`/staff/${editing.id}`, { method: 'PUT', body: JSON.stringify(data) }) + : apiFetch('/staff', { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['staff'] }) + setShowForm(false) + setEditing(null) + reset(emptyStaff) + }, + }) + + const remove = useMutation({ + mutationFn: (id: string) => apiFetch(`/staff/${id}`, { method: 'DELETE' }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['staff'] }), + }) + + function openNew() { + setEditing(null) + reset(emptyStaff) + setShowForm(true) + } + + function openEdit(s: Staff) { + setEditing(s) + reset({ name: s.name, email: s.email, phone: s.phone, type: s.type, hourly_rate: s.hourly_rate, active: s.active }) + setShowForm(true) + } + + return ( +
+
+
+

Técnicos

+

{staff.length} técnicos

+
+ +
+ + {showForm && ( +
+

+ {editing ? 'Editar Técnico' : 'Novo Técnico'} +

+
save.mutate(d))} className="grid grid-cols-2 gap-4"> +
+ + + {errors.name &&

{errors.name.message}

} +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+
+ + +
+ {save.error &&

{(save.error as Error).message}

} +
+ + +
+
+
+ )} + + {isLoading ? ( +

A carregar...

+ ) : staff.length === 0 ? ( +

Nenhum técnico registado.

+ ) : ( +
+ + + + + + + + + + + + + {staff.map((s) => ( + + + + + + + + + ))} + +
NomeTipoEmail€/horaEstado
{s.name}{s.type === 'internal' ? 'Interno' : 'Externo'}{s.email || '—'}{s.hourly_rate.toFixed(2)} € + + {s.active ? 'Activo' : 'Inactivo'} + + + + +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/app/WorkOrderDetailPage.tsx b/frontend/src/pages/app/WorkOrderDetailPage.tsx new file mode 100644 index 0000000..8e40e20 --- /dev/null +++ b/frontend/src/pages/app/WorkOrderDetailPage.tsx @@ -0,0 +1,465 @@ +import { useState } from 'react' +import { useParams, Link } from 'react-router' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { useForm, type Resolver } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { apiFetch } from '@/lib/api' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { WorkOrderDetail, CatalogItem, Staff } from '@/lib/types' + +const CATEGORY_LABEL: Record = { + mao_de_obra: 'Mão de Obra', + diagnostico: 'Diagnóstico', + manutencao: 'Manutenção', + pecas_mecanicas: 'Peças Mecânicas', + pneus_jantes: 'Pneus / Jantes', + eletrica: 'Elétrica / Eletrónica', + fluidos: 'Fluidos / Óleos', + ar_condicionado: 'Ar Condicionado', + carrocaria: 'Carroçaria / Pintura', + consumiveis: 'Consumíveis', + acessorios: 'Acessórios', + outro: 'Outro', +} + +const STATUS_LABELS: Record = { + open: 'Aberta', + in_progress: 'Em Curso', + completed: 'Concluída', + invoiced: 'Faturada', + cancelled: 'Cancelada', +} + +const TRANSITIONS: Record = { + open: ['in_progress', 'cancelled'], + in_progress: ['completed', 'cancelled'], + completed: ['invoiced', 'cancelled'], + invoiced: [], + cancelled: [], +} + +// ─── Item form ──────────────────────────────────────────────────────────────── + +const itemSchema = z.object({ + catalog_item_id: z.string(), + description: z.string().min(1, 'Descrição obrigatória'), + qty: z.coerce.number().positive('Quantidade deve ser positiva'), + unit_price: z.coerce.number().min(0), + discount_pct: z.coerce.number().min(0).max(100), +}) +type ItemForm = z.infer +const emptyItem: ItemForm = { catalog_item_id: '', description: '', qty: 1, unit_price: 0, discount_pct: 0 } + +// ─── Staff hours form ───────────────────────────────────────────────────────── + +const shSchema = z.object({ + staff_id: z.string().min(1, 'Seleccione um técnico'), + hours: z.coerce.number().positive('Horas deve ser positivo'), + cost_per_hour: z.coerce.number().min(0), +}) +type SHForm = z.infer +const emptySH: SHForm = { staff_id: '', hours: 1, cost_per_hour: 0 } + +// ─── Page ───────────────────────────────────────────────────────────────────── + +export default function WorkOrderDetailPage() { + const { id } = useParams<{ id: string }>() + const qc = useQueryClient() + const [showAddItem, setShowAddItem] = useState(false) + const [showAddStaff, setShowAddStaff] = useState(false) + + const { data: detail, isLoading } = useQuery({ + queryKey: ['work-order', id], + queryFn: () => apiFetch(`/work-orders/${id}`), + }) + + const { data: catalogItems = [] } = useQuery({ + queryKey: ['catalog'], + queryFn: () => apiFetch('/catalog'), + }) + + const { data: staffList = [] } = useQuery({ + queryKey: ['staff'], + queryFn: () => apiFetch('/staff'), + }) + + // lookup maps + const catalogMap = Object.fromEntries(catalogItems.map(c => [c.id, c])) + const staffMap = Object.fromEntries(staffList.map(s => [s.id, s])) + + // ── Transitions ────────────────────────────────────────────────────────────── + + const transition = useMutation({ + mutationFn: (status: string) => + apiFetch(`/work-orders/${id}/transition`, { method: 'POST', body: JSON.stringify({ status }) }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }), + }) + + // ── Items ───────────────────────────────────────────────────────────────────── + + const { + register: regItem, + handleSubmit: handleItem, + setValue: setItemVal, + reset: resetItem, + formState: { errors: itemErrors }, + } = useForm({ + resolver: zodResolver(itemSchema) as Resolver, + defaultValues: emptyItem, + }) + + function onCatalogSelect(e: React.ChangeEvent) { + const cid = e.target.value + setItemVal('catalog_item_id', cid) + if (cid) { + const found = catalogMap[cid] + if (found) { + setItemVal('description', found.name) + setItemVal('unit_price', found.base_price) + } + } + } + + const addItem = useMutation({ + mutationFn: (data: ItemForm) => + apiFetch(`/work-orders/${id}/items`, { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['work-order', id] }) + setShowAddItem(false) + resetItem(emptyItem) + }, + }) + + const removeItem = useMutation({ + mutationFn: (itemId: string) => + apiFetch(`/work-orders/${id}/items/${itemId}`, { method: 'DELETE' }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }), + }) + + // ── Staff hours ─────────────────────────────────────────────────────────────── + + const { + register: regSH, + handleSubmit: handleSH, + setValue: setSHVal, + reset: resetSH, + formState: { errors: shErrors }, + } = useForm({ + resolver: zodResolver(shSchema) as Resolver, + defaultValues: emptySH, + }) + + function onStaffSelect(e: React.ChangeEvent) { + const sid = e.target.value + setSHVal('staff_id', sid) + if (sid) { + const found = staffMap[sid] + if (found) setSHVal('cost_per_hour', found.hourly_rate) + } + } + + const addSH = useMutation({ + mutationFn: (data: SHForm) => + apiFetch(`/work-orders/${id}/staff-hours`, { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['work-order', id] }) + setShowAddStaff(false) + resetSH(emptySH) + }, + }) + + const removeSH = useMutation({ + mutationFn: (shId: string) => + apiFetch(`/work-orders/${id}/staff-hours/${shId}`, { method: 'DELETE' }), + onSuccess: () => qc.invalidateQueries({ queryKey: ['work-order', id] }), + }) + + // ── Render ──────────────────────────────────────────────────────────────────── + + if (isLoading) return

A carregar...

+ if (!detail) return

Ordem não encontrada.

+ + const itemsTotal = detail.items.reduce((s, i) => s + i.total, 0) + const hoursTotal = detail.staff_hours.reduce((s, h) => s + h.total, 0) + const nextStates = TRANSITIONS[detail.status] ?? [] + const editable = detail.status !== 'invoiced' && detail.status !== 'cancelled' + + return ( +
+ {/* Header */} +
+ + ← Ordens + + / +

Ordem #{detail.number}

+ + {STATUS_LABELS[detail.status]} + +
+ + {/* Transitions */} + {nextStates.length > 0 && ( +
+ Transição: + {nextStates.map((s) => ( + + ))} +
+ )} + + {/* ── Items ─────────────────────────────────────────────────────────────── */} +
+
+

Itens

+ {editable && ( + + )} +
+ + {showAddItem && ( +
+
addItem.mutate(d))} className="grid grid-cols-3 gap-3"> +
+ + +
+
+ + + {itemErrors.description &&

{itemErrors.description.message}

} +
+
+ + + {itemErrors.qty &&

{itemErrors.qty.message}

} +
+
+ + +
+
+ + +
+ {addItem.error &&

{(addItem.error as Error).message}

} +
+ + +
+
+
+ )} + + {detail.items.length === 0 ? ( +

Sem itens.

+ ) : ( +
+ + + + + + + + + + + + + {detail.items.map((item) => { + const cat = item.catalog_item_id ? catalogMap[item.catalog_item_id] : null + return ( + + + + + + + + + ) + })} + + + + + + + {hoursTotal > 0 && ( + + + + + )} + + + + + +
ArtigoQtd.Preço Unit.Desc.%Total
+
{item.description}
+ {cat && ( +
+ {CATEGORY_LABEL[cat.category] ?? cat.category} +
+ )} +
{item.qty}{item.unit_price.toFixed(2)} €{item.discount_pct}%{item.total.toFixed(2)} € + {editable && ( + + )} +
Subtotal itens{itemsTotal.toFixed(2)} € +
Mão de obra{hoursTotal.toFixed(2)} € +
Total + {(itemsTotal + hoursTotal).toFixed(2)} € + +
+
+ )} +
+ + {/* ── Staff hours ───────────────────────────────────────────────────────── */} +
+
+

Técnicos

+ {editable && ( + + )} +
+ + {showAddStaff && ( +
+
addSH.mutate(d))} className="grid grid-cols-3 gap-3"> +
+ + + {shErrors.staff_id &&

{shErrors.staff_id.message}

} +
+
+ + + {shErrors.hours &&

{shErrors.hours.message}

} +
+
+ + +
+
+

Preço preenchido automaticamente
do cadastro do técnico

+
+ {addSH.error &&

{(addSH.error as Error).message}

} +
+ + +
+
+
+ )} + + {detail.staff_hours.length === 0 ? ( +

Sem técnicos registados nesta ordem.

+ ) : ( +
+ + + + + + + + + + + + {detail.staff_hours.map((sh) => { + const staff = staffMap[sh.staff_id] + return ( + + + + + + + + ) + })} + +
TécnicoHoras€/horaTotal
+
{staff?.name ?? '—'}
+ {staff && ( +
+ {staff.type === 'internal' ? 'Interno' : 'Externo'} +
+ )} +
{sh.hours}{sh.cost_per_hour.toFixed(2)} €{sh.total.toFixed(2)} € + {editable && ( + + )} +
+
+ )} +
+
+ ) +} diff --git a/frontend/src/pages/app/WorkOrdersPage.tsx b/frontend/src/pages/app/WorkOrdersPage.tsx new file mode 100644 index 0000000..9613e09 --- /dev/null +++ b/frontend/src/pages/app/WorkOrdersPage.tsx @@ -0,0 +1,194 @@ +import { useState } from 'react' +import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query' +import { Link } from 'react-router' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { apiFetch } from '@/lib/api' +import { Badge } from '@/components/ui/badge' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { WorkOrder, Client, Vehicle } from '@/lib/types' + +const STATUS_LABELS: Record = { + open: 'Aberta', + in_progress: 'Em Curso', + completed: 'Concluída', + invoiced: 'Faturada', + cancelled: 'Cancelada', +} + +const STATUS_VARIANT: Record = { + open: 'secondary', + in_progress: 'default', + completed: 'default', + invoiced: 'secondary', + cancelled: 'destructive', +} + +const schema = z.object({ + client_id: z.string(), + vehicle_id: z.string(), + internal_notes: z.string(), +}) +type FormData = z.infer + +const emptyOrder: FormData = { client_id: '', vehicle_id: '', internal_notes: '' } + +export default function WorkOrdersPage() { + const qc = useQueryClient() + const [statusFilter, setStatusFilter] = useState('') + const [showForm, setShowForm] = useState(false) + + const { data: orders = [], isLoading } = useQuery({ + queryKey: ['work-orders', statusFilter], + queryFn: () => apiFetch(`/work-orders${statusFilter ? `?status=${statusFilter}` : ''}`), + }) + + const { data: clients = [] } = useQuery({ + queryKey: ['clients'], + queryFn: () => apiFetch('/clients'), + }) + + const { register, handleSubmit, watch, reset } = useForm({ + resolver: zodResolver(schema), + defaultValues: emptyOrder, + }) + + const selectedClientId = watch('client_id') + + const { data: vehicles = [] } = useQuery({ + queryKey: ['vehicles', selectedClientId], + queryFn: () => apiFetch(`/clients/${selectedClientId}/vehicles`), + enabled: !!selectedClientId, + }) + + const create = useMutation({ + mutationFn: (data: FormData) => + apiFetch('/work-orders', { method: 'POST', body: JSON.stringify(data) }), + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['work-orders'] }) + setShowForm(false) + reset() + }, + }) + + return ( +
+
+
+

Ordens de Trabalho

+

{orders.length} ordens

+
+ +
+ +
+ {['', 'open', 'in_progress', 'completed', 'invoiced', 'cancelled'].map((s) => ( + + ))} +
+ + {showForm && ( +
+

Nova Ordem de Trabalho

+
create.mutate(d))} className="grid grid-cols-2 gap-4"> +
+ + +
+
+ + +
+
+ + +
+ {create.error && ( +

{create.error.message}

+ )} +
+ + +
+
+
+ )} + + {isLoading ? ( +

A carregar...

+ ) : orders.length === 0 ? ( +

Nenhuma ordem de trabalho.

+ ) : ( +
+ + + + + + + + + + + {orders.map((o) => ( + + + + + + + ))} + +
EstadoCriada
#{o.number} + + {STATUS_LABELS[o.status]} + + + {new Intl.DateTimeFormat('pt-PT').format(new Date(o.created_at))} + + + Ver detalhe → + +
+
+ )} +
+ ) +} diff --git a/frontend/src/pages/auth/LoginPage.tsx b/frontend/src/pages/auth/LoginPage.tsx new file mode 100644 index 0000000..a7826b1 --- /dev/null +++ b/frontend/src/pages/auth/LoginPage.tsx @@ -0,0 +1,93 @@ +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { useState } from 'react' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import { useLogin } from '@/hooks/useAuth' + +const schema = z.object({ + email: z.string().email('Email inválido'), + password: z.string().min(1, 'Password obrigatória'), + tenant_slug: z.string().optional(), +}) +type FormData = z.infer + +export default function LoginPage() { + const [showSlug, setShowSlug] = useState(false) + const { mutate: login, isPending, error } = useLogin() + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ resolver: zodResolver(schema) }) + + const onSubmit = (data: FormData) => { + login({ + email: data.email, + password: data.password, + tenant_slug: data.tenant_slug || undefined, + }) + } + + return ( +
+
+
+

TechXCar

+

Gestão de Oficina

+
+ +
+
+ + + {errors.email &&

{errors.email.message}

} +
+ +
+ + + {errors.password &&

{errors.password.message}

} +
+ + {showSlug && ( +
+ + +
+ )} + + {error &&

{error.message}

} + + + + +
+
+
+ ) +} diff --git a/frontend/src/pages/public/InviteRedeemPage.tsx b/frontend/src/pages/public/InviteRedeemPage.tsx new file mode 100644 index 0000000..eeb8a23 --- /dev/null +++ b/frontend/src/pages/public/InviteRedeemPage.tsx @@ -0,0 +1,160 @@ +import { useParams, useNavigate } from 'react-router' +import { useQuery, useMutation } from '@tanstack/react-query' +import { useForm } from 'react-hook-form' +import { zodResolver } from '@hookform/resolvers/zod' +import { z } from 'zod' +import { apiFetch } from '@/lib/api' +import { useAuthStore } from '@/store/authStore' +import { Button } from '@/components/ui/button' +import { Input } from '@/components/ui/input' +import { Label } from '@/components/ui/label' +import type { Invite } from '@/lib/types' + +const schema = z.object({ + tenant_name: z.string().min(2, 'Nome obrigatório (mínimo 2 caracteres)'), + tenant_slug: z + .string() + .min(2, 'Slug obrigatório') + .regex(/^[a-z0-9-]+$/, 'Apenas letras minúsculas, números e hífens'), + admin_email: z.string().email('Email inválido'), + admin_password: z.string().min(8, 'Mínimo 8 caracteres'), + admin_name: z.string().min(2, 'Nome obrigatório'), +}) +type FormData = z.infer + +interface RedeemResponse { + access_token: string + tenant_slug: string +} + +export default function InviteRedeemPage() { + const { token } = useParams<{ token: string }>() + const navigate = useNavigate() + const { setAuth } = useAuthStore() + + const { + data: invite, + isLoading, + error: inviteError, + } = useQuery({ + queryKey: ['invite', token], + queryFn: () => apiFetch(`/invites/${token}`), + retry: false, + }) + + const { + register, + handleSubmit, + formState: { errors }, + } = useForm({ resolver: zodResolver(schema) }) + + const redeem = useMutation({ + mutationFn: (data: FormData) => + apiFetch(`/invites/${token}/redeem`, { + method: 'POST', + body: JSON.stringify(data), + }), + onSuccess: (data) => { + setAuth( + { id: '', email: '', name: '', role: 'tenant_admin', tenantId: undefined }, + data.access_token, + ) + navigate('/app', { replace: true }) + }, + }) + + if (isLoading) { + return ( +
+

A validar convite...

+
+ ) + } + + if (inviteError || !invite) { + return ( +
+
+

Convite inválido

+

+ Este convite não existe, expirou ou já foi utilizado. +

+
+
+ ) + } + + return ( +
+
+
+

TechXCar

+

Criar conta da oficina

+
+ +
redeem.mutate(data))} + className="bg-white p-8 rounded-lg shadow-sm border border-gray-200 space-y-4" + > +
+ + Dados da oficina + +
+ + + {errors.tenant_name && ( +

{errors.tenant_name.message}

+ )} +
+
+ + +

+ Usado no URL — apenas letras minúsculas, números e hífens. +

+ {errors.tenant_slug && ( +

{errors.tenant_slug.message}

+ )} +
+
+ +
+ + Conta de administrador + +
+ + + {errors.admin_name && ( +

{errors.admin_name.message}

+ )} +
+
+ + + {errors.admin_email && ( +

{errors.admin_email.message}

+ )} +
+
+ + + {errors.admin_password && ( +

{errors.admin_password.message}

+ )} +
+
+ + {redeem.error && ( +

{redeem.error.message}

+ )} + + +
+
+
+ ) +} diff --git a/frontend/src/store/authStore.test.ts b/frontend/src/store/authStore.test.ts new file mode 100644 index 0000000..088485c --- /dev/null +++ b/frontend/src/store/authStore.test.ts @@ -0,0 +1,103 @@ +import { describe, it, expect, beforeEach } from 'vitest' +import { useAuthStore } from './authStore' + +describe('authStore', () => { + beforeEach(() => { + useAuthStore.setState({ + user: null, + accessToken: null, + isAuthenticated: false, + previousSession: null, + }) + }) + + it('starts unauthenticated', () => { + const state = useAuthStore.getState() + expect(state.isAuthenticated).toBe(false) + expect(state.user).toBeNull() + expect(state.accessToken).toBeNull() + }) + + it('setAuth stores user and token', () => { + const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const } + useAuthStore.getState().setAuth(user, 'tok123') + + const state = useAuthStore.getState() + expect(state.isAuthenticated).toBe(true) + expect(state.user).toEqual(user) + expect(state.accessToken).toBe('tok123') + }) + + it('clearAuth resets to unauthenticated', () => { + const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const } + useAuthStore.getState().setAuth(user, 'tok123') + useAuthStore.getState().clearAuth() + + const state = useAuthStore.getState() + expect(state.isAuthenticated).toBe(false) + expect(state.user).toBeNull() + expect(state.accessToken).toBeNull() + }) + + it('updateToken replaces only the token', () => { + const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const } + useAuthStore.getState().setAuth(user, 'old-token') + useAuthStore.getState().updateToken('new-token') + + const state = useAuthStore.getState() + expect(state.accessToken).toBe('new-token') + expect(state.user).toEqual(user) + }) + + it('impersonateTenant saves previous session and sets new auth', () => { + const originalUser = { id: '1', email: 'admin@a.com', name: 'Admin', role: 'super_admin' as const } + const tenantUser = { id: '2', email: 'tenant@a.com', name: 'Tenant User', role: 'tenant_admin' as const, tenantId: 'tenant-123' } + + // Setup original session + useAuthStore.getState().setAuth(originalUser, 'admin-token') + + // Impersonate tenant + useAuthStore.getState().impersonateTenant('tenant-token', tenantUser) + + const state = useAuthStore.getState() + expect(state.user).toEqual(tenantUser) + expect(state.accessToken).toBe('tenant-token') + expect(state.isAuthenticated).toBe(true) + expect(state.previousSession).toEqual({ + token: 'admin-token', + user: originalUser, + }) + }) + + it('restoreSession restores previous session and clears previousSession', () => { + const originalUser = { id: '1', email: 'admin@a.com', name: 'Admin', role: 'super_admin' as const } + const tenantUser = { id: '2', email: 'tenant@a.com', name: 'Tenant User', role: 'tenant_admin' as const, tenantId: 'tenant-123' } + + // Setup original session and impersonate + useAuthStore.getState().setAuth(originalUser, 'admin-token') + useAuthStore.getState().impersonateTenant('tenant-token', tenantUser) + + // Restore + useAuthStore.getState().restoreSession() + + const state = useAuthStore.getState() + expect(state.user).toEqual(originalUser) + expect(state.accessToken).toBe('admin-token') + expect(state.isAuthenticated).toBe(true) + expect(state.previousSession).toBeNull() + }) + + it('restoreSession when no previousSession is a no-op', () => { + const user = { id: '1', email: 'a@a.com', name: 'Test', role: 'tenant_admin' as const } + useAuthStore.getState().setAuth(user, 'tok123') + + const stateBefore = useAuthStore.getState() + useAuthStore.getState().restoreSession() + const stateAfter = useAuthStore.getState() + + expect(stateAfter.user).toEqual(stateBefore.user) + expect(stateAfter.accessToken).toEqual(stateBefore.accessToken) + expect(stateAfter.isAuthenticated).toBe(true) + expect(stateAfter.previousSession).toBeNull() + }) +}) diff --git a/frontend/src/store/authStore.ts b/frontend/src/store/authStore.ts new file mode 100644 index 0000000..c91b967 --- /dev/null +++ b/frontend/src/store/authStore.ts @@ -0,0 +1,76 @@ +import { create } from 'zustand' +import { persist } from 'zustand/middleware' + +export type UserRole = 'super_admin' | 'tenant_admin' | 'manager' | 'technician' + +export interface AuthUser { + id: string + email: string + name: string + role: UserRole + tenantId?: string +} + +interface PreviousSession { + token: string + user: AuthUser +} + +interface AuthState { + user: AuthUser | null + accessToken: string | null + isAuthenticated: boolean + previousSession: PreviousSession | null + setAuth: (user: AuthUser, accessToken: string) => void + clearAuth: () => void + updateToken: (accessToken: string) => void + impersonateTenant: (token: string, user: AuthUser) => void + restoreSession: () => void +} + +export const useAuthStore = create()( + persist( + (set, get) => ({ + user: null, + accessToken: null, + isAuthenticated: false, + previousSession: null, + setAuth: (user, accessToken) => + set({ user, accessToken, isAuthenticated: true, previousSession: null }), + clearAuth: () => + set({ user: null, accessToken: null, isAuthenticated: false, previousSession: null }), + updateToken: (accessToken) => + set({ accessToken }), + impersonateTenant: (token, user) => { + const { accessToken, user: currentUser } = get() + set({ + accessToken: token, + user, + isAuthenticated: true, + previousSession: currentUser && accessToken + ? { token: accessToken, user: currentUser } + : null, + }) + }, + restoreSession: () => { + const { previousSession } = get() + if (!previousSession) return + set({ + accessToken: previousSession.token, + user: previousSession.user, + isAuthenticated: true, + previousSession: null, + }) + }, + }), + { + name: 'techxcar-auth', + partialize: (state) => ({ + user: state.user, + accessToken: state.accessToken, + isAuthenticated: state.isAuthenticated, + // previousSession intentionally excluded — impersonation does not survive refresh + }), + } + ) +) diff --git a/frontend/src/test/setup.ts b/frontend/src/test/setup.ts new file mode 100644 index 0000000..ae08a1d --- /dev/null +++ b/frontend/src/test/setup.ts @@ -0,0 +1,15 @@ +import '@testing-library/jest-dom' + +const localStorageMock = (() => { + let store: Record = {} + return { + getItem: (key: string) => store[key] ?? null, + setItem: (key: string, value: string) => { store[key] = value }, + removeItem: (key: string) => { delete store[key] }, + clear: () => { store = {} }, + get length() { return Object.keys(store).length }, + key: (index: number) => Object.keys(store)[index] ?? null, + } +})() + +Object.defineProperty(globalThis, 'localStorage', { value: localStorageMock }) diff --git a/frontend/src/vite-env.d.ts b/frontend/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/frontend/src/vite-env.d.ts @@ -0,0 +1 @@ +/// diff --git a/frontend/tsconfig.app.json b/frontend/tsconfig.app.json new file mode 100644 index 0000000..d541922 --- /dev/null +++ b/frontend/tsconfig.app.json @@ -0,0 +1,23 @@ +{ + "compilerOptions": { + "target": "ES2020", + "useDefineForClassFields": true, + "lib": ["ES2020", "DOM", "DOM.Iterable"], + "module": "ESNext", + "skipLibCheck": true, + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "isolatedModules": true, + "moduleDetection": "force", + "noEmit": true, + "jsx": "react-jsx", + "strict": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "paths": { + "@/*": ["./src/*"] + } + }, + "include": ["src"] +} diff --git a/frontend/tsconfig.app.tsbuildinfo b/frontend/tsconfig.app.tsbuildinfo new file mode 100644 index 0000000..791a254 --- /dev/null +++ b/frontend/tsconfig.app.tsbuildinfo @@ -0,0 +1 @@ +{"root":["./src/App.tsx","./src/main.tsx","./src/vite-env.d.ts","./src/components/layout/AdminLayout.tsx","./src/components/layout/AppLayout.tsx","./src/components/ui/badge.tsx","./src/components/ui/button.tsx","./src/components/ui/dialog.tsx","./src/components/ui/input.tsx","./src/components/ui/label.tsx","./src/hooks/useAuth.ts","./src/hooks/useClients.test.ts","./src/hooks/useClients.ts","./src/lib/api.ts","./src/lib/queryClient.ts","./src/lib/types.ts","./src/lib/utils.ts","./src/pages/admin/DashboardPage.tsx","./src/pages/admin/TenantsPage.tsx","./src/pages/app/CatalogPage.tsx","./src/pages/app/ClientsPage.tsx","./src/pages/app/DashboardPage.tsx","./src/pages/app/ExpensesPage.tsx","./src/pages/app/InvoicesPage.tsx","./src/pages/app/SettingsPage.tsx","./src/pages/app/StaffPage.tsx","./src/pages/app/WorkOrderDetailPage.tsx","./src/pages/app/WorkOrdersPage.tsx","./src/pages/auth/LoginPage.tsx","./src/pages/public/InviteRedeemPage.tsx","./src/store/authStore.test.ts","./src/store/authStore.ts","./src/test/setup.ts"],"version":"6.0.3"} \ No newline at end of file diff --git a/frontend/tsconfig.json b/frontend/tsconfig.json new file mode 100644 index 0000000..1ffef60 --- /dev/null +++ b/frontend/tsconfig.json @@ -0,0 +1,7 @@ +{ + "files": [], + "references": [ + { "path": "./tsconfig.app.json" }, + { "path": "./tsconfig.node.json" } + ] +} diff --git a/frontend/tsconfig.node.json b/frontend/tsconfig.node.json new file mode 100644 index 0000000..d3c52ea --- /dev/null +++ b/frontend/tsconfig.node.json @@ -0,0 +1,24 @@ +{ + "compilerOptions": { + "tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo", + "target": "es2023", + "lib": ["ES2023"], + "module": "esnext", + "types": ["node"], + "skipLibCheck": true, + + /* Bundler mode */ + "moduleResolution": "bundler", + "allowImportingTsExtensions": true, + "verbatimModuleSyntax": true, + "moduleDetection": "force", + "noEmit": true, + + /* Linting */ + "noUnusedLocals": true, + "noUnusedParameters": true, + "erasableSyntaxOnly": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["vite.config.ts"] +} diff --git a/frontend/vite.config.ts b/frontend/vite.config.ts new file mode 100644 index 0000000..838b645 --- /dev/null +++ b/frontend/vite.config.ts @@ -0,0 +1,29 @@ +import { defineConfig } from 'vitest/config' +import react from '@vitejs/plugin-react' +import tailwindcss from '@tailwindcss/vite' +import path from 'path' + +export default defineConfig({ + plugins: [ + react(), + tailwindcss(), + ], + resolve: { + alias: { + '@': path.resolve(__dirname, './src'), + }, + }, + server: { + proxy: { + '/api': { + target: 'http://localhost:8080', + changeOrigin: true, + }, + }, + }, + test: { + environment: 'jsdom', + globals: true, + setupFiles: ['./src/test/setup.ts'], + }, +}) diff --git a/readme-app.md b/readme-app.md new file mode 100644 index 0000000..54c5b46 --- /dev/null +++ b/readme-app.md @@ -0,0 +1,429 @@ +# TechXCar — Análise do Repositório + +**Data**: 02/07/2026 +**Branch**: `feat/plan2-auth-multitenancy` + +--- + +## 📋 Visão Geral + +**TechXCar** é um SaaS multi-tenant de gestão de oficina automóvel, construído com Go (Fiber) no backend e React 19 + TypeScript + Vite no frontend. Cada oficina (tenant) tem um schema PostgreSQL isolado (`tenant_`). + +--- + +## 🏗 Stack Tecnológica + +| Camada | Tecnologia | +|---|---| +| **Frontend** | React 19, TypeScript 6, Vite 8, Tailwind CSS 4, React Router 7, TanStack Query 5, Zustand 5, React Hook Form + Zod | +| **Backend** | Go 1.25, Fiber v2, pgx v5, golang-jwt, golang-migrate, bcrypt, go-redis | +| **Base de Dados** | PostgreSQL 16 (multi-tenancy via schema-per-tenant) | +| **Cache** | Redis 7 | +| **PDF** | go-pdf/fpdf (server-side, nativo — sem Chrome headless) | +| **Infra** | Docker Compose, Nginx, Coolify | + +--- + +## 📁 Estrutura do Projeto + +``` +techxcar/ +├── frontend/ # React 19 + Vite +│ ├── src/ +│ │ ├── components/ +│ │ │ ├── ui/ # shadcn-style (button, input, label, badge, dialog) +│ │ │ └── layout/ # AppLayout, AdminLayout +│ │ ├── hooks/ # useAuth, useClients +│ │ ├── lib/ # api.ts, queryClient.ts, types.ts, utils.ts +│ │ ├── pages/ +│ │ │ ├── auth/ # LoginPage +│ │ │ ├── admin/ # DashboardPage, TenantsPage +│ │ │ ├── app/ # 10 páginas de gestão da oficina +│ │ │ └── public/ # InviteRedeemPage +│ │ ├── store/ # authStore (Zustand + persist) +│ │ └── test/ # setup Vitest +│ ├── public/ # favicon, icons +│ ├── Dockerfile # Nginx a servir build Vite +│ └── nginx.conf +├── backend/ # Go + Fiber +│ ├── cmd/server/main.go # Entrypoint +│ ├── internal/ +│ │ ├── auth/ # (10 ficheiros) JWT, bcrypt, middleware, rate-limit, routes +│ │ ├── tenant/ # (6 ficheiros) multi-tenant, invites, provisioning, access +│ │ ├── client/ # (4 ficheiros) clientes + veículos CRUD +│ │ ├── catalog/ # (4 ficheiros) catálogo de itens CRUD +│ │ ├── workorder/ # (4 ficheiros) OT CRUD + state machine + items + staff hours +│ │ ├── staff/ # (4 ficheiros) técnicos CRUD +│ │ ├── expense/ # (4 ficheiros) despesas CRUD +│ │ ├── invoice/ # (4 ficheiros) faturação + geração PDF +│ │ ├── settings/ # (4 ficheiros) definições da oficina +│ │ ├── server/ # (2 ficheiros) Fiber app, health routes, error handler +│ │ └── config/ # (2 ficheiros) env config (DATABASE_URL, JWT_SECRET, etc.) +│ ├── pkg/ +│ │ ├── database/ # pgxpool wrapper, migrations (public + tenant) +│ │ ├── redis/ # go-redis wrapper +│ │ └── pdf/ # go-pdf/fpdf generation +│ ├── migrations/ +│ │ ├── public/ # 2 migrações (tenants, invites, super_admins, platform_settings) +│ │ └── tenant/ # 2 migrações (11 tabelas por tenant) +│ ├── Dockerfile # Multi-stage Go build +│ ├── go.mod / go.sum +│ └── Makefile (via CLAUDE.md) +├── docker-compose.yml # Dev: postgres + redis + backend + frontend +├── docker-compose.prod.yml # Produção (Coolify) +├── .env.example # Template de env vars +└── docs/ + └── superpowers/ + ├── specs/ # Design specs (arquitetura, multi-tenancy, impersonação) + └── plans/ # Planos de implementação (6 planos) +``` + +--- + +## 🔐 Autenticação & Autorização + +### Mecanismo + +- **JWT** com access token (15 min) + refresh token (30 dias, httpOnly cookie) +- **Refresh rotativo**: armazenado em Redis (`refresh:`) — cada refresh gera novo token +- **Rate limiting**: 10 tentativas/min/IP nas rotas de auth (via Redis) + +### Roles + +| Role | Acesso | +|---|---| +| `super_admin` | Painel `/admin`, gestão de tenants, impersonação | +| `tenant_admin` | Acesso total à oficina | +| `manager` | Acesso operacional (sem settings financeiras) | +| `technician` | Acesso às OTs atribuídas | + +### Fluxo de Login + +``` +POST /api/v1/auth/login { email, password, tenant_slug? } +``` + +1. Se `tenant_slug` vazio → autentica como `super_admin` (tabela `public.super_admins`) +2. Se `tenant_slug` preenchido → procura tenant → procura user no schema do tenant +3. Devolve `access_token` (body) + `refresh_token` (httpOnly cookie) + +### Middleware Stack + +``` +RequireAuth(secret) → valida JWT, extrai claims +RequireRole("tenant_admin", "manager") → verifica role +TenantMiddleware(db) → adquire conexão, SET search_path = tenant_, public +``` + +--- + +## 🏢 Multi-tenancy (Schema-per-tenant) + +### Estrutura + +- **Schema `public`**: `tenants`, `invites`, `super_admins`, `platform_settings` +- **Schema `tenant_`** (por oficina): todas as tabelas de negócio + +### Provisionamento + +1. Super-admin cria tenant (`INSERT INTO public.tenants`) +2. `ProvisionTenantSchema()` cria schema e corre migrations de tenant +3. Cria user admin no schema do tenant + +### Isolamento + +- `TenantMiddleware` adquire conexão pgxpool dedicada por request +- Define `SET search_path = "tenant_", public` +- **Bug corrigido**: schema name usa underscores em vez de hífens +- `validTenantID` regex: `^[a-zA-Z0-9_-]{1,63}$` + +### Impersonação (Super-admin → Tenant) + +1. `POST /api/v1/admin/tenants/:id/access` → gera token JWT com `role="tenant_admin"` + `tenantID` +2. Frontend guarda sessão anterior em `previousSession` (não persistido) +3. Navega para `/app` com banner amarelo: "TechXCar Admin — a gerir: " +4. "Voltar" restaura sessão anterior (`restoreSession()`) + +--- + +## 🧩 Módulos de Negócio + +### 1. Auth (`internal/auth/`) + +| Ficheiro | Descrição | +|---|---| +| `handler.go` | Login, refresh, logout handlers | +| `jwt.go` | Geração/validação de tokens (access + refresh) | +| `bcrypt.go` | Hash + verify com cost 12 | +| `middleware.go` | RequireAuth, RequireRole, TenantMiddleware | +| `ratelimit.go` | Rate limiter com Redis storage | +| `routes.go` | Registo de rotas `/api/v1/auth/*` | + +### 2. Tenant / Invites (`internal/tenant/`) + +| Ficheiro | Descrição | +|---|---| +| `handler.go` | CRUD tenants, invites, redeem, access | +| `repository.go` | Queries: super_admins, tenants, users, invites | +| `routes.go` | Registo de rotas + `loginAdapter` | + +**Endpoints:** +- `GET /api/v1/admin/tenants` — listar (super_admin) +- `POST /api/v1/admin/tenants` — criar + provisionar schema +- `POST /api/v1/admin/tenants/:id/invite` — gerar convite para tenant +- `POST /api/v1/admin/tenants/:id/access` — impersonar tenant +- `POST /api/v1/admin/invites` — gerar convite global +- `GET /api/v1/invites/:token` — consultar convite (público) +- `POST /api/v1/invites/:token/redeem` — resgatar convite + criar conta + +### 3. Work Orders (`internal/workorder/`) + +**Máquina de estados:** +``` +open → in_progress → completed → invoiced + ↓ + cancelled (de qualquer estado excepto invoiced) +``` + +**Endpoints:** +- `GET /api/v1/work-orders` — listar (com filtro `?status=`) +- `POST /api/v1/work-orders` — criar +- `GET /api/v1/work-orders/:id` — detalhe (com items + staff_hours) +- `PUT /api/v1/work-orders/:id` — actualizar +- `POST /api/v1/work-orders/:id/transition` — transição de estado +- `POST /api/v1/work-orders/:id/items` — adicionar item +- `DELETE /api/v1/work-orders/:id/items/:itemId` — remover item +- `POST /api/v1/work-orders/:id/staff-hours` — adicionar horas técnico +- `DELETE /api/v1/work-orders/:id/staff-hours/:shId` — remover horas + +**Destaques:** +- `wo_status_log` regista cada transição (from, to, changed_by, timestamp) +- Totais calculados via `GENERATED ALWAYS AS` no PostgreSQL + +### 4. Clientes & Veículos (`internal/client/`) + +- CRUD clientes + veículos +- Veículos aninhados a clientes (opcional) +- Veículo pode ser criado sem cliente + +### 5. Catálogo (`internal/catalog/`) + +- Items com código, nome, categoria, unidade (`un`, `hora`, `litro`, `kg`) +- Preço base + activo/inactivo +- Preço copiado para OT no momento de adição (editável) + +### 6. Staff (`internal/staff/`) + +- Interno (com conta no sistema) / Externo (registo simplificado) +- Custo/hora para cálculo de mão de obra + +### 7. Despesas (`internal/expense/`) + +- Tipos: `fuel`, `parts`, `tools`, `other` +- Filtro por tipo, ordenado por data + +### 8. Definições (`internal/settings/`) + +| Chave | Descrição | +|---|---| +| `company_name` | Nome da oficina | +| `company_nif` | NIF | +| `company_address` | Morada | +| `company_iban` | IBAN | +| `company_phone` | Telefone | +| `company_email` | Email | + +Apenas `tenant_admin` pode escrever; `manager` pode ler. + +### 9. Faturação (`internal/invoice/`) + +- Criação de orçamentos (`quote`) e faturas (`invoice`) +- Geração PDF com go-pdf/fpdf (dados da oficina + cliente + veículo + items + staff hours) +- Ao faturar, OT transita automaticamente para `invoiced` +- PDFs servidos via endpoint autenticado + +### 10. Health (`internal/server/health.go`) + +``` +GET /api/v1/health → { "status": "ok", "version": "0.1.0" } +``` + +--- + +## 🖥 Frontend + +### Páginas Implementadas + +| Rota | Página | Função | +|---|---|---| +| `/login` | LoginPage | Autenticação (com/sem slug) | +| `/invite/:token` | InviteRedeemPage | Resgatar convite (criar oficina) | +| `/admin` | AdminDashboardPage | Placeholder | +| `/admin/tenants` | TenantsPage | Listar/criar tenants, gerar convites, Gerir (impersonar) | +| `/app` | DashboardPage | Placeholder | +| `/app/clients` | ClientsPage | CRUD clientes + veículos inline | +| `/app/catalog` | CatalogPage | CRUD catálogo com categorias | +| `/app/work-orders` | WorkOrdersPage | Listar OTs com filtro de estado | +| `/app/work-orders/:id` | WorkOrderDetailPage | Detalhe OT: items, staff_hours, transições | +| `/app/staff` | StaffPage | CRUD técnicos | +| `/app/expenses` | ExpensesPage | CRUD despesas com filtro | +| `/app/invoices` | InvoicesPage | Listar faturas, gerar novo documento, download PDF | +| `/app/settings` | SettingsPage | Definições da oficina (formulário) | + +### UI/UX + +- **Tema escuro**: `bg-slate-950`, texto branco, sidebar slate-900 +- **Componentes shadcn-style**: Button, Input, Label, Badge, Dialog +- **Formulários**: React Hook Form + Zod (validação client-side) +- **Cache**: TanStack Query (staleTime 5 min, retry apenas em erros 500+) +- **Auto-refresh**: apiFetch tenta refresh em 401, limpa sessão se falhar + +### Store (Zustand) + +``` +authStore (persistida em localStorage): + - user, accessToken, isAuthenticated + - previousSession (NÃO persistido — apenas para impersonação) + - setAuth, clearAuth, updateToken + - impersonateTenant, restoreSession +``` + +### Layouts + +**AppLayout** (área da oficina): +- Sidebar com navegação: Dashboard, OT, Clientes, Catálogo, Técnicos, Despesas, Faturação, Definições +- Banner de impersonação (amarelo) quando super-admin gere tenant +- Botão "Terminar sessão" + +**AdminLayout** (área super-admin): +- Sidebar simplificada: Dashboard, Oficinas +- Botão "Terminar sessão" + +**Protecção**: `RequireAuth` wrapper verifica roles + redirect para `/login` se não autenticado + +--- + +## 🗄 Base de Dados + +### Migrations Public (2) + +| Migração | Descrição | +|---|---| +| `000001` | `uuid-ossp`, `tenants`, `invites`, `super_admins`, `platform_settings` + índices | +| `000002` | `invites.tenant_id` → nullable + índice condicional | + +### Migrations Tenant (2) + +| Migração | Descrição | +|---|---| +| `000001` | 11 tabelas: `users`, `clients`, `vehicles`, `staff`, `catalog_items`, `work_orders`, `wo_items`, `wo_staff_hours`, `wo_status_log`, `invoices`, `expenses`, `tenant_settings` | +| `000002` | Adiciona `fuel_type` a `vehicles` | + +### Destaques do Schema + +- **Colunas geradas**: `wo_items.total = qty * unit_price * (1 - discount_pct / 100)`, `wo_staff_hours.total = hours * cost_per_hour` +- **Serial unique**: `work_orders.number` e `invoices.number` (únicos por schema tenant) +- **ON DELETE**: SET NULL para FKs opcionais, CASCADE para dependentes, RESTRICT para invoices +- **Índices**: status, client_id, plate, date + +--- + +## 🧪 Testes + +### Backend (Go + testify) + +| Pacote | Testes | +|---|---| +| `auth` | JWT gen/validation (access + refresh), bcrypt (hash + verify), middleware (auth, role), login handler (stub repo) | +| `tenant` | CRUD super_admin, CRUD tenant, invites (create + use), access handler (success, not found, inactive) | +| `workorder` | CRUD OT, transições de estado, validação de transições (11 casos) | +| `config` | Parse de env vars | +| `database` | Pool creation | +| `server` | Health endpoint | +| `redis` | Conexão | + +**Integração**: testes com `TEST_DATABASE_URL` / `TEST_REDIS_URL` usam `t.Skip()` quando não definidas. + +### Frontend (Vitest + Testing Library) + +- `authStore.test.ts` +- `useClients.test.ts` +- Setup: `src/test/setup.ts` + +--- + +## 🐳 Docker + +### docker-compose.yml (Dev) + +```yaml +services: + postgres: # 16-alpine, healthcheck, porta 5432 + redis: # 7-alpine, appendonly, healthcheck, porta 6379 + backend: # Go build, porta 8080, depende de postgres+redis + frontend: # Nginx build Vite, porta 3000, depende de backend + +volumes: postgres_data, redis_data, pdf_storage +``` + +### docker-compose.prod.yml + +Produção para Coolify com HTTPS via Let's Encrypt. + +### Dockerfiles + +- **Backend**: Multi-stage (builder Go → distroless/debug) +- **Frontend**: Build Vite → Nginx (porta 8080 no container) + +--- + +## 🔍 Observações Técnicas + +### Pontos Fortes + +- **Arquitetura multi-tenant sólida**: schema isolation com validação de inputs e search_path dinâmico +- **Máquina de estados robusta**: transições validadas, log de todas as mudanças +- **Auto-refresh de token**: renovação silenciosa sem perder sessão +- **Impersonação bem desenhada**: session restore puramente client-side, sem persistência +- **PDF nativo**: go-pdf/fpdf em vez de chromedp (menos dependências, mais rápido) +- **Colunas GENERATED**: totais computados pelo PostgreSQL (consistência garantida) +- **Cobertura de testes**: handler tests com stub repo, integração opcional + +### Problemas / Risco + +1. **Acoplamento entre packages**: `internal/invoice/handler.go` importa `internal/workorder` para `GetWorkOrderDetail` e `TransitionStatus` — risco de dependência circular ao crescer +2. **PDF path hardcoded**: `storageRoot = "/app/storage"` — spec diz configurável (S3-compatible) +3. **Sem paginação**: listagens (`GET /clients`, etc.) sem limit/offset — problemático com muitos registos +4. **Go 1.25.0**: versão futura (não lançada oficialmente) — pode não compilar em todos os ambientes +5. **Vite 8**: bleeding edge — possível instabilidade ou breaking changes +6. **Dashboard placeholders**: admin e app Dashboard sem métricas reais +7. **fuel_type hardcoded**: frontend tem lista fixa em vez de vir do backend/catálogo +8. **Sem notificações**: módulo `notification/` (Telegram + SMTP) não implementado +9. **Sem relatórios**: módulo `report/` não implementado +10. **i18n**: apenas estrutura preparada, só Português de Portugal + +--- + +## ✅ Estado Actual + +### Implementado +- [x] Autenticação (login, refresh, logout, rate-limit) +- [x] Multi-tenancy (schema isolation + provisioning) +- [x] Convites (redeem flow completo com criação de tenant + schema + admin) +- [x] Impersonação super-admin (acesso a tenants com session restore) +- [x] Clientes + Veículos (CRUD, inline por cliente) +- [x] Catálogo de itens (CRUD com categorias e unidades) +- [x] Ordens de Trabalho (CRUD + state machine + items + staff hours + status log) +- [x] Técnicos (CRUD, internos/externos, custo/hora) +- [x] Despesas (CRUD por tipo) +- [x] Definições da oficina (6 chaves) +- [x] Faturação/Orçamentos (geração PDF com go-pdf/fpdf) +- [x] Frontend completo (11 páginas, tema escuro, navegação sidebar) + +### Não Implementado +- [ ] Notificações (Telegram + SMTP) +- [ ] Relatórios com métricas reais +- [ ] i18n completo (apenas PT) +- [ ] Paginação em listagens +- [ ] Upload de logo para PDF +- [ ] Armazenamento S3 para PDFs diff --git a/skills-lock.json b/skills-lock.json new file mode 100644 index 0000000..cf32588 --- /dev/null +++ b/skills-lock.json @@ -0,0 +1,17 @@ +{ + "version": 1, + "skills": { + "caveman": { + "source": "juliusbrussee/caveman", + "sourceType": "github", + "skillPath": "skills/caveman/SKILL.md", + "computedHash": "1902fa0b569912d0c05736d8d98a72097d9b82719aac88c0c1d03bb546f9176d" + }, + "frontend-design": { + "source": "anthropics/skills", + "sourceType": "github", + "skillPath": "skills/frontend-design/SKILL.md", + "computedHash": "4eabc66183767153e404b39d1b839b1c37f2d82d86f0a0d7e880a579d8d62336" + } + } +}