Inicial
This commit is contained in:
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
@@ -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.
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/caveman
|
||||||
Symlink
+1
@@ -0,0 +1 @@
|
|||||||
|
../../.agents/skills/frontend-design
|
||||||
@@ -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)
|
||||||
+18
@@ -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
|
||||||
@@ -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_<uuid>` schema: all workshop data (users, clients, vehicles, work_orders, etc.)
|
||||||
|
|
||||||
|
Every API request sets `search_path = tenant_<uuid>, 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 <access_token>` 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`.
|
||||||
@@ -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_<uuid>` schema: all workshop data (users, clients, vehicles, work_orders, etc.)
|
||||||
|
|
||||||
|
Every API request sets `search_path = tenant_<uuid>, 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 <access_token>` 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`.
|
||||||
@@ -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"]
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
)
|
||||||
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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(),
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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))
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE vehicles DROP COLUMN IF EXISTS fuel_type;
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
ALTER TABLE vehicles ADD COLUMN IF NOT EXISTS fuel_type TEXT;
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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())
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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()
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
@@ -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:
|
||||||
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
File diff suppressed because it is too large
Load Diff
@@ -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<AuthState>()(
|
||||||
|
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 `<tbody>`, add a fifth column header `<th>` in `<thead>`:
|
||||||
|
```tsx
|
||||||
|
<th className="text-left px-4 py-3 text-slate-400 font-medium">Ações</th>
|
||||||
|
```
|
||||||
|
|
||||||
|
4. In each `<tr>` inside the `.map()`, add a fifth `<td>` after the "Criada" cell:
|
||||||
|
```tsx
|
||||||
|
<td className="px-4 py-3">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
onClick={() => accessTenant.mutate(t.id)}
|
||||||
|
disabled={accessTenant.isPending}
|
||||||
|
>
|
||||||
|
{accessTenant.isPending ? '...' : 'Gerir'}
|
||||||
|
</Button>
|
||||||
|
</td>
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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 (
|
||||||
|
<div className="flex flex-col h-screen bg-slate-950">
|
||||||
|
{previousSession && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 bg-amber-500 text-amber-950 text-sm font-medium shrink-0">
|
||||||
|
<span>
|
||||||
|
TechXCar Admin — a gerir: <strong>{user?.name}</strong>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleRestore}
|
||||||
|
className="px-3 py-1 rounded bg-amber-950 text-amber-100 hover:bg-amber-900 text-xs font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
← Voltar ao painel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<aside className="w-64 bg-slate-900 border-r border-slate-800 flex flex-col">
|
||||||
|
<div className="p-4 border-b border-slate-800">
|
||||||
|
<h1 className="text-lg font-bold text-white">TechXCar</h1>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Gestão de Oficina</p>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 p-3 space-y-1">
|
||||||
|
{nav.map(({ to, label, end }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
end={end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-slate-700 text-white'
|
||||||
|
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="p-3 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={() => logout()}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm text-slate-400 hover:text-white rounded-md hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
Terminar sessão
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main className="flex-1 overflow-auto p-6 text-white">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **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"
|
||||||
|
```
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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_<uuid>`: dados isolados de cada oficina
|
||||||
|
- Middleware no backend define `SET search_path = tenant_<uuid>` 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_<uuid>` (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)
|
||||||
@@ -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: `<p className="text-slate-400">A carregar...</p>`
|
||||||
|
- Empty states: `<p className="text-slate-500 text-sm">Nenhum registo.</p>`
|
||||||
|
- 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
|
||||||
@@ -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_<uuid-with-hyphens>"` but schemas are named `tenant_<uuid-with-underscores>`. 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=<id>` 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: <tenant name> [← 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: <user.name> [← 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)
|
||||||
@@ -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?
|
||||||
@@ -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"]
|
||||||
@@ -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...
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
|
```
|
||||||
@@ -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;"
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
])
|
||||||
@@ -0,0 +1,13 @@
|
|||||||
|
<!doctype html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8" />
|
||||||
|
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||||
|
<title>frontend</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="root"></div>
|
||||||
|
<script type="module" src="/src/main.tsx"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
|
}
|
||||||
Generated
+5532
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 9.3 KiB |
@@ -0,0 +1,24 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg">
|
||||||
|
<symbol id="bluesky-icon" viewBox="0 0 16 17">
|
||||||
|
<g clip-path="url(#bluesky-clip)"><path fill="#08060d" d="M7.75 7.735c-.693-1.348-2.58-3.86-4.334-5.097-1.68-1.187-2.32-.981-2.74-.79C.188 2.065.1 2.812.1 3.251s.241 3.602.398 4.13c.52 1.744 2.367 2.333 4.07 2.145-2.495.37-4.71 1.278-1.805 4.512 3.196 3.309 4.38-.71 4.987-2.746.608 2.036 1.307 5.91 4.93 2.746 2.72-2.746.747-4.143-1.747-4.512 1.702.189 3.55-.4 4.07-2.145.156-.528.397-3.691.397-4.13s-.088-1.186-.575-1.406c-.42-.19-1.06-.395-2.741.79-1.755 1.24-3.64 3.752-4.334 5.099"/></g>
|
||||||
|
<defs><clipPath id="bluesky-clip"><path fill="#fff" d="M.1.85h15.3v15.3H.1z"/></clipPath></defs>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="discord-icon" viewBox="0 0 20 19">
|
||||||
|
<path fill="#08060d" d="M16.224 3.768a14.5 14.5 0 0 0-3.67-1.153c-.158.286-.343.67-.47.976a13.5 13.5 0 0 0-4.067 0c-.128-.306-.317-.69-.476-.976A14.4 14.4 0 0 0 3.868 3.77C1.546 7.28.916 10.703 1.231 14.077a14.7 14.7 0 0 0 4.5 2.306q.545-.748.965-1.587a9.5 9.5 0 0 1-1.518-.74q.191-.14.372-.293c2.927 1.369 6.107 1.369 8.999 0q.183.152.372.294-.723.437-1.52.74.418.838.963 1.588a14.6 14.6 0 0 0 4.504-2.308c.37-3.911-.63-7.302-2.644-10.309m-9.13 8.234c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.894 0 1.614.82 1.599 1.82.001 1-.705 1.82-1.6 1.82m5.91 0c-.878 0-1.599-.82-1.599-1.82 0-.998.705-1.82 1.6-1.82.893 0 1.614.82 1.599 1.82 0 1-.706 1.82-1.6 1.82"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="documentation-icon" viewBox="0 0 21 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="m15.5 13.333 1.533 1.322c.645.555.967.833.967 1.178s-.322.623-.967 1.179L15.5 18.333m-3.333-5-1.534 1.322c-.644.555-.966.833-.966 1.178s.322.623.966 1.179l1.534 1.321"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M17.167 10.836v-4.32c0-1.41 0-2.117-.224-2.68-.359-.906-1.118-1.621-2.08-1.96-.599-.21-1.349-.21-2.848-.21-2.623 0-3.935 0-4.983.369-1.684.591-3.013 1.842-3.641 3.428C3 6.449 3 7.684 3 10.154v2.122c0 2.558 0 3.838.706 4.726q.306.383.713.671c.76.536 1.79.64 3.581.66"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M3 10a2.78 2.78 0 0 1 2.778-2.778c.555 0 1.209.097 1.748-.047.48-.129.854-.503.982-.982.145-.54.048-1.194.048-1.749a2.78 2.78 0 0 1 2.777-2.777"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="github-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M9.356 1.85C5.05 1.85 1.57 5.356 1.57 9.694a7.84 7.84 0 0 0 5.324 7.44c.387.079.528-.168.528-.376 0-.182-.013-.805-.013-1.454-2.165.467-2.616-.935-2.616-.935-.349-.91-.864-1.143-.864-1.143-.71-.48.051-.48.051-.48.787.051 1.2.805 1.2.805.695 1.194 1.817.857 2.268.649.064-.507.27-.857.49-1.052-1.728-.182-3.545-.857-3.545-3.87 0-.857.31-1.558.8-2.104-.078-.195-.349-1 .077-2.078 0 0 .657-.208 2.14.805a7.5 7.5 0 0 1 1.946-.26c.657 0 1.328.092 1.946.26 1.483-1.013 2.14-.805 2.14-.805.426 1.078.155 1.883.078 2.078.502.546.799 1.247.799 2.104 0 3.013-1.818 3.675-3.558 3.87.284.247.528.714.528 1.454 0 1.052-.012 1.896-.012 2.156 0 .208.142.455.528.377a7.84 7.84 0 0 0 5.324-7.441c.013-4.338-3.48-7.844-7.773-7.844" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="social-icon" viewBox="0 0 20 20">
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M12.5 6.667a4.167 4.167 0 1 0-8.334 0 4.167 4.167 0 0 0 8.334 0"/>
|
||||||
|
<path fill="none" stroke="#aa3bff" stroke-linecap="round" stroke-linejoin="round" stroke-width="1.35" d="M2.5 16.667a5.833 5.833 0 0 1 8.75-5.053m3.837.474.513 1.035c.07.144.257.282.414.309l.93.155c.596.1.736.536.307.965l-.723.73a.64.64 0 0 0-.152.531l.207.903c.164.715-.213.991-.84.618l-.872-.52a.63.63 0 0 0-.577 0l-.872.52c-.624.373-1.003.094-.84-.618l.207-.903a.64.64 0 0 0-.152-.532l-.723-.729c-.426-.43-.289-.864.306-.964l.93-.156a.64.64 0 0 0 .412-.31l.513-1.034c.28-.562.735-.562 1.012 0"/>
|
||||||
|
</symbol>
|
||||||
|
<symbol id="x-icon" viewBox="0 0 19 19">
|
||||||
|
<path fill="#08060d" fill-rule="evenodd" d="M1.893 1.98c.052.072 1.245 1.769 2.653 3.77l2.892 4.114c.183.261.333.48.333.486s-.068.089-.152.183l-.522.593-.765.867-3.597 4.087c-.375.426-.734.834-.798.905a1 1 0 0 0-.118.148c0 .01.236.017.664.017h.663l.729-.83c.4-.457.796-.906.879-.999a692 692 0 0 0 1.794-2.038c.034-.037.301-.34.594-.675l.551-.624.345-.392a7 7 0 0 1 .34-.374c.006 0 .93 1.306 2.052 2.903l2.084 2.965.045.063h2.275c1.87 0 2.273-.003 2.266-.021-.008-.02-1.098-1.572-3.894-5.547-2.013-2.862-2.28-3.246-2.273-3.266.008-.019.282-.332 2.085-2.38l2-2.274 1.567-1.782c.022-.028-.016-.03-.65-.03h-.674l-.3.342a871 871 0 0 1-1.782 2.025c-.067.075-.405.458-.75.852a100 100 0 0 1-.803.91c-.148.172-.299.344-.99 1.127-.304.343-.32.358-.345.327-.015-.019-.904-1.282-1.976-2.808L6.365 1.85H1.8zm1.782.91 8.078 11.294c.772 1.08 1.413 1.973 1.425 1.984.016.017.241.02 1.05.017l1.03-.004-2.694-3.766L7.796 5.75 5.722 2.852l-1.039-.004-1.039-.004z" clip-rule="evenodd"/>
|
||||||
|
</symbol>
|
||||||
|
</svg>
|
||||||
|
After Width: | Height: | Size: 4.9 KiB |
@@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 <Navigate to="/login" replace />
|
||||||
|
// 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 <Navigate to="/login" replace />
|
||||||
|
}
|
||||||
|
return <>{children}</>
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function App() {
|
||||||
|
return (
|
||||||
|
<QueryClientProvider client={queryClient}>
|
||||||
|
<BrowserRouter>
|
||||||
|
<Routes>
|
||||||
|
<Route path="/login" element={<LoginPage />} />
|
||||||
|
<Route path="/invite/:token" element={<InviteRedeemPage />} />
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/admin"
|
||||||
|
element={
|
||||||
|
<RequireAuth allowedRoles={['super_admin']}>
|
||||||
|
<AdminLayout />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<AdminDashboardPage />} />
|
||||||
|
<Route path="tenants" element={<TenantsPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route
|
||||||
|
path="/app"
|
||||||
|
element={
|
||||||
|
<RequireAuth allowedRoles={['tenant_admin', 'manager', 'technician']}>
|
||||||
|
<AppLayout />
|
||||||
|
</RequireAuth>
|
||||||
|
}
|
||||||
|
>
|
||||||
|
<Route index element={<AppDashboardPage />} />
|
||||||
|
<Route path="clients" element={<ClientsPage />} />
|
||||||
|
<Route path="catalog" element={<CatalogPage />} />
|
||||||
|
<Route path="work-orders" element={<WorkOrdersPage />} />
|
||||||
|
<Route path="work-orders/:id" element={<WorkOrderDetailPage />} />
|
||||||
|
<Route path="staff" element={<StaffPage />} />
|
||||||
|
<Route path="expenses" element={<ExpensesPage />} />
|
||||||
|
<Route path="invoices" element={<InvoicesPage />} />
|
||||||
|
<Route path="settings" element={<SettingsPage />} />
|
||||||
|
</Route>
|
||||||
|
|
||||||
|
<Route path="/" element={<Navigate to="/app" replace />} />
|
||||||
|
</Routes>
|
||||||
|
</BrowserRouter>
|
||||||
|
</QueryClientProvider>
|
||||||
|
)
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 13 KiB |
@@ -0,0 +1 @@
|
|||||||
|
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="35.93" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 228"><path fill="#00D8FF" d="M210.483 73.824a171.49 171.49 0 0 0-8.24-2.597c.465-1.9.893-3.777 1.273-5.621c6.238-30.281 2.16-54.676-11.769-62.708c-13.355-7.7-35.196.329-57.254 19.526a171.23 171.23 0 0 0-6.375 5.848a155.866 155.866 0 0 0-4.241-3.917C100.759 3.829 77.587-4.822 63.673 3.233C50.33 10.957 46.379 33.89 51.995 62.588a170.974 170.974 0 0 0 1.892 8.48c-3.28.932-6.445 1.924-9.474 2.98C17.309 83.498 0 98.307 0 113.668c0 15.865 18.582 31.778 46.812 41.427a145.52 145.52 0 0 0 6.921 2.165a167.467 167.467 0 0 0-2.01 9.138c-5.354 28.2-1.173 50.591 12.134 58.266c13.744 7.926 36.812-.22 59.273-19.855a145.567 145.567 0 0 0 5.342-4.923a168.064 168.064 0 0 0 6.92 6.314c21.758 18.722 43.246 26.282 56.54 18.586c13.731-7.949 18.194-32.003 12.4-61.268a145.016 145.016 0 0 0-1.535-6.842c1.62-.48 3.21-.974 4.76-1.488c29.348-9.723 48.443-25.443 48.443-41.52c0-15.417-17.868-30.326-45.517-39.844Zm-6.365 70.984c-1.4.463-2.836.91-4.3 1.345c-3.24-10.257-7.612-21.163-12.963-32.432c5.106-11 9.31-21.767 12.459-31.957c2.619.758 5.16 1.557 7.61 2.4c23.69 8.156 38.14 20.213 38.14 29.504c0 9.896-15.606 22.743-40.946 31.14Zm-10.514 20.834c2.562 12.94 2.927 24.64 1.23 33.787c-1.524 8.219-4.59 13.698-8.382 15.893c-8.067 4.67-25.32-1.4-43.927-17.412a156.726 156.726 0 0 1-6.437-5.87c7.214-7.889 14.423-17.06 21.459-27.246c12.376-1.098 24.068-2.894 34.671-5.345a134.17 134.17 0 0 1 1.386 6.193ZM87.276 214.515c-7.882 2.783-14.16 2.863-17.955.675c-8.075-4.657-11.432-22.636-6.853-46.752a156.923 156.923 0 0 1 1.869-8.499c10.486 2.32 22.093 3.988 34.498 4.994c7.084 9.967 14.501 19.128 21.976 27.15a134.668 134.668 0 0 1-4.877 4.492c-9.933 8.682-19.886 14.842-28.658 17.94ZM50.35 144.747c-12.483-4.267-22.792-9.812-29.858-15.863c-6.35-5.437-9.555-10.836-9.555-15.216c0-9.322 13.897-21.212 37.076-29.293c2.813-.98 5.757-1.905 8.812-2.773c3.204 10.42 7.406 21.315 12.477 32.332c-5.137 11.18-9.399 22.249-12.634 32.792a134.718 134.718 0 0 1-6.318-1.979Zm12.378-84.26c-4.811-24.587-1.616-43.134 6.425-47.789c8.564-4.958 27.502 2.111 47.463 19.835a144.318 144.318 0 0 1 3.841 3.545c-7.438 7.987-14.787 17.08-21.808 26.988c-12.04 1.116-23.565 2.908-34.161 5.309a160.342 160.342 0 0 1-1.76-7.887Zm110.427 27.268a347.8 347.8 0 0 0-7.785-12.803c8.168 1.033 15.994 2.404 23.343 4.08c-2.206 7.072-4.956 14.465-8.193 22.045a381.151 381.151 0 0 0-7.365-13.322Zm-45.032-43.861c5.044 5.465 10.096 11.566 15.065 18.186a322.04 322.04 0 0 0-30.257-.006c4.974-6.559 10.069-12.652 15.192-18.18ZM82.802 87.83a323.167 323.167 0 0 0-7.227 13.238c-3.184-7.553-5.909-14.98-8.134-22.152c7.304-1.634 15.093-2.97 23.209-3.984a321.524 321.524 0 0 0-7.848 12.897Zm8.081 65.352c-8.385-.936-16.291-2.203-23.593-3.793c2.26-7.3 5.045-14.885 8.298-22.6a321.187 321.187 0 0 0 7.257 13.246c2.594 4.48 5.28 8.868 8.038 13.147Zm37.542 31.03c-5.184-5.592-10.354-11.779-15.403-18.433c4.902.192 9.899.29 14.978.29c5.218 0 10.376-.117 15.453-.343c-4.985 6.774-10.018 12.97-15.028 18.486Zm52.198-57.817c3.422 7.8 6.306 15.345 8.596 22.52c-7.422 1.694-15.436 3.058-23.88 4.071a382.417 382.417 0 0 0 7.859-13.026a347.403 347.403 0 0 0 7.425-13.565Zm-16.898 8.101a358.557 358.557 0 0 1-12.281 19.815a329.4 329.4 0 0 1-23.444.823c-7.967 0-15.716-.248-23.178-.732a310.202 310.202 0 0 1-12.513-19.846h.001a307.41 307.41 0 0 1-10.923-20.627a310.278 310.278 0 0 1 10.89-20.637l-.001.001a307.318 307.318 0 0 1 12.413-19.761c7.613-.576 15.42-.876 23.31-.876H128c7.926 0 15.743.303 23.354.883a329.357 329.357 0 0 1 12.335 19.695a358.489 358.489 0 0 1 11.036 20.54a329.472 329.472 0 0 1-11 20.722Zm22.56-122.124c8.572 4.944 11.906 24.881 6.52 51.026c-.344 1.668-.73 3.367-1.15 5.09c-10.622-2.452-22.155-4.275-34.23-5.408c-7.034-10.017-14.323-19.124-21.64-27.008a160.789 160.789 0 0 1 5.888-5.4c18.9-16.447 36.564-22.941 44.612-18.3ZM128 90.808c12.625 0 22.86 10.235 22.86 22.86s-10.235 22.86-22.86 22.86s-22.86-10.235-22.86-22.86s10.235-22.86 22.86-22.86Z"></path></svg>
|
||||||
|
After Width: | Height: | Size: 4.0 KiB |
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 8.5 KiB |
@@ -0,0 +1,55 @@
|
|||||||
|
import { Outlet, NavLink } from 'react-router'
|
||||||
|
import { useLogout } from '@/hooks/useAuth'
|
||||||
|
|
||||||
|
export default function AdminLayout() {
|
||||||
|
const { mutate: logout } = useLogout()
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-screen bg-slate-950">
|
||||||
|
<aside className="w-64 bg-slate-900 border-r border-slate-800 flex flex-col">
|
||||||
|
<div className="p-4 border-b border-slate-800">
|
||||||
|
<h1 className="text-lg font-bold text-white">TechXCar</h1>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Administração</p>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 p-3 space-y-1">
|
||||||
|
<NavLink
|
||||||
|
to="/admin"
|
||||||
|
end
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-slate-700 text-white'
|
||||||
|
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Dashboard
|
||||||
|
</NavLink>
|
||||||
|
<NavLink
|
||||||
|
to="/admin/tenants"
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-slate-700 text-white'
|
||||||
|
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
Oficinas
|
||||||
|
</NavLink>
|
||||||
|
</nav>
|
||||||
|
<div className="p-3 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={() => logout()}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm text-slate-400 hover:text-white rounded-md hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
Terminar sessão
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main className="flex-1 overflow-auto p-6 text-white">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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 (
|
||||||
|
<div className="flex flex-col h-screen bg-slate-950">
|
||||||
|
{previousSession && (
|
||||||
|
<div className="flex items-center justify-between px-4 py-2 bg-amber-500 text-amber-950 text-sm font-medium shrink-0">
|
||||||
|
<span>
|
||||||
|
TechXCar Admin — a gerir: <strong>{user?.name}</strong>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
onClick={handleRestore}
|
||||||
|
className="px-3 py-1 rounded bg-amber-950 text-amber-100 hover:bg-amber-900 text-xs font-semibold transition-colors"
|
||||||
|
>
|
||||||
|
← Voltar ao painel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<div className="flex flex-1 overflow-hidden">
|
||||||
|
<aside className="w-64 bg-slate-900 border-r border-slate-800 flex flex-col">
|
||||||
|
<div className="p-4 border-b border-slate-800">
|
||||||
|
<h1 className="text-lg font-bold text-white">TechXCar</h1>
|
||||||
|
<p className="text-xs text-slate-400 mt-0.5">Gestão de Oficina</p>
|
||||||
|
</div>
|
||||||
|
<nav className="flex-1 p-3 space-y-1">
|
||||||
|
{nav.map(({ to, label, end }) => (
|
||||||
|
<NavLink
|
||||||
|
key={to}
|
||||||
|
to={to}
|
||||||
|
end={end}
|
||||||
|
className={({ isActive }) =>
|
||||||
|
`flex items-center px-3 py-2 rounded-md text-sm transition-colors ${
|
||||||
|
isActive
|
||||||
|
? 'bg-slate-700 text-white'
|
||||||
|
: 'text-slate-400 hover:bg-slate-800 hover:text-white'
|
||||||
|
}`
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{label}
|
||||||
|
</NavLink>
|
||||||
|
))}
|
||||||
|
</nav>
|
||||||
|
<div className="p-3 border-t border-slate-800">
|
||||||
|
<button
|
||||||
|
onClick={() => logout()}
|
||||||
|
className="w-full text-left px-3 py-2 text-sm text-slate-400 hover:text-white rounded-md hover:bg-slate-800 transition-colors"
|
||||||
|
>
|
||||||
|
Terminar sessão
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
<main className="flex-1 overflow-auto p-6 text-white">
|
||||||
|
<Outlet />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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<HTMLDivElement>,
|
||||||
|
VariantProps<typeof badgeVariants> {}
|
||||||
|
|
||||||
|
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||||
|
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||||
|
}
|
||||||
|
|
||||||
|
export { Badge, badgeVariants }
|
||||||
@@ -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<HTMLButtonElement>,
|
||||||
|
VariantProps<typeof buttonVariants> {
|
||||||
|
asChild?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
||||||
|
({ className, variant, size, asChild = false, ...props }, ref) => {
|
||||||
|
const Comp = asChild ? Slot : 'button'
|
||||||
|
return (
|
||||||
|
<Comp
|
||||||
|
className={cn(buttonVariants({ variant, size, className }))}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Button.displayName = 'Button'
|
||||||
|
|
||||||
|
export { Button, buttonVariants }
|
||||||
@@ -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<typeof DialogPrimitive.Overlay>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Overlay
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||||
|
|
||||||
|
const DialogContent = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Content>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content>
|
||||||
|
>(({ className, children, ...props }, ref) => (
|
||||||
|
<DialogPortal>
|
||||||
|
<DialogOverlay />
|
||||||
|
<DialogPrimitive.Content
|
||||||
|
ref={ref}
|
||||||
|
className={cn(
|
||||||
|
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 bg-slate-900 border border-slate-700 p-6 shadow-lg rounded-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%]',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
{children}
|
||||||
|
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-slate-400 focus:ring-offset-2 disabled:pointer-events-none">
|
||||||
|
<X className="h-4 w-4 text-slate-400" />
|
||||||
|
<span className="sr-only">Fechar</span>
|
||||||
|
</DialogPrimitive.Close>
|
||||||
|
</DialogPrimitive.Content>
|
||||||
|
</DialogPortal>
|
||||||
|
))
|
||||||
|
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||||
|
|
||||||
|
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||||
|
<div className={cn('flex flex-col space-y-1.5', className)} {...props} />
|
||||||
|
)
|
||||||
|
DialogHeader.displayName = 'DialogHeader'
|
||||||
|
|
||||||
|
const DialogTitle = React.forwardRef<
|
||||||
|
React.ComponentRef<typeof DialogPrimitive.Title>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<DialogPrimitive.Title
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-lg font-semibold text-white', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||||
|
|
||||||
|
export {
|
||||||
|
Dialog,
|
||||||
|
DialogTrigger,
|
||||||
|
DialogPortal,
|
||||||
|
DialogOverlay,
|
||||||
|
DialogClose,
|
||||||
|
DialogContent,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
}
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
import * as React from 'react'
|
||||||
|
import { cn } from '@/lib/utils'
|
||||||
|
|
||||||
|
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {}
|
||||||
|
|
||||||
|
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
||||||
|
({ className, type, ...props }, ref) => {
|
||||||
|
return (
|
||||||
|
<input
|
||||||
|
type={type}
|
||||||
|
className={cn(
|
||||||
|
'flex h-9 w-full rounded-md border border-gray-300 bg-white px-3 py-1 text-sm shadow-sm transition-colors placeholder:text-gray-400 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50',
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
ref={ref}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
Input.displayName = 'Input'
|
||||||
|
|
||||||
|
export { Input }
|
||||||
@@ -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<typeof LabelPrimitive.Root>,
|
||||||
|
React.ComponentPropsWithoutRef<typeof LabelPrimitive.Root>
|
||||||
|
>(({ className, ...props }, ref) => (
|
||||||
|
<LabelPrimitive.Root
|
||||||
|
ref={ref}
|
||||||
|
className={cn('text-sm font-medium text-gray-700 leading-none peer-disabled:cursor-not-allowed peer-disabled:opacity-70', className)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
))
|
||||||
|
Label.displayName = LabelPrimitive.Root.displayName
|
||||||
|
|
||||||
|
export { Label }
|
||||||
@@ -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<LoginResponse>('/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 })
|
||||||
|
},
|
||||||
|
})
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user