Compare commits

...
Author SHA1 Message Date
rmcampos 3f3b6f7cba Update backend-cd workflow file to be Gitea-compliant 2026-06-16 15:22:11 +00:00
rmcampos 84ae5618f5 Fix wring directory for frontend builds 2026-06-16 14:43:58 +00:00
rmcampos 3956c49f6e Update frontend-cd file to be Gitea-compliant 2026-06-16 14:35:41 +00:00
rmcampos 32e7015123 fix: missing api keys in backend after security updates 2026-06-13 13:19:02 -03:00
rmcampos aedabd33de fix: missing jwt secret 2026-06-11 14:34:04 -03:00
rmcampos bab13be673 chore: update default images to latest 2026-06-11 14:22:45 -03:00
rmcampos 65ed7046c2 fix: trailing slash in allowrd origins really 2026-06-11 13:52:30 -03:00
rmcampos b868e4ba39 docs: update changelog 2026-06-11 13:44:56 -03:00
rmcampos 4dbc2973cb fix: lint issue in frontend 2026-06-11 13:37:17 -03:00
rmcampos e54cf0dee1 fix: cors allowed origins missing in prod 2026-06-11 13:33:22 -03:00
rmcamposandGitHub e0c0b45afd Fix security findings 68 (#72)
* Resolve security findings H1-H6 from issue 68

* feat: security improvements by Claude over Gemini implementation
2026-06-11 13:32:18 -03:00
rmcampos ebcaa975c7 docs: update changelog 2026-06-11 10:12:10 -03:00
9f1fabeadc fix: resolve security audit findings (C1–C5 hardening) (#71)
- Require SEED_ADMIN_PASSWORD env var; remove plaintext password logging from seed
- Add JWT expiry (8h) and live disabledAt + role DB check on every authenticated request
- Fix rate-limit keyGenerator to use IP only (body unavailable at onRequest phase)
- Add constant-time bcrypt comparison with dummy hash to prevent user enumeration via timing
- Normalize email to lowercase+trim in lockout key to close case-variant bypass
- Cap exponential backoff at 30 min to prevent permanent lockout via Infinity overflow
- Add lastAttempt timestamp to loginFailures entries with 1h TTL cleanup interval
- Gate customer POST/PUT/PATCH and sales POST/PUT to requireAdmin (parity with products)
- Remove redundant [app.authenticate] from dashboard routes (requireAdmin hook covers all)
- Add return before reply.send(err) in both authenticate and requireAdmin catch blocks
- Use live DB role in requireAdmin so role changes take effect immediately

Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-11 10:04:36 -03:00
rmcampos 5a0f7a3ab5 docs: update changelog 2026-06-10 17:01:39 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>rmcampos
ab92fb2968 Enable hard-delete for Customers and POS with cascading data cleanup while preserving Customer disable (#65)
* Initial plan

* feat: add hard-delete flows for customers and POS

* fix: restore customer disable while keeping hard delete

* fix: keep both customer disable and delete actions

* fix: phone number formatting in customer modal

* fix: disabled customers when updated get enabled again

* docs: update changelog

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
2026-06-10 16:58:01 -03:00
rmcampos 389f2d875a docs: update changelog 2026-06-10 16:17:36 -03:00
rmcampos f21c0fb089 Merge branch 'main' of github.com:RMCampos/polpa-gestao 2026-06-10 16:06:12 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
bf7a0b7208 feat: Dashboard card for inactive customer POSes (10+ days) (#63)
* Initial plan

* feat: add inactive customer POS dashboard card (10+ days)

* refactor: reuse existing config for inactive-pos fetch

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-10 16:05:26 -03:00
rmcampos f065a10562 chore: remove issue skill from repo 2026-06-10 15:02:56 -03:00
rmcampos 2d2db29487 docs: update changelog to link to tags 2026-06-08 13:26:59 -03:00
rmcampos 680bffad2f chore: update changelog file 2026-06-08 13:22:43 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>rmcampos
9295a7af26 Add optional customer notes field across DB, API, and customer modal (#61)
* Initial plan

* feat: add optional customer notes field

* chore: fix error and add changelog file

* docs: update changelog with customer notes feature

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
2026-06-08 13:17:32 -03:00
rmcampos 7fd4c48b4f fix: unable to type all 14 digits for enterprise docs on customer creation 2026-06-08 12:20:38 -03:00
rmcampos bb2a7085f4 chore: improve terraform plan with variables 2026-06-04 20:26:34 +02:00
rmcamposandGitHub 815ae47138 Update README.md 2026-06-03 15:36:36 -03:00
rmcamposandGitHub fc1784cdc4 fix: wrong doppler secret name in more places 2026-06-03 15:10:08 -03:00
rmcamposandGitHub 7ff632081f fix: wrong doppler secret name 2026-06-03 15:05:41 -03:00
rmcamposandGitHub 8590b6277c feat: update workflows to use doppler 2026-06-03 15:00:39 -03:00
rmcamposandGitHub f0d45b7af8 Update config name in frontend CD workflow 2026-06-03 14:06:58 -03:00
rmcampos 6d1a9c8d8e feat: improve frontend build ci to use doppler 2026-06-03 18:56:35 +02:00
rmcampos e01b416d18 feat: improve running locally with Taskfile 2026-06-03 18:35:43 +02:00
rmcampos f2161e1adf chore: update containers names and flows for better naming 2026-06-03 15:27:06 +02:00
rmcampos 14ab25dec9 feat: add doppler and taskfile for professional experience - no sleep 2026-06-03 00:04:32 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
d065da2cd2 Add POS by Region summary to dashboard API and UI (#59)
* Initial plan

* feat: add POS by region dashboard summary

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-01 15:10:28 -03:00
rmcampos dc06d1e0f6 ci: add grouping to deploy workflow keeping only one running 2026-06-01 14:26:21 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
e016ca8940 feat: add optional region field to POS (#58)
* Initial plan

* feat: add optional region field to POS

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-01 14:12:34 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
730501c268 Add dashboard drill-down for Total Fridges (POS-level modal + API) (#54)
* Initial plan

* Add dashboard fridge drill-down endpoint and modal

* Refine fridge drill-down sorting and accessibility

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-27 13:44:10 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>rmcampos
01b34f62ad Add POS Industry Summary endpoint and dashboard card (#52)
* Initial plan

* Add dashboard POS industry summary endpoint and card

* Normalize industry values in dashboard summary

* chore: make the pos by industry card 6 columns

* chore: change fridge input type to text

* chore: change fridge input type to text and allow to empty it

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
2026-05-27 12:02:27 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
81df1a2369 Add industry field to Customer POS (#50)
* Initial plan

* feat: add industry field to customer POS

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-05-27 10:24:34 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>rmcampos
2ed9969f33 Prevent duplicate Customer and POS creation (#49)
* Initial plan

* Prevent duplicate Customer and POS creation

- Backend: Check for duplicate name (case-insensitive) and phone on Customer create/update
- Backend: Check for duplicate phone on CustomerPos create/update
- All uniqueness checks are wrapped in Prisma transactions with throw/catch pattern
- Soft-deleted records (disabledAt != null) are excluded from checks
- Returns 409 Conflict with specific field error messages
- Frontend: Show backend error message directly in customer save toast

* Address code review feedback: use helper, fix disabledAt filter, standardize error handling

- Extract conflictError() helper to avoid repeated Object.assign pattern
- Fix document duplicate check to also filter by disabledAt: null (consistent with name/phone)
- Standardize all POS routes to use try-catch instead of .catch()

* Add missing document check on Customer create and address check on CustomerPos create/update

- Customer POST: explicitly check for duplicate document inside transaction
- CustomerPos POST/PUT: add address uniqueness check (case-insensitive) alongside phone check
- All checks respect soft-delete (disabledAt: null)

* feat: filter customer address

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
2026-05-27 09:51:06 -03:00
rmcampos 1beca9f2be feat: change qty input to text type in sales page 2026-05-22 17:26:46 -03:00
rmcampos ed4e47021a feat: update closest page to include customer name 2026-05-19 23:05:02 +02:00
rmcamposandGitHub 952fe394db Add Google Maps API key to build arguments 2026-05-19 17:34:39 -03:00
rmcamposandGitHub c1ce19d7fc fix: add missing google maps api key to pipeline 2026-05-19 17:29:00 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCampos
5ed074bacf Add public /closest GPS page and nearest POS API with optional POS geocoding and map actions (#46)
* Initial plan

* feat: add public closest POS flow with GPS and geocoding

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/83f23ca1-4184-4b62-bfb6-f540f0cf228b

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* chore: address review feedback for closest POS feature

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/83f23ca1-4184-4b62-bfb6-f540f0cf228b

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* feat: refine closest page layout and distance display

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/4df0e1f9-3ca3-445b-8386-65d28c29ce74

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* feat: add maps action buttons to closest POS cards

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/20058858-128c-495f-87d0-7a0f86f3c0f0

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* fix: harden maps links and improve button accessibility

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/20058858-128c-495f-87d0-7a0f86f3c0f0

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
2026-05-19 17:19:00 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCamposrmcampos
1ed67f981f Add banner flags to Customer POS across schema, API, and Customers UI (#44)
* Initial plan

* feat: add banner flags to customer POS model and UI

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/fc52b7a1-ad74-4e23-b2ad-1b01b7cd0626

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* fix: improve layout and fix label name

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
2026-05-19 14:26:52 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCampos
75c367f700 Add active customer and fridge totals to dashboard sales summary (#41)
* Initial plan

* feat: add total customer and fridge metrics to dashboard summary

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/7948292d-7cd7-4360-a09a-5434db711f03

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
2026-05-11 16:00:04 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCamposrmcampos
35cc0e5d01 feat: Next Visit Tracking and Visits Management View (#39)
* Initial plan

* feat: implement Next Visit Tracking and Visits Management View

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/8d2a7533-e172-4839-9acf-48daa6dc3ba7

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* fix: address code review feedback - improve type safety and fix wording

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/8d2a7533-e172-4839-9acf-48daa6dc3ba7

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* fix: regenerate Prisma client to include nextVisitDate and visitedAt fields

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/e7506c35-a040-449e-b066-102921983690

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

* feat: display day name in the next visit card

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
Co-authored-by: Ricardo Campos <ricardompcampos@gmail.com>
2026-05-11 15:40:00 -03:00
CopilotGitHubcopilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>RMCampos
ab464cee56 Add fridge count to customer POS records (#37)
* Initial plan

* Add fridge count to customer POS

Agent-Logs-Url: https://github.com/RMCampos/polpa-gestao/sessions/eeb51ab2-fe81-4dfc-8dfd-7ecf96a5e3bd

Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: RMCampos <2219519+RMCampos@users.noreply.github.com>
2026-05-11 14:41:17 -03:00
74 changed files with 4733 additions and 472 deletions
-11
View File
@@ -1,11 +0,0 @@
description = "Create a formatted GitHub issue"
prompt = """
Act as a project manager. Based on the following input: {{args}}
Create a GitHub issue using the `gh` CLI tool with this exact structure:
- Title: [Feat/Bug] <short summary>
- Body: A detailed description, technical requirements, and acceptance criteria.
- Label: Automatically determine if it's 'bug', 'enhencement', or 'task'.
Once the user confirms the plan, execute the command:
`gh issue create --title "[Type] Title" --body "..." --label "..."
"""
+33 -30
View File
@@ -10,11 +10,23 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: easynode-debian
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/backend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Generate version tag
id: version
run: |
@@ -23,69 +35,60 @@ jobs:
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "Generated tag: ${TAG}"
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Install dependencies
run: npm install
working-directory: ./backend
- name: Use Node.js 22.x
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: 'npm'
cache-dependency-path: backend/package-lock.json
- run: cd backend && npm i
- run: cd backend && npx tsc
- name: Run build
run: npx tsc
working-directory: ./backend
env:
BUILD_VERSION: ${{ steps.version.outputs.tag }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
run: docker buildx inspect --bootstrap
- name: Login to GitHub Container Registry
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker image
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}/backend
images: rmcampos/polpa-gestao-api
tags: |
type=raw,value=${{ steps.version.outputs.tag }}
type=raw,value=latest,enable={{ is_default_branch }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: ./backend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
sbom: false
cache-from: type=registry,ref=rmcampos/polpa-gestao-api:buildcache
cache-to: type=registry,ref=rmcampos/polpa-gestao-api:buildcache,mode=max
build-args: |
BUILD_VERSION=${{ steps.version.outputs.tag }}
- name: Build and push prisma image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: ./backend
push: true
target: prisma
tags: |
ghcr.io/rmcampos/polpa-gestao/backend:${{ steps.version.outputs.tag }}-prisma
ghcr.io/rmcampos/polpa-gestao/backend:latest-prisma
provenance: false
sbom: false
docker.io/rmcampos/polpa-gestao-api:${{ steps.version.outputs.tag }}-prisma
docker.io/rmcampos/polpa-gestao-api:latest-prisma
- name: Create and push Git tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
git push origin ${{ steps.version.outputs.tag }}
+38 -22
View File
@@ -1,5 +1,9 @@
name: Deploy to prod
concurrency:
group: deploy-production
cancel-in-progress: true
on:
workflow_dispatch:
inputs:
@@ -37,10 +41,15 @@ jobs:
- name: Setup kubectl
uses: azure/setup-kubectl@v4
- name: Setup Doppler CLI
uses: dopplerhq/cli-action@v4
- name: Setup Kubeconfig
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
doppler run --config prd_secrets -- bash -c 'echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config'
chmod 600 ~/.kube/config
- name: Validate cluster access
@@ -80,9 +89,8 @@ jobs:
- name: Terraform Init
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: terraform init -input=false
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: doppler run --config prd_secrets -- terraform init -input=false
- name: Terraform Validate
working-directory: terraform
@@ -92,18 +100,23 @@ jobs:
id: check-changes
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
BACKEND_IMAGE: ${{ steps.deploy-vars.outputs.backend_image }}
FRONTEND_IMAGE: ${{ steps.deploy-vars.outputs.frontend_image }}
run: |
timeout 1m terraform plan -input=false -out=tfplan \
-var="db_user=${{ secrets.DB_USER }}" \
-var="db_password=${{ secrets.DB_PASSWORD }}" \
-var="db_name=${{ secrets.DB_NAME }}" \
-var="cpf_cnpj_api_token=${{ secrets.CPF_CNPJ_API_TOKEN }}" \
-var="r2_access_key=${{ secrets.R2_ACCESS_KEY_ID }}" \
-var="r2_secret_key=${{ secrets.R2_SECRET_ACCESS_KEY }}" \
-var="backend_image=${{ steps.deploy-vars.outputs.backend_image }}" \
-var="frontend_image=${{ steps.deploy-vars.outputs.frontend_image }}"
doppler run --config prd_secrets -- bash -c '
export TF_VAR_db_user="$DB_USER"
export TF_VAR_db_password="$DB_PASSWORD"
export TF_VAR_db_name="$DB_NAME"
export TF_VAR_cpf_cnpj_api_token="$CPF_CNPJ_API_TOKEN"
export TF_VAR_google_maps_api_key="$GOOGLE_MAPS_API_KEY"
export TF_VAR_jwt_secret="$JWT_SECRET"
export TF_VAR_r2_access_key="$AWS_ACCESS_KEY_ID"
export TF_VAR_r2_secret_key="$AWS_SECRET_ACCESS_KEY"
export TF_VAR_backend_image="$BACKEND_IMAGE"
export TF_VAR_frontend_image="$FRONTEND_IMAGE"
timeout 1m terraform plan -input=false -out=tfplan
'
terraform show -json tfplan > tfplan.json
if jq -e '.resource_changes | length == 0' tfplan.json >/dev/null; then
echo "no_changes=true" >> "$GITHUB_OUTPUT"
@@ -144,23 +157,26 @@ jobs:
name: tfplan
path: terraform
- name: Setup Doppler CLI
uses: dopplerhq/cli-action@v4
- name: Setup Kubeconfig
env:
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: |
mkdir -p ~/.kube
echo "${{ secrets.KUBECONFIG_DATA }}" | base64 -d > ~/.kube/config
doppler run --config prd_secrets -- bash -c 'echo "$KUBECONFIG_DATA" | base64 -d > ~/.kube/config'
chmod 600 ~/.kube/config
- name: Terraform Init
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: terraform init -input=false
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: doppler run --config prd_secrets -- terraform init -input=false
- name: Terraform Apply
working-directory: terraform
env:
AWS_ACCESS_KEY_ID: ${{ secrets.R2_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.R2_SECRET_ACCESS_KEY }}
run: timeout 1m terraform apply tfplan
DOPPLER_TOKEN: ${{ secrets.DOPPLER_AT_SECRETS }}
run: doppler run --config prd_secrets -- timeout 2m terraform apply tfplan
+42 -36
View File
@@ -10,11 +10,37 @@ on:
jobs:
build:
runs-on: ubuntu-latest
runs-on: easynode-debian
permissions:
contents: write
packages: write
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Cache npm dependencies
uses: actions/cache@v3
with:
path: ~/.npm
key: ${{ runner.os }}-npm-${{ hashFiles('**/frontend/package-lock.json') }}
restore-keys: |
${{ runner.os }}-npm-
- name: Install dependencies
run: npm install
working-directory: ./frontend
- name: Run lint
run: npm run lint
working-directory: ./frontend
- name: Run build
run: npm run build
working-directory: ./frontend
env:
VITE_BUILD_NUMBER: ${{ steps.version.outputs.build_number }}
- name: Generate version tag
id: version
run: |
@@ -24,63 +50,43 @@ jobs:
echo "tag=${TAG}" >> $GITHUB_OUTPUT
echo "build_number=${BUILD_NUMBER}" >> $GITHUB_OUTPUT
echo "Generated tag: ${TAG}"
echo "Generated build number: ${BUILD_NUMBER}"
echo "Generated build number: ${BUILD_NUMBER}"
- uses: actions/checkout@v6
with:
fetch-depth: 0
- name: Use Node.js 22.x
uses: actions/setup-node@v4
with:
node-version: 22.x
cache: 'npm'
cache-dependency-path: frontend/package-lock.json
- run: cd frontend && npm i
- run: cd frontend && npm run lint
- run: cd frontend && npm run build
env:
VITE_BUILD_NUMBER: ${{ steps.version.outputs.build_number }}
VITE_CPF_CNPJ_API_TOKEN: ${{ secrets.CPF_CNPJ_API_TOKEN }}
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
run: docker buildx inspect --bootstrap
- name: Login to GitHub Container Registry
- name: Log in to Docker Hub
uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
username: ${{ secrets.DOCKERHUB_USERNAME }}
password: ${{ secrets.DOCKERHUB_TOKEN }}
- name: Extract metadata for Docker image
- name: Extract metadata for Docker
id: meta
uses: docker/metadata-action@v5
with:
images: ghcr.io/${{ github.repository }}/frontend
images: rmcampos/polpa-gestao-app
tags: |
type=raw,value=${{ steps.version.outputs.tag }}
type=raw,value=latest,enable={{ is_default_branch }}
- name: Build and push Docker image
uses: docker/build-push-action@v6
uses: docker/build-push-action@v5
with:
context: ./frontend
push: true
tags: ${{ steps.meta.outputs.tags }}
labels: ${{ steps.meta.outputs.labels }}
provenance: false
sbom: false
cache-from: type=registry,ref=rmcampos/polpa-gestao-app:buildcache
cache-to: type=registry,ref=rmcampos/polpa-gestao-app:buildcache,mode=max
build-args: |
VITE_BACKEND_SERVER=${{ vars.BACKEND_SERVER_URL }}
VITE_CPF_CNPJ_API_TOKEN=${{ secrets.CPF_CNPJ_API_TOKEN }}
VITE_BUILD_NUMBER=${{ steps.version.outputs.build_number }}
VITE_BUILD=${{ steps.version.outputs.tag }}
- name: Create and push Git tag
run: |
git config user.name "github-actions[bot]"
git config user.email "github-actions[bot]@users.noreply.github.com"
git config user.name "gitea-actions[bot]"
git config user.email "gitea-actions[bot]@noreply.lightroasted.vps-kinghost.net"
git tag -a ${{ steps.version.outputs.tag }} -m "Release ${{ steps.version.outputs.tag }}"
git push origin ${{ steps.version.outputs.tag }}
git push origin ${{ steps.version.outputs.tag }}
+3 -1
View File
@@ -4,4 +4,6 @@ dist
# IDE
.idea
.vscode
.vscode
backend/validate-security-fixes.mjs
+112
View File
@@ -0,0 +1,112 @@
# AGENTS.md
This document provides context and guidelines for AI agents interacting with the Polpa Gestão project.
## Project Overview
Polpa Gestão is a full-stack business management web application designed for managing customers, products, sales, and delivery routes. It serves as a comprehensive platform for administrative tasks within a business.
## Architecture
The project has a monorepo-like structure with distinct `backend` and `frontend` components.
- **Backend**: Developed with Node.js 22, Fastify 5, and TypeScript, utilizing Prisma ORM for database interaction.
- API Endpoints: Defined in `backend/src/routes/`.
- Database Schema: Managed with Prisma in `backend/prisma/`.
- **Frontend**: Built with React 19, TypeScript, Vite, and styled with Bootstrap 5.
- Main Views: Located in `frontend/src/pages/`.
- Reusable Components: Found in `frontend/src/components/`.
- **Database**: PostgreSQL 15.
- **Infrastructure**: Docker, Docker Compose for local orchestration.
## Local Development Setup for Agents
The recommended way for an AI agent to set up and run the project for local interaction is using **Taskfile + Doppler** (which wraps Docker Compose).
### Prerequisites
- Docker and Docker Compose
- [Task](https://taskfile.dev/installation/)
- [Doppler CLI](https://docs.doppler.com/docs/cli)
### Configure Doppler
Project defaults are defined in `doppler.yaml`:
- project: `polpa-gestao`
- config: `dev_ricardo`
Authenticate and configure Doppler before running tasks:
```bash
doppler login
doppler setup --project polpa-gestao --config dev_ricardo
```
### Run with Taskfile
To start all services in background:
```bash
task dev-up
```
To stop:
```bash
task dev-down
```
To stop and remove volumes/orphans:
```bash
task dev-tier-down
```
Once running, services are accessible at:
- **Frontend**: http://localhost:5173
- **Backend API**: http://localhost:3000
- **Database**: localhost:5432 (User: `admin`, Password: `adminpassword`, DB: `polpa_gestao`)
### Build images with Taskfile
Use these tasks for local image builds:
```bash
task build-frontend
task build-backend
task build-prisma
```
All build tasks run with `doppler run`, so required secrets (for example API tokens and maps keys) come from Doppler-managed environment values.
## Interaction Points for Agents
### Backend API
Agents can interact with the backend API via HTTP requests to `http://localhost:3000`.
- **Authentication**: Routes requiring authentication use JWT. Agents might need to obtain a token via a login endpoint (e.g., `/auth/login`) and include it in subsequent requests.
- **Data Models**: Database schema and models are defined in `backend/prisma/schema.prisma`. Agents modifying data should be aware of these structures.
- **Validation**: Backend endpoints often have schema validation. Agents should adhere to expected request body/query parameter formats.
### Database
Direct database interaction through Prisma commands can be performed within the `backend` directory.
- **Schema Changes**: Any changes to the database schema require updating `backend/prisma/schema.prisma` and running `npx prisma migrate dev`.
- **Seeding**: The database can be seeded using `backend/run-seed.sh`.
## Development Conventions for Agents
- **Naming**: Follow `camelCase` for TypeScript variables, functions, and database fields.
- **Types**: Adhere strictly to TypeScript. Shared types are often in `frontend/src/types.ts` or relevant backend models.
- **Code Location**:
- Backend routes: `backend/src/routes/`
- Frontend pages: `frontend/src/pages/`
- Frontend components: `frontend/src/components/`
## Testing
Currently, there is no formal test suite implemented. Agents making changes should consider adding basic tests for new functionalities in `backend/tests` or `frontend/src/__tests__` if appropriate.
+143
View File
@@ -0,0 +1,143 @@
# Changelog
All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/),
and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
## [Unreleased]
## 2026-06-13 #1
### Fixed
- Missing Google Maps API Key and CPF CNPJ API Key integration in backend deployment.
### Docker images
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.11.34](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.11.34)
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.11.34-prisma](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.11.34)
## 2026-06-11 #3
### Fixed
- Login issue due to jwt secrets missing in production.
## 2026-06-11 #2
### Added
- Created `CustomerDeleted` audit database table and model to log details of deleted customers.
- Created `/api/proxy/validator` and `/api/proxy/geocode` endpoints in backend to proxy third-party validation and mapping requests securely.
- Added `/api/users/me` and `/api/users/logout` endpoints in backend for cookie session verification and cleanup.
### Changed
- Configured explicit allowed origins for CORS from `ALLOWED_ORIGINS` environment variable, defaulting to `http://localhost:5173`.
- Gated public closest POS endpoint `/api/public/pos/closest` with rate-limiting (max 15/min) and removed `lat`, `lng`, and `lastBuyingDate` fields from its response.
- Configured `@fastify/jwt` to read tokens from `polpaAuth` HttpOnly cookie.
- Overrode frontend `localStorage` to store `token` and `user` strictly in-memory (JS variables) instead of persistent disk storage.
- Proxy validation and geocoding in frontend `Customers.tsx` through backend instead of using direct third-party calls.
- Compiled production backend build without TS source maps or declarations using a specialized `tsconfig.prod.json`.
- JWT token is no longer returned in login or `/api/users/me` response bodies — token is exclusively transported via the `polpaAuth` HttpOnly cookie and never exposed to JavaScript.
- Removed all `Authorization: Bearer` headers from frontend API calls — authentication relies solely on the HttpOnly cookie sent automatically by the browser.
- `Storage.prototype` override now only intercepts the `user` key (token interception removed as token no longer exists in JS context).
- Cookie signing reverted to `signed: false` — JWT's own HMAC signature provides integrity; `@fastify/cookie` layer was redundant and non-functional in v11.
### Fixed
- H1: Public endpoint leaks customer GPS + buying data.
- H2: CORS reflects any origin.
- H3: JWT in localStorage (XSS-accessible).
- H4: API tokens baked into frontend Docker image.
- H5: Hard delete of sales (no audit trail) mitigated by auditing customer deletes via `CustomerDeleted`.
- H6: Source maps served in production.
- Parameter injection vulnerability in `/api/proxy/validator`: `value` and `token` query parameters are now URL-encoded before being forwarded to the Invertexto API.
- Session re-issue on every `/api/users/me` call removed — endpoint now returns user data only, without silently extending the session on each page load.
- `Storage.prototype` override applied at prototype level instead of instance level, fixing Firefox compatibility where instance-level assignment was silently ignored.
### Docker images
- [ghcr.io/rmcampos/polpa-gestao/frontend:app-v2026.06.11.52](https://github.com/RMCampos/polpa-gestao/releases/tag/app-v2026.06.11.52)
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.11.34](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.11.34)
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.11.34-prisma](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.11.34)
-
## 2026-06-11 #1
### Added
- Scoped rate limiting using `@fastify/rate-limit` for `/api/users/login` endpoint keyed by IP + email.
- In-memory login failure tracking with exponential backoff lockout after 3 consecutive failures.
- Role-based authorization middleware via `requireAdmin` decorator on Fastify backend.
- Enforced admin role requirement on Users management, Product modification, Customer/POS deletion, Sale deletion, and Dashboard routes.
- Frontend role guards and conditional sidebar rendering to restrict non-admin users from accessing Users or Dashboard views.
### Changed
- Removed hardcoded fallback JWT signature secret.
- Seed script updated to read admin credentials from environment variables or generate a secure random password on first seed.
- Docker compose configuration updated to forward `JWT_SECRET` to the backend container.
### Fixed
- Hardcoded default admin user credentials security vulnerability (C2).
- Zero role-based authorization model allowing non-admin users to reach admin endpoints (C3).
- Potential authentication bypass due to fallback JWT secret when environment variable is missing (C1).
- No brute-force protection on user login (C4).
### Docker images
- [ghcr.io/rmcampos/polpa-gestao/frontend:app-v2026.06.11.50](https://github.com/RMCampos/polpa-gestao/releases/tag/app-v2026.06.11.50)
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.11.33](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.11.33)
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.11.33-prisma](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.11.33)
## 2026-06-10 #2
### Added
- Option do delete customers and POSes, with a confirmation modal to prevent accidental deletions.
### Changed
- Customer and POSes tables foreign keys recreating them with `ON DELETE CASCADE` to ensure related records are removed when a customer or POS is deleted.
- Updates on a disabled customer make it enabled again.
### Fixed
- Clearing up the phone number input on the customer modal.
### Docker images
- [ghcr.io/rmcampos/polpa-gestao/frontend:app-v2026.06.10.49](https://github.com/RMCampos/polpa-gestao/releases/tag/app-v2026.06.10.49)
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.10.32](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.10.32)
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.10.32-prisma](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.10.32)
## 2026-06-10 #1
### Added
- Card to the dashboard page displaying customer POSes and their last buying date for those who haven't bought anything in 10 days or more.
### Docker images
- [ghcr.io/rmcampos/polpa-gestao/frontend:app-v2026.06.10.48](https://github.com/RMCampos/polpa-gestao/releases/tag/app-v2026.06.10.48)
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.10.31](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.10.31)
- [ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.10.31-prisma](https://github.com/RMCampos/polpa-gestao/releases/tag/api-v2026.06.10.31)
## [app-v2026.06.08.47](https://github.com/RMCampos/polpa-gestao/releases/tag/app-v2026.06.08.47) - 2026-06-08
### Added
- Professional local development setup with Taskfile and Doppler for secure secret management.
- Doppler integration in CI/CD workflows for streamlined secret handling.
- POS by Region summary on the dashboard for better regional insights.
- Dashboard drill-down for Total Fridges with POS-level modal and API.
- POS Industry Summary endpoint and dashboard card.
- Industry field to Customer POS for categorization.
- Optional `region` field to POS for filtering and reporting.
- Optional `notes` field for customers in Prisma schema and database migration.
- Notes textarea in the customer create/edit modal with support for loading existing notes.
- Optional notes snippet display on customer cards.
### Changed
- Improved Terraform deployment plan with variables for better configurability.
- Updated README with clearer setup instructions.
- CI/CD workflows updated to use Doppler for environment secrets.
- Frontend build CI improved with Doppler integration.
- Container names and Docker flows updated for better naming consistency.
- Quantity input in sales page changed to text type for better UX.
- Closest page updated to include customer name for easier identification.
- Customer POST/PUT API handlers now accept and persist optional `notes`.
### Fixed
- Unable to type all 14 digits for enterprise documents on customer creation.
- Wrong Doppler secret name in multiple workflow files.
- Prevented duplicate Customer and POS creation.
## [[app-v2026.06.03.45]](https://github.com/RMCampos/polpa-gestao/releases/tag/app-v2026.06.03.45) - 2026-06-03
- Initial tagged release.
+56 -14
View File
@@ -30,16 +30,30 @@ A dashboard provides analytics and reporting. The application includes user mana
| CI/CD | GitHub Actions → GitHub Container Registry |
| Deployment | Terraform |
## Running Locally with Docker
## Running Locally with Taskfile + Doppler
The entire application stack (database, backend API, and frontend) can be started with a single command using Docker Compose.
The project uses `Taskfile.yml` to standardize local commands and `doppler.yaml` to define Doppler project/config for secrets.
**Prerequisites:** [Docker](https://docs.docker.com/get-docker/) and [Docker Compose](https://docs.docker.com/compose/install/) installed.
**Prerequisites:** [Docker](https://docs.docker.com/get-docker/), [Docker Compose](https://docs.docker.com/compose/install/), [Task](https://taskfile.dev/installation/), and [Doppler CLI](https://docs.doppler.com/docs/cli).
**Start all services:**
### 1. Authenticate and configure Doppler
`doppler.yaml` defaults to:
- project: `polpa-gestao`
- config: `dev_secrets`
```bash
docker compose up
doppler login
doppler setup --project polpa-gestao --config dev_secrets
# PS: tokens will be handled directly in runs, injected as ENV VARS
```
### 2. Start services
```bash
task dev-up
```
This command will:
@@ -54,29 +68,57 @@ This command will:
|---|---|
| Frontend | http://localhost:5173 |
| Backend API | http://localhost:3000 |
| Database | `localhost:5432` (user: `admin`, password: `adminpassword`, db: `polpa_gestao`) |
| Database | `localhost:5432` (Please run `task dev-db-access` to get DB credentials) |
**Optional environment variable:**
### Optional ngrok stack
To enable CPF/CNPJ document validation, set the `VITE_CPF_CNPJ_API_TOKEN` variable before starting:
To run the ngrok compose file:
```bash
VITE_CPF_CNPJ_API_TOKEN=your_token_here docker compose up
task dev-up-ngrok
```
**Stop all services:**
### Build Docker images
```bash
docker compose down
task build-all
# For ngrok, use
task build-all-ngrok
```
Database data is persisted in a Docker volume (`pgdata`) and will survive container restarts. To also remove the volume when stopping, run `docker compose down -v`.
Build tasks run with `doppler run`, which injects secrets as environment variables (for example `CPF_CNPJ_API_TOKEN` and `GOOGLE_MAPS_API_KEY`) during Docker builds.
### Stop services
```bash
task dev-down
# For ngrok, use
task dev-down-ngrok
```
To also remove volumes/orphans:
```bash
task dev-tier-down
```
Database data is persisted in a Docker volume (`pgdata`) and survives regular restarts.
## Running Locally with Docker Compose (Alternative)
If you prefer not to use Taskfile, you can still start the stack directly:
```bash
docker compose up
```
## Deployment
The application is deployed using **Terraform**. The infrastructure-as-code configuration can be found in the following public repository:
The application is deployed using **Terraform**. The infrastructure-as-code configuration can be found in the terraform directory:
[https://github.com/RMCampos/personal-projects-iaac/blob/main/polpa-gestao/main.tf](https://github.com/RMCampos/personal-projects-iaac/blob/main/polpa-gestao/main.tf)
[terraform/main.tf](terraform/main.tf)
Docker images are built and published to the GitHub Container Registry automatically via GitHub Actions on every push:
+193
View File
@@ -0,0 +1,193 @@
# SECURITY-AUDIT: polpa-gestao
> Date: 2026-06-09
> Scope: full repo (backend, frontend, infra, CI/CD)
> Method: static analysis
---
## CRITICAL
### C1 — Hardcoded JWT fallback secret
**File:** `backend/src/index.ts:14`
**Risk:** if `JWT_SECRET` env missing, uses string `'supersecret'`. Anyone knows this can forge tokens = full system access.
**Fix:** rm fallback; make `JWT_SECRET` required at startup + crash if unset.
### C2 — Default admin creds in seed file
**File:** `backend/src/seed.ts:7-15`
**Risk:** user `admin@polpagestao.com` / `admin123` hardcoded. If seed runs in prod (possible via `run-seed.sh`), trivial brute force gives admin access.
**Fix:** read creds from env or generate random password on first seed; log it to stdout only.
### C3 — Zero role-based authorization
**File:** all route files + `backend/src/index.ts:17-23`
**Risk:** `authenticate` decorator only checks JWT validity. Never checks `user.role`. Any authed user (role `user`) can: create other users (`POST /api/users`), delete products, read all sales, etc. Flat permission model — no separation of concerns.
**Fix:** add middleware that checks `request.user.role` against required role per route.
### C4 — No brute-force protection on login
**File:** `backend/src/routes/users.ts:6-21`
**Risk:** `POST /api/users/login` has zero rate limiting, no CAPTCHA, no account lockout. Attacker can spray passwords indefinitely.
**Fix:** rate-limit by IP + email (e.g., `@fastify/rate-limit`); add exponential backoff after N failures.
---
## HIGH
### H1 — Public endpoint leaks customer GPS + buying data
**File:** `backend/src/routes/public.ts:34-88` + `frontend/src/pages/ClosestPos.tsx`
**Risk:** `GET /api/public/pos/closest` requires zero auth. Returns customer names, full addresses, GPS coords, and last buying dates. Anyone can scrape this to build customer profiles + location history.
**Fix:** add basic auth or rate-limit heavily; strip `lastBuyingDate`; return only distance + address (not lat/lng).
### H2 — CORS reflects any origin
**File:** `backend/src/index.ts:8-11`
**Risk:** `origin: true` = any website can call API from browser. Combined with localStorage JWT (H3), XSS on any subdomain = full account takeover.
**Fix:** set explicit allowed origins (env var).
### H3 — JWT in localStorage (XSS-accessible)
**File:** `frontend/src/pages/Login.tsx:29`, `frontend/src/App.tsx:17`, all pages read from `localStorage`
**Risk:** Any XSS (even minor) steals token permanently. No HttpOnly/SameSite cookie protection.
**Fix:** use HttpOnly + Secure + SameSite cookies for token transport; keep JS token only in memory.
### H4 — API tokens baked into frontend Docker image
**File:** `frontend/Dockerfile:7-16` + `docker-compose.yml:64-68`
**Risk:** `VITE_CPF_CNPJ_API_TOKEN` + `VITE_GOOGLE_MAPS_API_KEY` are build args embedded in final JS bundle. Anyone who pulls `ghcr.io/rmcampos/polpa-gestao/frontend` can extract them from `dist/assets/*.js`.
**Fix:** proxy these API calls through backend (never expose tokens to client); or restrict token scopes to minimum.
### H5 — Hard delete of sales (no audit trail)
**File:** `backend/src/routes/sales.ts:254-275`
**Risk:** `DELETE /api/sales/:id` does `prisma.sale.delete()` — irreversible data loss. No soft-delete, no audit log. Financial records vanish.
**Fix:** add `disabledAt` column like other models; keep sale data but mark inactive.
### H6 — Source maps served in production
**File:** `backend/tsconfig.json:21-24`
**Risk:** `sourceMap: true`, `declaration: true`, `declarationMap: true` — production `dist/` maps back to TypeScript sources. Anyone decompiling the image sees full source code.
**Fix:** set `sourceMap: false`, `declaration: false`, `declarationMap: false` for production build.
### H7 — CI deploys to prod without manual approval
**File:** `.github/workflows/deploy.yml:20-22`
**Risk:** `workflow_run` on any completed backend/frontend CD directly triggers Terraform apply. No human gate before prod changes. A broken build can take down production.
**Fix:** require manual `workflow_dispatch` approval for production apply.
---
## MEDIUM
### M1 — DB exposed on host port
**File:** `docker-compose.yml:12`
**Risk:** `5432:5432` mapped to host. Anyone on same network can probe PostgreSQL.
**Fix:** remove port mapping or bind to `127.0.0.1:5432:5432`.
### M2 — ngrok tunnels dev to public internet
**File:** `Taskfile.yml` (ngrok task)
**Risk:** dev environment exposed via ngrok public URL. If dev DB has real data, it's exposed.
**Fix:** use Tailscale/WireGuard instead of ngrok; or ensure dev DB has fake data.
### M3 — No input validation (everything cast `as any`)
**File:** all route files (e.g., `users.ts:7`, `products.ts:22`, `customers.ts`, `sales.ts`)
**Risk:** request body typed `as any` everywhere. No Zod/Joi schema. Prisma catches type errors but not business logic validation. SQL injection unlikely (Prisma paramerizes) but unexpected types can crash or cause weird state.
**Fix:** define Fastify JSON schemas per route; use Zod for runtime validation.
### M4 — Prisma errors leak schema details to client
**File:** `backend/src/routes/products.ts:28-34`
**Risk:** `PrismaClientValidationError` message split and sent to client. Leaks field names, types, constraints.
**Fix:** return generic error to client; log full error server-side only.
### M5 — No HTTPS in compose (plaintext traffic)
**File:** `docker-compose.yml` (all services), `nginx/nginx.conf`
**Risk:** dev mode traffic is HTTP. If accessed over network (or via ngrok), credentials + tokens in plaintext.
**Fix:** terminate TLS at nginx or use Traefik with self-signed cert in dev.
### M6 — Terraform R2 backend skips validation
**File:** `terraform/main.tf:9-19`
**Risk:** `skip_credentials_validation`, `skip_region_validation`, `skip_s3_checksum` = misconfiguration-friendly. State may contain plaintext secrets.
**Fix:** remove skips; configure proper AWS env vars; enable state encryption.
### M7 — No soft-delete stack for visits/routes
**File:** `backend/prisma/schema.prisma` (Route, CustomerPos have no `disabledAt`)
**Risk:** Cascade deletes can destroy route data. No recovery.
**Fix:** add `disabledAt` to all models; soft-delete everywhere.
### M8 — Broad CI permissions
**File:** `.github/workflows/backend-cd.yml:15`, `deploy.yml`
**Risk:** `contents: write` + `packages: write`. Deploy workflow has access to production Kubeconfig via Doppler token.
**Fix:** restrict to `contents: read`, `packages: write` on workflow scope; use OIDC instead of long-lived Doppler tokens.
---
## LOW
### L1 — `strict: false` in backend TS
**File:** `backend/tsconfig.json:38`
**Risk:** no strict null checks. Null pointer bugs slip to runtime.
**Fix:** `strict: true` + fix type errors.
### L2 — `*.pem` in .dockerignore but not .gitignore
**File:** `backend/.dockerignore:7``*.pem` excluded from Docker build.
**Risk:** if someone places a .pem key in repo root, git would track it.
**Fix:** add `*.pem` to root `.gitignore`.
### L3 — Password stored as plain `String` in Prisma
**File:** `backend/prisma/schema.prisma:15`
**Risk:** no DB-level constraint on password min length. An app bug could store short/empty hashes. (Mitigated by bcrypt at app level.)
**Fix:** add `@db.VarChar(60)` to match bcrypt output length.
### L4 — No `enabledAt`/`disabledAt` on `Route` model
**File:** `backend/prisma/schema.prisma:96-104`
**Risk:** routes can only be deleted, not soft-disabled.
**Fix:** add `disabledAt DateTime?` to Route schema.
### L5 — `VITE_BUILD_NUMBER: snapshot` in compose
**File:** `docker-compose.yml:68`
**Risk:** images built locally not traceable to any CI run or git commit.
**Fix:** use `git describe --tags` or commit SHA.
### L6 — DB backups not encrypted client-side
**File:** `terraform/main.tf:385-390`
**Risk:** backups uploaded to R2 without client-side encryption. R2 server-side encryption may not be enabled.
**Fix:** encrypt with age/gpg before upload, or use `--sse aws:kms`.
### L7 — No dependency scanning/fuzzing
**File:** entire repo
**Risk:** no Dependabot/Renovate/Snyk for `package.json` deps. Supply chain risk unmanaged.
**Fix:** enable Dependabot or Renovate for npm + Docker.
---
## SUMMARY
| Severity | Count | Key issues |
|----------|-------|------------|
| CRITICAL | 4 | JWT secret fallback, default admin creds, zero RBAC, no login rate-limit |
| HIGH | 7 | Public GPS leak, open CORS, localStorage JWT, API tokens in image, hard deletes, source maps in prod, no deploy approval gate |
| MEDIUM | 8 | DB port exposed, ngrok tunnel, no validation, Prisma errors leaked, plaintext HTTP, Terraform skips, no soft-delete, broad CI permissions |
| LOW | 7 | strict:false, .pem git tracking, password column type, missing disableAt, build snapshot, unencrypted backups, no dependabot |
**Top 3 priorities:**
1. Remove JWT fallback secret (C1) — crash if `JWT_SECRET` unset
2. Add role checks to `authenticate` middleware (C3) — `admin` vs `user` access
3. Rate-limit login endpoint (C4) — `@fastify/rate-limit`
+97
View File
@@ -0,0 +1,97 @@
# https://taskfile.dev
version: '3'
silent: true
tasks:
build-all:
desc: Build frontend, backend and prisma Docker images using docker compose
cmd: |
export $(doppler secrets download --no-file --format env --config dev_tokens | sed 's/"//g' | xargs) && \
doppler run --config dev_secrets -- docker compose -f docker-compose.yml build
build-all-ngrok:
desc: Build frontend, backend and prisma Docker images using docker-compose.ngrok.yml
cmd: |
export $(doppler secrets download --no-file --format env --config dev_tokens | sed 's/"//g' | xargs) && \
doppler run --config dev_secrets -- docker compose -f docker-compose.ngrok.yml build
dev-up:
desc: Start the development environment using docker compose
deps: [build-all]
cmd: |
export $(doppler secrets download --no-file --format env --config dev_tokens | sed 's/"//g' | xargs) && \
doppler run --config dev_secrets -- docker compose -f docker-compose.yml up -d
dev-up-ngrok:
desc: Start the development environment using docker compose and ngrok
deps: [build-all-ngrok]
cmds:
- |
export $(doppler secrets download --no-file --format env --config dev_tokens | sed 's/"//g' | xargs) && \
doppler run --config dev_secrets -- docker compose -f docker-compose.ngrok.yml up -d
- task: ngrok-up
dev-down:
desc: Stop the development environment using docker compose
cmd: docker compose -f docker-compose.yml down
dev-down-ngrok:
desc: Stop the development environment using docker compose and ngrok
cmds:
- docker compose -f docker-compose.ngrok.yml down
- task: ngrok-down
dev-db-access:
desc: Print local DB credentials
cmd: |
doppler run --config dev_secrets -- sh -c '
echo "Local database credentials:"
echo "- Database name=$DB_NAME"
echo "- Database user=$DB_USER"
echo "- Database password=$DB_PASSWORD"
'
dev-tier-down:
desc: Stop the development environment using docker compose, removing volumes
cmd: docker compose -f docker-compose.yml down -v --remove-orphans
ngrok-up:
desc: Start ngrok tunnel in background
cmds:
- ./nginx/start-proxy.sh
- |
bash -c '
if pgrep -f "ngrok http" | grep -v $$ > /dev/null; then
echo "ngrok is already running, skipping..."
else
nohup ngrok http 8080 --log=stdout --log-format=logfmt --log-level=info > /tmp/ngrok.log 2>&1 &
echo $! > /tmp/ngrok.pid
echo "ngrok started with PID $(cat /tmp/ngrok.pid)"
sleep 3
if ! pgrep -f "ngrok http" > /dev/null; then
echo "ngrok failed to start, log output:"
cat /tmp/ngrok.log
exit 1
fi
echo "ngrok is running"
fi
'
- curl -s http://localhost:4040/api/tunnels | jq -r '.tunnels[0].public_url'
ngrok-down:
desc: Stop ngrok tunnel
cmds:
- docker stop nginx-proxy 2> /dev/null || true
- |
if [ -f /tmp/ngrok.pid ]; then
echo "Killing ngrok PID $(cat /tmp/ngrok.pid)"
/bin/kill -9 $(cat /tmp/ngrok.pid)
rm -rf /tmp/ngrok.pid
else
echo "No PID file found. trying pkill..."
pkill -9 -f ngrok || true
fi
- rm -rf /tmp/ngrok.log
-2
View File
@@ -1,7 +1,5 @@
node_modules
.next
.env
.env.local
npm-debug.log*
yarn-debug.log*
yarn-error.log*
-2
View File
@@ -1,5 +1,3 @@
node_modules
# Keep environment variables out of version control
.env
/generated/prisma
+2 -2
View File
@@ -17,12 +17,12 @@ WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=deps /app/src/generated/prisma ./src/generated/prisma
COPY package*.json ./
COPY tsconfig.json ./
COPY tsconfig.json tsconfig.prod.json ./
COPY prisma ./prisma
COPY prisma.config.ts ./
COPY src ./src
RUN npx tsc
RUN npx tsc -p tsconfig.prod.json
FROM node:22-bookworm-slim AS runner
+43
View File
@@ -9,8 +9,10 @@
"version": "1.0.0",
"license": "ISC",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/rate-limit": "^11.0.0",
"@prisma/adapter-pg": "^7.7.0",
"@prisma/client": "^7.7.0",
"bcrypt": "^6.0.0",
@@ -92,6 +94,26 @@
"fast-uri": "^3.0.0"
}
},
"node_modules/@fastify/cookie": {
"version": "11.0.2",
"resolved": "https://registry.npmjs.org/@fastify/cookie/-/cookie-11.0.2.tgz",
"integrity": "sha512-GWdwdGlgJxyvNv+QcKiGNevSspMQXncjMZ1J8IvuDQk0jvkzgWWZFNC2En3s+nHndZBGV8IbLwOI/sxCZw/mzA==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"cookie": "^1.0.0",
"fastify-plugin": "^5.0.0"
}
},
"node_modules/@fastify/cors": {
"version": "11.2.0",
"resolved": "https://registry.npmjs.org/@fastify/cors/-/cors-11.2.0.tgz",
@@ -225,6 +247,27 @@
"ipaddr.js": "^2.1.0"
}
},
"node_modules/@fastify/rate-limit": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/@fastify/rate-limit/-/rate-limit-11.0.0.tgz",
"integrity": "sha512-kCs+G59SitZw9TL/ekFe+MrzXk20dEp6zPAM8WEZjFl5Ubvv5ksTbEXYr4jGlBwWAKn78q+NFsj5CN75zXLjaw==",
"funding": [
{
"type": "github",
"url": "https://github.com/sponsors/fastify"
},
{
"type": "opencollective",
"url": "https://opencollective.com/fastify"
}
],
"license": "MIT",
"dependencies": {
"@lukeed/ms": "^2.0.2",
"fastify-plugin": "^5.0.0",
"toad-cache": "^3.7.0"
}
},
"node_modules/@hono/node-server": {
"version": "1.19.11",
"resolved": "https://registry.npmjs.org/@hono/node-server/-/node-server-1.19.11.tgz",
+2
View File
@@ -11,8 +11,10 @@
"license": "ISC",
"type": "commonjs",
"dependencies": {
"@fastify/cookie": "^11.0.2",
"@fastify/cors": "^11.2.0",
"@fastify/jwt": "^10.0.0",
"@fastify/rate-limit": "^11.0.0",
"@prisma/adapter-pg": "^7.7.0",
"@prisma/client": "^7.7.0",
"bcrypt": "^6.0.0",
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "CustomerPos" ADD COLUMN "fridgeCount" INTEGER NOT NULL DEFAULT 0;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "Sale" ADD COLUMN "nextVisitDate" TIMESTAMP(3),
ADD COLUMN "visitedAt" TIMESTAMP(3);
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "CustomerPos" ADD COLUMN "banner" BOOLEAN NOT NULL DEFAULT false,
ADD COLUMN "indiBanner" BOOLEAN NOT NULL DEFAULT false;
@@ -0,0 +1,4 @@
-- AlterTable
ALTER TABLE "CustomerPos"
ADD COLUMN "lat" DOUBLE PRECISION,
ADD COLUMN "lng" DOUBLE PRECISION;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "CustomerPos"
ADD COLUMN "industry" TEXT;
@@ -0,0 +1,3 @@
-- AlterTable
ALTER TABLE "CustomerPos"
ADD COLUMN "region" TEXT;
@@ -0,0 +1,2 @@
-- AlterTable
ALTER TABLE "Customer" ADD COLUMN "notes" TEXT;
@@ -0,0 +1,23 @@
-- DropForeignKey
ALTER TABLE "CustomerPos" DROP CONSTRAINT "CustomerPos_customerId_fkey";
-- DropForeignKey
ALTER TABLE "RouteCustomerPos" DROP CONSTRAINT "RouteCustomerPos_customerPosId_fkey";
-- DropForeignKey
ALTER TABLE "Sale" DROP CONSTRAINT "Sale_customerPosId_fkey";
-- DropForeignKey
ALTER TABLE "SaleProduct" DROP CONSTRAINT "SaleProduct_saleId_fkey";
-- AddForeignKey
ALTER TABLE "CustomerPos" ADD CONSTRAINT "CustomerPos_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "Customer"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "Sale" ADD CONSTRAINT "Sale_customerPosId_fkey" FOREIGN KEY ("customerPosId") REFERENCES "CustomerPos"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "SaleProduct" ADD CONSTRAINT "SaleProduct_saleId_fkey" FOREIGN KEY ("saleId") REFERENCES "Sale"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "RouteCustomerPos" ADD CONSTRAINT "RouteCustomerPos_customerPosId_fkey" FOREIGN KEY ("customerPosId") REFERENCES "CustomerPos"("id") ON DELETE CASCADE ON UPDATE CASCADE;
@@ -0,0 +1,13 @@
-- CreateTable
CREATE TABLE "CustomerDeleted" (
"id" UUID NOT NULL,
"customerId" UUID NOT NULL,
"name" TEXT NOT NULL,
"document" TEXT,
"phone" TEXT,
"personName" VARCHAR(30),
"notes" TEXT,
"deletedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "CustomerDeleted_pkey" PRIMARY KEY ("id")
);
@@ -0,0 +1,3 @@
# Please do not edit this file manually
# It should be added in your version-control system (e.g., Git)
provider = "postgresql"
+26 -4
View File
@@ -25,6 +25,7 @@ model Customer {
document String? @unique
phone String?
personName String? @db.VarChar(30)
notes String?
pos CustomerPos[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -34,10 +35,17 @@ model Customer {
model CustomerPos {
id String @id @default(uuid()) @db.Uuid
customerId String @db.Uuid
customer Customer @relation(fields: [customerId], references: [id])
customer Customer @relation(fields: [customerId], references: [id], onDelete: Cascade)
address String
phone String
industry String?
personName String? @db.VarChar(30)
fridgeCount Int @default(0)
banner Boolean @default(false)
indiBanner Boolean @default(false)
region String?
lat Float?
lng Float?
sales Sale[]
routes RouteCustomerPos[]
createdAt DateTime @default(now())
@@ -60,12 +68,14 @@ model Product {
model Sale {
id String @id @default(uuid()) @db.Uuid
customerPosId String @db.Uuid
customerPos CustomerPos @relation(fields: [customerPosId], references: [id])
customerPos CustomerPos @relation(fields: [customerPosId], references: [id], onDelete: Cascade)
delivered Boolean @default(false)
paymentMethod String
paymentDueDate DateTime?
paymentDate DateTime?
comments String?
nextVisitDate DateTime?
visitedAt DateTime?
products SaleProduct[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -74,7 +84,7 @@ model Sale {
model SaleProduct {
saleId String @db.Uuid
productId String @db.Uuid
sale Sale @relation(fields: [saleId], references: [id])
sale Sale @relation(fields: [saleId], references: [id], onDelete: Cascade)
product Product @relation(fields: [productId], references: [id])
quantity Int
createdAt DateTime @default(now())
@@ -97,7 +107,19 @@ model RouteCustomerPos {
routeId String @db.Uuid
customerPosId String @db.Uuid
route Route @relation(fields: [routeId], references: [id])
customerPos CustomerPos @relation(fields: [customerPosId], references: [id])
customerPos CustomerPos @relation(fields: [customerPosId], references: [id], onDelete: Cascade)
@@id([routeId, customerPosId])
}
model CustomerDeleted {
id String @id @default(uuid()) @db.Uuid
customerId String @db.Uuid
name String
document String?
phone String?
personName String? @db.VarChar(30)
notes String?
deletedAt DateTime @default(now())
}
+4 -1
View File
@@ -1,6 +1,9 @@
#!/bin/bash
docker compose exec backend node dist/seed.js
docker compose exec \
-e SEED_ADMIN_EMAIL="$1" \
-e SEED_ADMIN_PASSWORD="$2" \
polpa_backend node dist/seed.js
# for prod
# kubectl exec -n production -it polpa-gestao-backend-6d7d9f8c7b-abc12 -- node dist/seed.js
+1
View File
@@ -3,5 +3,6 @@ import fastify from 'fastify';
declare module 'fastify' {
export interface FastifyInstance {
authenticate: any;
requireAdmin: any;
}
}
+5
View File
@@ -57,3 +57,8 @@ export type Route = Prisma.RouteModel
*
*/
export type RouteCustomerPos = Prisma.RouteCustomerPosModel
/**
* Model CustomerDeleted
*
*/
export type CustomerDeleted = Prisma.CustomerDeletedModel
+5
View File
@@ -79,3 +79,8 @@ export type Route = Prisma.RouteModel
*
*/
export type RouteCustomerPos = Prisma.RouteCustomerPosModel
/**
* Model CustomerDeleted
*
*/
export type CustomerDeleted = Prisma.CustomerDeletedModel
+102 -48
View File
@@ -162,17 +162,6 @@ export type StringNullableWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type FloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type IntFilter<$PrismaModel = never> = {
equals?: number | Prisma.IntFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListIntFieldRefInput<$PrismaModel>
@@ -184,20 +173,20 @@ export type IntFilter<$PrismaModel = never> = {
not?: Prisma.NestedIntFilter<$PrismaModel> | number
}
export type FloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
export type BoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type FloatNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
_min?: Prisma.NestedFloatFilter<$PrismaModel>
_max?: Prisma.NestedFloatFilter<$PrismaModel>
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
}
export type IntWithAggregatesFilter<$PrismaModel = never> = {
@@ -216,11 +205,6 @@ export type IntWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type BoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type BoolWithAggregatesFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolWithAggregatesFilter<$PrismaModel> | boolean
@@ -229,6 +213,49 @@ export type BoolWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type FloatNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
}
export type FloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type FloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
_min?: Prisma.NestedFloatFilter<$PrismaModel>
_max?: Prisma.NestedFloatFilter<$PrismaModel>
}
export type NestedUuidFilter<$PrismaModel = never> = {
equals?: string | Prisma.StringFieldRefInput<$PrismaModel>
in?: string[] | Prisma.ListStringFieldRefInput<$PrismaModel>
@@ -388,31 +415,20 @@ export type NestedStringNullableWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedStringNullableFilter<$PrismaModel>
}
export type NestedFloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
export type NestedBoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
}
export type NestedFloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
export type NestedFloatNullableFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
_min?: Prisma.NestedFloatFilter<$PrismaModel>
_max?: Prisma.NestedFloatFilter<$PrismaModel>
not?: Prisma.NestedFloatNullableFilter<$PrismaModel> | number | null
}
export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
@@ -431,9 +447,15 @@ export type NestedIntWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedIntFilter<$PrismaModel>
}
export type NestedBoolFilter<$PrismaModel = never> = {
equals?: boolean | Prisma.BooleanFieldRefInput<$PrismaModel>
not?: Prisma.NestedBoolFilter<$PrismaModel> | boolean
export type NestedFloatFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatFilter<$PrismaModel> | number
}
export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
@@ -444,4 +466,36 @@ export type NestedBoolWithAggregatesFilter<$PrismaModel = never> = {
_max?: Prisma.NestedBoolFilter<$PrismaModel>
}
export type NestedFloatNullableWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel> | null
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel> | null
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatNullableWithAggregatesFilter<$PrismaModel> | number | null
_count?: Prisma.NestedIntNullableFilter<$PrismaModel>
_avg?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_sum?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_min?: Prisma.NestedFloatNullableFilter<$PrismaModel>
_max?: Prisma.NestedFloatNullableFilter<$PrismaModel>
}
export type NestedFloatWithAggregatesFilter<$PrismaModel = never> = {
equals?: number | Prisma.FloatFieldRefInput<$PrismaModel>
in?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
notIn?: number[] | Prisma.ListFloatFieldRefInput<$PrismaModel>
lt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
lte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gt?: number | Prisma.FloatFieldRefInput<$PrismaModel>
gte?: number | Prisma.FloatFieldRefInput<$PrismaModel>
not?: Prisma.NestedFloatWithAggregatesFilter<$PrismaModel> | number
_count?: Prisma.NestedIntFilter<$PrismaModel>
_avg?: Prisma.NestedFloatFilter<$PrismaModel>
_sum?: Prisma.NestedFloatFilter<$PrismaModel>
_min?: Prisma.NestedFloatFilter<$PrismaModel>
_max?: Prisma.NestedFloatFilter<$PrismaModel>
}
File diff suppressed because one or more lines are too long
@@ -391,7 +391,8 @@ export const ModelName = {
Sale: 'Sale',
SaleProduct: 'SaleProduct',
Route: 'Route',
RouteCustomerPos: 'RouteCustomerPos'
RouteCustomerPos: 'RouteCustomerPos',
CustomerDeleted: 'CustomerDeleted'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -407,7 +408,7 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
omit: GlobalOmitOptions
}
meta: {
modelProps: "user" | "customer" | "customerPos" | "product" | "sale" | "saleProduct" | "route" | "routeCustomerPos"
modelProps: "user" | "customer" | "customerPos" | "product" | "sale" | "saleProduct" | "route" | "routeCustomerPos" | "customerDeleted"
txIsolationLevel: TransactionIsolationLevel
}
model: {
@@ -1003,6 +1004,80 @@ export type TypeMap<ExtArgs extends runtime.Types.Extensions.InternalArgs = runt
}
}
}
CustomerDeleted: {
payload: Prisma.$CustomerDeletedPayload<ExtArgs>
fields: Prisma.CustomerDeletedFieldRefs
operations: {
findUnique: {
args: Prisma.CustomerDeletedFindUniqueArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload> | null
}
findUniqueOrThrow: {
args: Prisma.CustomerDeletedFindUniqueOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>
}
findFirst: {
args: Prisma.CustomerDeletedFindFirstArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload> | null
}
findFirstOrThrow: {
args: Prisma.CustomerDeletedFindFirstOrThrowArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>
}
findMany: {
args: Prisma.CustomerDeletedFindManyArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>[]
}
create: {
args: Prisma.CustomerDeletedCreateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>
}
createMany: {
args: Prisma.CustomerDeletedCreateManyArgs<ExtArgs>
result: BatchPayload
}
createManyAndReturn: {
args: Prisma.CustomerDeletedCreateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>[]
}
delete: {
args: Prisma.CustomerDeletedDeleteArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>
}
update: {
args: Prisma.CustomerDeletedUpdateArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>
}
deleteMany: {
args: Prisma.CustomerDeletedDeleteManyArgs<ExtArgs>
result: BatchPayload
}
updateMany: {
args: Prisma.CustomerDeletedUpdateManyArgs<ExtArgs>
result: BatchPayload
}
updateManyAndReturn: {
args: Prisma.CustomerDeletedUpdateManyAndReturnArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>[]
}
upsert: {
args: Prisma.CustomerDeletedUpsertArgs<ExtArgs>
result: runtime.Types.Utils.PayloadToResult<Prisma.$CustomerDeletedPayload>
}
aggregate: {
args: Prisma.CustomerDeletedAggregateArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.AggregateCustomerDeleted>
}
groupBy: {
args: Prisma.CustomerDeletedGroupByArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CustomerDeletedGroupByOutputType>[]
}
count: {
args: Prisma.CustomerDeletedCountArgs<ExtArgs>
result: runtime.Types.Utils.Optional<Prisma.CustomerDeletedCountAggregateOutputType> | number
}
}
}
}
} & {
other: {
@@ -1062,6 +1137,7 @@ export const CustomerScalarFieldEnum = {
document: 'document',
phone: 'phone',
personName: 'personName',
notes: 'notes',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
disabledAt: 'disabledAt'
@@ -1075,7 +1151,14 @@ export const CustomerPosScalarFieldEnum = {
customerId: 'customerId',
address: 'address',
phone: 'phone',
industry: 'industry',
personName: 'personName',
fridgeCount: 'fridgeCount',
banner: 'banner',
indiBanner: 'indiBanner',
region: 'region',
lat: 'lat',
lng: 'lng',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
disabledAt: 'disabledAt'
@@ -1106,6 +1189,8 @@ export const SaleScalarFieldEnum = {
paymentDueDate: 'paymentDueDate',
paymentDate: 'paymentDate',
comments: 'comments',
nextVisitDate: 'nextVisitDate',
visitedAt: 'visitedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
@@ -1144,6 +1229,20 @@ export const RouteCustomerPosScalarFieldEnum = {
export type RouteCustomerPosScalarFieldEnum = (typeof RouteCustomerPosScalarFieldEnum)[keyof typeof RouteCustomerPosScalarFieldEnum]
export const CustomerDeletedScalarFieldEnum = {
id: 'id',
customerId: 'customerId',
name: 'name',
document: 'document',
phone: 'phone',
personName: 'personName',
notes: 'notes',
deletedAt: 'deletedAt'
} as const
export type CustomerDeletedScalarFieldEnum = (typeof CustomerDeletedScalarFieldEnum)[keyof typeof CustomerDeletedScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
@@ -1202,20 +1301,6 @@ export type ListDateTimeFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaM
/**
* Reference to a field of type 'Float'
*/
export type FloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float'>
/**
* Reference to a field of type 'Float[]'
*/
export type ListFloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float[]'>
/**
* Reference to a field of type 'Int'
*/
@@ -1236,6 +1321,20 @@ export type ListIntFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel,
export type BooleanFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Boolean'>
/**
* Reference to a field of type 'Float'
*/
export type FloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float'>
/**
* Reference to a field of type 'Float[]'
*/
export type ListFloatFieldRefInput<$PrismaModel> = FieldRefInputType<$PrismaModel, 'Float[]'>
/**
* Batch Payload for updateMany & deleteMany & createMany
*/
@@ -1354,6 +1453,7 @@ export type GlobalOmitConfig = {
saleProduct?: Prisma.SaleProductOmit
route?: Prisma.RouteOmit
routeCustomerPos?: Prisma.RouteCustomerPosOmit
customerDeleted?: Prisma.CustomerDeletedOmit
}
/* Types for Logging */
@@ -58,7 +58,8 @@ export const ModelName = {
Sale: 'Sale',
SaleProduct: 'SaleProduct',
Route: 'Route',
RouteCustomerPos: 'RouteCustomerPos'
RouteCustomerPos: 'RouteCustomerPos',
CustomerDeleted: 'CustomerDeleted'
} as const
export type ModelName = (typeof ModelName)[keyof typeof ModelName]
@@ -97,6 +98,7 @@ export const CustomerScalarFieldEnum = {
document: 'document',
phone: 'phone',
personName: 'personName',
notes: 'notes',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
disabledAt: 'disabledAt'
@@ -110,7 +112,14 @@ export const CustomerPosScalarFieldEnum = {
customerId: 'customerId',
address: 'address',
phone: 'phone',
industry: 'industry',
personName: 'personName',
fridgeCount: 'fridgeCount',
banner: 'banner',
indiBanner: 'indiBanner',
region: 'region',
lat: 'lat',
lng: 'lng',
createdAt: 'createdAt',
updatedAt: 'updatedAt',
disabledAt: 'disabledAt'
@@ -141,6 +150,8 @@ export const SaleScalarFieldEnum = {
paymentDueDate: 'paymentDueDate',
paymentDate: 'paymentDate',
comments: 'comments',
nextVisitDate: 'nextVisitDate',
visitedAt: 'visitedAt',
createdAt: 'createdAt',
updatedAt: 'updatedAt'
} as const
@@ -179,6 +190,20 @@ export const RouteCustomerPosScalarFieldEnum = {
export type RouteCustomerPosScalarFieldEnum = (typeof RouteCustomerPosScalarFieldEnum)[keyof typeof RouteCustomerPosScalarFieldEnum]
export const CustomerDeletedScalarFieldEnum = {
id: 'id',
customerId: 'customerId',
name: 'name',
document: 'document',
phone: 'phone',
personName: 'personName',
notes: 'notes',
deletedAt: 'deletedAt'
} as const
export type CustomerDeletedScalarFieldEnum = (typeof CustomerDeletedScalarFieldEnum)[keyof typeof CustomerDeletedScalarFieldEnum]
export const SortOrder = {
asc: 'asc',
desc: 'desc'
+1
View File
@@ -16,4 +16,5 @@ export type * from './models/Sale.js'
export type * from './models/SaleProduct.js'
export type * from './models/Route.js'
export type * from './models/RouteCustomerPos.js'
export type * from './models/CustomerDeleted.js'
export type * from './commonInputTypes.js'
@@ -30,6 +30,7 @@ export type CustomerMinAggregateOutputType = {
document: string | null
phone: string | null
personName: string | null
notes: string | null
createdAt: Date | null
updatedAt: Date | null
disabledAt: Date | null
@@ -41,6 +42,7 @@ export type CustomerMaxAggregateOutputType = {
document: string | null
phone: string | null
personName: string | null
notes: string | null
createdAt: Date | null
updatedAt: Date | null
disabledAt: Date | null
@@ -52,6 +54,7 @@ export type CustomerCountAggregateOutputType = {
document: number
phone: number
personName: number
notes: number
createdAt: number
updatedAt: number
disabledAt: number
@@ -65,6 +68,7 @@ export type CustomerMinAggregateInputType = {
document?: true
phone?: true
personName?: true
notes?: true
createdAt?: true
updatedAt?: true
disabledAt?: true
@@ -76,6 +80,7 @@ export type CustomerMaxAggregateInputType = {
document?: true
phone?: true
personName?: true
notes?: true
createdAt?: true
updatedAt?: true
disabledAt?: true
@@ -87,6 +92,7 @@ export type CustomerCountAggregateInputType = {
document?: true
phone?: true
personName?: true
notes?: true
createdAt?: true
updatedAt?: true
disabledAt?: true
@@ -171,6 +177,7 @@ export type CustomerGroupByOutputType = {
document: string | null
phone: string | null
personName: string | null
notes: string | null
createdAt: Date
updatedAt: Date
disabledAt: Date | null
@@ -203,6 +210,7 @@ export type CustomerWhereInput = {
document?: Prisma.StringNullableFilter<"Customer"> | string | null
phone?: Prisma.StringNullableFilter<"Customer"> | string | null
personName?: Prisma.StringNullableFilter<"Customer"> | string | null
notes?: Prisma.StringNullableFilter<"Customer"> | string | null
createdAt?: Prisma.DateTimeFilter<"Customer"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Customer"> | Date | string
disabledAt?: Prisma.DateTimeNullableFilter<"Customer"> | Date | string | null
@@ -215,6 +223,7 @@ export type CustomerOrderByWithRelationInput = {
document?: Prisma.SortOrderInput | Prisma.SortOrder
phone?: Prisma.SortOrderInput | Prisma.SortOrder
personName?: Prisma.SortOrderInput | Prisma.SortOrder
notes?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
disabledAt?: Prisma.SortOrderInput | Prisma.SortOrder
@@ -230,6 +239,7 @@ export type CustomerWhereUniqueInput = Prisma.AtLeast<{
name?: Prisma.StringFilter<"Customer"> | string
phone?: Prisma.StringNullableFilter<"Customer"> | string | null
personName?: Prisma.StringNullableFilter<"Customer"> | string | null
notes?: Prisma.StringNullableFilter<"Customer"> | string | null
createdAt?: Prisma.DateTimeFilter<"Customer"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Customer"> | Date | string
disabledAt?: Prisma.DateTimeNullableFilter<"Customer"> | Date | string | null
@@ -242,6 +252,7 @@ export type CustomerOrderByWithAggregationInput = {
document?: Prisma.SortOrderInput | Prisma.SortOrder
phone?: Prisma.SortOrderInput | Prisma.SortOrder
personName?: Prisma.SortOrderInput | Prisma.SortOrder
notes?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
disabledAt?: Prisma.SortOrderInput | Prisma.SortOrder
@@ -259,6 +270,7 @@ export type CustomerScalarWhereWithAggregatesInput = {
document?: Prisma.StringNullableWithAggregatesFilter<"Customer"> | string | null
phone?: Prisma.StringNullableWithAggregatesFilter<"Customer"> | string | null
personName?: Prisma.StringNullableWithAggregatesFilter<"Customer"> | string | null
notes?: Prisma.StringNullableWithAggregatesFilter<"Customer"> | string | null
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Customer"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Customer"> | Date | string
disabledAt?: Prisma.DateTimeNullableWithAggregatesFilter<"Customer"> | Date | string | null
@@ -270,6 +282,7 @@ export type CustomerCreateInput = {
document?: string | null
phone?: string | null
personName?: string | null
notes?: string | null
createdAt?: Date | string
updatedAt?: Date | string
disabledAt?: Date | string | null
@@ -282,6 +295,7 @@ export type CustomerUncheckedCreateInput = {
document?: string | null
phone?: string | null
personName?: string | null
notes?: string | null
createdAt?: Date | string
updatedAt?: Date | string
disabledAt?: Date | string | null
@@ -294,6 +308,7 @@ export type CustomerUpdateInput = {
document?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
@@ -306,6 +321,7 @@ export type CustomerUncheckedUpdateInput = {
document?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
@@ -318,6 +334,7 @@ export type CustomerCreateManyInput = {
document?: string | null
phone?: string | null
personName?: string | null
notes?: string | null
createdAt?: Date | string
updatedAt?: Date | string
disabledAt?: Date | string | null
@@ -329,6 +346,7 @@ export type CustomerUpdateManyMutationInput = {
document?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
@@ -340,6 +358,7 @@ export type CustomerUncheckedUpdateManyInput = {
document?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
@@ -351,6 +370,7 @@ export type CustomerCountOrderByAggregateInput = {
document?: Prisma.SortOrder
phone?: Prisma.SortOrder
personName?: Prisma.SortOrder
notes?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
disabledAt?: Prisma.SortOrder
@@ -362,6 +382,7 @@ export type CustomerMaxOrderByAggregateInput = {
document?: Prisma.SortOrder
phone?: Prisma.SortOrder
personName?: Prisma.SortOrder
notes?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
disabledAt?: Prisma.SortOrder
@@ -373,6 +394,7 @@ export type CustomerMinOrderByAggregateInput = {
document?: Prisma.SortOrder
phone?: Prisma.SortOrder
personName?: Prisma.SortOrder
notes?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
disabledAt?: Prisma.SortOrder
@@ -407,6 +429,7 @@ export type CustomerCreateWithoutPosInput = {
document?: string | null
phone?: string | null
personName?: string | null
notes?: string | null
createdAt?: Date | string
updatedAt?: Date | string
disabledAt?: Date | string | null
@@ -418,6 +441,7 @@ export type CustomerUncheckedCreateWithoutPosInput = {
document?: string | null
phone?: string | null
personName?: string | null
notes?: string | null
createdAt?: Date | string
updatedAt?: Date | string
disabledAt?: Date | string | null
@@ -445,6 +469,7 @@ export type CustomerUpdateWithoutPosInput = {
document?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
@@ -456,6 +481,7 @@ export type CustomerUncheckedUpdateWithoutPosInput = {
document?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
phone?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
notes?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
@@ -498,6 +524,7 @@ export type CustomerSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs
document?: boolean
phone?: boolean
personName?: boolean
notes?: boolean
createdAt?: boolean
updatedAt?: boolean
disabledAt?: boolean
@@ -511,6 +538,7 @@ export type CustomerSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Exte
document?: boolean
phone?: boolean
personName?: boolean
notes?: boolean
createdAt?: boolean
updatedAt?: boolean
disabledAt?: boolean
@@ -522,6 +550,7 @@ export type CustomerSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Exte
document?: boolean
phone?: boolean
personName?: boolean
notes?: boolean
createdAt?: boolean
updatedAt?: boolean
disabledAt?: boolean
@@ -533,12 +562,13 @@ export type CustomerSelectScalar = {
document?: boolean
phone?: boolean
personName?: boolean
notes?: boolean
createdAt?: boolean
updatedAt?: boolean
disabledAt?: boolean
}
export type CustomerOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "document" | "phone" | "personName" | "createdAt" | "updatedAt" | "disabledAt", ExtArgs["result"]["customer"]>
export type CustomerOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "name" | "document" | "phone" | "personName" | "notes" | "createdAt" | "updatedAt" | "disabledAt", ExtArgs["result"]["customer"]>
export type CustomerInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
pos?: boolean | Prisma.Customer$posArgs<ExtArgs>
_count?: boolean | Prisma.CustomerCountOutputTypeDefaultArgs<ExtArgs>
@@ -557,6 +587,7 @@ export type $CustomerPayload<ExtArgs extends runtime.Types.Extensions.InternalAr
document: string | null
phone: string | null
personName: string | null
notes: string | null
createdAt: Date
updatedAt: Date
disabledAt: Date | null
@@ -989,6 +1020,7 @@ export interface CustomerFieldRefs {
readonly document: Prisma.FieldRef<"Customer", 'String'>
readonly phone: Prisma.FieldRef<"Customer", 'String'>
readonly personName: Prisma.FieldRef<"Customer", 'String'>
readonly notes: Prisma.FieldRef<"Customer", 'String'>
readonly createdAt: Prisma.FieldRef<"Customer", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"Customer", 'DateTime'>
readonly disabledAt: Prisma.FieldRef<"Customer", 'DateTime'>
File diff suppressed because it is too large Load Diff
@@ -20,16 +20,37 @@ export type CustomerPosModel = runtime.Types.Result.DefaultSelection<Prisma.$Cus
export type AggregateCustomerPos = {
_count: CustomerPosCountAggregateOutputType | null
_avg: CustomerPosAvgAggregateOutputType | null
_sum: CustomerPosSumAggregateOutputType | null
_min: CustomerPosMinAggregateOutputType | null
_max: CustomerPosMaxAggregateOutputType | null
}
export type CustomerPosAvgAggregateOutputType = {
fridgeCount: number | null
lat: number | null
lng: number | null
}
export type CustomerPosSumAggregateOutputType = {
fridgeCount: number | null
lat: number | null
lng: number | null
}
export type CustomerPosMinAggregateOutputType = {
id: string | null
customerId: string | null
address: string | null
phone: string | null
industry: string | null
personName: string | null
fridgeCount: number | null
banner: boolean | null
indiBanner: boolean | null
region: string | null
lat: number | null
lng: number | null
createdAt: Date | null
updatedAt: Date | null
disabledAt: Date | null
@@ -40,7 +61,14 @@ export type CustomerPosMaxAggregateOutputType = {
customerId: string | null
address: string | null
phone: string | null
industry: string | null
personName: string | null
fridgeCount: number | null
banner: boolean | null
indiBanner: boolean | null
region: string | null
lat: number | null
lng: number | null
createdAt: Date | null
updatedAt: Date | null
disabledAt: Date | null
@@ -51,7 +79,14 @@ export type CustomerPosCountAggregateOutputType = {
customerId: number
address: number
phone: number
industry: number
personName: number
fridgeCount: number
banner: number
indiBanner: number
region: number
lat: number
lng: number
createdAt: number
updatedAt: number
disabledAt: number
@@ -59,12 +94,31 @@ export type CustomerPosCountAggregateOutputType = {
}
export type CustomerPosAvgAggregateInputType = {
fridgeCount?: true
lat?: true
lng?: true
}
export type CustomerPosSumAggregateInputType = {
fridgeCount?: true
lat?: true
lng?: true
}
export type CustomerPosMinAggregateInputType = {
id?: true
customerId?: true
address?: true
phone?: true
industry?: true
personName?: true
fridgeCount?: true
banner?: true
indiBanner?: true
region?: true
lat?: true
lng?: true
createdAt?: true
updatedAt?: true
disabledAt?: true
@@ -75,7 +129,14 @@ export type CustomerPosMaxAggregateInputType = {
customerId?: true
address?: true
phone?: true
industry?: true
personName?: true
fridgeCount?: true
banner?: true
indiBanner?: true
region?: true
lat?: true
lng?: true
createdAt?: true
updatedAt?: true
disabledAt?: true
@@ -86,7 +147,14 @@ export type CustomerPosCountAggregateInputType = {
customerId?: true
address?: true
phone?: true
industry?: true
personName?: true
fridgeCount?: true
banner?: true
indiBanner?: true
region?: true
lat?: true
lng?: true
createdAt?: true
updatedAt?: true
disabledAt?: true
@@ -128,6 +196,18 @@ export type CustomerPosAggregateArgs<ExtArgs extends runtime.Types.Extensions.In
* Count returned CustomerPos
**/
_count?: true | CustomerPosCountAggregateInputType
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
*
* Select which fields to average
**/
_avg?: CustomerPosAvgAggregateInputType
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
*
* Select which fields to sum
**/
_sum?: CustomerPosSumAggregateInputType
/**
* {@link https://www.prisma.io/docs/concepts/components/prisma-client/aggregations Aggregation Docs}
*
@@ -161,6 +241,8 @@ export type CustomerPosGroupByArgs<ExtArgs extends runtime.Types.Extensions.Inte
take?: number
skip?: number
_count?: CustomerPosCountAggregateInputType | true
_avg?: CustomerPosAvgAggregateInputType
_sum?: CustomerPosSumAggregateInputType
_min?: CustomerPosMinAggregateInputType
_max?: CustomerPosMaxAggregateInputType
}
@@ -170,11 +252,20 @@ export type CustomerPosGroupByOutputType = {
customerId: string
address: string
phone: string
industry: string | null
personName: string | null
fridgeCount: number
banner: boolean
indiBanner: boolean
region: string | null
lat: number | null
lng: number | null
createdAt: Date
updatedAt: Date
disabledAt: Date | null
_count: CustomerPosCountAggregateOutputType | null
_avg: CustomerPosAvgAggregateOutputType | null
_sum: CustomerPosSumAggregateOutputType | null
_min: CustomerPosMinAggregateOutputType | null
_max: CustomerPosMaxAggregateOutputType | null
}
@@ -202,7 +293,14 @@ export type CustomerPosWhereInput = {
customerId?: Prisma.UuidFilter<"CustomerPos"> | string
address?: Prisma.StringFilter<"CustomerPos"> | string
phone?: Prisma.StringFilter<"CustomerPos"> | string
industry?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
personName?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
fridgeCount?: Prisma.IntFilter<"CustomerPos"> | number
banner?: Prisma.BoolFilter<"CustomerPos"> | boolean
indiBanner?: Prisma.BoolFilter<"CustomerPos"> | boolean
region?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
lat?: Prisma.FloatNullableFilter<"CustomerPos"> | number | null
lng?: Prisma.FloatNullableFilter<"CustomerPos"> | number | null
createdAt?: Prisma.DateTimeFilter<"CustomerPos"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"CustomerPos"> | Date | string
disabledAt?: Prisma.DateTimeNullableFilter<"CustomerPos"> | Date | string | null
@@ -216,7 +314,14 @@ export type CustomerPosOrderByWithRelationInput = {
customerId?: Prisma.SortOrder
address?: Prisma.SortOrder
phone?: Prisma.SortOrder
industry?: Prisma.SortOrderInput | Prisma.SortOrder
personName?: Prisma.SortOrderInput | Prisma.SortOrder
fridgeCount?: Prisma.SortOrder
banner?: Prisma.SortOrder
indiBanner?: Prisma.SortOrder
region?: Prisma.SortOrderInput | Prisma.SortOrder
lat?: Prisma.SortOrderInput | Prisma.SortOrder
lng?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
disabledAt?: Prisma.SortOrderInput | Prisma.SortOrder
@@ -233,7 +338,14 @@ export type CustomerPosWhereUniqueInput = Prisma.AtLeast<{
customerId?: Prisma.UuidFilter<"CustomerPos"> | string
address?: Prisma.StringFilter<"CustomerPos"> | string
phone?: Prisma.StringFilter<"CustomerPos"> | string
industry?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
personName?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
fridgeCount?: Prisma.IntFilter<"CustomerPos"> | number
banner?: Prisma.BoolFilter<"CustomerPos"> | boolean
indiBanner?: Prisma.BoolFilter<"CustomerPos"> | boolean
region?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
lat?: Prisma.FloatNullableFilter<"CustomerPos"> | number | null
lng?: Prisma.FloatNullableFilter<"CustomerPos"> | number | null
createdAt?: Prisma.DateTimeFilter<"CustomerPos"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"CustomerPos"> | Date | string
disabledAt?: Prisma.DateTimeNullableFilter<"CustomerPos"> | Date | string | null
@@ -247,13 +359,22 @@ export type CustomerPosOrderByWithAggregationInput = {
customerId?: Prisma.SortOrder
address?: Prisma.SortOrder
phone?: Prisma.SortOrder
industry?: Prisma.SortOrderInput | Prisma.SortOrder
personName?: Prisma.SortOrderInput | Prisma.SortOrder
fridgeCount?: Prisma.SortOrder
banner?: Prisma.SortOrder
indiBanner?: Prisma.SortOrder
region?: Prisma.SortOrderInput | Prisma.SortOrder
lat?: Prisma.SortOrderInput | Prisma.SortOrder
lng?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
disabledAt?: Prisma.SortOrderInput | Prisma.SortOrder
_count?: Prisma.CustomerPosCountOrderByAggregateInput
_avg?: Prisma.CustomerPosAvgOrderByAggregateInput
_max?: Prisma.CustomerPosMaxOrderByAggregateInput
_min?: Prisma.CustomerPosMinOrderByAggregateInput
_sum?: Prisma.CustomerPosSumOrderByAggregateInput
}
export type CustomerPosScalarWhereWithAggregatesInput = {
@@ -264,7 +385,14 @@ export type CustomerPosScalarWhereWithAggregatesInput = {
customerId?: Prisma.UuidWithAggregatesFilter<"CustomerPos"> | string
address?: Prisma.StringWithAggregatesFilter<"CustomerPos"> | string
phone?: Prisma.StringWithAggregatesFilter<"CustomerPos"> | string
industry?: Prisma.StringNullableWithAggregatesFilter<"CustomerPos"> | string | null
personName?: Prisma.StringNullableWithAggregatesFilter<"CustomerPos"> | string | null
fridgeCount?: Prisma.IntWithAggregatesFilter<"CustomerPos"> | number
banner?: Prisma.BoolWithAggregatesFilter<"CustomerPos"> | boolean
indiBanner?: Prisma.BoolWithAggregatesFilter<"CustomerPos"> | boolean
region?: Prisma.StringNullableWithAggregatesFilter<"CustomerPos"> | string | null
lat?: Prisma.FloatNullableWithAggregatesFilter<"CustomerPos"> | number | null
lng?: Prisma.FloatNullableWithAggregatesFilter<"CustomerPos"> | number | null
createdAt?: Prisma.DateTimeWithAggregatesFilter<"CustomerPos"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"CustomerPos"> | Date | string
disabledAt?: Prisma.DateTimeNullableWithAggregatesFilter<"CustomerPos"> | Date | string | null
@@ -274,7 +402,14 @@ export type CustomerPosCreateInput = {
id?: string
address: string
phone: string
industry?: string | null
personName?: string | null
fridgeCount?: number
banner?: boolean
indiBanner?: boolean
region?: string | null
lat?: number | null
lng?: number | null
createdAt?: Date | string
updatedAt?: Date | string
disabledAt?: Date | string | null
@@ -288,7 +423,14 @@ export type CustomerPosUncheckedCreateInput = {
customerId: string
address: string
phone: string
industry?: string | null
personName?: string | null
fridgeCount?: number
banner?: boolean
indiBanner?: boolean
region?: string | null
lat?: number | null
lng?: number | null
createdAt?: Date | string
updatedAt?: Date | string
disabledAt?: Date | string | null
@@ -300,7 +442,14 @@ export type CustomerPosUpdateInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
address?: Prisma.StringFieldUpdateOperationsInput | string
phone?: Prisma.StringFieldUpdateOperationsInput | string
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
@@ -314,7 +463,14 @@ export type CustomerPosUncheckedUpdateInput = {
customerId?: Prisma.StringFieldUpdateOperationsInput | string
address?: Prisma.StringFieldUpdateOperationsInput | string
phone?: Prisma.StringFieldUpdateOperationsInput | string
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
@@ -327,7 +483,14 @@ export type CustomerPosCreateManyInput = {
customerId: string
address: string
phone: string
industry?: string | null
personName?: string | null
fridgeCount?: number
banner?: boolean
indiBanner?: boolean
region?: string | null
lat?: number | null
lng?: number | null
createdAt?: Date | string
updatedAt?: Date | string
disabledAt?: Date | string | null
@@ -337,7 +500,14 @@ export type CustomerPosUpdateManyMutationInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
address?: Prisma.StringFieldUpdateOperationsInput | string
phone?: Prisma.StringFieldUpdateOperationsInput | string
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
@@ -348,7 +518,14 @@ export type CustomerPosUncheckedUpdateManyInput = {
customerId?: Prisma.StringFieldUpdateOperationsInput | string
address?: Prisma.StringFieldUpdateOperationsInput | string
phone?: Prisma.StringFieldUpdateOperationsInput | string
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
@@ -369,18 +546,38 @@ export type CustomerPosCountOrderByAggregateInput = {
customerId?: Prisma.SortOrder
address?: Prisma.SortOrder
phone?: Prisma.SortOrder
industry?: Prisma.SortOrder
personName?: Prisma.SortOrder
fridgeCount?: Prisma.SortOrder
banner?: Prisma.SortOrder
indiBanner?: Prisma.SortOrder
region?: Prisma.SortOrder
lat?: Prisma.SortOrder
lng?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
disabledAt?: Prisma.SortOrder
}
export type CustomerPosAvgOrderByAggregateInput = {
fridgeCount?: Prisma.SortOrder
lat?: Prisma.SortOrder
lng?: Prisma.SortOrder
}
export type CustomerPosMaxOrderByAggregateInput = {
id?: Prisma.SortOrder
customerId?: Prisma.SortOrder
address?: Prisma.SortOrder
phone?: Prisma.SortOrder
industry?: Prisma.SortOrder
personName?: Prisma.SortOrder
fridgeCount?: Prisma.SortOrder
banner?: Prisma.SortOrder
indiBanner?: Prisma.SortOrder
region?: Prisma.SortOrder
lat?: Prisma.SortOrder
lng?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
disabledAt?: Prisma.SortOrder
@@ -391,12 +588,25 @@ export type CustomerPosMinOrderByAggregateInput = {
customerId?: Prisma.SortOrder
address?: Prisma.SortOrder
phone?: Prisma.SortOrder
industry?: Prisma.SortOrder
personName?: Prisma.SortOrder
fridgeCount?: Prisma.SortOrder
banner?: Prisma.SortOrder
indiBanner?: Prisma.SortOrder
region?: Prisma.SortOrder
lat?: Prisma.SortOrder
lng?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
disabledAt?: Prisma.SortOrder
}
export type CustomerPosSumOrderByAggregateInput = {
fridgeCount?: Prisma.SortOrder
lat?: Prisma.SortOrder
lng?: Prisma.SortOrder
}
export type CustomerPosScalarRelationFilter = {
is?: Prisma.CustomerPosWhereInput
isNot?: Prisma.CustomerPosWhereInput
@@ -444,6 +654,26 @@ export type CustomerPosUncheckedUpdateManyWithoutCustomerNestedInput = {
deleteMany?: Prisma.CustomerPosScalarWhereInput | Prisma.CustomerPosScalarWhereInput[]
}
export type IntFieldUpdateOperationsInput = {
set?: number
increment?: number
decrement?: number
multiply?: number
divide?: number
}
export type BoolFieldUpdateOperationsInput = {
set?: boolean
}
export type NullableFloatFieldUpdateOperationsInput = {
set?: number | null
increment?: number
decrement?: number
multiply?: number
divide?: number
}
export type CustomerPosCreateNestedOneWithoutSalesInput = {
create?: Prisma.XOR<Prisma.CustomerPosCreateWithoutSalesInput, Prisma.CustomerPosUncheckedCreateWithoutSalesInput>
connectOrCreate?: Prisma.CustomerPosCreateOrConnectWithoutSalesInput
@@ -476,7 +706,14 @@ export type CustomerPosCreateWithoutCustomerInput = {
id?: string
address: string
phone: string
industry?: string | null
personName?: string | null
fridgeCount?: number
banner?: boolean
indiBanner?: boolean
region?: string | null
lat?: number | null
lng?: number | null
createdAt?: Date | string
updatedAt?: Date | string
disabledAt?: Date | string | null
@@ -488,7 +725,14 @@ export type CustomerPosUncheckedCreateWithoutCustomerInput = {
id?: string
address: string
phone: string
industry?: string | null
personName?: string | null
fridgeCount?: number
banner?: boolean
indiBanner?: boolean
region?: string | null
lat?: number | null
lng?: number | null
createdAt?: Date | string
updatedAt?: Date | string
disabledAt?: Date | string | null
@@ -530,7 +774,14 @@ export type CustomerPosScalarWhereInput = {
customerId?: Prisma.UuidFilter<"CustomerPos"> | string
address?: Prisma.StringFilter<"CustomerPos"> | string
phone?: Prisma.StringFilter<"CustomerPos"> | string
industry?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
personName?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
fridgeCount?: Prisma.IntFilter<"CustomerPos"> | number
banner?: Prisma.BoolFilter<"CustomerPos"> | boolean
indiBanner?: Prisma.BoolFilter<"CustomerPos"> | boolean
region?: Prisma.StringNullableFilter<"CustomerPos"> | string | null
lat?: Prisma.FloatNullableFilter<"CustomerPos"> | number | null
lng?: Prisma.FloatNullableFilter<"CustomerPos"> | number | null
createdAt?: Prisma.DateTimeFilter<"CustomerPos"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"CustomerPos"> | Date | string
disabledAt?: Prisma.DateTimeNullableFilter<"CustomerPos"> | Date | string | null
@@ -540,7 +791,14 @@ export type CustomerPosCreateWithoutSalesInput = {
id?: string
address: string
phone: string
industry?: string | null
personName?: string | null
fridgeCount?: number
banner?: boolean
indiBanner?: boolean
region?: string | null
lat?: number | null
lng?: number | null
createdAt?: Date | string
updatedAt?: Date | string
disabledAt?: Date | string | null
@@ -553,7 +811,14 @@ export type CustomerPosUncheckedCreateWithoutSalesInput = {
customerId: string
address: string
phone: string
industry?: string | null
personName?: string | null
fridgeCount?: number
banner?: boolean
indiBanner?: boolean
region?: string | null
lat?: number | null
lng?: number | null
createdAt?: Date | string
updatedAt?: Date | string
disabledAt?: Date | string | null
@@ -580,7 +845,14 @@ export type CustomerPosUpdateWithoutSalesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
address?: Prisma.StringFieldUpdateOperationsInput | string
phone?: Prisma.StringFieldUpdateOperationsInput | string
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
@@ -593,7 +865,14 @@ export type CustomerPosUncheckedUpdateWithoutSalesInput = {
customerId?: Prisma.StringFieldUpdateOperationsInput | string
address?: Prisma.StringFieldUpdateOperationsInput | string
phone?: Prisma.StringFieldUpdateOperationsInput | string
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
@@ -604,7 +883,14 @@ export type CustomerPosCreateWithoutRoutesInput = {
id?: string
address: string
phone: string
industry?: string | null
personName?: string | null
fridgeCount?: number
banner?: boolean
indiBanner?: boolean
region?: string | null
lat?: number | null
lng?: number | null
createdAt?: Date | string
updatedAt?: Date | string
disabledAt?: Date | string | null
@@ -617,7 +903,14 @@ export type CustomerPosUncheckedCreateWithoutRoutesInput = {
customerId: string
address: string
phone: string
industry?: string | null
personName?: string | null
fridgeCount?: number
banner?: boolean
indiBanner?: boolean
region?: string | null
lat?: number | null
lng?: number | null
createdAt?: Date | string
updatedAt?: Date | string
disabledAt?: Date | string | null
@@ -644,7 +937,14 @@ export type CustomerPosUpdateWithoutRoutesInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
address?: Prisma.StringFieldUpdateOperationsInput | string
phone?: Prisma.StringFieldUpdateOperationsInput | string
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
@@ -657,7 +957,14 @@ export type CustomerPosUncheckedUpdateWithoutRoutesInput = {
customerId?: Prisma.StringFieldUpdateOperationsInput | string
address?: Prisma.StringFieldUpdateOperationsInput | string
phone?: Prisma.StringFieldUpdateOperationsInput | string
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
@@ -668,7 +975,14 @@ export type CustomerPosCreateManyCustomerInput = {
id?: string
address: string
phone: string
industry?: string | null
personName?: string | null
fridgeCount?: number
banner?: boolean
indiBanner?: boolean
region?: string | null
lat?: number | null
lng?: number | null
createdAt?: Date | string
updatedAt?: Date | string
disabledAt?: Date | string | null
@@ -678,7 +992,14 @@ export type CustomerPosUpdateWithoutCustomerInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
address?: Prisma.StringFieldUpdateOperationsInput | string
phone?: Prisma.StringFieldUpdateOperationsInput | string
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
@@ -690,7 +1011,14 @@ export type CustomerPosUncheckedUpdateWithoutCustomerInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
address?: Prisma.StringFieldUpdateOperationsInput | string
phone?: Prisma.StringFieldUpdateOperationsInput | string
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
@@ -702,7 +1030,14 @@ export type CustomerPosUncheckedUpdateManyWithoutCustomerInput = {
id?: Prisma.StringFieldUpdateOperationsInput | string
address?: Prisma.StringFieldUpdateOperationsInput | string
phone?: Prisma.StringFieldUpdateOperationsInput | string
industry?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
personName?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
fridgeCount?: Prisma.IntFieldUpdateOperationsInput | number
banner?: Prisma.BoolFieldUpdateOperationsInput | boolean
indiBanner?: Prisma.BoolFieldUpdateOperationsInput | boolean
region?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
lat?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
lng?: Prisma.NullableFloatFieldUpdateOperationsInput | number | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
disabledAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
@@ -753,7 +1088,14 @@ export type CustomerPosSelect<ExtArgs extends runtime.Types.Extensions.InternalA
customerId?: boolean
address?: boolean
phone?: boolean
industry?: boolean
personName?: boolean
fridgeCount?: boolean
banner?: boolean
indiBanner?: boolean
region?: boolean
lat?: boolean
lng?: boolean
createdAt?: boolean
updatedAt?: boolean
disabledAt?: boolean
@@ -768,7 +1110,14 @@ export type CustomerPosSelectCreateManyAndReturn<ExtArgs extends runtime.Types.E
customerId?: boolean
address?: boolean
phone?: boolean
industry?: boolean
personName?: boolean
fridgeCount?: boolean
banner?: boolean
indiBanner?: boolean
region?: boolean
lat?: boolean
lng?: boolean
createdAt?: boolean
updatedAt?: boolean
disabledAt?: boolean
@@ -780,7 +1129,14 @@ export type CustomerPosSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.E
customerId?: boolean
address?: boolean
phone?: boolean
industry?: boolean
personName?: boolean
fridgeCount?: boolean
banner?: boolean
indiBanner?: boolean
region?: boolean
lat?: boolean
lng?: boolean
createdAt?: boolean
updatedAt?: boolean
disabledAt?: boolean
@@ -792,13 +1148,20 @@ export type CustomerPosSelectScalar = {
customerId?: boolean
address?: boolean
phone?: boolean
industry?: boolean
personName?: boolean
fridgeCount?: boolean
banner?: boolean
indiBanner?: boolean
region?: boolean
lat?: boolean
lng?: boolean
createdAt?: boolean
updatedAt?: boolean
disabledAt?: boolean
}
export type CustomerPosOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "customerId" | "address" | "phone" | "personName" | "createdAt" | "updatedAt" | "disabledAt", ExtArgs["result"]["customerPos"]>
export type CustomerPosOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "customerId" | "address" | "phone" | "industry" | "personName" | "fridgeCount" | "banner" | "indiBanner" | "region" | "lat" | "lng" | "createdAt" | "updatedAt" | "disabledAt", ExtArgs["result"]["customerPos"]>
export type CustomerPosInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
customer?: boolean | Prisma.CustomerDefaultArgs<ExtArgs>
sales?: boolean | Prisma.CustomerPos$salesArgs<ExtArgs>
@@ -824,7 +1187,14 @@ export type $CustomerPosPayload<ExtArgs extends runtime.Types.Extensions.Interna
customerId: string
address: string
phone: string
industry: string | null
personName: string | null
fridgeCount: number
banner: boolean
indiBanner: boolean
region: string | null
lat: number | null
lng: number | null
createdAt: Date
updatedAt: Date
disabledAt: Date | null
@@ -1258,7 +1628,14 @@ export interface CustomerPosFieldRefs {
readonly customerId: Prisma.FieldRef<"CustomerPos", 'String'>
readonly address: Prisma.FieldRef<"CustomerPos", 'String'>
readonly phone: Prisma.FieldRef<"CustomerPos", 'String'>
readonly industry: Prisma.FieldRef<"CustomerPos", 'String'>
readonly personName: Prisma.FieldRef<"CustomerPos", 'String'>
readonly fridgeCount: Prisma.FieldRef<"CustomerPos", 'Int'>
readonly banner: Prisma.FieldRef<"CustomerPos", 'Boolean'>
readonly indiBanner: Prisma.FieldRef<"CustomerPos", 'Boolean'>
readonly region: Prisma.FieldRef<"CustomerPos", 'String'>
readonly lat: Prisma.FieldRef<"CustomerPos", 'Float'>
readonly lng: Prisma.FieldRef<"CustomerPos", 'Float'>
readonly createdAt: Prisma.FieldRef<"CustomerPos", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"CustomerPos", 'DateTime'>
readonly disabledAt: Prisma.FieldRef<"CustomerPos", 'DateTime'>
@@ -447,14 +447,6 @@ export type FloatFieldUpdateOperationsInput = {
divide?: number
}
export type IntFieldUpdateOperationsInput = {
set?: number
increment?: number
decrement?: number
multiply?: number
divide?: number
}
export type ProductCreateNestedOneWithoutSalesInput = {
create?: Prisma.XOR<Prisma.ProductCreateWithoutSalesInput, Prisma.ProductUncheckedCreateWithoutSalesInput>
connectOrCreate?: Prisma.ProductCreateOrConnectWithoutSalesInput
+79 -5
View File
@@ -32,6 +32,8 @@ export type SaleMinAggregateOutputType = {
paymentDueDate: Date | null
paymentDate: Date | null
comments: string | null
nextVisitDate: Date | null
visitedAt: Date | null
createdAt: Date | null
updatedAt: Date | null
}
@@ -44,6 +46,8 @@ export type SaleMaxAggregateOutputType = {
paymentDueDate: Date | null
paymentDate: Date | null
comments: string | null
nextVisitDate: Date | null
visitedAt: Date | null
createdAt: Date | null
updatedAt: Date | null
}
@@ -56,6 +60,8 @@ export type SaleCountAggregateOutputType = {
paymentDueDate: number
paymentDate: number
comments: number
nextVisitDate: number
visitedAt: number
createdAt: number
updatedAt: number
_all: number
@@ -70,6 +76,8 @@ export type SaleMinAggregateInputType = {
paymentDueDate?: true
paymentDate?: true
comments?: true
nextVisitDate?: true
visitedAt?: true
createdAt?: true
updatedAt?: true
}
@@ -82,6 +90,8 @@ export type SaleMaxAggregateInputType = {
paymentDueDate?: true
paymentDate?: true
comments?: true
nextVisitDate?: true
visitedAt?: true
createdAt?: true
updatedAt?: true
}
@@ -94,6 +104,8 @@ export type SaleCountAggregateInputType = {
paymentDueDate?: true
paymentDate?: true
comments?: true
nextVisitDate?: true
visitedAt?: true
createdAt?: true
updatedAt?: true
_all?: true
@@ -179,6 +191,8 @@ export type SaleGroupByOutputType = {
paymentDueDate: Date | null
paymentDate: Date | null
comments: string | null
nextVisitDate: Date | null
visitedAt: Date | null
createdAt: Date
updatedAt: Date
_count: SaleCountAggregateOutputType | null
@@ -212,6 +226,8 @@ export type SaleWhereInput = {
paymentDueDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
paymentDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
comments?: Prisma.StringNullableFilter<"Sale"> | string | null
nextVisitDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
visitedAt?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Sale"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Sale"> | Date | string
customerPos?: Prisma.XOR<Prisma.CustomerPosScalarRelationFilter, Prisma.CustomerPosWhereInput>
@@ -226,6 +242,8 @@ export type SaleOrderByWithRelationInput = {
paymentDueDate?: Prisma.SortOrderInput | Prisma.SortOrder
paymentDate?: Prisma.SortOrderInput | Prisma.SortOrder
comments?: Prisma.SortOrderInput | Prisma.SortOrder
nextVisitDate?: Prisma.SortOrderInput | Prisma.SortOrder
visitedAt?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
customerPos?: Prisma.CustomerPosOrderByWithRelationInput
@@ -243,6 +261,8 @@ export type SaleWhereUniqueInput = Prisma.AtLeast<{
paymentDueDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
paymentDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
comments?: Prisma.StringNullableFilter<"Sale"> | string | null
nextVisitDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
visitedAt?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Sale"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Sale"> | Date | string
customerPos?: Prisma.XOR<Prisma.CustomerPosScalarRelationFilter, Prisma.CustomerPosWhereInput>
@@ -257,6 +277,8 @@ export type SaleOrderByWithAggregationInput = {
paymentDueDate?: Prisma.SortOrderInput | Prisma.SortOrder
paymentDate?: Prisma.SortOrderInput | Prisma.SortOrder
comments?: Prisma.SortOrderInput | Prisma.SortOrder
nextVisitDate?: Prisma.SortOrderInput | Prisma.SortOrder
visitedAt?: Prisma.SortOrderInput | Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
_count?: Prisma.SaleCountOrderByAggregateInput
@@ -275,6 +297,8 @@ export type SaleScalarWhereWithAggregatesInput = {
paymentDueDate?: Prisma.DateTimeNullableWithAggregatesFilter<"Sale"> | Date | string | null
paymentDate?: Prisma.DateTimeNullableWithAggregatesFilter<"Sale"> | Date | string | null
comments?: Prisma.StringNullableWithAggregatesFilter<"Sale"> | string | null
nextVisitDate?: Prisma.DateTimeNullableWithAggregatesFilter<"Sale"> | Date | string | null
visitedAt?: Prisma.DateTimeNullableWithAggregatesFilter<"Sale"> | Date | string | null
createdAt?: Prisma.DateTimeWithAggregatesFilter<"Sale"> | Date | string
updatedAt?: Prisma.DateTimeWithAggregatesFilter<"Sale"> | Date | string
}
@@ -286,6 +310,8 @@ export type SaleCreateInput = {
paymentDueDate?: Date | string | null
paymentDate?: Date | string | null
comments?: string | null
nextVisitDate?: Date | string | null
visitedAt?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
customerPos: Prisma.CustomerPosCreateNestedOneWithoutSalesInput
@@ -300,6 +326,8 @@ export type SaleUncheckedCreateInput = {
paymentDueDate?: Date | string | null
paymentDate?: Date | string | null
comments?: string | null
nextVisitDate?: Date | string | null
visitedAt?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
products?: Prisma.SaleProductUncheckedCreateNestedManyWithoutSaleInput
@@ -312,6 +340,8 @@ export type SaleUpdateInput = {
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
customerPos?: Prisma.CustomerPosUpdateOneRequiredWithoutSalesNestedInput
@@ -326,6 +356,8 @@ export type SaleUncheckedUpdateInput = {
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
products?: Prisma.SaleProductUncheckedUpdateManyWithoutSaleNestedInput
@@ -339,6 +371,8 @@ export type SaleCreateManyInput = {
paymentDueDate?: Date | string | null
paymentDate?: Date | string | null
comments?: string | null
nextVisitDate?: Date | string | null
visitedAt?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
}
@@ -350,6 +384,8 @@ export type SaleUpdateManyMutationInput = {
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -362,6 +398,8 @@ export type SaleUncheckedUpdateManyInput = {
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -384,6 +422,8 @@ export type SaleCountOrderByAggregateInput = {
paymentDueDate?: Prisma.SortOrder
paymentDate?: Prisma.SortOrder
comments?: Prisma.SortOrder
nextVisitDate?: Prisma.SortOrder
visitedAt?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
@@ -396,6 +436,8 @@ export type SaleMaxOrderByAggregateInput = {
paymentDueDate?: Prisma.SortOrder
paymentDate?: Prisma.SortOrder
comments?: Prisma.SortOrder
nextVisitDate?: Prisma.SortOrder
visitedAt?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
@@ -408,6 +450,8 @@ export type SaleMinOrderByAggregateInput = {
paymentDueDate?: Prisma.SortOrder
paymentDate?: Prisma.SortOrder
comments?: Prisma.SortOrder
nextVisitDate?: Prisma.SortOrder
visitedAt?: Prisma.SortOrder
createdAt?: Prisma.SortOrder
updatedAt?: Prisma.SortOrder
}
@@ -459,10 +503,6 @@ export type SaleUncheckedUpdateManyWithoutCustomerPosNestedInput = {
deleteMany?: Prisma.SaleScalarWhereInput | Prisma.SaleScalarWhereInput[]
}
export type BoolFieldUpdateOperationsInput = {
set?: boolean
}
export type SaleCreateNestedOneWithoutProductsInput = {
create?: Prisma.XOR<Prisma.SaleCreateWithoutProductsInput, Prisma.SaleUncheckedCreateWithoutProductsInput>
connectOrCreate?: Prisma.SaleCreateOrConnectWithoutProductsInput
@@ -484,6 +524,8 @@ export type SaleCreateWithoutCustomerPosInput = {
paymentDueDate?: Date | string | null
paymentDate?: Date | string | null
comments?: string | null
nextVisitDate?: Date | string | null
visitedAt?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
products?: Prisma.SaleProductCreateNestedManyWithoutSaleInput
@@ -496,6 +538,8 @@ export type SaleUncheckedCreateWithoutCustomerPosInput = {
paymentDueDate?: Date | string | null
paymentDate?: Date | string | null
comments?: string | null
nextVisitDate?: Date | string | null
visitedAt?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
products?: Prisma.SaleProductUncheckedCreateNestedManyWithoutSaleInput
@@ -538,6 +582,8 @@ export type SaleScalarWhereInput = {
paymentDueDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
paymentDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
comments?: Prisma.StringNullableFilter<"Sale"> | string | null
nextVisitDate?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
visitedAt?: Prisma.DateTimeNullableFilter<"Sale"> | Date | string | null
createdAt?: Prisma.DateTimeFilter<"Sale"> | Date | string
updatedAt?: Prisma.DateTimeFilter<"Sale"> | Date | string
}
@@ -549,6 +595,8 @@ export type SaleCreateWithoutProductsInput = {
paymentDueDate?: Date | string | null
paymentDate?: Date | string | null
comments?: string | null
nextVisitDate?: Date | string | null
visitedAt?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
customerPos: Prisma.CustomerPosCreateNestedOneWithoutSalesInput
@@ -562,6 +610,8 @@ export type SaleUncheckedCreateWithoutProductsInput = {
paymentDueDate?: Date | string | null
paymentDate?: Date | string | null
comments?: string | null
nextVisitDate?: Date | string | null
visitedAt?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
}
@@ -589,6 +639,8 @@ export type SaleUpdateWithoutProductsInput = {
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
customerPos?: Prisma.CustomerPosUpdateOneRequiredWithoutSalesNestedInput
@@ -602,6 +654,8 @@ export type SaleUncheckedUpdateWithoutProductsInput = {
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -613,6 +667,8 @@ export type SaleCreateManyCustomerPosInput = {
paymentDueDate?: Date | string | null
paymentDate?: Date | string | null
comments?: string | null
nextVisitDate?: Date | string | null
visitedAt?: Date | string | null
createdAt?: Date | string
updatedAt?: Date | string
}
@@ -624,6 +680,8 @@ export type SaleUpdateWithoutCustomerPosInput = {
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
products?: Prisma.SaleProductUpdateManyWithoutSaleNestedInput
@@ -636,6 +694,8 @@ export type SaleUncheckedUpdateWithoutCustomerPosInput = {
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
products?: Prisma.SaleProductUncheckedUpdateManyWithoutSaleNestedInput
@@ -648,6 +708,8 @@ export type SaleUncheckedUpdateManyWithoutCustomerPosInput = {
paymentDueDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
paymentDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
comments?: Prisma.NullableStringFieldUpdateOperationsInput | string | null
nextVisitDate?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
visitedAt?: Prisma.NullableDateTimeFieldUpdateOperationsInput | Date | string | null
createdAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
updatedAt?: Prisma.DateTimeFieldUpdateOperationsInput | Date | string
}
@@ -691,6 +753,8 @@ export type SaleSelect<ExtArgs extends runtime.Types.Extensions.InternalArgs = r
paymentDueDate?: boolean
paymentDate?: boolean
comments?: boolean
nextVisitDate?: boolean
visitedAt?: boolean
createdAt?: boolean
updatedAt?: boolean
customerPos?: boolean | Prisma.CustomerPosDefaultArgs<ExtArgs>
@@ -706,6 +770,8 @@ export type SaleSelectCreateManyAndReturn<ExtArgs extends runtime.Types.Extensio
paymentDueDate?: boolean
paymentDate?: boolean
comments?: boolean
nextVisitDate?: boolean
visitedAt?: boolean
createdAt?: boolean
updatedAt?: boolean
customerPos?: boolean | Prisma.CustomerPosDefaultArgs<ExtArgs>
@@ -719,6 +785,8 @@ export type SaleSelectUpdateManyAndReturn<ExtArgs extends runtime.Types.Extensio
paymentDueDate?: boolean
paymentDate?: boolean
comments?: boolean
nextVisitDate?: boolean
visitedAt?: boolean
createdAt?: boolean
updatedAt?: boolean
customerPos?: boolean | Prisma.CustomerPosDefaultArgs<ExtArgs>
@@ -732,11 +800,13 @@ export type SaleSelectScalar = {
paymentDueDate?: boolean
paymentDate?: boolean
comments?: boolean
nextVisitDate?: boolean
visitedAt?: boolean
createdAt?: boolean
updatedAt?: boolean
}
export type SaleOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "customerPosId" | "delivered" | "paymentMethod" | "paymentDueDate" | "paymentDate" | "comments" | "createdAt" | "updatedAt", ExtArgs["result"]["sale"]>
export type SaleOmit<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = runtime.Types.Extensions.GetOmit<"id" | "customerPosId" | "delivered" | "paymentMethod" | "paymentDueDate" | "paymentDate" | "comments" | "nextVisitDate" | "visitedAt" | "createdAt" | "updatedAt", ExtArgs["result"]["sale"]>
export type SaleInclude<ExtArgs extends runtime.Types.Extensions.InternalArgs = runtime.Types.Extensions.DefaultArgs> = {
customerPos?: boolean | Prisma.CustomerPosDefaultArgs<ExtArgs>
products?: boolean | Prisma.Sale$productsArgs<ExtArgs>
@@ -763,6 +833,8 @@ export type $SalePayload<ExtArgs extends runtime.Types.Extensions.InternalArgs =
paymentDueDate: Date | null
paymentDate: Date | null
comments: string | null
nextVisitDate: Date | null
visitedAt: Date | null
createdAt: Date
updatedAt: Date
}, ExtArgs["result"]["sale"]>
@@ -1197,6 +1269,8 @@ export interface SaleFieldRefs {
readonly paymentDueDate: Prisma.FieldRef<"Sale", 'DateTime'>
readonly paymentDate: Prisma.FieldRef<"Sale", 'DateTime'>
readonly comments: Prisma.FieldRef<"Sale", 'String'>
readonly nextVisitDate: Prisma.FieldRef<"Sale", 'DateTime'>
readonly visitedAt: Prisma.FieldRef<"Sale", 'DateTime'>
readonly createdAt: Prisma.FieldRef<"Sale", 'DateTime'>
readonly updatedAt: Prisma.FieldRef<"Sale", 'DateTime'>
}
+60 -3
View File
@@ -1,43 +1,100 @@
import 'dotenv/config';
import Fastify from 'fastify';
import cors from '@fastify/cors';
import jwt from '@fastify/jwt';
import rateLimit from '@fastify/rate-limit';
import cookie from '@fastify/cookie';
import { prisma } from './prisma';
if (!process.env.JWT_SECRET) {
console.error("CRITICAL ERROR: JWT_SECRET environment variable is not set. Exiting...");
process.exit(1);
}
const app = Fastify({ logger: true });
app.register(cookie);
const allowedOrigins = process.env.ALLOWED_ORIGINS
? process.env.ALLOWED_ORIGINS.split(',').map(o => o.trim())
: ['http://localhost:5173'];
app.register(cors, {
origin: true,
origin: allowedOrigins,
methods: ['GET', 'POST', 'PUT', 'DELETE', 'OPTIONS', 'PATCH'],
credentials: true,
});
app.register(jwt, {
secret: process.env.JWT_SECRET || 'supersecret'
secret: process.env.JWT_SECRET,
cookie: {
cookieName: 'polpaAuth',
signed: false
}
});
app.register(rateLimit, {
global: false,
});
app.decorate('authenticate', async (request: any, reply: any) => {
try {
await request.jwtVerify()
const dbUser = await prisma.user.findUnique({
where: { id: (request.user as any).id },
select: { disabledAt: true }
});
if (!dbUser || dbUser.disabledAt) {
return reply.code(401).send({ error: 'Invalid credentials' });
}
} catch (err) {
reply.send(err)
return reply.send(err)
}
})
app.decorate('requireAdmin', async (request: any, reply: any) => {
try {
await request.jwtVerify()
const dbUser = await prisma.user.findUnique({
where: { id: (request.user as any).id },
select: { disabledAt: true, role: true }
});
if (!dbUser || dbUser.disabledAt) {
return reply.code(401).send({ error: 'Invalid credentials' });
}
if (dbUser.role !== 'admin') {
return reply.code(403).send({ error: 'Forbidden: Admin access required' })
}
} catch (err) {
return reply.send(err)
}
})
// Register Routes
import usersRoutes from './routes/users';
import customersRoutes from './routes/customers';
import customerPosRoutes from './routes/customer-pos';
import productsRoutes from './routes/products';
import routesApi from './routes/routes';
import salesRoutes from './routes/sales';
import visitsRoutes from './routes/visits';
import dashboardRoutes from './routes/dashboard';
import healthRoutes from './routes/health';
import publicRoutes from './routes/public';
import proxyRoutes from './routes/proxy';
app.register(usersRoutes, { prefix: '/api/users' });
app.register(customersRoutes, { prefix: '/api/customers' });
app.register(customerPosRoutes, { prefix: '/api/customer-pos' });
app.register(productsRoutes, { prefix: '/api/products' });
app.register(routesApi, { prefix: '/api/routes' });
app.register(salesRoutes, { prefix: '/api/sales' });
app.register(visitsRoutes, { prefix: '/api/visits' });
app.register(dashboardRoutes, { prefix: '/api/dashboard' });
app.register(healthRoutes, { prefix: '/api/health' });
app.register(publicRoutes, { prefix: '/api/public' });
app.register(proxyRoutes, { prefix: '/api/proxy' });
app.get('/health', async (request, reply) => {
return { status: 'ok' };
+15
View File
@@ -0,0 +1,15 @@
import { FastifyInstance } from 'fastify';
import { prisma } from '../prisma';
export default async function customerPosRoutes(app: FastifyInstance) {
app.delete('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
const { id } = request.params as any;
try {
await prisma.customerPos.delete({ where: { id } });
return { success: true };
} catch (e: any) {
if (e?.code === 'P2025') return reply.code(404).send({ error: 'Point of sale not found' });
return reply.code(500).send({ error: 'Failed to delete point of sale' });
}
});
}
+306 -34
View File
@@ -1,6 +1,119 @@
import { FastifyInstance } from 'fastify';
import { prisma } from '../prisma';
type PosCoordinates = {
lat: number | null;
lng: number | null;
};
const POS_INDUSTRIES = new Set([
'Academia',
'Conveniência',
'Hamburgueria',
'Lanchonete',
'Mercado',
'Panificadora',
'Restaurante',
'Sorveteria',
'Verdureira/Frutaria',
'Parque aquático',
'Recanto',
'Pesque e pague',
'Arena de esporte'
]);
const parseFridgeCount = (value: unknown): number | undefined => {
if (value === undefined || value === null || value === '') return undefined;
const parsedValue = Number(value);
if (!Number.isInteger(parsedValue) || parsedValue < 0) return 0;
return parsedValue;
};
const parseBooleanFlag = (value: unknown): boolean | undefined => {
if (value === undefined || value === null) return undefined;
if (typeof value === 'boolean') return value;
return undefined;
};
const parseCoordinate = (value: unknown): number | null | undefined => {
if (value === undefined) return undefined;
if (value === null || value === '') return null;
const parsedValue = Number(value);
if (!Number.isFinite(parsedValue)) return undefined;
return parsedValue;
};
const parseIndustry = (value: unknown): string | null | undefined => {
if (value === undefined) return undefined;
if (value === null) return null;
if (typeof value !== 'string') return undefined;
const normalizedValue = value.trim();
if (!normalizedValue) return null;
if (!POS_INDUSTRIES.has(normalizedValue)) return undefined;
return normalizedValue;
};
const geocodeAddress = async (address: string): Promise<PosCoordinates | null> => {
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey || !address?.trim()) return null;
const geocodeUrl = new URL('https://maps.googleapis.com/maps/api/geocode/json');
geocodeUrl.searchParams.set('address', address.trim());
geocodeUrl.searchParams.set('key', apiKey);
try {
const response = await fetch(geocodeUrl.toString());
if (!response.ok) return null;
const data = await response.json() as {
status?: string;
results?: Array<{ geometry?: { location?: { lat?: number; lng?: number } } }>;
};
if (data.status !== 'OK' || !data.results?.length) return null;
const location = data.results[0]?.geometry?.location;
if (typeof location?.lat !== 'number' || typeof location?.lng !== 'number') return null;
return { lat: location.lat, lng: location.lng };
} catch {
return null;
}
};
const resolvePosCoordinates = async ({
address,
lat,
lng
}: {
address?: string;
lat: number | null | undefined;
lng: number | null | undefined;
}): Promise<PosCoordinates | undefined> => {
const hasLat = lat !== undefined;
const hasLng = lng !== undefined;
if (hasLat || hasLng) {
if (!hasLat || !hasLng) return undefined;
return { lat: lat ?? null, lng: lng ?? null };
}
if (!address?.trim()) return undefined;
const geocoded = await geocodeAddress(address);
if (!geocoded) return undefined;
return geocoded;
};
const conflictError = (message: string): Error & { statusCode: number } =>
Object.assign(new Error(message), { statusCode: 409 });
const normalizeOptionalString = (value: unknown): string | null | undefined => {
if (value === undefined) return undefined;
if (value === null || value === '') return null;
if (typeof value !== 'string') return undefined;
const trimmed = value.trim();
return trimmed || null;
};
export default async function customersRoutes(app: FastifyInstance) {
// Get all customers (with pos optionally)
app.get('/', { preValidation: [app.authenticate] }, async (request, reply) => {
@@ -23,33 +136,71 @@ export default async function customersRoutes(app: FastifyInstance) {
});
// Create customer
app.post('/', { preValidation: [app.authenticate] }, async (request, reply) => {
const { name, document, phone, personName } = request.body as any;
app.post('/', { preValidation: [app.requireAdmin] }, async (request, reply) => {
const { name, document, phone, personName, notes } = request.body as any;
const docValue = document || null;
const notesValue = normalizeOptionalString(notes);
try {
return await prisma.customer.create({ data: { name, document: docValue, phone, personName } });
return await prisma.$transaction(async (tx) => {
const existingByName = await tx.customer.findFirst({
where: { name: { equals: name, mode: 'insensitive' }, disabledAt: null }
});
if (existingByName) throw conflictError('A customer with this name already exists');
if (phone) {
const existingByPhone = await tx.customer.findFirst({
where: { phone, disabledAt: null }
});
if (existingByPhone) throw conflictError('A customer with this phone number already exists');
}
if (docValue) {
const existingByDoc = await tx.customer.findFirst({
where: { document: docValue, disabledAt: null }
});
if (existingByDoc) throw conflictError('A customer with this document already exists');
}
return tx.customer.create({ data: { name, document: docValue, phone, personName, notes: notesValue ?? null } });
});
} catch (e: any) {
if (e.code === 'P2002') return reply.code(400).send({ error: 'Document already exists' });
if (e.statusCode === 409) return reply.code(409).send({ error: e.message });
if (e.code === 'P2002') return reply.code(409).send({ error: 'Document already exists' });
throw e;
}
});
// Update customer
app.put('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
app.put('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
const { id } = request.params as any;
const { name, document, phone, personName } = request.body as any;
const { name, document, phone, personName, notes } = request.body as any;
const docValue = document || null;
const notesValue = normalizeOptionalString(notes);
try {
if (docValue) {
const existing = await prisma.customer.findFirst({
where: { document: docValue, id: { not: id } }
return await prisma.$transaction(async (tx) => {
const existingByName = await tx.customer.findFirst({
where: { name: { equals: name, mode: 'insensitive' }, disabledAt: null, id: { not: id } }
});
if (existing) {
return reply.code(400).send({ error: 'Document already exists for another customer' });
if (existingByName) throw conflictError('A customer with this name already exists');
if (phone) {
const existingByPhone = await tx.customer.findFirst({
where: { phone, disabledAt: null, id: { not: id } }
});
if (existingByPhone) throw conflictError('A customer with this phone number already exists');
}
}
return await prisma.customer.update({ where: { id }, data: { name, document: docValue, phone, personName } });
if (docValue) {
const existingByDoc = await tx.customer.findFirst({
where: { document: docValue, disabledAt: null, id: { not: id } }
});
if (existingByDoc) throw conflictError('Document already exists for another customer');
}
return tx.customer.update({ where: { id }, data: { name, document: docValue, phone, personName, notes: notesValue ?? null, disabledAt: null } });
});
} catch (e: any) {
if (e.statusCode === 409) return reply.code(409).send({ error: e.message });
if (e && e.code === 'P2025') {
return reply.code(404).send({ error: 'Customer not found' });
}
@@ -57,38 +208,159 @@ export default async function customersRoutes(app: FastifyInstance) {
}
});
// Delete customer (soft)
app.delete('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
// Disable customer (soft)
app.patch('/:id/disable', { preValidation: [app.requireAdmin] }, async (request, reply) => {
const { id } = request.params as any;
await prisma.customer.update({ where: { id }, data: { disabledAt: new Date() } });
return { success: true };
try {
const existingCustomer = await prisma.customer.findUnique({ where: { id }, select: { disabledAt: true } });
if (!existingCustomer) return reply.code(404).send({ error: 'Customer not found' });
if (existingCustomer.disabledAt) return reply.code(409).send({ error: 'Customer is already disabled' });
await prisma.customer.update({ where: { id }, data: { disabledAt: new Date() } });
return { success: true };
} catch (e) {
request.log.error({ err: e, customerId: id }, 'Failed to disable customer');
return reply.code(500).send({ error: 'Failed to disable customer' });
}
});
// Delete customer (hard)
app.delete('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
const { id } = request.params as any;
try {
const existing = await prisma.customer.findUnique({
where: { id }
});
if (!existing) return reply.code(404).send({ error: 'Customer not found' });
await prisma.$transaction(async (tx) => {
await tx.customerDeleted.create({
data: {
customerId: existing.id,
name: existing.name,
document: existing.document,
phone: existing.phone,
personName: existing.personName,
notes: existing.notes
}
});
await tx.customer.delete({ where: { id } });
});
return { success: true };
} catch (e: any) {
if (e?.code === 'P2025') return reply.code(404).send({ error: 'Customer not found' });
return reply.code(500).send({ error: 'Failed to delete customer' });
}
});
// --- Point of Sales for Customer ---
app.post('/:customerId/pos', { preValidation: [app.authenticate] }, async (request, reply) => {
const { customerId } = request.params as any;
const { address, phone, personName } = request.body as any;
return prisma.customerPos.create({
data: { customerId, address, phone, personName }
});
const { address, phone, personName, industry, fridgeCount, banner, indiBanner, lat, lng, region } = request.body as any;
const normalizedIndustry = parseIndustry(industry);
const normalizedRegion = normalizeOptionalString(region);
const parsedLat = parseCoordinate(lat);
const parsedLng = parseCoordinate(lng);
if (industry !== undefined && normalizedIndustry === undefined) return reply.code(400).send({ error: 'Invalid industry' });
if (lat !== undefined && parsedLat === undefined) return reply.code(400).send({ error: 'Invalid lat coordinate' });
if (lng !== undefined && parsedLng === undefined) return reply.code(400).send({ error: 'Invalid lng coordinate' });
const coordinates = await resolvePosCoordinates({ address, lat: parsedLat, lng: parsedLng });
if ((parsedLat !== undefined || parsedLng !== undefined) && !coordinates) {
return reply.code(400).send({ error: 'Both lat and lng must be provided together' });
}
try {
return await prisma.$transaction(async (tx) => {
if (phone) {
const existingByPhone = await tx.customerPos.findFirst({
where: { phone, disabledAt: null }
});
if (existingByPhone) throw conflictError('A point of sale with this phone number already exists');
}
if (address) {
const existingByAddress = await tx.customerPos.findFirst({
where: { address: { equals: address, mode: 'insensitive' }, disabledAt: null }
});
if (existingByAddress) throw conflictError('A point of sale with this address already exists');
}
return tx.customerPos.create({
data: {
customerId,
address,
phone,
industry: normalizedIndustry ?? null,
personName,
fridgeCount: parseFridgeCount(fridgeCount) ?? 0,
banner: parseBooleanFlag(banner) ?? false,
indiBanner: parseBooleanFlag(indiBanner) ?? false,
region: normalizedRegion ?? null,
...(coordinates ? { lat: coordinates.lat, lng: coordinates.lng } : {})
}
});
});
} catch (e: any) {
if (e.statusCode === 409) return reply.code(409).send({ error: e.message });
throw e;
}
});
app.put('/pos/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as any;
const { address, phone, personName } = request.body as any;
return prisma.customerPos.update({
where: { id },
data: { address, phone, personName }
});
const { address, phone, personName, industry, fridgeCount, banner, indiBanner, lat, lng, region } = request.body as any;
const normalizedIndustry = parseIndustry(industry);
const normalizedFridgeCount = parseFridgeCount(fridgeCount);
const normalizedBanner = parseBooleanFlag(banner);
const normalizedIndiBanner = parseBooleanFlag(indiBanner);
const parsedLat = parseCoordinate(lat);
const parsedLng = parseCoordinate(lng);
const normalizedRegion = normalizeOptionalString(region);
if (industry !== undefined && normalizedIndustry === undefined) return reply.code(400).send({ error: 'Invalid industry' });
if (lat !== undefined && parsedLat === undefined) return reply.code(400).send({ error: 'Invalid lat coordinate' });
if (lng !== undefined && parsedLng === undefined) return reply.code(400).send({ error: 'Invalid lng coordinate' });
const coordinates = await resolvePosCoordinates({ address, lat: parsedLat, lng: parsedLng });
if ((parsedLat !== undefined || parsedLng !== undefined) && !coordinates) {
return reply.code(400).send({ error: 'Both lat and lng must be provided together' });
}
try {
return await prisma.$transaction(async (tx) => {
if (phone) {
const existingByPhone = await tx.customerPos.findFirst({
where: { phone, disabledAt: null, id: { not: id } }
});
if (existingByPhone) throw conflictError('A point of sale with this phone number already exists');
}
if (address) {
const existingByAddress = await tx.customerPos.findFirst({
where: { address: { equals: address, mode: 'insensitive' }, disabledAt: null, id: { not: id } }
});
if (existingByAddress) throw conflictError('A point of sale with this address already exists');
}
return tx.customerPos.update({
where: { id },
data: {
address,
phone,
personName,
...(normalizedIndustry !== undefined ? { industry: normalizedIndustry } : {}),
...(normalizedFridgeCount !== undefined ? { fridgeCount: normalizedFridgeCount } : {}),
...(normalizedBanner !== undefined ? { banner: normalizedBanner } : {}),
...(normalizedIndiBanner !== undefined ? { indiBanner: normalizedIndiBanner } : {}),
...(normalizedRegion !== undefined ? { region: normalizedRegion } : {}),
...(coordinates ? { lat: coordinates.lat, lng: coordinates.lng } : {})
}
});
});
} catch (e: any) {
if (e.statusCode === 409) return reply.code(409).send({ error: e.message });
throw e;
}
});
app.delete('/pos/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
const { id } = request.params as any;
await prisma.customerPos.update({
where: { id },
data: { disabledAt: new Date() }
});
return { success: true };
});
}
+127 -13
View File
@@ -66,8 +66,12 @@ function getDateRange(range: string): { startDate: Date; endDate: Date | null }
return { startDate, endDate };
}
const INACTIVE_THRESHOLD_DAYS = 10;
const MS_PER_DAY = 1000 * 60 * 60 * 24;
export default async function dashboardRoutes(app: FastifyInstance) {
app.get('/sales-by-customer', { preValidation: [app.authenticate] }, async (request, reply) => {
app.addHook('preValidation', app.requireAdmin);
app.get('/sales-by-customer', async (request, reply) => {
const { range } = request.query as { range: string };
const { startDate, endDate } = getDateRange(range);
@@ -114,7 +118,7 @@ export default async function dashboardRoutes(app: FastifyInstance) {
.sort((a, b) => b.totalAmount - a.totalAmount);
});
app.get('/sales-by-product', { preValidation: [app.authenticate] }, async (request, reply) => {
app.get('/sales-by-product', async (request, reply) => {
const { range } = request.query as { range: string };
const { startDate, endDate } = getDateRange(range);
@@ -144,21 +148,28 @@ export default async function dashboardRoutes(app: FastifyInstance) {
.sort((a, b) => b.totalAmount - a.totalAmount);
});
app.get('/sales-summary', { preValidation: [app.authenticate] }, async (request, reply) => {
app.get('/sales-summary', async (request, reply) => {
const { range } = request.query as { range: string };
const { startDate, endDate } = getDateRange(range);
const sales = await prisma.sale.findMany({
where: { createdAt: { gte: startDate, ...(endDate ? { lte: endDate } : {}) } },
select: {
products: {
select: {
quantity: true,
product: { select: { price: true } }
const [sales, totalCustomers, totalFridges] = await Promise.all([
prisma.sale.findMany({
where: { createdAt: { gte: startDate, ...(endDate ? { lte: endDate } : {}) } },
select: {
products: {
select: {
quantity: true,
product: { select: { price: true } }
}
}
}
}
});
}),
prisma.customer.count({ where: { disabledAt: null } }),
prisma.customerPos.aggregate({
_sum: { fridgeCount: true },
where: { disabledAt: null }
})
]);
const totalSales = sales.length;
let totalAmount = 0;
@@ -173,6 +184,109 @@ export default async function dashboardRoutes(app: FastifyInstance) {
const averageAmount = totalSales > 0 ? totalAmount / totalSales : 0;
return { totalSales, totalAmount, averageAmount };
return {
totalSales,
totalAmount,
averageAmount,
totalCustomers,
totalFridges: totalFridges._sum.fridgeCount ?? 0
};
});
app.get('/fridges', async () => {
const fridges = await prisma.customerPos.findMany({
where: {
disabledAt: null,
fridgeCount: { gt: 0 }
},
select: {
id: true,
address: true,
fridgeCount: true,
customer: { select: { name: true } }
}
});
return fridges.sort((a, b) => {
const byCustomer = (a.customer.name ?? '').localeCompare(b.customer.name ?? '');
if (byCustomer !== 0) return byCustomer;
return a.address.localeCompare(b.address);
});
});
app.get('/industries-summary', async () => {
const grouped = await prisma.customerPos.groupBy({
by: ['industry'],
_count: { _all: true },
where: { disabledAt: null }
});
const fallbackIndustry = 'Não Informado';
const summaryMap = new Map<string, number>();
for (const item of grouped) {
const normalizedIndustry = item.industry?.trim() || fallbackIndustry;
summaryMap.set(normalizedIndustry, (summaryMap.get(normalizedIndustry) ?? 0) + item._count._all);
}
return Array.from(summaryMap.entries())
.map(([industry, count]) => ({ industry, count }))
.sort((a, b) => b.count - a.count);
});
app.get('/regions-summary', async () => {
const grouped = await prisma.customerPos.groupBy({
by: ['region'],
_count: { _all: true },
where: { disabledAt: null }
});
const fallbackRegion = 'Não Informado';
const summaryMap = new Map<string, number>();
for (const item of grouped) {
const normalizedRegion = item.region?.trim() || fallbackRegion;
summaryMap.set(normalizedRegion, (summaryMap.get(normalizedRegion) ?? 0) + item._count._all);
}
return Array.from(summaryMap.entries())
.map(([region, count]) => ({ region, count }))
.sort((a, b) => b.count - a.count);
});
app.get('/inactive-pos', async () => {
const now = new Date();
const poses = await prisma.customerPos.findMany({
where: { disabledAt: null },
select: {
id: true,
address: true,
createdAt: true,
customer: { select: { name: true } },
sales: {
orderBy: { createdAt: 'desc' },
take: 1,
select: { createdAt: true }
}
}
});
return poses
.map(pos => {
const lastSale = pos.sales[0] ?? null;
const lastBuyingDate = lastSale?.createdAt ?? null;
const referenceDate = lastBuyingDate ?? pos.createdAt;
const daysInactive = Math.floor((now.getTime() - referenceDate.getTime()) / MS_PER_DAY);
return {
posId: pos.id,
customerName: pos.customer.name,
posAddress: pos.address,
lastBuyingDate: lastBuyingDate?.toISOString() ?? null,
daysInactive
};
})
.filter(item => item.daysInactive >= INACTIVE_THRESHOLD_DAYS)
.sort((a, b) => b.daysInactive - a.daysInactive);
});
}
+3 -3
View File
@@ -18,7 +18,7 @@ export default async function productsRoutes(app: FastifyInstance) {
return product;
});
app.post('/', { preValidation: [app.authenticate] }, async (request, reply) => {
app.post('/', { preValidation: [app.requireAdmin] }, async (request, reply) => {
const { name, price, stock, cost } = request.body as any;
try {
return await prisma.product.create({
@@ -37,7 +37,7 @@ export default async function productsRoutes(app: FastifyInstance) {
}
});
app.put('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
app.put('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
const { id } = request.params as any;
const { name, price, stock, cost, disabledAt } = request.body as any;
try {
@@ -58,7 +58,7 @@ export default async function productsRoutes(app: FastifyInstance) {
}
});
app.delete('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
app.delete('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
const { id } = request.params as any;
await prisma.product.update({
where: { id },
+51
View File
@@ -0,0 +1,51 @@
import { FastifyInstance } from 'fastify';
export default async function proxyRoutes(app: FastifyInstance) {
app.get('/validator', { preValidation: [app.authenticate] }, async (request, reply) => {
const { value } = request.query as { value?: string };
if (!value) {
return reply.code(400).send({ error: 'value query parameter is required' });
}
const token = process.env.CPF_CNPJ_API_TOKEN;
if (!token) {
return reply.code(500).send({ error: 'CPF_CNPJ_API_TOKEN is not configured on the server' });
}
try {
const response = await fetch(`https://api.invertexto.com/v1/validator?token=${encodeURIComponent(token)}&value=${encodeURIComponent(value)}`);
if (!response.ok) {
return reply.code(response.status).send({ error: 'Failed to validate document via proxy' });
}
const data = await response.json();
return data;
} catch (error) {
app.log.error(error);
return reply.code(500).send({ error: 'Error calling validation API' });
}
});
app.get('/geocode', { preValidation: [app.authenticate] }, async (request, reply) => {
const { address } = request.query as { address?: string };
if (!address) {
return reply.code(400).send({ error: 'address query parameter is required' });
}
const apiKey = process.env.GOOGLE_MAPS_API_KEY;
if (!apiKey) {
return reply.code(500).send({ error: 'GOOGLE_MAPS_API_KEY is not configured on the server' });
}
try {
const response = await fetch(`https://maps.googleapis.com/maps/api/geocode/json?address=${encodeURIComponent(address)}&key=${apiKey}`);
if (!response.ok) {
return reply.code(response.status).send({ error: 'Failed to geocode address via proxy' });
}
const data = await response.json();
return data;
} catch (error) {
app.log.error(error);
return reply.code(500).send({ error: 'Error calling geocoding API' });
}
});
}
+86
View File
@@ -0,0 +1,86 @@
import { FastifyInstance } from 'fastify';
import { prisma } from '../prisma';
type PosClosestResponse = {
id: string;
customerName: string;
address: string;
distanceKm: number;
};
const earthRadiusKm = 6371;
const degreesToRadians = (value: number): number => (value * Math.PI) / 180;
const calculateDistanceKm = (originLat: number, originLng: number, destinationLat: number, destinationLng: number): number => {
const deltaLat = degreesToRadians(destinationLat - originLat);
const deltaLng = degreesToRadians(destinationLng - originLng);
const originLatRad = degreesToRadians(originLat);
const destinationLatRad = degreesToRadians(destinationLat);
const haversineComponent = (
Math.sin(deltaLat / 2) ** 2
+ Math.cos(originLatRad) * Math.cos(destinationLatRad) * Math.sin(deltaLng / 2) ** 2
);
const arc = 2 * Math.atan2(Math.sqrt(haversineComponent), Math.sqrt(1 - haversineComponent));
return earthRadiusKm * arc;
};
export default async function publicRoutes(app: FastifyInstance) {
app.get('/pos/closest', {
config: {
rateLimit: {
max: 15,
timeWindow: '1 minute',
keyGenerator: (request: any) => request.ip
}
}
}, async (request, reply) => {
const { lat, lng } = request.query as { lat?: string; lng?: string };
if (lat === undefined || lng === undefined) {
return reply.code(400).send({ error: 'lat and lng query parameters are required' });
}
const referenceLat = Number(lat);
const referenceLng = Number(lng);
if (!Number.isFinite(referenceLat) || !Number.isFinite(referenceLng)) {
return reply.code(400).send({ error: 'lat and lng query parameters must be valid numbers' });
}
const activePosList = await prisma.customerPos.findMany({
where: {
disabledAt: null,
lat: { not: null },
lng: { not: null },
customer: { disabledAt: null }
},
select: {
id: true,
address: true,
lat: true,
lng: true,
customer: {
select: { name: true }
}
}
});
const closestPos = activePosList
.map((pos): PosClosestResponse => {
const posLat = pos.lat as number;
const posLng = pos.lng as number;
return {
id: pos.id,
customerName: pos.customer.name,
address: pos.address,
distanceKm: calculateDistanceKm(referenceLat, referenceLng, posLat, posLng)
};
})
.sort((a, b) => a.distanceKm - b.distanceKm)
.slice(0, 10);
return closestPos;
});
}
+9 -3
View File
@@ -42,13 +42,14 @@ export default async function salesRoutes(app: FastifyInstance) {
});
// Create sale
app.post('/', { preValidation: [app.authenticate] }, async (request, reply) => {
app.post('/', { preValidation: [app.requireAdmin] }, async (request, reply) => {
const {
customerPosId,
paymentMethod,
paymentDueDate,
paymentDate,
comments,
nextVisitDate,
products // Array of { productId, quantity }
} = request.body as any;
@@ -72,6 +73,7 @@ export default async function salesRoutes(app: FastifyInstance) {
paymentDueDate: paymentDueDate ? new Date(paymentDueDate) : null,
paymentDate: paymentDate ? new Date(paymentDate) : null,
comments,
nextVisitDate: nextVisitDate ? new Date(nextVisitDate) : null,
products: {
create: products.map((p: any) => ({
productId: p.productId,
@@ -103,7 +105,7 @@ export default async function salesRoutes(app: FastifyInstance) {
});
// Update sale (e.g. mark as paid, edit fields, replace products)
app.put('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
app.put('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
const { id } = request.params as any;
const {
customerPosId,
@@ -112,6 +114,8 @@ export default async function salesRoutes(app: FastifyInstance) {
paymentDueDate,
paymentDate,
comments,
nextVisitDate,
visitedAt,
products
} = request.body as any;
@@ -135,6 +139,8 @@ export default async function salesRoutes(app: FastifyInstance) {
if (paymentDueDate !== undefined) data.paymentDueDate = paymentDueDate ? new Date(paymentDueDate) : null;
if (paymentDate !== undefined) data.paymentDate = paymentDate ? new Date(paymentDate) : null;
if (comments !== undefined) data.comments = comments;
if (nextVisitDate !== undefined) data.nextVisitDate = nextVisitDate ? new Date(nextVisitDate) : null;
if (visitedAt !== undefined) data.visitedAt = visitedAt ? new Date(visitedAt) : null;
// If products are provided, replace the entire product list with stock management
if (products !== undefined && !Array.isArray(products)) {
@@ -245,7 +251,7 @@ export default async function salesRoutes(app: FastifyInstance) {
});
// Delete sale (hard delete)
app.delete('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
app.delete('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
const { id } = request.params as any;
try {
const existing = await prisma.sale.findUnique({
+94 -12
View File
@@ -2,25 +2,107 @@ import { FastifyInstance } from 'fastify';
import bcrypt from 'bcrypt';
import { prisma } from '../prisma';
interface LoginFailure {
consecutiveFailures: number;
lockoutUntil?: number;
lastAttempt: number;
}
// NOTE: Process-local. In a multi-replica deployment, replace with a shared
// store (e.g. Redis) to enforce lockout across all instances.
const loginFailures = new Map<string, LoginFailure>();
const MAX_CONSECUTIVE_FAILURES = 3;
const BASE_LOCKOUT_MS = 2000;
const MAX_LOCKOUT_MS = 30 * 60 * 1000; // 30 minutes
const FAILURE_ENTRY_TTL_MS = 60 * 60 * 1000; // 1 hour
// Dummy hash for constant-time bcrypt comparison when user is missing/disabled,
// preventing user enumeration via response timing.
const DUMMY_HASH = bcrypt.hashSync('__timing_prevention__', 10);
setInterval(() => {
const cutoff = Date.now() - FAILURE_ENTRY_TTL_MS;
for (const [key, record] of loginFailures.entries()) {
if (record.lastAttempt < cutoff) loginFailures.delete(key);
}
}, 10 * 60 * 1000).unref();
export default async function usersRoutes(app: FastifyInstance) {
app.post('/login', async (request, reply) => {
app.post('/login', {
config: {
rateLimit: {
max: 10,
timeWindow: '1 minute',
keyGenerator: (request: any) => request.ip
}
}
}, async (request, reply) => {
const { email, password } = request.body as any;
const clientIp = request.ip;
const normalizedEmail = (email || '').toLowerCase().trim();
const failureKey = `${clientIp}-${normalizedEmail || 'unknown'}`;
// Check if locked out
const failureRecord = loginFailures.get(failureKey);
if (failureRecord?.lockoutUntil && failureRecord.lockoutUntil > Date.now()) {
const waitTime = Math.ceil((failureRecord.lockoutUntil - Date.now()) / 1000);
return reply.code(429).send({
error: `Too many failed login attempts. Please wait ${waitTime} seconds.`
});
}
const user = await prisma.user.findUnique({ where: { email } });
if (!user || user.disabledAt) {
// Always run bcrypt to prevent user enumeration via response timing
const hashToCompare = (user && !user.disabledAt) ? user.password : DUMMY_HASH;
const isValid = (await bcrypt.compare(password, hashToCompare)) && !!user && !user.disabledAt;
if (!isValid) {
const record = failureRecord || { consecutiveFailures: 0, lastAttempt: 0 };
record.consecutiveFailures += 1;
record.lastAttempt = Date.now();
if (record.consecutiveFailures >= MAX_CONSECUTIVE_FAILURES) {
const rawBackoff = BASE_LOCKOUT_MS * Math.pow(2, record.consecutiveFailures - MAX_CONSECUTIVE_FAILURES);
record.lockoutUntil = Date.now() + Math.min(rawBackoff, MAX_LOCKOUT_MS);
}
loginFailures.set(failureKey, record);
return reply.code(401).send({ error: 'Invalid credentials' });
}
const validPassword = await bcrypt.compare(password, user.password);
if (!validPassword) {
return reply.code(401).send({ error: 'Invalid credentials' });
}
// Success: clear failures
loginFailures.delete(failureKey);
const token = app.jwt.sign({ id: user.id, email: user.email, role: user.role });
return { token, user: { id: user.id, name: user.name, email: user.email, role: user.role } };
reply.setCookie('polpaAuth', app.jwt.sign({ id: user.id, email: user.email, role: user.role }, { expiresIn: '8h' }), {
path: '/',
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 8 * 60 * 60
});
return { user: { id: user.id, name: user.name, email: user.email, role: user.role } };
});
app.get('/', { preValidation: [app.authenticate] }, async (request, reply) => {
app.get('/me', { preValidation: [app.authenticate] }, async (request, reply) => {
const userPayload = request.user as any;
const dbUser = await prisma.user.findUnique({
where: { id: userPayload.id }
});
if (!dbUser || dbUser.disabledAt) {
return reply.code(401).send({ error: 'User not found or disabled' });
}
return { user: { id: dbUser.id, name: dbUser.name, email: dbUser.email, role: dbUser.role } };
});
app.post('/logout', async (request, reply) => {
reply.clearCookie('polpaAuth', { path: '/' });
return { success: true };
});
app.get('/', { preValidation: [app.requireAdmin] }, async (request, reply) => {
const showDisabled = (request.query as any).showDisabled as string === 'true';
const users = await prisma.user.findMany({
where: showDisabled ? {} : { disabledAt: null },
@@ -29,7 +111,7 @@ export default async function usersRoutes(app: FastifyInstance) {
return users;
});
app.post('/', { preValidation: [app.authenticate] }, async (request, reply) => {
app.post('/', { preValidation: [app.requireAdmin] }, async (request, reply) => {
const { name, email, password, role } = request.body as any;
const existing = await prisma.user.findUnique({ where: { email } });
@@ -45,7 +127,7 @@ export default async function usersRoutes(app: FastifyInstance) {
return { id: user.id, name: user.name, email: user.email, role: user.role };
});
app.put('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
app.put('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
const { id } = request.params as any;
const { name, email, role, password } = request.body as any;
@@ -66,7 +148,7 @@ export default async function usersRoutes(app: FastifyInstance) {
return { id: user.id, name: user.name, email: user.email, role: user.role };
});
app.delete('/:id', { preValidation: [app.authenticate] }, async (request, reply) => {
app.delete('/:id', { preValidation: [app.requireAdmin] }, async (request, reply) => {
const { id } = request.params as any;
await prisma.user.update({
where: { id },
+39
View File
@@ -0,0 +1,39 @@
import { FastifyInstance } from 'fastify';
import { Prisma } from '../generated/prisma/client';
import { prisma } from '../prisma';
function parseBoolean(value: unknown): boolean | undefined {
if (typeof value === 'boolean') return value;
if (typeof value === 'string') {
const normalized = value.trim().toLowerCase();
if (normalized === 'true') return true;
if (normalized === 'false') return false;
}
return undefined;
}
interface VisitsQuery {
showVisited?: string;
}
export default async function visitsRoutes(app: FastifyInstance) {
// Get all visits (sales with nextVisitDate set)
app.get('/', { preValidation: [app.authenticate] }, async (request, reply) => {
const { showVisited } = request.query as VisitsQuery;
const parsedShowVisited = parseBoolean(showVisited);
const where: Prisma.SaleWhereInput = {
nextVisitDate: { not: null },
visitedAt: parsedShowVisited === true ? { not: null } : null
};
return prisma.sale.findMany({
where,
include: {
customerPos: { include: { customer: true } },
products: { include: { product: true } }
},
orderBy: { nextVisitDate: 'asc' }
});
});
}
+13 -3
View File
@@ -1,19 +1,29 @@
import 'dotenv/config';
import { prisma } from './prisma';
import bcrypt from 'bcrypt';
async function main() {
const existing = await prisma.user.findFirst();
if (!existing) {
const hashedPassword = await bcrypt.hash('admin123', 10);
const email = process.env.SEED_ADMIN_EMAIL || 'admin@polpagestao.com';
const password = process.env.SEED_ADMIN_PASSWORD;
if (!password) {
console.error('ERROR: SEED_ADMIN_PASSWORD environment variable is required. Set it and re-run the seed script.');
process.exit(1);
}
const hashedPassword = await bcrypt.hash(password, 10);
await prisma.user.create({
data: {
name: 'Admin User',
email: 'admin@polpagestao.com',
email: email,
password: hashedPassword,
role: 'admin'
}
});
console.log('Created default admin user: admin@polpagestao.com / admin123');
console.log(`Created default admin user: ${email}`);
} else {
console.log('Database already seeded with users.');
}
+8
View File
@@ -0,0 +1,8 @@
{
"extends": "./tsconfig.json",
"compilerOptions": {
"sourceMap": false,
"declaration": false,
"declarationMap": false
}
}
+21 -19
View File
@@ -1,53 +1,56 @@
---
services:
postgres:
polpa_db:
image: postgres:15
container_name: polpa_gestao_db
container_name: polpa_db
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: adminpassword
POSTGRES_DB: polpa_gestao
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d polpa_gestao"]
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME} -h localhost || exit 1"]
interval: 5s
timeout: 5s
retries: 5
networks:
- polpa-network
prisma-push:
polpa_prisma:
build:
context: ./backend
target: prisma
image: ghcr.io/rmcampos/polpa-gestao/backend-prisma:latest
container_name: polpa_gestao_prisma_push
container_name: polpa_prisma
environment:
DATABASE_URL: postgres://admin:adminpassword@postgres:5432/polpa_gestao
DATABASE_URL: postgres://${DB_USER}:${DB_PASSWORD}@polpa_db:5432/${DB_NAME}
command: ["npx", "prisma", "migrate", "deploy"]
depends_on:
postgres:
polpa_db:
condition: service_healthy
restart: "no"
networks:
- polpa-network
backend:
polpa_backend:
build: ./backend
image: ghcr.io/rmcampos/polpa-gestao/backend:latest
container_name: polpa_gestao_backend
container_name: polpa_backend
environment:
DATABASE_URL: postgres://admin:adminpassword@postgres:5432/polpa_gestao
DATABASE_URL: postgres://${DB_USER}:${DB_PASSWORD}@polpa_db:5432/${DB_NAME}
GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY}
JWT_SECRET: ${JWT_SECRET}
CPF_CNPJ_API_TOKEN: ${CPF_CNPJ_API_TOKEN}
ports:
- "3000:3000"
depends_on:
postgres:
polpa_db:
condition: service_healthy
prisma-push:
polpa_prisma:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "node", "healthcheck.js"]
@@ -57,19 +60,18 @@ services:
networks:
- polpa-network
frontend:
polpa_frontend:
build:
context: ./frontend
args:
VITE_BACKEND_SERVER: "/api/.."
VITE_CPF_CNPJ_API_TOKEN: ${VITE_CPF_CNPJ_API_TOKEN}
VITE_BUILD_NUMBER: snapshot
image: ghcr.io/rmcampos/polpa-gestao/frontend:latest
container_name: polpa_gestao_frontend
container_name: polpa_frontend
ports:
- "5173:80"
depends_on:
- backend
- polpa_backend
networks:
- polpa-network
+21 -19
View File
@@ -1,53 +1,56 @@
---
services:
postgres:
polpa_db:
image: postgres:15
container_name: polpa_gestao_db
container_name: polpa_db
environment:
POSTGRES_USER: admin
POSTGRES_PASSWORD: adminpassword
POSTGRES_DB: polpa_gestao
POSTGRES_USER: ${DB_USER}
POSTGRES_PASSWORD: ${DB_PASSWORD}
POSTGRES_DB: ${DB_NAME}
ports:
- "5432:5432"
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres -d polpa_gestao"]
test: ["CMD-SHELL", "pg_isready -U ${DB_USER} -d ${DB_NAME} -h localhost || exit 1"]
interval: 5s
timeout: 5s
retries: 5
networks:
- polpa-network
prisma-push:
polpa_prisma:
build:
context: ./backend
target: prisma
image: ghcr.io/rmcampos/polpa-gestao/backend-prisma:latest
container_name: polpa_gestao_prisma_push
container_name: polpa_prisma
environment:
DATABASE_URL: postgres://admin:adminpassword@postgres:5432/polpa_gestao
DATABASE_URL: postgres://${DB_USER}:${DB_PASSWORD}@polpa_db:5432/${DB_NAME}
command: ["npx", "prisma", "migrate", "deploy"]
depends_on:
postgres:
polpa_db:
condition: service_healthy
restart: "no"
networks:
- polpa-network
backend:
polpa_backend:
build: ./backend
image: ghcr.io/rmcampos/polpa-gestao/backend:latest
container_name: polpa_gestao_backend
container_name: polpa_backend
environment:
DATABASE_URL: postgres://admin:adminpassword@postgres:5432/polpa_gestao
DATABASE_URL: postgres://${DB_USER}:${DB_PASSWORD}@polpa_db:5432/${DB_NAME}
GOOGLE_MAPS_API_KEY: ${GOOGLE_MAPS_API_KEY}
JWT_SECRET: ${JWT_SECRET}
CPF_CNPJ_API_TOKEN: ${CPF_CNPJ_API_TOKEN}
ports:
- "3000:3000"
depends_on:
postgres:
polpa_db:
condition: service_healthy
prisma-push:
polpa_prisma:
condition: service_completed_successfully
healthcheck:
test: ["CMD", "node", "healthcheck.js"]
@@ -57,19 +60,18 @@ services:
networks:
- polpa-network
frontend:
polpa_frontend:
build:
context: ./frontend
args:
VITE_BACKEND_SERVER: http://localhost:3000
VITE_CPF_CNPJ_API_TOKEN: ${VITE_CPF_CNPJ_API_TOKEN}
VITE_BUILD_NUMBER: snapshot
image: ghcr.io/rmcampos/polpa-gestao/frontend:latest
container_name: polpa_gestao_frontend
container_name: polpa_frontend
ports:
- "5173:80"
depends_on:
- backend
- polpa_backend
networks:
- polpa-network
+3
View File
@@ -0,0 +1,3 @@
setup:
project: polpa-gestao
config: dev_secrets
-1
View File
@@ -1,5 +1,4 @@
node_modules
dist
.env
npm-debug.log*
.DS_Store
-2
View File
@@ -1,2 +0,0 @@
VITE_BACKEND_SERVER=http://localhost:3000
CPF_CNPJ_API_TOKEN=your_api_key_here
+1 -2
View File
@@ -5,14 +5,13 @@ WORKDIR /app
# Accept build arguments
ARG VITE_BACKEND_SERVER
ARG VITE_CPF_CNPJ_API_TOKEN
ARG VITE_BUILD_NUMBER
# Set as environment variables for the build
ENV VITE_BACKEND_SERVER=$VITE_BACKEND_SERVER
ENV VITE_CPF_CNPJ_API_TOKEN=$VITE_CPF_CNPJ_API_TOKEN
ENV VITE_BUILD_NUMBER=$VITE_BUILD_NUMBER
# Copy package files
COPY package.json package-lock.json ./
+59 -9
View File
@@ -7,32 +7,75 @@ import Customers from './pages/Customers';
import Products from './pages/Products';
import RoutesPage from './pages/Routes';
import Sales from './pages/Sales';
import Visits from './pages/Visits';
import ClosestPos from './pages/ClosestPos';
import Sidebar from './components/Sidebar';
import { Toast } from './components/Toast';
import { ConfirmDialog } from './components/ConfirmDialog';
import axios from 'axios';
export default function App() {
const [token, setToken] = useState<string | null>(localStorage.getItem('token'));
const [token, setToken] = useState<string | null>(null);
const [loading, setLoading] = useState(true);
const navigate = useNavigate();
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
useEffect(() => {
if (!token) {
if (window.location.pathname !== '/login') {
const initAuth = async () => {
try {
const response = await axios.get(`${apiBase}/api/users/me`);
localStorage.setItem('user', JSON.stringify(response.data.user));
setToken(response.data.user?.id ?? null);
} catch {
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
} finally {
setLoading(false);
}
};
initAuth();
}, [apiBase]);
useEffect(() => {
if (!loading && !token) {
const isPublicPath = window.location.pathname === '/login' || window.location.pathname === '/closest';
if (!isPublicPath) {
navigate('/login');
}
}
}, [token, navigate]);
}, [token, loading, navigate]);
const handleLogout = () => {
const handleLogout = async () => {
try {
await axios.post(`${apiBase}/api/users/logout`);
} catch (err) {
console.error('Logout failed:', err);
}
localStorage.removeItem('token');
localStorage.removeItem('user');
setToken(null);
};
if (loading) {
return (
<div className="d-flex align-items-center justify-content-center" style={{ minHeight: '100vh', width: '100vw', backgroundColor: '#0f172a', color: '#fff' }}>
<div className="text-center">
<div className="spinner-border text-primary mb-3" role="status">
<span className="visually-hidden">Loading...</span>
</div>
<p className="m-0 text-white-50">Loading Polpa Gestão...</p>
</div>
</div>
);
}
if (!token) {
return (
<>
<Routes>
<Route path="/closest" element={<ClosestPos />} />
<Route path="/login" element={<Login setToken={setToken} />} />
<Route path="*" element={<Navigate to="/login" replace />} />
</Routes>
@@ -42,20 +85,27 @@ export default function App() {
);
}
const user = JSON.parse(localStorage.getItem('user') || '{}');
const isAdmin = user.role === 'admin';
const defaultRoute = isAdmin ? "/dashboard" : "/routes";
return (
<>
<div className="app-container">
<Sidebar onLogout={handleLogout} />
<main className="main-content animate-fade-in">
<Routes>
<Route path="/" element={<Navigate to="/dashboard" replace />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/users" element={<Users />} />
<Route path="/" element={<Navigate to={defaultRoute} replace />} />
<Route path="/dashboard" element={isAdmin ? <Dashboard /> : <Navigate to="/routes" replace />} />
<Route path="/users" element={isAdmin ? <Users /> : <Navigate to="/routes" replace />} />
<Route path="/customers" element={<Customers />} />
<Route path="/products" element={<Products />} />
<Route path="/routes" element={<RoutesPage />} />
<Route path="/sales" element={<Sales />} />
<Route path="*" element={<Navigate to="/dashboard" replace />} />
<Route path="/visits" element={<Visits />} />
<Route path="/closest" element={<ClosestPos />} />
<Route path="*" element={<Navigate to={defaultRoute} replace />} />
</Routes>
</main>
</div>
+28 -15
View File
@@ -7,7 +7,9 @@ interface SidebarProps {
export default function Sidebar({ onLogout }: SidebarProps) {
const location = useLocation();
const userName = JSON.parse(localStorage.getItem('user') || '{}').name;
const user = JSON.parse(localStorage.getItem('user') || '{}');
const userName = user.name || 'User';
const isAdmin = user.role === 'admin';
const buildNumber = import.meta.env.VITE_BUILD_NUMBER || 'dev';
const [isOpen, setIsOpen] = useState(false);
@@ -43,20 +45,24 @@ export default function Sidebar({ onLogout }: SidebarProps) {
</div>
<nav className="nav flex-column w-100 mb-auto">
<Link
to="/dashboard"
className={`nav-link ${location.pathname === '/dashboard' ? 'active' : ''}`}
onClick={closeMenu}
>
<i className="bi bi-speedometer2 me-2"></i> Dashboard
</Link>
<Link
to="/users"
className={`nav-link ${location.pathname === '/users' ? 'active' : ''}`}
onClick={closeMenu}
>
<i className="bi bi-people me-2"></i> Users
</Link>
{isAdmin && (
<Link
to="/dashboard"
className={`nav-link ${location.pathname === '/dashboard' ? 'active' : ''}`}
onClick={closeMenu}
>
<i className="bi bi-speedometer2 me-2"></i> Dashboard
</Link>
)}
{isAdmin && (
<Link
to="/users"
className={`nav-link ${location.pathname === '/users' ? 'active' : ''}`}
onClick={closeMenu}
>
<i className="bi bi-people me-2"></i> Users
</Link>
)}
<Link
to="/customers"
className={`nav-link ${location.pathname === '/customers' ? 'active' : ''}`}
@@ -85,6 +91,13 @@ export default function Sidebar({ onLogout }: SidebarProps) {
>
<i className="bi bi-cart-check me-2"></i> Sales
</Link>
<Link
to="/visits"
className={`nav-link ${location.pathname === '/visits' ? 'active' : ''}`}
onClick={closeMenu}
>
<i className="bi bi-calendar-event me-2"></i> Visits
</Link>
</nav>
<div className="mt-auto">
+31
View File
@@ -6,6 +6,37 @@ import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.css';
import './index.css';
import App from './App.tsx';
import axios from 'axios';
axios.defaults.withCredentials = true;
let memoryUser: string | null = null;
const _origGetItem = Storage.prototype.getItem;
const _origSetItem = Storage.prototype.setItem;
const _origRemoveItem = Storage.prototype.removeItem;
Storage.prototype.getItem = function (key: string): string | null {
if (this === localStorage) {
if (key === 'user') return memoryUser;
}
return _origGetItem.call(this, key);
};
Storage.prototype.setItem = function (key: string, value: string): void {
if (this === localStorage) {
if (key === 'user') { memoryUser = value; return; }
}
_origSetItem.call(this, key, value);
};
Storage.prototype.removeItem = function (key: string): void {
if (this === localStorage) {
if (key === 'user') { memoryUser = null; return; }
}
_origRemoveItem.call(this, key);
};
createRoot(document.getElementById('root')!).render(
<StrictMode>
+131
View File
@@ -0,0 +1,131 @@
import { useCallback, useEffect, useState } from 'react';
import axios from 'axios';
type ClosestPos = {
id: string;
customerName: string;
address: string;
distanceKm: number;
};
const formatDistance = (distanceKm: number): string => {
const distanceMeters = Math.round(distanceKm * 1000);
if (distanceMeters < 1000) return `${distanceMeters} m away`;
return `${distanceKm.toFixed(2)} km away`;
};
const openAddressInMaps = (address: string, preferredApp?: 'google' | 'apple') => {
const encodedAddress = encodeURIComponent(address);
const isIOS = /iPhone|iPad|iPod/i.test(navigator.userAgent);
const isAndroid = /Android/i.test(navigator.userAgent);
if (isIOS) {
if (preferredApp === 'google') {
window.location.href = `comgooglemaps://?q=${encodedAddress}`;
} else {
window.location.href = `https://maps.apple.com/?q=${encodedAddress}`;
}
} else if (isAndroid) {
window.location.href = `geo:0,0?q=${encodedAddress}`;
} else {
window.open(`https://www.google.com/maps/search/?api=1&query=${encodedAddress}`, '_blank');
}
};
export default function ClosestPos() {
const [items, setItems] = useState<ClosestPos[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
const fetchClosestPos = useCallback(async () => {
if (!navigator.geolocation) {
setError('Geolocation is not supported by this browser.');
return;
}
setLoading(true);
setError('');
setItems([]);
navigator.geolocation.getCurrentPosition(async ({ coords }) => {
try {
const response = await axios.get<ClosestPos[]>(
`${apiBase}/api/public/pos/closest`,
{ params: { lat: coords.latitude, lng: coords.longitude } }
);
setItems(response.data);
} catch {
setError('Failed to load closest points of sale.');
} finally {
setLoading(false);
}
}, () => {
setError('Location access is required to find closest points of sale.');
setLoading(false);
}, {
enableHighAccuracy: true,
timeout: 10000
});
}, [apiBase]);
useEffect(() => {
fetchClosestPos();
}, [fetchClosestPos]);
return (
<div className="container py-4 py-md-5 animate-fade-in d-flex flex-column" style={{ minHeight: '100vh' }}>
<div className="d-flex justify-content-between align-items-center mb-4">
<div>
<h2 className="fw-bold m-0">Closest Points of Sale</h2>
<p className="text-secondary m-0">Using your current GPS location</p>
</div>
<button type="button" className="btn btn-outline-light" onClick={fetchClosestPos} disabled={loading}>
{loading ? 'Locating...' : 'Refresh'}
</button>
</div>
{error && (
<div className="alert alert-danger" style={{ backgroundColor: 'rgba(239, 68, 68, 0.2)', border: '1px solid var(--danger-color)', color: '#fff' }}>
{error}
</div>
)}
<div className="d-flex flex-column gap-3">
{items.map((pos) => (
<div key={pos.id} className="w-100">
<div className="glass-card p-4 d-flex flex-column" style={{ minHeight: '140px' }}>
<strong className="text-white fs-5">{pos.customerName}</strong>
<div className="text-white-50 small mb-2">{pos.address}</div>
<div className="text-secondary small mt-2">{formatDistance(pos.distanceKm)}</div>
<div className="d-flex justify-content-end gap-2 pt-3">
<button
type="button"
className="btn btn-outline-light btn-sm"
aria-label={`Open ${pos.address} in Google Maps`}
onClick={() => openAddressInMaps(pos.address, 'google')}
>
Open in Google Maps
</button>
<button
type="button"
className="btn btn-outline-light btn-sm"
aria-label={`Open ${pos.address} in Apple Maps`}
onClick={() => openAddressInMaps(pos.address, 'apple')}
>
Open in Apple Maps
</button>
</div>
</div>
</div>
))}
</div>
{!loading && !error && items.length === 0 && (
<div className="text-secondary mt-4">No points of sale with coordinates were found.</div>
)}
<footer className="text-center text-secondary small mt-auto pt-4">© 2026 Polpa Gestão</footer>
</div>
);
}
+284 -41
View File
@@ -1,9 +1,25 @@
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { createPortal } from 'react-dom';
import axios from 'axios';
import { useToast } from '../context/toast';
import type { Customer, CustomerPOS } from '../types';
const POS_INDUSTRY_OPTIONS = [
'Academia',
'Conveniência',
'Hamburgueria',
'Lanchonete',
'Mercado',
'Panificadora',
'Restaurante',
'Sorveteria',
'Verdureira/Frutaria',
'Parque aquático',
'Recanto',
'Pesque e pague',
'Arena de esporte'
] as const;
const toErrorMessage = (err: unknown, fallback: string): string => {
if (axios.isAxiosError(err)) {
const data = err.response?.data as { message?: string; error?: string } | undefined;
@@ -16,8 +32,8 @@ export default function Customers() {
const toast = useToast();
const [customers, setCustomers] = useState<Customer[]>([]);
const [showModal, setShowModal] = useState(false);
const [newCustomer, setNewCustomer] = useState<Customer>({ name: '', document: '', phone: '', personName: '' });
const emptyPos: CustomerPOS = { address: '', phone: '', personName: '' };
const [newCustomer, setNewCustomer] = useState<Customer>({ name: '', document: '', phone: '', personName: '', notes: '' });
const emptyPos: CustomerPOS = { address: '', phone: '', industry: '', personName: '', fridgeCount: undefined, banner: false, indiBanner: false, lat: null, lng: null, region: null };
const [editingCustomer, setEditingCustomer] = useState<string | null>(null);
const [showPosModal, setShowPosModal] = useState(false);
const [selectedCustomer, setSelectedCustomer] = useState<Customer | null>(null);
@@ -25,28 +41,46 @@ export default function Customers() {
const [editingPos, setEditingPos] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [docValidation, setDocValidation] = useState<{ valid: boolean | null, loading: boolean }>({ valid: null, loading: false });
const docAbortRef = useRef<AbortController | null>(null);
const [selectedPhone, setSelectedPhone] = useState<{ number: string, name: string } | null>(null);
const [showPhoneModal, setShowPhoneModal] = useState(false);
const [showDisabled, setShowDisabled] = useState<boolean>(false);
const [filterText, setFilterText] = useState<string>('');
const token = localStorage.getItem('token');
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
const cpfCnpjApiToken = import.meta.env.VITE_CPF_CNPJ_API_TOKEN || '';
const fetchCustomers = useCallback(async () => {
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
const res = await axios.get(`${apiBase}/api/customers?showDisabled=${showDisabled}`, config);
const res = await axios.get(`${apiBase}/api/customers?showDisabled=${showDisabled}`);
setCustomers(res.data);
} catch (err) {
console.error('Failed to load customers', err);
}
}, [token, apiBase, showDisabled]);
}, [apiBase, showDisabled]);
useEffect(() => {
fetchCustomers();
}, [fetchCustomers]);
const addressFilterMatches = (pos: CustomerPOS[] | undefined, text: string): boolean => {
if (pos && Array.isArray(pos)) {
for (let i = 0; i < pos.length; i++) {
const normalizedAddress = pos[i].address
.replace(/á/g, 'a')
.replace(/ã/g, 'a')
.replace(/â/g, 'a')
.replace(/ê/g, 'e')
.replace(/é/g, 'e')
.replace(/ó/g, 'o')
.replace(/ú/g, 'u')
.replace(/ç/g, 'c')
.toLowerCase();
return normalizedAddress.includes(text.toLowerCase());
}
}
return false;
};
const filteredCustomers = useMemo(() => {
if (!filterText.trim()) return customers;
const lower = filterText.toLowerCase();
@@ -54,7 +88,8 @@ export default function Customers() {
return customers.filter((c: Customer) =>
c.name.toLowerCase().includes(lower) ||
(c.personName && c.personName.toLowerCase().includes(lower)) ||
(digits && c.phone && c.phone.replace(/\D/g, '').includes(digits))
(digits && c.phone && c.phone.replace(/\D/g, '').includes(digits)) ||
addressFilterMatches(c.pos, filterText)
);
}, [customers, filterText]);
@@ -62,44 +97,53 @@ export default function Customers() {
e.preventDefault();
setLoading(true);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
if (editingCustomer) {
await axios.put(`${apiBase}/api/customers/${editingCustomer}`, newCustomer, config);
await axios.put(`${apiBase}/api/customers/${editingCustomer}`, newCustomer);
} else {
await axios.post(`${apiBase}/api/customers`, newCustomer, config);
await axios.post(`${apiBase}/api/customers`, newCustomer);
}
setShowModal(false);
setNewCustomer({ name: '', document: '', phone: '', personName: '' });
setNewCustomer({ name: '', document: '', phone: '', personName: '', notes: '' });
setEditingCustomer(null);
fetchCustomers();
toast.showToast(editingCustomer ? 'Customer updated successfully.' : 'Customer created successfully.', 'success');
} catch (err) {
toast.showToast(`Failed to save customer. Document (CNPJ/CPF) may already exist: ${toErrorMessage(err, 'Unknown error')}`, 'error');
toast.showToast(`Failed to save customer: ${toErrorMessage(err, 'Unknown error')}`, 'error');
} finally {
setLoading(false);
}
};
const validateDocument = async (doc: string) => {
if (!doc || doc.length < 11) {
if (!doc || (doc.length !== 11 && doc.length !== 14)) {
setDocValidation({ valid: null, loading: false });
return;
}
docAbortRef.current?.abort();
const controller = new AbortController();
docAbortRef.current = controller;
setDocValidation({ valid: null, loading: true });
try {
const res = await axios.get(`https://api.invertexto.com/v1/validator?token=${cpfCnpjApiToken}&value=${doc}`);
setNewCustomer(prev => ({ ...prev, document: res.data.formatted }));
const res = await axios.get(
`${apiBase}/api/proxy/validator?value=${doc}`,
{ signal: controller.signal }
);
if (res.data.valid) {
setNewCustomer(prev => ({ ...prev, document: res.data.formatted }));
}
setDocValidation({ valid: res.data.valid, loading: false });
} catch (err) {
if (axios.isCancel(err)) return;
setDocValidation({ valid: false, loading: false });
console.error('Document validation failed', err);
}
};
const handleDocumentChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const rawVal = e.target.value.replace(/\D/g, '');
const rawVal = e.target.value.replace(/\D/g, '').slice(0, 14);
setNewCustomer({ ...newCustomer, document: rawVal });
if (rawVal.length >= 11) {
if (rawVal.length === 11 || rawVal.length === 14) {
validateDocument(rawVal);
} else {
setDocValidation({ valid: null, loading: false });
@@ -109,21 +153,29 @@ export default function Customers() {
const formatPhone = (val: string) => {
let r = val.replace(/\D/g, '');
r = r.replace(/^0/, '');
if (!r) return '';
if (r.length > 10) {
r = r.replace(/^(\d\d)(\d{5})(\d{4}).*/, '($1) $2-$3');
const ddd = r.slice(0, 2);
const part1 = r.slice(2, 7);
const part2 = r.slice(7, 11);
return `(${ddd}) ${part1}-${part2}`;
} else if (r.length > 5) {
r = r.replace(/^(\d\d)(\d{4})(\d{0,4}).*/, '($1) $2-$3');
const ddd = r.slice(0, 2);
const part1 = r.slice(2, 6);
const part2 = r.slice(6);
return `(${ddd}) ${part1}` + (part2 ? `-${part2}` : '');
} else if (r.length > 2) {
r = r.replace(/^(\d\d)(\d{0,5})/, '($1) $2');
const ddd = r.slice(0, 2);
const part = r.slice(2);
return `(${ddd}) ${part}`;
} else {
r = r.replace(/^(\d*)/, '($1');
return `(${r}`;
}
return r;
};
const openNewModal = () => {
setEditingCustomer(null);
setNewCustomer({ name: '', document: '', phone: '', personName: '' });
setNewCustomer({ name: '', document: '', phone: '', personName: '', notes: '' });
setDocValidation({ valid: null, loading: false });
setShowModal(true);
};
@@ -139,7 +191,7 @@ export default function Customers() {
return;
}
setEditingCustomer(c.id);
setNewCustomer({ name: c.name, document: c.document || '', phone: c.phone || '', personName: c.personName || '' });
setNewCustomer({ name: c.name, document: c.document || '', phone: c.phone || '', personName: c.personName || '', notes: c.notes || '' });
setDocValidation({ valid: null, loading: false });
setShowModal(true);
};
@@ -163,7 +215,18 @@ export default function Customers() {
return;
}
setEditingPos(p.id);
setNewPos({ address: p.address, phone: p.phone, personName: p.personName || '' });
setNewPos({
address: p.address,
phone: p.phone,
industry: p.industry || '',
personName: p.personName || '',
fridgeCount: p.fridgeCount ?? undefined,
banner: p.banner ?? false,
indiBanner: p.indiBanner ?? false,
lat: p.lat ?? null,
lng: p.lng ?? null,
region: p.region || null
});
};
const cancelEditPos = () => {
@@ -176,19 +239,18 @@ export default function Customers() {
if (!selectedCustomer) return;
setLoading(true);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
if (editingPos) {
await axios.put(`${apiBase}/api/customers/pos/${editingPos}`, newPos, config);
await axios.put(`${apiBase}/api/customers/pos/${editingPos}`, newPos);
toast.showToast('Point of sale updated successfully.', 'success');
} else {
await axios.post(`${apiBase}/api/customers/${selectedCustomer.id}/pos`, newPos, config);
await axios.post(`${apiBase}/api/customers/${selectedCustomer.id}/pos`, newPos);
toast.showToast('Point of sale added successfully.', 'success');
}
setNewPos(emptyPos);
setEditingPos(null);
fetchCustomers();
const res = await axios.get(`${apiBase}/api/customers/${selectedCustomer.id}`, config);
const res = await axios.get(`${apiBase}/api/customers/${selectedCustomer.id}`);
setSelectedCustomer(res.data);
} catch (err) {
toast.showToast(`Failed to save POS: ${toErrorMessage(err, 'Unknown error')}`, 'error');
@@ -197,11 +259,37 @@ export default function Customers() {
}
};
const handleGeocodePos = async () => {
if (!newPos.address?.trim()) {
toast.showToast('Enter an address before geocoding.', 'error');
return;
}
setLoading(true);
try {
const response = await axios.get(`${apiBase}/api/proxy/geocode`, { params: { address: newPos.address.trim() } });
const location = response.data?.results?.[0]?.geometry?.location;
if (response.data?.status !== 'OK' || typeof location?.lat !== 'number' || typeof location?.lng !== 'number') {
toast.showToast('Address not found for geocoding.', 'error');
return;
}
setNewPos((prev) => ({ ...prev, lat: location.lat, lng: location.lng }));
toast.showToast('Geocode set successfully.', 'success');
} catch (err) {
toast.showToast(`Failed to geocode address: ${toErrorMessage(err, 'Unknown error')}`, 'error');
} finally {
setLoading(false);
}
};
const handleDeletePos = async (posId: string | undefined) => {
if (!posId) return;
const confirmed = await toast.confirm({
title: 'Delete POS',
message: 'Are you sure you want to delete this POS?',
message: 'Are you sure you want to permanently delete this POS? All related sales history and route assignments will be permanently deleted.',
confirmText: 'Delete',
cancelText: 'Cancel',
isDangerous: true
@@ -209,12 +297,11 @@ export default function Customers() {
if (!confirmed) return;
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
await axios.delete(`${apiBase}/api/customers/pos/${posId}`, config);
await axios.delete(`${apiBase}/api/customer-pos/${posId}`);
fetchCustomers();
if (selectedCustomer && selectedCustomer.id) {
const res = await axios.get(`${apiBase}/api/customers/${selectedCustomer.id}`, config);
const res = await axios.get(`${apiBase}/api/customers/${selectedCustomer.id}`);
setSelectedCustomer(res.data);
}
toast.showToast('Point of sale deleted successfully.', 'success');
@@ -236,10 +323,9 @@ export default function Customers() {
setLoading(true);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
await axios.delete(`${apiBase}/api/customers/${editingCustomer}`, config);
await axios.patch(`${apiBase}/api/customers/${editingCustomer}/disable`, {});
setShowModal(false);
setNewCustomer({ name: '', document: '', phone: '', personName: '' });
setNewCustomer({ name: '', document: '', phone: '', personName: '', notes: '' });
setEditingCustomer(null);
fetchCustomers();
toast.showToast('Customer disabled successfully.', 'success');
@@ -250,6 +336,32 @@ export default function Customers() {
}
};
const handleDeleteCustomer = async () => {
if (!editingCustomer) return;
const confirmed = await toast.confirm({
title: 'Delete Customer',
message: 'Are you sure you want to permanently delete this customer? All associated POSes, sales history, and route assignments will be permanently deleted.',
confirmText: 'Delete',
cancelText: 'Cancel',
isDangerous: true
});
if (!confirmed) return;
setLoading(true);
try {
await axios.delete(`${apiBase}/api/customers/${editingCustomer}`);
setShowModal(false);
setNewCustomer({ name: '', document: '', phone: '', personName: '', notes: '' });
setEditingCustomer(null);
fetchCustomers();
toast.showToast('Customer deleted successfully.', 'success');
} catch (err) {
toast.showToast(`Failed to delete customer: ${toErrorMessage(err, 'Unknown error')}`, 'error');
} finally {
setLoading(false);
}
};
const formatDocument = (doc: string) => {
const cleaned = doc.replace(/\D/g, '');
if (cleaned.length === 11) {
@@ -308,6 +420,11 @@ export default function Customers() {
<div className="text-secondary small mt-1">
<i className="bi bi-file-earmark-text me-1"></i>{c.document ? formatDocument(c.document) : 'N/A'}
</div>
{c.notes?.trim() && (
<div className="text-secondary small mt-1">
<i className="bi bi-journal-text me-1"></i>{c.notes.trim().slice(0, 80)}{c.notes.trim().length > 80 ? '...' : ''}
</div>
)}
</div>
<div className="d-flex flex-column gap-1 text-secondary small mb-3">
<div>
@@ -368,7 +485,7 @@ export default function Customers() {
<div className="mb-3">
<label className="form-label text-secondary">Document (CNPJ/CPF) (Optional)</label>
<div className="input-group">
<input type="text" className="form-control" value={newCustomer.document || ''} onChange={handleDocumentChange} placeholder="Type numbers only..." />
<input type="text" className="form-control" value={newCustomer.document || ''} onChange={handleDocumentChange} placeholder="Type numbers only..." maxLength={18} />
{docValidation.loading && <span className="input-group-text bg-secondary text-white border-secondary">...</span>}
{!docValidation.loading && docValidation.valid === true && <span className="input-group-text bg-success text-white border-success">Valid</span>}
{!docValidation.loading && docValidation.valid === false && <span className="input-group-text bg-danger text-white border-danger">Invalid</span>}
@@ -378,11 +495,24 @@ export default function Customers() {
<label className="form-label text-secondary">Phone (Optional)</label>
<input type="text" className="form-control" value={newCustomer.phone} onChange={e => setNewCustomer({ ...newCustomer, phone: formatPhone(e.target.value) })} placeholder="(11) 99999-9999" maxLength={15} />
</div>
<div className="mb-3">
<label className="form-label text-secondary">Notes (Optional)</label>
<textarea
className="form-control"
rows={3}
value={newCustomer.notes || ''}
onChange={e => setNewCustomer({ ...newCustomer, notes: e.target.value })}
placeholder="Add notes/observations about this customer..."
/>
</div>
</div>
<div className="modal-footer border-top-0" style={{ borderColor: 'var(--glass-border)' }}>
<button type="button" className="btn btn-outline-light" onClick={() => setShowModal(false)}>Cancel</button>
{editingCustomer && (
<button type="button" className="btn btn-outline-danger" onClick={handleDisableCustomer}>Disable</button>
<button type="button" className="btn btn-outline-warning" onClick={handleDisableCustomer}>Disable</button>
)}
{editingCustomer && (
<button type="button" className="btn btn-outline-danger" onClick={handleDeleteCustomer}>Delete</button>
)}
<button type="submit" className="btn btn-primary" disabled={loading}>{loading ? 'Saving...' : 'Save Customer'}</button>
</div>
@@ -427,10 +557,25 @@ export default function Customers() {
</button>
) : 'N/A'}
</div>
<div className="text-secondary small">
Industry: {p.industry || 'N/A'}
</div>
<div className="text-secondary small">
Region: {p.region || 'N/A'}
</div>
<div className="text-secondary small">
Fridges: {p.fridgeCount ?? 0}
</div>
<div className="text-secondary small">
Banner: {p.banner ? 'Yes' : 'No'} | Indi Banner: {p.indiBanner ? 'Yes' : 'No'}
</div>
<div className="text-secondary small">
Geocode: {p.lat != null && p.lng != null ? 'Set' : 'Missing'}
</div>
</div>
<div className="d-flex gap-2">
<button className="btn btn-sm btn-outline-primary" onClick={() => openEditPos(p)}>Edit</button>
<button className="btn btn-sm btn-outline-danger" onClick={() => handleDeletePos(p.id)}>Remove</button>
<button className="btn btn-sm btn-outline-danger" onClick={() => handleDeletePos(p.id)}>Delete</button>
</div>
</li>
))}
@@ -470,10 +615,108 @@ export default function Customers() {
maxLength={30}
/>
</div>
<div className="col-md-5">
<label className="form-label text-secondary small">Industry (Optional)</label>
<select
className="form-select form-select-sm"
value={newPos.industry || ''}
onChange={e =>
setNewPos({
...newPos,
industry: e.target.value,
})
}
>
<option value="">Select an industry...</option>
{POS_INDUSTRY_OPTIONS.map((industryOption) => (
<option key={industryOption} value={industryOption}>{industryOption}</option>
))}
</select>
</div>
<div className="col-md-7">
<label className="form-label text-secondary small">Region (Optional)</label>
<input
type="text"
className="form-control form-control-sm"
value={newPos.region || ''}
onChange={e =>
setNewPos({
...newPos,
region: e.target.value,
})
}
placeholder="e.g. North, Downtown..."
/>
</div>
<div className="col-md-5">
<label className="form-label text-secondary small">Fridges</label>
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
className="form-control form-control-sm"
value={String(newPos.fridgeCount ?? '')}
onChange={e => {
const raw = e.target.value;
if (raw === '') {
setNewPos({
...newPos,
fridgeCount: undefined,
})
return;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isNaN(parsed) && parsed > 0) {
setNewPos({
...newPos,
fridgeCount: parsed,
})
}
}}
/>
</div>
<div className="col-md-7">
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="pos-banner"
checked={newPos.banner}
onChange={e =>
setNewPos({
...newPos,
banner: e.target.checked,
})
}
/>
<label className="form-check-label text-secondary small" htmlFor="pos-banner">
Banner
</label>
</div>
<div className="form-check">
<input
className="form-check-input"
type="checkbox"
id="pos-indi-banner"
checked={newPos.indiBanner}
onChange={e =>
setNewPos({
...newPos,
indiBanner: e.target.checked,
})
}
/>
<label className="form-check-label text-secondary small" htmlFor="pos-indi-banner">
Wind Banner
</label>
</div>
</div>
<div className="col-md-5 d-flex align-items-end gap-2">
{editingPos && (
<button type="button" className="btn btn-sm btn-outline-secondary w-100" onClick={cancelEditPos}>Cancel</button>
)}
<button type="button" className="btn btn-sm btn-outline-info w-100" onClick={handleGeocodePos} disabled={loading}>Geocode</button>
<button type="submit" className="btn btn-sm btn-success w-100" disabled={loading}>{loading ? '...' : editingPos ? 'Save' : 'Add'}</button>
</div>
</div>
+192 -7
View File
@@ -1,13 +1,19 @@
import { useEffect, useState } from 'react';
import { createPortal } from 'react-dom';
import axios from 'axios';
import type { SalesByCustomer, SalesByProduct, SalesSummary } from '../types';
import type { FridgePosSummary, InactivePosSummary, IndustriesSummary, RegionsSummary, SalesByCustomer, SalesByProduct, SalesSummary } from '../types';
export default function Dashboard() {
const [range, setRange] = useState('last-30-days');
const [salesByProduct, setSalesByProduct] = useState<SalesByProduct[]>([]);
const [salesByCustomer, setSalesByCustomer] = useState<SalesByCustomer[]>([]);
const [salesSummary, setSalesSummary] = useState<SalesSummary | null>(null);
const token = localStorage.getItem('token');
const [industriesSummary, setIndustriesSummary] = useState<IndustriesSummary[]>([]);
const [regionsSummary, setRegionsSummary] = useState<RegionsSummary[]>([]);
const [fridgePoses, setFridgePoses] = useState<FridgePosSummary[]>([]);
const [showFridgesModal, setShowFridgesModal] = useState(false);
const [loadingFridgePoses, setLoadingFridgePoses] = useState(false);
const [inactivePoses, setInactivePoses] = useState<InactivePosSummary[]>([]);
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
const ranges = [
@@ -26,23 +32,26 @@ export default function Dashboard() {
useEffect(() => {
const fetchDashboard = async () => {
try {
const config = {
headers: { Authorization: `Bearer ${token}` },
params: { range }
};
const config = { params: { range } };
const resProducts = await axios.get(`${apiBase}/api/dashboard/sales-by-product`, config);
const resCustomers = await axios.get(`${apiBase}/api/dashboard/sales-by-customer`, config);
const resSummary = await axios.get(`${apiBase}/api/dashboard/sales-summary`, config);
const resIndustries = await axios.get(`${apiBase}/api/dashboard/industries-summary`, config);
const resRegions = await axios.get(`${apiBase}/api/dashboard/regions-summary`, config);
const resInactivePoses = await axios.get(`${apiBase}/api/dashboard/inactive-pos`, config);
setSalesByProduct(resProducts.data);
setSalesByCustomer(resCustomers.data);
setSalesSummary(resSummary.data);
setIndustriesSummary(resIndustries.data);
setRegionsSummary(resRegions.data);
setInactivePoses(resInactivePoses.data);
} catch (err) {
console.error('Failed to load dashboard', err);
}
};
fetchDashboard();
}, [token, apiBase, range]);
}, [apiBase, range]);
const top3Products = [...salesByProduct]
.sort((a, b) => b.totalQuantity - a.totalQuantity)
@@ -52,6 +61,22 @@ export default function Dashboard() {
.sort((a, b) => b.totalAmount - a.totalAmount)
.slice(0, 3);
const { totalCustomers, totalFridges } = salesSummary ?? { totalCustomers: 0, totalFridges: 0 };
const openFridgesModal = async () => {
setShowFridgesModal(true);
setLoadingFridgePoses(true);
try {
const response = await axios.get(`${apiBase}/api/dashboard/fridges`);
setFridgePoses(response.data);
} catch (err) {
console.error('Failed to load fridge POS details', err);
setFridgePoses([]);
} finally {
setLoadingFridgePoses(false);
}
};
return (
<div>
<div className="d-flex justify-content-between align-items-center mb-4">
@@ -96,6 +121,36 @@ export default function Dashboard() {
</div>
</div>
{/* Total Customers */}
<div className="col-12 col-sm-6 col-lg-3">
<div className="glass-card p-4 h-100">
<h6 className="text-secondary mb-2">Total Customers</h6>
<h3 className="fw-bold text-info mb-0">{salesSummary ? totalCustomers : '—'}</h3>
<small className="text-secondary">active customers</small>
</div>
</div>
{/* Total Fridges */}
<div className="col-12 col-sm-6 col-lg-3">
<div
className="glass-card p-4 h-100"
role="button"
tabIndex={0}
style={{ cursor: 'pointer' }}
onClick={openFridgesModal}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openFridgesModal();
}
}}
>
<h6 className="text-secondary mb-2">Total Fridges</h6>
<h3 className="fw-bold text-warning mb-0">{salesSummary ? totalFridges : '—'}</h3>
<small className="text-secondary">active POS total</small>
</div>
</div>
{/* Top 3 Products */}
<div className="col-12 col-sm-6 col-lg-3">
<div className="glass-card p-4 h-100">
@@ -135,6 +190,44 @@ export default function Dashboard() {
)}
</div>
</div>
{/* POS by Industry */}
<div className="col-12 col-sm-6 col-lg-6">
<div className="glass-card p-4 h-100">
<h6 className="text-secondary mb-2">POS by Industry</h6>
{industriesSummary.length === 0 ? (
<p className="text-secondary mb-0 small">No data available.</p>
) : (
<ul className="list-group list-group-flush" style={{ background: 'transparent' }}>
{industriesSummary.map((item) => (
<li key={item.industry} className="list-group-item d-flex justify-content-between align-items-center text-white px-0" style={{ background: 'transparent', borderBottomColor: 'var(--glass-border)' }}>
<span>{item.industry}</span>
<span className="badge bg-secondary rounded-pill">{item.count}</span>
</li>
))}
</ul>
)}
</div>
</div>
{/* POS by Region */}
<div className="col-12 col-sm-6 col-lg-6">
<div className="glass-card p-4 h-100">
<h6 className="text-secondary mb-2">POS by Region</h6>
{regionsSummary.length === 0 ? (
<p className="text-secondary mb-0 small">No data available.</p>
) : (
<ul className="list-group list-group-flush" style={{ background: 'transparent' }}>
{regionsSummary.map((item) => (
<li key={item.region} className="list-group-item d-flex justify-content-between align-items-center text-white px-0" style={{ background: 'transparent', borderBottomColor: 'var(--glass-border)' }}>
<span>{item.region}</span>
<span className="badge bg-secondary rounded-pill">{item.count}</span>
</li>
))}
</ul>
)}
</div>
</div>
</div>
<div className="row g-4">
@@ -178,6 +271,98 @@ export default function Dashboard() {
</div>
</div>
</div>
{/* Inactive Customer POSes */}
<div className="row g-4 mt-0">
<div className="col-12">
<div className="glass-card p-4">
<h4 className="mb-3">Inactive Customer POSes (10+ days)</h4>
{inactivePoses.length === 0 ? (
<p className="text-secondary mb-0">No inactive POSes found.</p>
) : (
<div className="table-responsive">
<table className="table table-dark table-hover align-middle mb-0">
<thead>
<tr>
<th>Customer</th>
<th>POS Address</th>
<th>Last Buying Date</th>
<th className="text-end">Days Inactive</th>
</tr>
</thead>
<tbody>
{inactivePoses.map((item: InactivePosSummary) => (
<tr key={item.posId}>
<td>{item.customerName}</td>
<td>{item.posAddress}</td>
<td>{item.lastBuyingDate ? new Date(item.lastBuyingDate).toLocaleDateString() : '—'}</td>
<td className="text-end">
<span className="badge bg-danger rounded-pill">{item.daysInactive}d</span>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
</div>
</div>
{showFridgesModal && createPortal(
<>
<div className="modal-backdrop fade show" style={{ zIndex: 1040 }}></div>
<div
className="modal fade show d-block"
tabIndex={-1}
role="dialog"
aria-modal="true"
aria-labelledby="fridges-modal-title"
style={{ zIndex: 1050 }}
>
<div className="modal-dialog modal-dialog-centered modal-lg">
<div className="modal-content glass-card">
<div className="modal-header border-bottom-0" style={{ borderColor: 'var(--glass-border)' }}>
<h5 id="fridges-modal-title" className="modal-title text-white">POS with Fridges</h5>
<button type="button" className="btn-close btn-close-white" onClick={() => setShowFridgesModal(false)}></button>
</div>
<div className="modal-body">
{loadingFridgePoses ? (
<p className="text-secondary mb-0">Loading...</p>
) : fridgePoses.length === 0 ? (
<p className="text-secondary mb-0">No active POS with fridges found.</p>
) : (
<div className="table-responsive">
<table className="table table-dark table-hover align-middle mb-0">
<thead>
<tr>
<th>Customer</th>
<th>Address</th>
<th className="text-end">Fridge Count</th>
</tr>
</thead>
<tbody>
{fridgePoses.map((pos) => (
<tr key={pos.id}>
<td>{pos.customer.name}</td>
<td>{pos.address}</td>
<td className="text-end">{pos.fridgeCount}</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
<div className="modal-footer border-top-0" style={{ borderColor: 'var(--glass-border)' }}>
<button type="button" className="btn btn-outline-light" onClick={() => setShowFridgesModal(false)}>Close</button>
</div>
</div>
</div>
</div>
</>,
document.body
)}
</div>
);
}
+2 -3
View File
@@ -25,10 +25,9 @@ export default function Login({ setToken }: LoginProps) {
password
});
const { token, user } = response.data;
localStorage.setItem('token', token);
const { user } = response.data;
localStorage.setItem('user', JSON.stringify(user));
setToken(token);
setToken(user.id);
navigate('/dashboard');
} catch (err) {
if (axios.isAxiosError(err)) {
+5 -9
View File
@@ -12,18 +12,16 @@ export default function Products() {
const [editingProduct, setEditingProduct] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showDisabled, setShowDisabled] = useState<boolean>(false);
const token = localStorage.getItem('token');
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
const fetchProducts = useCallback(async () => {
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
const res = await axios.get(`${apiBase}/api/products?showDisabled=${showDisabled}`, config);
const res = await axios.get(`${apiBase}/api/products?showDisabled=${showDisabled}`);
setProducts(res.data);
} catch (err) {
console.error('Failed to load products', err);
}
}, [token, apiBase, showDisabled]);
}, [apiBase, showDisabled]);
useEffect(() => {
fetchProducts();
@@ -33,11 +31,10 @@ export default function Products() {
e.preventDefault();
setLoading(true);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
if (editingProduct) {
await axios.put(`${apiBase}/api/products/${editingProduct}`, newProduct, config);
await axios.put(`${apiBase}/api/products/${editingProduct}`, newProduct);
} else {
await axios.post(`${apiBase}/api/products`, newProduct, config);
await axios.post(`${apiBase}/api/products`, newProduct);
}
setShowModal(false);
setNewProduct({ id: '', name: '', price: 0, stock: 0, cost: 0 });
@@ -86,9 +83,8 @@ export default function Products() {
setLoading(true);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
const disabledProduct: Product = { ...newProduct, disabledAt: new Date().toISOString() };
await axios.put(`${apiBase}/api/products/${editingProduct}`, disabledProduct, config);
await axios.put(`${apiBase}/api/products/${editingProduct}`, disabledProduct);
setShowModal(false);
setNewProduct({ id: '', name: '', price: 0, stock: 0, cost: 0 });
setEditingProduct(null);
+16 -24
View File
@@ -58,28 +58,25 @@ export default function RoutesPage() {
const [newRoute, setNewRoute] = useState<Route>({ name: '', completed: false, dayOfWeek: 0, customerPos: [] });
const [editingRoute, setEditingRoute] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const token = localStorage.getItem('token');
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
const fetchRoutes = useCallback(async () => {
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
const res = await axios.get<RouteApiResponse[]>(`${apiBase}/api/routes`, config);
const res = await axios.get<RouteApiResponse[]>(`${apiBase}/api/routes`);
setRoutesData(res.data.map(normalizeRoute));
} catch (err) {
console.error('Failed to load routes', err);
}
}, [token, apiBase]);
}, [apiBase]);
const fetchCustomers = useCallback(async () => {
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
const res = await axios.get<Customer[]>(`${apiBase}/api/customers`, config);
const res = await axios.get<Customer[]>(`${apiBase}/api/customers`);
setCustomers(res.data);
} catch (err) {
console.error('Failed to load customers', err);
}
}, [token, apiBase]);
}, [apiBase]);
useEffect(() => {
fetchRoutes();
@@ -90,14 +87,13 @@ export default function RoutesPage() {
e.preventDefault();
setLoading(true);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
if (editingRoute) {
const updatePayload = {
name: newRoute.name,
dayOfWeek: newRoute.dayOfWeek,
completed: newRoute.completed,
};
await axios.put(`${apiBase}/api/routes/${editingRoute}`, updatePayload, config);
await axios.put(`${apiBase}/api/routes/${editingRoute}`, updatePayload);
} else {
const createPayload = {
name: newRoute.name,
@@ -106,7 +102,7 @@ export default function RoutesPage() {
.map((cp: CustomerPOS) => cp.id)
.filter((id): id is string => Boolean(id)),
};
await axios.post(`${apiBase}/api/routes`, createPayload, config);
await axios.post(`${apiBase}/api/routes`, createPayload);
}
setShowModal(false);
setNewRoute({ name: '', completed: false, dayOfWeek: 0, customerPos: [] });
@@ -122,8 +118,7 @@ export default function RoutesPage() {
const toggleRouteCompleted = async (route: Route) => {
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
await axios.put(`${apiBase}/api/routes/${route.id}`, { completed: !route.completed }, config);
await axios.put(`${apiBase}/api/routes/${route.id}`, { completed: !route.completed });
fetchRoutes();
} catch (err) {
if (axios.isAxiosError(err) && err.response) {
@@ -162,7 +157,6 @@ export default function RoutesPage() {
if (!selectedRoute || !selectedPosIdToAdd) return;
setLoading(true);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
const existingPosIds = (selectedRoute.customerPos || [])
.map((cp: CustomerPOS) => cp.id)
.filter((id): id is string => Boolean(id));
@@ -171,13 +165,13 @@ export default function RoutesPage() {
setLoading(false);
return;
}
const newPosIds = [...existingPosIds, selectedPosIdToAdd];
await axios.put(`${apiBase}/api/routes/${selectedRoute.id}`, { customerPosIds: newPosIds }, config);
await axios.put(`${apiBase}/api/routes/${selectedRoute.id}`, { customerPosIds: newPosIds });
setSelectedPosIdToAdd('');
fetchRoutes();
const res = await axios.get<RouteApiResponse>(`${apiBase}/api/routes/${selectedRoute.id}`, config);
const res = await axios.get<RouteApiResponse>(`${apiBase}/api/routes/${selectedRoute.id}`);
setSelectedRoute(normalizeRoute(res.data));
toast.showToast('Stop added successfully', 'success');
} catch (err) {
@@ -200,7 +194,6 @@ export default function RoutesPage() {
setLoading(true);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
if (!selectedRoute.customerPos || selectedRoute.customerPos.length === 0) {
toast.showToast('No stops to remove.', 'warning');
setLoading(false);
@@ -216,11 +209,11 @@ export default function RoutesPage() {
return;
}
const newPosIds: string[] = existingPosIds.filter((id: string) => id !== posIdToRemove);
await axios.put(`${apiBase}/api/routes/${selectedRoute.id}`, { customerPosIds: newPosIds }, config);
await axios.put(`${apiBase}/api/routes/${selectedRoute.id}`, { customerPosIds: newPosIds });
fetchRoutes();
const res = await axios.get<RouteApiResponse>(`${apiBase}/api/routes/${selectedRoute.id}`, config);
const res = await axios.get<RouteApiResponse>(`${apiBase}/api/routes/${selectedRoute.id}`);
setSelectedRoute(normalizeRoute(res.data));
toast.showToast('Stop removed successfully', 'success');
} catch (err) {
@@ -243,8 +236,7 @@ export default function RoutesPage() {
setLoading(true);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
await axios.delete(`${apiBase}/api/routes/${editingRoute}`, config);
await axios.delete(`${apiBase}/api/routes/${editingRoute}`);
setShowModal(false);
setNewRoute({ name: '', completed: false, dayOfWeek: 0, customerPos: [] });
setEditingRoute(null);
+43 -19
View File
@@ -89,6 +89,7 @@ export default function Sales() {
const [paymentDueDate, setPaymentDueDate] = useState('');
const [paymentDate, setPaymentDate] = useState('');
const [comments, setComments] = useState('');
const [nextVisitDate, setNextVisitDate] = useState('');
const [cart, setCart] = useState<ProductCart[]>([]);
const [showDelivered, setShowDelivered] = useState(false);
const [filterText, setFilterText] = useState<string>('');
@@ -96,15 +97,12 @@ export default function Sales() {
const [editingMode, setEditingMode] = useState(false);
const [editingSaleId, setEditingSaleId] = useState<string | null>(null);
const token = localStorage.getItem('token');
const fetchData = useCallback(async () => {
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
const [salesRes, custRes, prodRes] = await Promise.all([
axios.get(`${apiBase}/api/sales?showDelivered=${showDelivered}`, config),
axios.get(`${apiBase}/api/customers`, config),
axios.get(`${apiBase}/api/products`, config)
axios.get(`${apiBase}/api/sales?showDelivered=${showDelivered}`),
axios.get(`${apiBase}/api/customers`),
axios.get(`${apiBase}/api/products`)
]);
setSales(salesRes.data);
setCustomers(custRes.data);
@@ -112,7 +110,7 @@ export default function Sales() {
} catch (err) {
console.error('Failed to load sales data', err);
}
}, [token, apiBase, showDelivered]);
}, [apiBase, showDelivered]);
useEffect(() => {
fetchData();
@@ -138,6 +136,7 @@ export default function Sales() {
setPaymentDueDate('');
setPaymentDate('');
setComments('');
setNextVisitDate('');
setCart([]);
setShowModal(true);
};
@@ -167,6 +166,12 @@ export default function Sales() {
}
setPaymentDate(paymentDateValue);
setComments(sale.comments || '');
let nextVisitDateValue = '';
if (sale.nextVisitDate) {
const nvDate = new Date(sale.nextVisitDate);
nextVisitDateValue = nvDate.toISOString().slice(0, 10);
}
setNextVisitDate(nextVisitDateValue);
setCart((sale.products || []).map((sp: SaleProduct) => ({
productId: sp.productId,
quantity: sp.quantity,
@@ -212,23 +217,23 @@ export default function Sales() {
setLoading(true);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
const payload = {
customerPosId,
paymentMethod,
paymentDueDate: paymentDueDate ? new Date(paymentDueDate).toISOString() : null,
paymentDate: paymentDate ? new Date(paymentDate).toISOString() : null,
comments,
nextVisitDate: nextVisitDate ? new Date(nextVisitDate).toISOString() : null,
products: cart.map(item => ({ productId: item.productId, quantity: item.quantity }))
};
if (editingMode && editingSaleId) {
await axios.put(`${apiBase}/api/sales/${editingSaleId}`, payload, config);
await axios.put(`${apiBase}/api/sales/${editingSaleId}`, payload);
handleCloseModal();
fetchData();
toast.showToast('Sale updated successfully.', 'success');
} else {
await axios.post(`${apiBase}/api/sales`, payload, config);
await axios.post(`${apiBase}/api/sales`, payload);
handleCloseModal();
fetchData();
toast.showToast('Sale recorded successfully.', 'success');
@@ -285,7 +290,6 @@ export default function Sales() {
const handleCloneSale = async (sale: Sale) => {
setLoading(true);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
const payload = {
customerPosId: sale.customerPosId,
paymentMethod: sale.paymentMethod,
@@ -297,7 +301,7 @@ export default function Sales() {
quantity: sp.quantity,
})),
};
await axios.post(`${apiBase}/api/sales`, payload, config);
await axios.post(`${apiBase}/api/sales`, payload);
fetchData();
toast.showToast('Sale cloned successfully.', 'success');
} catch (err) {
@@ -327,8 +331,7 @@ export default function Sales() {
setLoading(true);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
await axios.delete(`${apiBase}/api/sales/${sale.id}`, config);
await axios.delete(`${apiBase}/api/sales/${sale.id}`);
setShowDetailsModal(false);
fetchData();
toast.showToast('Sale deleted successfully.', 'success');
@@ -356,9 +359,8 @@ export default function Sales() {
setTogglingSaleId(sale.id);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
const nextPaymentDate = sale.paymentDate ? null : new Date().toISOString();
const response = await axios.put(`${apiBase}/api/sales/${sale.id}`, { paymentDate: nextPaymentDate }, config);
const response = await axios.put(`${apiBase}/api/sales/${sale.id}`, { paymentDate: nextPaymentDate });
const updatedSale = response.data as Sale;
setSales((currentSales) => currentSales.map((currentSale) => (
@@ -395,8 +397,7 @@ export default function Sales() {
setTogglingDeliverySaleId(sale.id);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
const response = await axios.put(`${apiBase}/api/sales/${sale.id}`, { delivered: !sale.delivered }, config);
const response = await axios.put(`${apiBase}/api/sales/${sale.id}`, { delivered: !sale.delivered });
const updatedSale = response.data as Sale;
setSales((currentSales) => currentSales.map((currentSale) => (
@@ -620,6 +621,10 @@ export default function Sales() {
<label className="form-label text-secondary small">Comments / Notes</label>
<textarea className="form-control" rows={3} value={comments} onChange={e => setComments(e.target.value)} placeholder="Any specific requirements..."></textarea>
</div>
<div className="mb-3">
<label className="form-label text-secondary small">Next Visit Date (Optional)</label>
<input type="date" className="form-control" value={nextVisitDate} onChange={e => setNextVisitDate(e.target.value)} />
</div>
</div>
<div className="col-lg-8">
@@ -655,7 +660,26 @@ export default function Sales() {
<div className="d-flex gap-2 align-items-end">
<div style={{ width: '90px' }}>
<label className="form-label text-secondary small mb-1">Qty</label>
<input type="number" className="form-control form-control-sm" min="1" value={item.quantity} onChange={(e) => handleUpdateCartItem(idx, 'quantity', parseInt(e.target.value) || 1)} required />
<input
type="text"
inputMode="numeric"
pattern="[0-9]*"
className="form-control form-control-sm"
value={String(item.quantity)}
onChange={(e) => {
const raw = e.target.value;
if (raw === '') {
handleUpdateCartItem(idx, 'quantity', '' as unknown as number);
return;
}
const parsed = Number.parseInt(raw, 10);
if (!Number.isNaN(parsed) && parsed > 0) {
handleUpdateCartItem(idx, 'quantity', parsed);
}
}}
required
/>
</div>
<div className="text-end ms-auto">
<div className="text-secondary small">Unit: R$ {item.price.toFixed(2)}</div>
+5 -9
View File
@@ -12,18 +12,16 @@ export default function Users() {
const [editingUser, setEditingUser] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const [showDisabled, setShowDisabled] = useState<boolean>(false);
const token = localStorage.getItem('token');
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
const fetchUsers = useCallback(async () => {
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
const res = await axios.get(`${apiBase}/api/users?showDisabled=${showDisabled}`, config);
const res = await axios.get(`${apiBase}/api/users?showDisabled=${showDisabled}`);
setUsers(res.data);
} catch (err) {
console.error('Failed to load users', err);
}
}, [token, apiBase, showDisabled]);
}, [apiBase, showDisabled]);
useEffect(() => {
fetchUsers();
@@ -33,16 +31,15 @@ export default function Users() {
e.preventDefault();
setLoading(true);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
const payload: User = { ...newUser };
if (editingUser && !payload.password) {
delete payload.password;
}
if (editingUser) {
await axios.put(`${apiBase}/api/users/${editingUser}`, payload, config);
await axios.put(`${apiBase}/api/users/${editingUser}`, payload);
} else {
await axios.post(`${apiBase}/api/users`, payload, config);
await axios.post(`${apiBase}/api/users`, payload);
}
setShowModal(false);
setNewUser({ name: '', email: '', password: '', role: 'user' });
@@ -91,8 +88,7 @@ export default function Users() {
setLoading(true);
try {
const config = { headers: { Authorization: `Bearer ${token}` } };
await axios.delete(`${apiBase}/api/users/${editingUser}`, { ...config });
await axios.delete(`${apiBase}/api/users/${editingUser}`);
setShowModal(false);
setNewUser({ name: '', email: '', password: '', role: 'user' });
setEditingUser(null);
+139
View File
@@ -0,0 +1,139 @@
import { useCallback, useEffect, useState } from 'react';
import axios from 'axios';
import { useToast } from '../context/toast';
import type { Sale } from '../types';
const formatDate = (value: string | null) => {
if (value) {
const valueDate = new Date(value);
const formatted = valueDate.toLocaleDateString('pt-BR');
switch (valueDate.getDay()) {
case 0: return `${formatted} - Sunday`;
case 1: return `${formatted} - Monday`;
case 2: return `${formatted} - Tuesday`;
case 3: return `${formatted} - Wednesday`;
case 4: return `${formatted} - Thursday`;
case 5: return `${formatted} - Friday`;
case 6: return `${formatted} - Saturday`;
}
}
return 'N/A';
}
const formatDateTime = (value: string) =>
new Date(value).toLocaleString('pt-BR');
export default function Visits() {
const toast = useToast();
const [visits, setVisits] = useState<Sale[]>([]);
const [showVisited, setShowVisited] = useState(false);
const [togglingVisitId, setTogglingVisitId] = useState<string | null>(null);
const apiBase = import.meta.env.VITE_BACKEND_SERVER || '/api';
const fetchVisits = useCallback(async () => {
try {
const res = await axios.get(`${apiBase}/api/visits?showVisited=${showVisited}`);
setVisits(res.data);
} catch (err) {
console.error('Failed to load visits', err);
toast.showToast('Failed to load visits.', 'error');
}
}, [apiBase, showVisited, toast]);
useEffect(() => {
fetchVisits();
}, [fetchVisits]);
const handleMarkVisited = async (visit: Sale) => {
if (!visit.id || togglingVisitId === visit.id) return;
setTogglingVisitId(visit.id);
try {
const nextVisitedAt = visit.visitedAt ? null : new Date().toISOString();
await axios.put(`${apiBase}/api/sales/${visit.id}`, { visitedAt: nextVisitedAt });
toast.showToast(nextVisitedAt ? 'Visit marked as completed.' : 'Visit marked as pending.', 'success');
await fetchVisits();
} catch (err) {
if (axios.isAxiosError(err)) {
toast.showToast(err.response?.data?.error || 'Failed to update visit status.', 'error');
} else {
toast.showToast('An unexpected error occurred.', 'error');
}
} finally {
setTogglingVisitId(null);
}
};
return (
<div className="animate-fade-in">
<div className="d-flex justify-content-between align-items-center mb-4">
<h2 className="fw-bold m-0">Visits</h2>
<div className="form-check form-switch m-0">
<input
id="show-visited"
className="form-check-input"
type="checkbox"
checked={showVisited}
onChange={(e) => setShowVisited(e.target.checked)}
/>
<label className="form-check-label text-secondary" htmlFor="show-visited">
Visited
</label>
</div>
</div>
<div className="row g-3">
{visits.map((visit: Sale) => {
const isUpdating = togglingVisitId === visit.id;
const isVisited = Boolean(visit.visitedAt);
const customerName = visit.customerPos?.customer?.name || 'Unknown';
const posPersonName = visit.customerPos?.personName || visit.customerPos?.customer?.personName || 'N/A';
const posAddress = visit.customerPos?.address || 'Unknown';
return (
<div key={visit.id} className="col-12 col-md-6 col-lg-4">
<div className="glass-card p-3 h-100 d-flex flex-column">
<div className="d-flex justify-content-between align-items-start mb-2">
<div>
<h6 className="fw-bold text-white m-0">{customerName}</h6>
<div className="text-secondary small">{posPersonName}</div>
</div>
<button
type="button"
className={`badge border-0 ${isVisited ? 'bg-success' : 'bg-warning text-dark'}`}
style={{ cursor: isUpdating ? 'wait' : 'pointer' }}
onClick={() => handleMarkVisited(visit)}
disabled={isUpdating}
title={isVisited ? 'Click to mark as pending' : 'Click to mark as visited'}
>
{isUpdating ? 'Updating...' : isVisited ? 'Visited' : 'Pending'}
</button>
</div>
<div className="text-secondary small mb-1">
<i className="bi bi-geo-alt me-1"></i>{posAddress}
</div>
<div className="text-secondary small mb-1">
<i className="bi bi-calendar-plus me-1"></i>Sale: {formatDateTime(visit.createdAt)}
</div>
<div className="text-secondary small mb-1">
<i className="bi bi-calendar-event me-1"></i>Next Visit: {formatDate(visit.nextVisitDate)}
</div>
{isVisited && visit.visitedAt && (
<div className="text-success small mt-auto pt-2">
<i className="bi bi-check-circle me-1"></i>Visited: {formatDateTime(visit.visitedAt)}
</div>
)}
</div>
</div>
);
})}
{visits.length === 0 && (
<div className="col-12 text-center text-secondary mt-4">
<p>{showVisited ? 'No completed visits found.' : 'No pending visits.'}</p>
</div>
)}
</div>
</div>
);
}
+39
View File
@@ -17,6 +17,35 @@ export type SalesSummary = {
totalSales: number;
totalAmount: number;
averageAmount: number;
totalCustomers: number;
totalFridges: number;
}
export type IndustriesSummary = {
industry: string;
count: number;
}
export type RegionsSummary = {
region: string;
count: number;
}
export type FridgePosSummary = {
id: string;
address: string;
fridgeCount: number;
customer: {
name: string;
};
}
export type InactivePosSummary = {
posId: string;
customerName: string;
posAddress: string;
lastBuyingDate: string | null;
daysInactive: number;
}
/* Users page types */
@@ -35,7 +64,14 @@ export type CustomerPOS = {
customerId?: string;
address: string;
phone: string;
industry?: string | null;
personName?: string;
fridgeCount?: number;
banner: boolean;
indiBanner: boolean;
lat?: number | null;
lng?: number | null;
region?: string | null;
customer?: Customer;
}
@@ -45,6 +81,7 @@ export type Customer = {
document?: string;
phone: string;
personName?: string;
notes?: string;
pos?: CustomerPOS[];
disabledAt?: string | null;
};
@@ -89,6 +126,8 @@ export type Sale = {
paymentDueDate: string | null;
paymentDate: string | null;
comments: string;
nextVisitDate: string | null;
visitedAt: string | null;
createdAt: string;
customerPos?: CustomerPOS;
products?: SaleProduct[];
+2 -2
View File
@@ -8,7 +8,7 @@ http {
# Handle double /api/api/ prefixing if it happens
rewrite ^/+api/+api/+(.*)$ /api/$1 break;
proxy_pass http://polpa_gestao_backend:3000;
proxy_pass http://polpa_backend:3000;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -16,7 +16,7 @@ http {
}
location / {
proxy_pass http://polpa_gestao_frontend:80;
proxy_pass http://polpa_frontend:80;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
+5 -5
View File
@@ -1,13 +1,13 @@
#!/bin/bash
# Stop and remove existing proxy if it exists
docker stop ngrok-proxy 2>/dev/null
docker rm ngrok-proxy 2>/dev/null
docker stop nginx-proxy 2>/dev/null
#docker rm nginx-proxy 2>/dev/null
docker run -d \
--name ngrok-proxy \
docker run -d --rm \
--name nginx-proxy \
-p 127.0.0.1:8080:8080 \
-v "$(pwd)/nginx/nginx.conf:/etc/nginx/nginx.conf:ro" \
--restart unless-stopped \
--network polpa-network \
nginx:stable
+49 -6
View File
@@ -43,6 +43,16 @@ variable "cpf_cnpj_api_token" {
sensitive = true
}
variable "google_maps_api_key" {
type = string
sensitive = true
}
variable "jwt_secret" {
type = string
sensitive = true
}
variable "r2_access_key" {
type = string
sensitive = true
@@ -65,12 +75,12 @@ variable "r2_endpoint" {
variable "backend_image" {
type = string
default = "ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.03.25.11"
default = "ghcr.io/rmcampos/polpa-gestao/backend:api-v2026.06.11.34"
}
variable "frontend_image" {
type = string
default = "ghcr.io/rmcampos/polpa-gestao/frontend:app-v2026.03.25.11"
default = "ghcr.io/rmcampos/polpa-gestao/frontend:app-v2026.06.11.52"
}
resource "kubernetes_namespace_v1" "polpa_gestao" {
@@ -86,10 +96,12 @@ resource "kubernetes_secret_v1" "polpa_gestao_secrets" {
}
data = {
postgres_user = var.db_user
postgres_password = var.db_password
postgres_db = var.db_name
cpf_cnpj_api_token = var.cpf_cnpj_api_token
postgres_user = var.db_user
postgres_password = var.db_password
postgres_db = var.db_name
cpf_cnpj_api_token = var.cpf_cnpj_api_token
jwt_secret = var.jwt_secret
google_maps_api_key = var.google_maps_api_key
}
}
@@ -213,6 +225,37 @@ resource "kubernetes_deployment_v1" "polpa_gestao_backend" {
name = "HOSTNAME"
value = "0.0.0.0"
}
env {
name = "ALLOWED_ORIGINS"
value = "https://polpa-gestao.darkroasted.vps-kinghost.net"
}
env {
name = "JWT_SECRET"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
key = "jwt_secret"
}
}
}
env {
name = "CPF_CNPJ_API_TOKEN"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
key = "cpf_cnpj_api_token"
}
}
}
env {
name = "GOOGLE_MAPS_API_KEY"
value_from {
secret_key_ref {
name = kubernetes_secret_v1.polpa_gestao_secrets.metadata[0].name
key = "google_maps_api_key"
}
}
}
resources {
limits = { memory = "512Mi", cpu = "500m" }
requests = { memory = "256Mi", cpu = "100m" }