Compare commits
29 commits
main
...
high-perfo
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
349673b7f8 | ||
|
|
de1b6fe828 | ||
|
|
6dfb7a1a2e | ||
|
|
a7501eafc8 | ||
|
|
1c9b4848a9 | ||
|
|
189f4bca60 | ||
|
|
1956acdcf3 | ||
|
|
ec41f6dad2 | ||
|
|
f67f6c2514 | ||
|
|
051a980f14 | ||
|
|
0d35bf5649 | ||
|
|
123a157e04 | ||
|
|
d0b10eac8f | ||
|
|
418da25d37 | ||
|
|
b2c93f4e33 | ||
|
|
0bda2b2f10 | ||
|
|
8adc84699e | ||
|
|
758f0699ff | ||
|
|
d0b768d602 | ||
|
|
30346b02d1 | ||
|
|
30df37b127 | ||
|
|
e428fe268c | ||
|
|
d79aa50c77 | ||
|
|
fc772c2acb | ||
|
|
1b1d98ebee | ||
|
|
c7fe1b7ad3 | ||
|
|
319b384f0a | ||
|
|
2c6d102205 | ||
|
|
52e30a1b4b |
39 changed files with 1568 additions and 653 deletions
|
|
@ -11,7 +11,7 @@ Usage:
|
||||||
This script:
|
This script:
|
||||||
1. Updates the newTag for the specified service to the SHA
|
1. Updates the newTag for the specified service to the SHA
|
||||||
2. Commits and pushes to the gitops repo
|
2. Commits and pushes to the gitops repo
|
||||||
3. ArgoCD detects the change and deploys
|
3. Flux detects the change and deploys
|
||||||
"""
|
"""
|
||||||
|
|
||||||
import argparse
|
import argparse
|
||||||
|
|
@ -98,10 +98,14 @@ def main():
|
||||||
image_name = f"nxtgauge-{args.service}"
|
image_name = f"nxtgauge-{args.service}"
|
||||||
|
|
||||||
# Find the right kustomization file based on service
|
# Find the right kustomization file based on service
|
||||||
if "frontend" in args.service or "admin" in args.service:
|
if "frontend" in args.service:
|
||||||
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-frontend-solid/overlays/prod/kustomization.yaml")
|
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-frontend-solid/overlays/prod/kustomization.yaml")
|
||||||
if not os.path.exists(kustomization_path):
|
if not os.path.exists(kustomization_path):
|
||||||
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-frontend-solid/base/kustomization.yaml")
|
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-frontend-solid/base/kustomization.yaml")
|
||||||
|
elif "admin" in args.service:
|
||||||
|
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-admin-solid/overlays/prod/kustomization.yaml")
|
||||||
|
if not os.path.exists(kustomization_path):
|
||||||
|
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-admin-solid/base/kustomization.yaml")
|
||||||
elif "ai-assistant" in args.service:
|
elif "ai-assistant" in args.service:
|
||||||
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-ai-assistant/overlays/prod/kustomization.yaml")
|
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-ai-assistant/overlays/prod/kustomization.yaml")
|
||||||
if not os.path.exists(kustomization_path):
|
if not os.path.exists(kustomization_path):
|
||||||
164
.forgejo/workflows/build.yaml
Normal file
164
.forgejo/workflows/build.yaml
Normal file
|
|
@ -0,0 +1,164 @@
|
||||||
|
name: build-and-release
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- high-performance
|
||||||
|
|
||||||
|
concurrency:
|
||||||
|
group: backend-build-${{ github.ref }}
|
||||||
|
cancel-in-progress: true
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
build:
|
||||||
|
runs-on: self-hosted
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Detect changed services
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
ALL_SERVICES='gateway users companies jobs leads job-seekers customers payments employees photographers makeup-artists tutors developers video-editors graphic-designers social-media-managers fitness-trainers catering-services ugc-content-creators cron'
|
||||||
|
|
||||||
|
if git rev-parse --verify HEAD^ >/dev/null 2>&1; then
|
||||||
|
CHANGED_FILES="$(git diff --name-only HEAD^ HEAD)"
|
||||||
|
else
|
||||||
|
CHANGED_FILES="$(git ls-files)"
|
||||||
|
fi
|
||||||
|
|
||||||
|
LAST_COMMIT_MSG="$(git log -1 --pretty=%B | tr '\n' ' ')"
|
||||||
|
|
||||||
|
force_full_build=false
|
||||||
|
if echo "$LAST_COMMIT_MSG" | grep -Eiq 'trigger build|force build|rebuild all'; then
|
||||||
|
force_full_build=true
|
||||||
|
elif echo "$CHANGED_FILES" | grep -Eq '^(\.forgejo/workflows/|Dockerfile|Cargo\.toml|Cargo\.lock|crates/|scripts/)'; then
|
||||||
|
force_full_build=true
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ "$force_full_build" = true ]; then
|
||||||
|
printf '%s\n' $ALL_SERVICES > /tmp/changed-services.txt
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
: > /tmp/changed-services.txt
|
||||||
|
while IFS= read -r f; do
|
||||||
|
case "$f" in
|
||||||
|
apps/gateway/*) echo gateway >> /tmp/changed-services.txt ;;
|
||||||
|
apps/users/*) echo users >> /tmp/changed-services.txt ;;
|
||||||
|
apps/companies/*) echo companies >> /tmp/changed-services.txt ;;
|
||||||
|
apps/jobs/*) echo jobs >> /tmp/changed-services.txt ;;
|
||||||
|
apps/leads/*) echo leads >> /tmp/changed-services.txt ;;
|
||||||
|
apps/job_seekers/*) echo job-seekers >> /tmp/changed-services.txt ;;
|
||||||
|
apps/customers/*) echo customers >> /tmp/changed-services.txt ;;
|
||||||
|
apps/payments/*) echo payments >> /tmp/changed-services.txt ;;
|
||||||
|
apps/employees/*) echo employees >> /tmp/changed-services.txt ;;
|
||||||
|
apps/photographers/*) echo photographers >> /tmp/changed-services.txt ;;
|
||||||
|
apps/makeup_artists/*) echo makeup-artists >> /tmp/changed-services.txt ;;
|
||||||
|
apps/tutors/*) echo tutors >> /tmp/changed-services.txt ;;
|
||||||
|
apps/developers/*) echo developers >> /tmp/changed-services.txt ;;
|
||||||
|
apps/video_editors/*) echo video-editors >> /tmp/changed-services.txt ;;
|
||||||
|
apps/graphic_designers/*) echo graphic-designers >> /tmp/changed-services.txt ;;
|
||||||
|
apps/social_media_managers/*) echo social-media-managers >> /tmp/changed-services.txt ;;
|
||||||
|
apps/fitness_trainers/*) echo fitness-trainers >> /tmp/changed-services.txt ;;
|
||||||
|
apps/catering_services/*) echo catering-services >> /tmp/changed-services.txt ;;
|
||||||
|
apps/ugc_content_creators/*) echo ugc-content-creators >> /tmp/changed-services.txt ;;
|
||||||
|
apps/cron/*) echo cron >> /tmp/changed-services.txt ;;
|
||||||
|
esac
|
||||||
|
done <<EOF2
|
||||||
|
$CHANGED_FILES
|
||||||
|
EOF2
|
||||||
|
|
||||||
|
sort -u /tmp/changed-services.txt -o /tmp/changed-services.txt
|
||||||
|
|
||||||
|
- name: Stop if nothing changed
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
if [ ! -s /tmp/changed-services.txt ]; then
|
||||||
|
echo "No backend service changes detected."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
- name: Set up Docker Buildx
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[ -s /tmp/changed-services.txt ] || exit 0
|
||||||
|
docker version
|
||||||
|
docker buildx create --use --name nxtgauge-builder || docker buildx use nxtgauge-builder
|
||||||
|
docker buildx inspect --bootstrap
|
||||||
|
|
||||||
|
- name: Login to registry
|
||||||
|
env:
|
||||||
|
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME || 'admin' }}
|
||||||
|
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD || 'Ashwin@2026' }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[ -s /tmp/changed-services.txt ] || exit 0
|
||||||
|
printf '%s' "$REGISTRY_PASSWORD" | docker login registry.nxtgauge.com -u "$REGISTRY_USERNAME" --password-stdin
|
||||||
|
|
||||||
|
- name: Build changed services
|
||||||
|
env:
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[ -s /tmp/changed-services.txt ] || exit 0
|
||||||
|
: > /tmp/built-services.tsv
|
||||||
|
while IFS= read -r service; do
|
||||||
|
[ -n "$service" ] || continue
|
||||||
|
metadata_file="/tmp/${service}-metadata.json"
|
||||||
|
image_ref="registry.nxtgauge.com/nxtgauge-rust-${service}:${SHA}"
|
||||||
|
|
||||||
|
docker buildx build --push \
|
||||||
|
--metadata-file "$metadata_file" \
|
||||||
|
-f Dockerfile.simple \
|
||||||
|
--build-arg SERVICE_NAME="$service" \
|
||||||
|
-t "$image_ref" \
|
||||||
|
.
|
||||||
|
|
||||||
|
digest="$(grep -o '"containerimage.digest":"sha256:[^"]*"' "$metadata_file" | cut -d'"' -f4)"
|
||||||
|
if [ -z "$digest" ]; then
|
||||||
|
echo "Failed to determine digest for $service" >&2
|
||||||
|
exit 1
|
||||||
|
fi
|
||||||
|
|
||||||
|
printf '%s\t%s\n' "$service" "$digest" >> /tmp/built-services.tsv
|
||||||
|
done < /tmp/changed-services.txt
|
||||||
|
|
||||||
|
- name: Update GitOps release state
|
||||||
|
env:
|
||||||
|
GITOPS_OWNER: ${{ secrets.GITOPS_OWNER || 'Traceworks2023' }}
|
||||||
|
GITOPS_REPO: ${{ secrets.GITOPS_REPO || 'nxtgauge-gitops' }}
|
||||||
|
GITOPS_BRANCH: ${{ secrets.GITOPS_BRANCH || 'main' }}
|
||||||
|
GITOPS_PUSH_USERNAME: ${{ secrets.GITOPS_PUSH_USERNAME || 'Traceworks2023' }}
|
||||||
|
GITOPS_PUSH_TOKEN: ${{ secrets.GITOPS_PUSH_TOKEN || secrets.GITHUB_PAT }}
|
||||||
|
SHA: ${{ github.sha }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
[ -s /tmp/built-services.tsv ] || exit 0
|
||||||
|
test -n "${GITOPS_PUSH_TOKEN:-}" || { echo "GITOPS_PUSH_TOKEN is empty"; exit 1; }
|
||||||
|
|
||||||
|
git clone "https://${GITOPS_PUSH_USERNAME}:${GITOPS_PUSH_TOKEN}@github.com/${GITOPS_OWNER}/${GITOPS_REPO}.git" /tmp/nxtgauge-gitops
|
||||||
|
cd /tmp/nxtgauge-gitops
|
||||||
|
git checkout "$GITOPS_BRANCH"
|
||||||
|
|
||||||
|
while IFS=$'\t' read -r service digest; do
|
||||||
|
[ -n "$service" ] || continue
|
||||||
|
./scripts/set-backend-rust-release.sh "$service" "$digest"
|
||||||
|
done < /tmp/built-services.tsv
|
||||||
|
|
||||||
|
if git diff --quiet; then
|
||||||
|
echo "GitOps repo already up to date."
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
|
||||||
|
git config user.name "forgejo-actions[bot]"
|
||||||
|
git config user.email "forgejo-actions@ci.nxtgauge.com"
|
||||||
|
git add \
|
||||||
|
apps/nxtgauge-backend-rust/overlays/prod/backend-release-state.tsv \
|
||||||
|
apps/nxtgauge-backend-rust/overlays/prod/release-patches.yaml \
|
||||||
|
apps/nxtgauge-backend-rust/overlays/prod/disabled-deployments.yaml
|
||||||
|
git commit -m "chore(gitops): update backend rust images for ${SHA}"
|
||||||
|
git push origin "HEAD:${GITOPS_BRANCH}"
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
name: build-and-push
|
name: Build Backend And Update GitOps
|
||||||
|
|
||||||
on:
|
on:
|
||||||
push:
|
push:
|
||||||
|
|
@ -7,267 +7,110 @@ on:
|
||||||
- high-performance
|
- high-performance
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
detect-changes:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
outputs:
|
|
||||||
services_csv: ${{ steps.detect.outputs.services_csv }}
|
|
||||||
has_changes: ${{ steps.detect.outputs.has_changes }}
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Detect changed services
|
|
||||||
id: detect
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
set_output() {
|
|
||||||
local key="$1"
|
|
||||||
local value="$2"
|
|
||||||
if [ -n "${GITHUB_OUTPUT:-}" ]; then
|
|
||||||
echo "$key=$value" >> "$GITHUB_OUTPUT"
|
|
||||||
fi
|
|
||||||
echo "::set-output name=$key::$value"
|
|
||||||
}
|
|
||||||
|
|
||||||
if git rev-parse --verify HEAD^ >/dev/null 2>&1; then
|
|
||||||
CHANGED_FILES=$(git diff --name-only HEAD^ HEAD)
|
|
||||||
else
|
|
||||||
CHANGED_FILES=$(git ls-files)
|
|
||||||
fi
|
|
||||||
|
|
||||||
LAST_COMMIT_MSG=$(git log -1 --pretty=%B | tr '\n' ' ')
|
|
||||||
|
|
||||||
echo "Changed files:"
|
|
||||||
echo "$CHANGED_FILES"
|
|
||||||
|
|
||||||
ALL_SERVICES='gateway,users,companies,jobs,leads,job-seekers,customers,payments,employees,photographers,makeup-artists,tutors,developers,video-editors,graphic-designers,social-media-managers,fitness-trainers,catering-services,ugc-content-creators,cron'
|
|
||||||
|
|
||||||
# Force full build for explicit trigger commits.
|
|
||||||
if echo "$LAST_COMMIT_MSG" | grep -Eiq 'trigger gitea pipeline|force build|rebuild all'; then
|
|
||||||
set_output "services_csv" "$ALL_SERVICES"
|
|
||||||
set_output "has_changes" "true"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Build everything for workflow/docker/shared backend changes.
|
|
||||||
if echo "$CHANGED_FILES" | grep -Eq '^(\.gitea/workflows/|Dockerfile|Dockerfile\.|Cargo\.toml|Cargo\.lock|crates/|scripts/)'; then
|
|
||||||
set_output "services_csv" "$ALL_SERVICES"
|
|
||||||
set_output "has_changes" "true"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
SERVICES=''
|
|
||||||
add_service() {
|
|
||||||
local svc="$1"
|
|
||||||
case ",${SERVICES}," in
|
|
||||||
*",${svc},"*) ;;
|
|
||||||
*)
|
|
||||||
if [ -z "$SERVICES" ]; then
|
|
||||||
SERVICES="$svc"
|
|
||||||
else
|
|
||||||
SERVICES="$SERVICES,$svc"
|
|
||||||
fi
|
|
||||||
;;
|
|
||||||
esac
|
|
||||||
}
|
|
||||||
|
|
||||||
while IFS= read -r f; do
|
|
||||||
case "$f" in
|
|
||||||
apps/gateway/*) add_service "gateway" ;;
|
|
||||||
apps/users/*) add_service "users" ;;
|
|
||||||
apps/companies/*) add_service "companies" ;;
|
|
||||||
apps/jobs/*) add_service "jobs" ;;
|
|
||||||
apps/leads/*) add_service "leads" ;;
|
|
||||||
apps/job_seekers/*) add_service "job-seekers" ;;
|
|
||||||
apps/customers/*) add_service "customers" ;;
|
|
||||||
apps/payments/*) add_service "payments" ;;
|
|
||||||
apps/employees/*) add_service "employees" ;;
|
|
||||||
apps/photographers/*) add_service "photographers" ;;
|
|
||||||
apps/makeup_artists/*) add_service "makeup-artists" ;;
|
|
||||||
apps/tutors/*) add_service "tutors" ;;
|
|
||||||
apps/developers/*) add_service "developers" ;;
|
|
||||||
apps/video_editors/*) add_service "video-editors" ;;
|
|
||||||
apps/graphic_designers/*) add_service "graphic-designers" ;;
|
|
||||||
apps/social_media_managers/*) add_service "social-media-managers" ;;
|
|
||||||
apps/fitness_trainers/*) add_service "fitness-trainers" ;;
|
|
||||||
apps/catering_services/*) add_service "catering-services" ;;
|
|
||||||
apps/ugc_content_creators/*) add_service "ugc-content-creators" ;;
|
|
||||||
apps/cron/*) add_service "cron" ;;
|
|
||||||
esac
|
|
||||||
done <<< "$CHANGED_FILES"
|
|
||||||
|
|
||||||
if [ -z "$SERVICES" ]; then
|
|
||||||
set_output "services_csv" ""
|
|
||||||
set_output "has_changes" "false"
|
|
||||||
else
|
|
||||||
set_output "services_csv" "$SERVICES"
|
|
||||||
set_output "has_changes" "true"
|
|
||||||
fi
|
|
||||||
|
|
||||||
build:
|
build:
|
||||||
needs: detect-changes
|
|
||||||
if: needs.detect-changes.outputs.has_changes == 'true'
|
|
||||||
runs-on: ubuntu-latest
|
runs-on: ubuntu-latest
|
||||||
env:
|
env:
|
||||||
DOCKER_HOST: unix:///var/run/docker.sock
|
DOCKER_HOST: unix:///var/run/docker.sock
|
||||||
strategy:
|
strategy:
|
||||||
fail-fast: false
|
fail-fast: false
|
||||||
matrix:
|
matrix:
|
||||||
service:
|
include:
|
||||||
- gateway
|
- app_dir: apps/catering_services
|
||||||
- users
|
image: registry.nxtgauge.com/nxtgauge-rust-catering-services
|
||||||
- companies
|
- app_dir: apps/companies
|
||||||
- jobs
|
image: registry.nxtgauge.com/nxtgauge-rust-companies
|
||||||
- leads
|
- app_dir: apps/cron
|
||||||
- job-seekers
|
image: registry.nxtgauge.com/nxtgauge-rust-cron
|
||||||
- customers
|
- app_dir: apps/customers
|
||||||
- payments
|
image: registry.nxtgauge.com/nxtgauge-rust-customers
|
||||||
- employees
|
- app_dir: apps/developers
|
||||||
- photographers
|
image: registry.nxtgauge.com/nxtgauge-rust-developers
|
||||||
- makeup-artists
|
- app_dir: apps/employees
|
||||||
- tutors
|
image: registry.nxtgauge.com/nxtgauge-rust-employees
|
||||||
- developers
|
- app_dir: apps/fitness_trainers
|
||||||
- video-editors
|
image: registry.nxtgauge.com/nxtgauge-rust-fitness-trainers
|
||||||
- graphic-designers
|
- app_dir: apps/gateway
|
||||||
- social-media-managers
|
image: registry.nxtgauge.com/nxtgauge-rust-gateway
|
||||||
- fitness-trainers
|
- app_dir: apps/graphic_designers
|
||||||
- catering-services
|
image: registry.nxtgauge.com/nxtgauge-rust-graphic-designers
|
||||||
- ugc-content-creators
|
- app_dir: apps/jobs
|
||||||
- cron
|
image: registry.nxtgauge.com/nxtgauge-rust-jobs
|
||||||
|
- app_dir: apps/job_seekers
|
||||||
|
image: registry.nxtgauge.com/nxtgauge-rust-job-seekers
|
||||||
|
- app_dir: apps/leads
|
||||||
|
image: registry.nxtgauge.com/nxtgauge-rust-leads
|
||||||
|
- app_dir: apps/makeup_artists
|
||||||
|
image: registry.nxtgauge.com/nxtgauge-rust-makeup-artists
|
||||||
|
- app_dir: apps/payments
|
||||||
|
image: registry.nxtgauge.com/nxtgauge-rust-payments
|
||||||
|
- app_dir: apps/photographers
|
||||||
|
image: registry.nxtgauge.com/nxtgauge-rust-photographers
|
||||||
|
- app_dir: apps/social_media_managers
|
||||||
|
image: registry.nxtgauge.com/nxtgauge-rust-social-media-managers
|
||||||
|
- app_dir: apps/tutors
|
||||||
|
image: registry.nxtgauge.com/nxtgauge-rust-tutors
|
||||||
|
- app_dir: apps/ugc_content_creators
|
||||||
|
image: registry.nxtgauge.com/nxtgauge-rust-ugc-content-creators
|
||||||
|
- app_dir: apps/users
|
||||||
|
image: registry.nxtgauge.com/nxtgauge-rust-users
|
||||||
|
- app_dir: apps/video_editors
|
||||||
|
image: registry.nxtgauge.com/nxtgauge-rust-video-editors
|
||||||
steps:
|
steps:
|
||||||
- name: Checkout
|
- name: Checkout
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
|
|
||||||
- name: Set up Docker Buildx
|
- name: Install Docker CLI
|
||||||
run: |
|
run: |
|
||||||
export DOCKER_HOST=unix:///var/run/docker.sock
|
apt-get update
|
||||||
docker version
|
apt-get install -y docker.io
|
||||||
docker buildx create --use || true
|
|
||||||
docker buildx inspect --bootstrap
|
|
||||||
|
|
||||||
- name: Login to Registry
|
- name: Log in to registry
|
||||||
env:
|
run: |
|
||||||
REGISTRY_HOSTPORT: ${{ secrets.REGISTRY_HOSTPORT }}
|
echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.nxtgauge.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
- name: Build and push backend service image
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export DOCKER_HOST=unix:///var/run/docker.sock
|
IMAGE_SHA="${{ matrix.image }}:${{ github.sha }}"
|
||||||
test -n "$REGISTRY_HOSTPORT"
|
IMAGE_LATEST="${{ matrix.image }}:latest"
|
||||||
for attempt in 1 2 3 4 5; do
|
docker build -f "${{ matrix.app_dir }}/Dockerfile" -t "${IMAGE_SHA}" -t "${IMAGE_LATEST}" .
|
||||||
echo "Registry login attempt $attempt to $REGISTRY_HOSTPORT"
|
docker push "${IMAGE_SHA}"
|
||||||
if echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOSTPORT" -u "$REGISTRY_USERNAME" --password-stdin; then
|
docker push "${IMAGE_LATEST}"
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Registry login failed (attempt $attempt); retrying..."
|
|
||||||
sleep $((attempt * 8))
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Registry login failed after retries"
|
update-gitops:
|
||||||
exit 1
|
needs: build
|
||||||
|
runs-on: ubuntu-latest
|
||||||
- name: Build and push
|
steps:
|
||||||
|
- name: Update GitOps backend tags
|
||||||
env:
|
env:
|
||||||
REGISTRY_HOSTPORT: ${{ secrets.REGISTRY_HOSTPORT }}
|
GITOPS_USERNAME: ${{ secrets.GITOPS_GITHUB_USERNAME || 'Traceworks2023' }}
|
||||||
SERVICES_CSV: ${{ needs.detect-changes.outputs.services_csv }}
|
GITOPS_PASSWORD: ${{ secrets.GITOPS_GITHUB_TOKEN || secrets.GITOPS_PAT }}
|
||||||
|
GITOPS_REPO: https://github.com/Traceworks2023/nxtgauge-gitops.git
|
||||||
|
IMAGE_TAG: ${{ github.sha }}
|
||||||
run: |
|
run: |
|
||||||
set -euo pipefail
|
set -euo pipefail
|
||||||
export DOCKER_HOST=unix:///var/run/docker.sock
|
test -n "${GITOPS_PASSWORD:-}" || { echo "GITOPS_PASSWORD is empty"; exit 1; }
|
||||||
if [ -n "$SERVICES_CSV" ] && ! echo ",$SERVICES_CSV," | grep -q ",${{ matrix.service }},"; then
|
AUTH="$(printf '%s' "${GITOPS_USERNAME}:${GITOPS_PASSWORD}" | base64 -w0)"
|
||||||
echo "Skipping unchanged service: ${{ matrix.service }}"
|
TMP_DIR="$(mktemp -d)"
|
||||||
exit 0
|
git -c http.extraHeader="AUTHORIZATION: basic ${AUTH}" clone --branch main "${GITOPS_REPO}" "${TMP_DIR}"
|
||||||
fi
|
cd "${TMP_DIR}"
|
||||||
|
python3 - <<'PY'
|
||||||
build_with_cache() {
|
from pathlib import Path
|
||||||
docker buildx build --push \
|
import os
|
||||||
-f Dockerfile.simple \
|
path = Path('apps/nxtgauge-backend-rust/overlays/prod/kustomization.yaml')
|
||||||
--build-arg SERVICE_NAME=${{ matrix.service }} \
|
lines = path.read_text().splitlines()
|
||||||
--cache-from type=registry,ref=$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:buildcache \
|
out = []
|
||||||
--cache-to type=registry,ref=$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:buildcache,mode=max \
|
for line in lines:
|
||||||
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }}" \
|
if line.strip().startswith('newTag:'):
|
||||||
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:high-performance-latest" \
|
indent = line[:len(line) - len(line.lstrip())]
|
||||||
.
|
out.append(f"{indent}newTag: {os.environ['IMAGE_TAG']}")
|
||||||
}
|
else:
|
||||||
|
out.append(line)
|
||||||
build_without_cache_export() {
|
path.write_text('\n'.join(out) + '\n')
|
||||||
docker buildx build --push \
|
PY
|
||||||
-f Dockerfile.simple \
|
git config user.name "forgejo-actions"
|
||||||
--build-arg SERVICE_NAME=${{ matrix.service }} \
|
git config user.email "forgejo-actions@nxtgauge.com"
|
||||||
--cache-from type=registry,ref=$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:buildcache \
|
git add apps/nxtgauge-backend-rust/overlays/prod/kustomization.yaml
|
||||||
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }}" \
|
git diff --cached --quiet && exit 0
|
||||||
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:high-performance-latest" \
|
git commit -m "chore(gitops): update backend images to ${IMAGE_TAG}"
|
||||||
.
|
git -c http.extraHeader="AUTHORIZATION: basic ${AUTH}" push origin main
|
||||||
}
|
|
||||||
|
|
||||||
for attempt in 1 2 3; do
|
|
||||||
echo "Build attempt $attempt with cache export for ${{ matrix.service }}"
|
|
||||||
if build_with_cache; then
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
echo "Attempt $attempt failed; retrying after backoff"
|
|
||||||
sleep $((attempt * 10))
|
|
||||||
done
|
|
||||||
|
|
||||||
echo "Falling back to build without cache export for ${{ matrix.service }}"
|
|
||||||
if ! build_without_cache_export; then
|
|
||||||
echo "Final fallback: push tags without cache"
|
|
||||||
docker buildx build --push \
|
|
||||||
-f Dockerfile.simple \
|
|
||||||
--build-arg SERVICE_NAME=${{ matrix.service }} \
|
|
||||||
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }}" \
|
|
||||||
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:high-performance-latest" \
|
|
||||||
.
|
|
||||||
fi
|
|
||||||
|
|
||||||
- name: Prune old image tags (keep latest 1 SHA)
|
|
||||||
if: success()
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
REGISTRY_HOST: ${{ secrets.REGISTRY_HOSTPORT }}
|
|
||||||
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
|
|
||||||
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
python3 .gitea/scripts/registry_prune.py \
|
|
||||||
--registry "$REGISTRY_HOST" \
|
|
||||||
--repo "nxtgauge-rust-${{ matrix.service }}" \
|
|
||||||
--username "$REGISTRY_USERNAME" \
|
|
||||||
--password "$REGISTRY_PASSWORD" \
|
|
||||||
--keep 1
|
|
||||||
|
|
||||||
- name: Update GitOps and trigger deployment
|
|
||||||
if: always()
|
|
||||||
continue-on-error: true
|
|
||||||
env:
|
|
||||||
GITEOPS_REPO: ${{ secrets.GITEOPS_REPO }}
|
|
||||||
GITEOPS_SSH_KEY: ${{ secrets.GITEOPS_SSH_KEY }}
|
|
||||||
run: |
|
|
||||||
set -euo pipefail
|
|
||||||
|
|
||||||
if [ -z "$GITEOPS_REPO" ]; then
|
|
||||||
echo "GITEOPS_REPO secret not set, skipping GitOps update"
|
|
||||||
exit 0
|
|
||||||
fi
|
|
||||||
|
|
||||||
# Clone gitops repo
|
|
||||||
GITEOPS_DIR=$(mktemp -d)
|
|
||||||
git clone "$GITEOPS_REPO" "$GITEOPS_DIR"
|
|
||||||
cd "$GITEOPS_DIR"
|
|
||||||
|
|
||||||
# Set up SSH key for push
|
|
||||||
mkdir -p ~/.ssh
|
|
||||||
echo "$GITEOPS_SSH_KEY" > ~/.ssh/id_ed25519
|
|
||||||
chmod 600 ~/.ssh/id_ed25519
|
|
||||||
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
|
|
||||||
|
|
||||||
# Update gitops with new SHA
|
|
||||||
python3 .gitea/scripts/update-gitops.py \
|
|
||||||
--repo "$GITEOPS_DIR" \
|
|
||||||
--service "${{ matrix.service }}" \
|
|
||||||
--sha "${{ gitea.sha }}" \
|
|
||||||
--message "chore: deploy ${{ matrix.service }}@${{ gitea.sha }}"
|
|
||||||
|
|
||||||
rm -rf "$GITEOPS_DIR"
|
|
||||||
|
|
|
||||||
40
.github/workflows/sync-to-forgejo.yml
vendored
Normal file
40
.github/workflows/sync-to-forgejo.yml
vendored
Normal file
|
|
@ -0,0 +1,40 @@
|
||||||
|
name: sync-to-forgejo
|
||||||
|
|
||||||
|
on:
|
||||||
|
push:
|
||||||
|
branches:
|
||||||
|
- main
|
||||||
|
- high-performance
|
||||||
|
|
||||||
|
jobs:
|
||||||
|
sync:
|
||||||
|
runs-on: ubuntu-latest
|
||||||
|
permissions:
|
||||||
|
contents: read
|
||||||
|
steps:
|
||||||
|
- name: Checkout
|
||||||
|
uses: actions/checkout@v4
|
||||||
|
with:
|
||||||
|
fetch-depth: 0
|
||||||
|
|
||||||
|
- name: Push branch to Forgejo
|
||||||
|
env:
|
||||||
|
FORGEJO_SECRET: ${{ secrets.FORGEJO_SECRET || secrets.GITEA_SECRET }}
|
||||||
|
FORGEJO_OWNER: ${{ secrets.FORGEJO_OWNER || 'ashwin' }}
|
||||||
|
FORGEJO_USERNAME: ${{ secrets.FORGEJO_USERNAME || secrets.GITEA_USERNAME || 'ashwin' }}
|
||||||
|
REPO: ${{ github.event.repository.name }}
|
||||||
|
BRANCH: ${{ github.ref_name }}
|
||||||
|
run: |
|
||||||
|
set -euo pipefail
|
||||||
|
test -n "${FORGEJO_SECRET:-}" || { echo "FORGEJO_SECRET is empty"; exit 1; }
|
||||||
|
|
||||||
|
AUTH="$(printf '%s' "${FORGEJO_USERNAME}:${FORGEJO_SECRET}" | base64 -w0)"
|
||||||
|
TARGET="https://ci.nxtgauge.com/${FORGEJO_OWNER}/${REPO}.git"
|
||||||
|
|
||||||
|
git config user.name "github-actions[bot]"
|
||||||
|
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
|
||||||
|
git remote remove forgejo 2>/dev/null || true
|
||||||
|
git remote add forgejo "${TARGET}"
|
||||||
|
|
||||||
|
git -c http.extraHeader="AUTHORIZATION: basic ${AUTH}" push forgejo "HEAD:${BRANCH}" --force
|
||||||
|
git -c http.extraHeader="AUTHORIZATION: basic ${AUTH}" push forgejo --tags --force
|
||||||
46
.github/workflows/sync-to-gitea.yml
vendored
46
.github/workflows/sync-to-gitea.yml
vendored
|
|
@ -1,46 +0,0 @@
|
||||||
name: sync-to-gitea
|
|
||||||
|
|
||||||
on:
|
|
||||||
push:
|
|
||||||
branches:
|
|
||||||
- high-performance
|
|
||||||
|
|
||||||
jobs:
|
|
||||||
sync:
|
|
||||||
runs-on: ubuntu-latest
|
|
||||||
permissions:
|
|
||||||
contents: write
|
|
||||||
steps:
|
|
||||||
- name: Checkout
|
|
||||||
uses: actions/checkout@v4
|
|
||||||
with:
|
|
||||||
fetch-depth: 0
|
|
||||||
|
|
||||||
- name: Sync to Gitea
|
|
||||||
env:
|
|
||||||
GITEA_TOKEN: ${{ secrets.GITEA_SECRET }}
|
|
||||||
REPO: ${{ github.event.repository.name }}
|
|
||||||
BRANCH: ${{ github.ref_name }}
|
|
||||||
run: |
|
|
||||||
set -euxo pipefail
|
|
||||||
export GIT_TERMINAL_PROMPT=0
|
|
||||||
export GIT_TRACE=1
|
|
||||||
export GIT_CURL_VERBOSE=1
|
|
||||||
|
|
||||||
USER="Admin"
|
|
||||||
TARGET="https://ci.nxtgauge.com/Admin/${REPO}.git"
|
|
||||||
AUTH="$(printf '%s' "${USER}:${GITEA_TOKEN}" | base64 -w0)"
|
|
||||||
|
|
||||||
test -n "${GITEA_TOKEN:-}" || (echo "GITEA_TOKEN empty" && exit 1)
|
|
||||||
curl -fsS -H "Authorization: token ${GITEA_TOKEN}" https://ci.nxtgauge.com/api/v1/user >/dev/null
|
|
||||||
curl -fsS -H "Authorization: Basic ${AUTH}" "${TARGET}/info/refs?service=git-receive-pack" >/dev/null
|
|
||||||
|
|
||||||
git config user.name "github-actions[bot]"
|
|
||||||
git config user.email "github-actions[bot]@users.noreply.github.com"
|
|
||||||
git config --global http.version HTTP/1.1
|
|
||||||
git config --global http.postBuffer 524288000
|
|
||||||
git remote remove gitea 2>/dev/null || true
|
|
||||||
git remote add gitea "${TARGET}"
|
|
||||||
|
|
||||||
git -c http.extraheader="Authorization: Basic ${AUTH}" push gitea "HEAD:${BRANCH}" --force
|
|
||||||
git -c http.extraheader="Authorization: Basic ${AUTH}" push gitea --tags --force
|
|
||||||
196
Cargo.lock
generated
196
Cargo.lock
generated
|
|
@ -599,6 +599,12 @@ dependencies = [
|
||||||
"fastrand",
|
"fastrand",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "base16ct"
|
||||||
|
version = "0.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "base64"
|
name = "base64"
|
||||||
version = "0.22.1"
|
version = "0.22.1"
|
||||||
|
|
@ -958,6 +964,18 @@ version = "0.8.21"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "crypto-bigint"
|
||||||
|
version = "0.5.5"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
|
||||||
|
dependencies = [
|
||||||
|
"generic-array",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "crypto-common"
|
name = "crypto-common"
|
||||||
version = "0.1.7"
|
version = "0.1.7"
|
||||||
|
|
@ -986,6 +1004,33 @@ dependencies = [
|
||||||
"cmov",
|
"cmov",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek"
|
||||||
|
version = "4.1.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
|
||||||
|
dependencies = [
|
||||||
|
"cfg-if",
|
||||||
|
"cpufeatures 0.2.17",
|
||||||
|
"curve25519-dalek-derive",
|
||||||
|
"digest 0.10.7",
|
||||||
|
"fiat-crypto",
|
||||||
|
"rustc_version",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "curve25519-dalek-derive"
|
||||||
|
version = "0.1.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
|
||||||
|
dependencies = [
|
||||||
|
"proc-macro2",
|
||||||
|
"quote",
|
||||||
|
"syn",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "customers"
|
name = "customers"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -1116,6 +1161,44 @@ version = "1.0.5"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ecdsa"
|
||||||
|
version = "0.16.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
|
||||||
|
dependencies = [
|
||||||
|
"der",
|
||||||
|
"digest 0.10.7",
|
||||||
|
"elliptic-curve",
|
||||||
|
"rfc6979",
|
||||||
|
"signature",
|
||||||
|
"spki",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519"
|
||||||
|
version = "2.2.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
|
||||||
|
dependencies = [
|
||||||
|
"pkcs8",
|
||||||
|
"signature",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ed25519-dalek"
|
||||||
|
version = "2.2.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
|
||||||
|
dependencies = [
|
||||||
|
"curve25519-dalek",
|
||||||
|
"ed25519",
|
||||||
|
"serde",
|
||||||
|
"sha2 0.10.9",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "either"
|
name = "either"
|
||||||
version = "1.16.0"
|
version = "1.16.0"
|
||||||
|
|
@ -1125,6 +1208,27 @@ dependencies = [
|
||||||
"serde",
|
"serde",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "elliptic-curve"
|
||||||
|
version = "0.13.8"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
|
||||||
|
dependencies = [
|
||||||
|
"base16ct",
|
||||||
|
"crypto-bigint",
|
||||||
|
"digest 0.10.7",
|
||||||
|
"ff",
|
||||||
|
"generic-array",
|
||||||
|
"group",
|
||||||
|
"hkdf",
|
||||||
|
"pem-rfc7468",
|
||||||
|
"pkcs8",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"sec1",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "email"
|
name = "email"
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
|
|
@ -1228,6 +1332,22 @@ version = "2.4.1"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "ff"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
|
||||||
|
dependencies = [
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "fiat-crypto"
|
||||||
|
version = "0.2.9"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "find-msvc-tools"
|
name = "find-msvc-tools"
|
||||||
version = "0.1.9"
|
version = "0.1.9"
|
||||||
|
|
@ -1434,6 +1554,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"typenum",
|
"typenum",
|
||||||
"version_check",
|
"version_check",
|
||||||
|
"zeroize",
|
||||||
]
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
|
|
@ -1495,6 +1616,17 @@ dependencies = [
|
||||||
"uuid",
|
"uuid",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "group"
|
||||||
|
version = "0.13.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
|
||||||
|
dependencies = [
|
||||||
|
"ff",
|
||||||
|
"rand_core 0.6.4",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "h2"
|
name = "h2"
|
||||||
version = "0.3.27"
|
version = "0.3.27"
|
||||||
|
|
@ -2048,11 +2180,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
|
checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
|
||||||
dependencies = [
|
dependencies = [
|
||||||
"base64",
|
"base64",
|
||||||
|
"ed25519-dalek",
|
||||||
"getrandom 0.2.17",
|
"getrandom 0.2.17",
|
||||||
|
"hmac 0.12.1",
|
||||||
"js-sys",
|
"js-sys",
|
||||||
|
"p256",
|
||||||
|
"p384",
|
||||||
"pem",
|
"pem",
|
||||||
|
"rand 0.8.6",
|
||||||
|
"rsa",
|
||||||
"serde",
|
"serde",
|
||||||
"serde_json",
|
"serde_json",
|
||||||
|
"sha2 0.10.9",
|
||||||
"signature",
|
"signature",
|
||||||
"simple_asn1",
|
"simple_asn1",
|
||||||
"zeroize",
|
"zeroize",
|
||||||
|
|
@ -2451,6 +2590,30 @@ version = "0.5.2"
|
||||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
|
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "p256"
|
||||||
|
version = "0.13.2"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
|
||||||
|
dependencies = [
|
||||||
|
"ecdsa",
|
||||||
|
"elliptic-curve",
|
||||||
|
"primeorder",
|
||||||
|
"sha2 0.10.9",
|
||||||
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "p384"
|
||||||
|
version = "0.13.1"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
|
||||||
|
dependencies = [
|
||||||
|
"ecdsa",
|
||||||
|
"elliptic-curve",
|
||||||
|
"primeorder",
|
||||||
|
"sha2 0.10.9",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "parking"
|
name = "parking"
|
||||||
version = "2.2.1"
|
version = "2.2.1"
|
||||||
|
|
@ -2632,6 +2795,15 @@ dependencies = [
|
||||||
"syn",
|
"syn",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "primeorder"
|
||||||
|
version = "0.13.6"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
|
||||||
|
dependencies = [
|
||||||
|
"elliptic-curve",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "proc-macro2"
|
name = "proc-macro2"
|
||||||
version = "1.0.106"
|
version = "1.0.106"
|
||||||
|
|
@ -2909,6 +3081,16 @@ dependencies = [
|
||||||
"webpki-roots 1.0.7",
|
"webpki-roots 1.0.7",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "rfc6979"
|
||||||
|
version = "0.4.0"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
|
||||||
|
dependencies = [
|
||||||
|
"hmac 0.12.1",
|
||||||
|
"subtle",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "ring"
|
name = "ring"
|
||||||
version = "0.17.14"
|
version = "0.17.14"
|
||||||
|
|
@ -3080,6 +3262,20 @@ dependencies = [
|
||||||
"untrusted",
|
"untrusted",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
[[package]]
|
||||||
|
name = "sec1"
|
||||||
|
version = "0.7.3"
|
||||||
|
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||||
|
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
|
||||||
|
dependencies = [
|
||||||
|
"base16ct",
|
||||||
|
"der",
|
||||||
|
"generic-array",
|
||||||
|
"pkcs8",
|
||||||
|
"subtle",
|
||||||
|
"zeroize",
|
||||||
|
]
|
||||||
|
|
||||||
[[package]]
|
[[package]]
|
||||||
name = "security-framework"
|
name = "security-framework"
|
||||||
version = "3.7.0"
|
version = "3.7.0"
|
||||||
|
|
|
||||||
|
|
@ -23,4 +23,5 @@ Required secrets:
|
||||||
- `REGISTRY_USERNAME`
|
- `REGISTRY_USERNAME`
|
||||||
- `REGISTRY_PASSWORD`
|
- `REGISTRY_PASSWORD`
|
||||||
|
|
||||||
See `.gitea/workflows/README.md` for details.
|
See `.forgejo/workflows/README.md` for details.
|
||||||
|
# Trigger build Fri Jun 12 03:45:06 AM IST 2026
|
||||||
|
|
|
||||||
|
|
@ -186,12 +186,12 @@ async fn approve_company(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
sqlx::query("UPDATE company_profiles SET status = 'ACTIVE', updated_at = NOW() WHERE id = $1")
|
sqlx::query("UPDATE company_profiles SET status = 'APPROVED', updated_at = NOW() WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
Ok(Json(serde_json::json!({ "status": "ACTIVE" })))
|
Ok(Json(serde_json::json!({ "status": "APPROVED" })))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn reject_company(
|
async fn reject_company(
|
||||||
|
|
|
||||||
|
|
@ -222,8 +222,7 @@ async fn submit_requirement(
|
||||||
|
|
||||||
async fn list_requests(
|
async fn list_requests(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
auth: AuthUser,
|
||||||
_auth: AuthUser,
|
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let page = q.page.unwrap_or(1);
|
let page = q.page.unwrap_or(1);
|
||||||
|
|
@ -233,12 +232,12 @@ async fn list_requests(
|
||||||
let rows_result = sqlx::query_as::<_, db::models::lead_request::LeadRequest>(
|
let rows_result = sqlx::query_as::<_, db::models::lead_request::LeadRequest>(
|
||||||
r#"
|
r#"
|
||||||
SELECT * FROM lead_requests
|
SELECT * FROM lead_requests
|
||||||
WHERE user_role_profile_id = $1
|
WHERE professional_user_id = $1
|
||||||
ORDER BY requested_at DESC
|
ORDER BY requested_at DESC
|
||||||
LIMIT $2 OFFSET $3
|
LIMIT $2 OFFSET $3
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(auth.user_id)
|
||||||
.bind(limit)
|
.bind(limit)
|
||||||
.bind(offset)
|
.bind(offset)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
|
|
@ -271,7 +270,7 @@ async fn approve_request(
|
||||||
Ok(updated) => {
|
Ok(updated) => {
|
||||||
match TracecoinWalletRepository::try_debit_reserved_tracecoins(
|
match TracecoinWalletRepository::try_debit_reserved_tracecoins(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
lead.user_role_profile_id,
|
lead.user_role_profile_id.unwrap(),
|
||||||
lead.tracecoins_reserved,
|
lead.tracecoins_reserved,
|
||||||
lead.id,
|
lead.id,
|
||||||
).await {
|
).await {
|
||||||
|
|
@ -307,7 +306,7 @@ async fn reject_request(
|
||||||
Ok(updated) => {
|
Ok(updated) => {
|
||||||
match TracecoinWalletRepository::try_release_reserved_tracecoins(
|
match TracecoinWalletRepository::try_release_reserved_tracecoins(
|
||||||
&state.pool,
|
&state.pool,
|
||||||
lead.user_role_profile_id,
|
lead.user_role_profile_id.unwrap(),
|
||||||
lead.tracecoins_reserved,
|
lead.tracecoins_reserved,
|
||||||
lead.id,
|
lead.id,
|
||||||
"LEAD_REJECTED",
|
"LEAD_REJECTED",
|
||||||
|
|
|
||||||
|
|
@ -83,7 +83,7 @@ impl Services {
|
||||||
|
|
||||||
|
|
||||||
fn resolve_upstream(&self, path: &str) -> Option<String> {
|
fn resolve_upstream(&self, path: &str) -> Option<String> {
|
||||||
// Auth, users, roles, notifications, runtime-config, config, KB, support
|
// Auth, users, roles, notifications, runtime-config, config, KB, support, reviews
|
||||||
if path.starts_with("/api/auth")
|
if path.starts_with("/api/auth")
|
||||||
|| path.starts_with("/api/users")
|
|| path.starts_with("/api/users")
|
||||||
|| path.starts_with("/api/v1/users")
|
|| path.starts_with("/api/v1/users")
|
||||||
|
|
@ -96,6 +96,7 @@ impl Services {
|
||||||
|| path.starts_with("/api/kb")
|
|| path.starts_with("/api/kb")
|
||||||
|| path.starts_with("/api/packages")
|
|| path.starts_with("/api/packages")
|
||||||
|| path.starts_with("/api/support")
|
|| path.starts_with("/api/support")
|
||||||
|
|| path.starts_with("/api/reviews")
|
||||||
|| path.starts_with("/api/admin/roles")
|
|| path.starts_with("/api/admin/roles")
|
||||||
|| path.starts_with("/api/admin/users")
|
|| path.starts_with("/api/admin/users")
|
||||||
|| path.starts_with("/api/admin/verifications")
|
|| path.starts_with("/api/admin/verifications")
|
||||||
|
|
|
||||||
28
apps/jobs/Dockerfile
Normal file
28
apps/jobs/Dockerfile
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
FROM rust:alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static && \
|
||||||
|
rustup target add x86_64-unknown-linux-musl
|
||||||
|
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY crates ./crates
|
||||||
|
COPY apps ./apps
|
||||||
|
|
||||||
|
ENV RUSTFLAGS='-C target-feature=+crt-static'
|
||||||
|
ENV OPENSSL_STATIC=1
|
||||||
|
ENV OPENSSL_DIR=/usr
|
||||||
|
RUN cargo build --release --bin jobs --target x86_64-unknown-linux-musl
|
||||||
|
|
||||||
|
FROM alpine:latest AS runtime
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
|
||||||
|
RUN adduser -D -u 1000 appuser
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /usr/src/app/target/x86_64-unknown-linux-musl/release/jobs ./jobs
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
CMD ["./jobs"]
|
||||||
28
apps/leads/Dockerfile
Normal file
28
apps/leads/Dockerfile
Normal file
|
|
@ -0,0 +1,28 @@
|
||||||
|
FROM rust:alpine AS builder
|
||||||
|
|
||||||
|
WORKDIR /usr/src/app
|
||||||
|
|
||||||
|
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static && \
|
||||||
|
rustup target add x86_64-unknown-linux-musl
|
||||||
|
|
||||||
|
COPY Cargo.toml Cargo.lock ./
|
||||||
|
COPY crates ./crates
|
||||||
|
COPY apps ./apps
|
||||||
|
|
||||||
|
ENV RUSTFLAGS='-C target-feature=+crt-static'
|
||||||
|
ENV OPENSSL_STATIC=1
|
||||||
|
ENV OPENSSL_DIR=/usr
|
||||||
|
RUN cargo build --release --bin leads --target x86_64-unknown-linux-musl
|
||||||
|
|
||||||
|
FROM alpine:latest AS runtime
|
||||||
|
|
||||||
|
RUN apk add --no-cache ca-certificates
|
||||||
|
|
||||||
|
RUN adduser -D -u 1000 appuser
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=builder /usr/src/app/target/x86_64-unknown-linux-musl/release/leads ./leads
|
||||||
|
|
||||||
|
USER appuser
|
||||||
|
|
||||||
|
CMD ["./leads"]
|
||||||
|
|
@ -380,6 +380,7 @@ async fn send_lead_request_ai(
|
||||||
};
|
};
|
||||||
|
|
||||||
let expires_at = chrono::Utc::now() + chrono::Duration::hours(24);
|
let expires_at = chrono::Utc::now() + chrono::Duration::hours(24);
|
||||||
|
let customer_id = lead.2.clone();
|
||||||
|
|
||||||
let result = sqlx::query_as::<_, LeadRequestRow>(
|
let result = sqlx::query_as::<_, LeadRequestRow>(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -390,7 +391,7 @@ async fn send_lead_request_ai(
|
||||||
)
|
)
|
||||||
.bind(payload.lead_id)
|
.bind(payload.lead_id)
|
||||||
.bind(user_role_profile_id)
|
.bind(user_role_profile_id)
|
||||||
.bind(user_id)
|
.bind(&customer_id) // customer_user_id from the lead
|
||||||
.bind(tracecoins_cost)
|
.bind(tracecoins_cost)
|
||||||
.bind(&ai_message)
|
.bind(&ai_message)
|
||||||
.bind(expires_at)
|
.bind(expires_at)
|
||||||
|
|
@ -419,7 +420,7 @@ async fn send_lead_request_ai(
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
"#
|
"#
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(&customer_id) // notify the customer
|
||||||
.bind("AI Auto-Respond Sent")
|
.bind("AI Auto-Respond Sent")
|
||||||
.bind("Your AI-assisted response has been sent to the customer.")
|
.bind("Your AI-assisted response has been sent to the customer.")
|
||||||
.bind("LEAD_REQUEST")
|
.bind("LEAD_REQUEST")
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::State,
|
extract::State,
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
routing::{get, post},
|
routing::{get, post, patch},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use reqwest::Client;
|
use reqwest::Client;
|
||||||
|
|
@ -41,6 +41,14 @@ pub struct CreateLead {
|
||||||
pub profession_key: String,
|
pub profession_key: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Deserialize)]
|
||||||
|
pub struct UpdateLead {
|
||||||
|
pub title: Option<String>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub location: Option<String>,
|
||||||
|
pub status: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
async fn list_leads(State(state): State<Arc<AppState>>) -> Result<Json<Vec<Lead>>, StatusCode> {
|
async fn list_leads(State(state): State<Arc<AppState>>) -> Result<Json<Vec<Lead>>, StatusCode> {
|
||||||
let leads = sqlx::query_as::<_, Lead>(
|
let leads = sqlx::query_as::<_, Lead>(
|
||||||
"SELECT id, title, description, location, profession_key, status, created_at FROM leads ORDER BY created_at DESC"
|
"SELECT id, title, description, location, profession_key, status, created_at FROM leads ORDER BY created_at DESC"
|
||||||
|
|
@ -94,6 +102,38 @@ async fn health() -> &'static str {
|
||||||
"Leads Service OK"
|
"Leads Service OK"
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn update_lead(
|
||||||
|
State(state): State<Arc<AppState>>,
|
||||||
|
axum::extract::Path(id): axum::extract::Path<uuid::Uuid>,
|
||||||
|
Json(payload): Json<UpdateLead>,
|
||||||
|
) -> Result<Json<Lead>, StatusCode> {
|
||||||
|
let status = payload.status.as_deref().unwrap_or("OPEN");
|
||||||
|
|
||||||
|
let lead = sqlx::query_as::<_, Lead>(
|
||||||
|
r#"
|
||||||
|
UPDATE leads
|
||||||
|
SET title = COALESCE($1, title),
|
||||||
|
description = COALESCE($2, description),
|
||||||
|
location = COALESCE($3, location),
|
||||||
|
status = $4,
|
||||||
|
updated_at = NOW()
|
||||||
|
WHERE id = $5
|
||||||
|
RETURNING id, title, description, location, profession_key, status, created_at
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&payload.title)
|
||||||
|
.bind(&payload.description)
|
||||||
|
.bind(&payload.location)
|
||||||
|
.bind(status)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
|
||||||
|
.ok_or(StatusCode::NOT_FOUND)?;
|
||||||
|
|
||||||
|
Ok(Json(lead))
|
||||||
|
}
|
||||||
|
|
||||||
#[tokio::main]
|
#[tokio::main]
|
||||||
async fn main() {
|
async fn main() {
|
||||||
tracing_subscriber::registry()
|
tracing_subscriber::registry()
|
||||||
|
|
@ -130,10 +170,13 @@ async fn main() {
|
||||||
|
|
||||||
let app = Router::new()
|
let app = Router::new()
|
||||||
.route("/health", get(health))
|
.route("/health", get(health))
|
||||||
|
.nest("/api", Router::new()
|
||||||
.route("/leads", get(list_leads))
|
.route("/leads", get(list_leads))
|
||||||
.route("/leads", post(create_lead))
|
.route("/leads", post(create_lead))
|
||||||
.route("/leads/{id}", get(get_lead))
|
.route("/leads/{id}", get(get_lead))
|
||||||
.nest("/api/lead-requests", lead_requests::router())
|
.route("/leads/{id}", patch(update_lead))
|
||||||
|
.nest("/lead-requests", lead_requests::router())
|
||||||
|
)
|
||||||
.layer(cors)
|
.layer(cors)
|
||||||
.with_state(state);
|
.with_state(state);
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,7 @@ async fn create_order(
|
||||||
|
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO payments (user_id, package_id, razorpay_order_id, amount, tracecoins_credited, status)
|
INSERT INTO payments (user_id, package_id, razorpay_order_id, amount_inr, tracecoins_credited, status)
|
||||||
VALUES ($1, $2, $3, $4, $5, 'PENDING')
|
VALUES ($1, $2, $3, $4, $5, 'PENDING')
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
@ -248,8 +248,8 @@ async fn verify_payment(
|
||||||
{
|
{
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, balance_after, reference_type, reference_id, description)
|
INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id)
|
||||||
VALUES ($1, 'CREDIT', $2, $2, 'PAYMENT', $3, 'Package purchase')
|
VALUES ($1, 'CREDIT', $2, 'PAYMENT', $3)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(wallet_id)
|
.bind(wallet_id)
|
||||||
|
|
@ -262,7 +262,7 @@ async fn verify_payment(
|
||||||
|
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO notifications (user_id, title, body, notification_type, reference_id)
|
INSERT INTO notifications (user_id, title, body, type, reference_id)
|
||||||
VALUES ($1, $2, $3, $4, $5)
|
VALUES ($1, $2, $3, $4, $5)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ use uuid::Uuid;
|
||||||
pub struct PackageTypeQuery {
|
pub struct PackageTypeQuery {
|
||||||
pub package_type: Option<String>,
|
pub package_type: Option<String>,
|
||||||
pub applicable_role: Option<String>,
|
pub applicable_role: Option<String>,
|
||||||
|
pub role: Option<String>,
|
||||||
pub active_only: Option<bool>,
|
pub active_only: Option<bool>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -29,48 +30,37 @@ pub struct CreatePackageRequest {
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub package_type: String,
|
pub package_type: String,
|
||||||
pub applicable_roles: Vec<String>,
|
pub role_key: Option<String>,
|
||||||
|
pub applicable_roles: Option<Vec<String>>,
|
||||||
pub tracecoins_amount: i32,
|
pub tracecoins_amount: i32,
|
||||||
pub price: i32,
|
pub price: Option<i32>,
|
||||||
pub duration_days: Option<i32>,
|
pub price_inr: Option<i32>,
|
||||||
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
|
|
||||||
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
|
|
||||||
pub is_promotional: Option<bool>,
|
|
||||||
pub is_active: Option<bool>,
|
pub is_active: Option<bool>,
|
||||||
pub features: Option<serde_json::Value>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Deserialize)]
|
#[derive(Debug, Deserialize)]
|
||||||
pub struct UpdatePackageRequest {
|
pub struct UpdatePackageRequest {
|
||||||
pub name: Option<String>,
|
pub name: Option<String>,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
|
pub role_key: Option<String>,
|
||||||
|
pub applicable_roles: Option<Vec<String>>,
|
||||||
pub tracecoins_amount: Option<i32>,
|
pub tracecoins_amount: Option<i32>,
|
||||||
pub price: Option<i32>,
|
pub price: Option<i32>,
|
||||||
pub duration_days: Option<i32>,
|
pub price_inr: Option<i32>,
|
||||||
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
|
|
||||||
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
|
|
||||||
pub is_promotional: Option<bool>,
|
|
||||||
pub is_active: Option<bool>,
|
pub is_active: Option<bool>,
|
||||||
pub features: Option<serde_json::Value>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, FromRow)]
|
#[derive(Debug, FromRow)]
|
||||||
pub struct PricingPackageRow {
|
pub struct PricingPackageRow {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub role_key: String,
|
||||||
pub package_type: String,
|
pub package_type: String,
|
||||||
pub applicable_roles: Vec<String>,
|
|
||||||
pub tracecoins_amount: i32,
|
pub tracecoins_amount: i32,
|
||||||
pub price: i32,
|
pub price_inr: i32,
|
||||||
pub duration_days: Option<i32>,
|
pub description: Option<String>,
|
||||||
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
|
|
||||||
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
|
|
||||||
pub is_promotional: bool,
|
|
||||||
pub is_active: bool,
|
pub is_active: bool,
|
||||||
pub features: Option<serde_json::Value>,
|
|
||||||
pub created_at: chrono::DateTime<chrono::Utc>,
|
pub created_at: chrono::DateTime<chrono::Utc>,
|
||||||
pub updated_at: chrono::DateTime<chrono::Utc>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize)]
|
#[derive(Debug, Serialize)]
|
||||||
|
|
@ -78,10 +68,12 @@ pub struct PricingPackageResponse {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub name: String,
|
pub name: String,
|
||||||
pub description: Option<String>,
|
pub description: Option<String>,
|
||||||
pub package_type: String,
|
pub role_key: String,
|
||||||
pub applicable_roles: Vec<String>,
|
pub applicable_roles: Vec<String>,
|
||||||
|
pub package_type: String,
|
||||||
pub tracecoins_amount: i32,
|
pub tracecoins_amount: i32,
|
||||||
pub price: i32,
|
pub price: i32,
|
||||||
|
pub price_inr: i32,
|
||||||
pub duration_days: Option<i32>,
|
pub duration_days: Option<i32>,
|
||||||
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
|
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
|
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
|
||||||
|
|
@ -96,33 +88,58 @@ pub struct PricingPackageResponse {
|
||||||
|
|
||||||
impl From<PricingPackageRow> for PricingPackageResponse {
|
impl From<PricingPackageRow> for PricingPackageResponse {
|
||||||
fn from(row: PricingPackageRow) -> Self {
|
fn from(row: PricingPackageRow) -> Self {
|
||||||
let now = chrono::Utc::now();
|
Self {
|
||||||
let is_expired = row.valid_until.map(|v| v < now).unwrap_or(false);
|
|
||||||
let is_not_started = row.valid_from.map(|v| v > now).unwrap_or(false);
|
|
||||||
let is_available = row.is_active && !is_expired && !is_not_started;
|
|
||||||
|
|
||||||
PricingPackageResponse {
|
|
||||||
id: row.id,
|
id: row.id,
|
||||||
name: row.name,
|
name: row.name,
|
||||||
description: row.description,
|
description: row.description,
|
||||||
|
role_key: row.role_key.clone(),
|
||||||
|
applicable_roles: vec![row.role_key],
|
||||||
package_type: row.package_type,
|
package_type: row.package_type,
|
||||||
applicable_roles: row.applicable_roles,
|
|
||||||
tracecoins_amount: row.tracecoins_amount,
|
tracecoins_amount: row.tracecoins_amount,
|
||||||
price: row.price,
|
price: row.price_inr,
|
||||||
duration_days: row.duration_days,
|
price_inr: row.price_inr,
|
||||||
valid_from: row.valid_from,
|
duration_days: None,
|
||||||
valid_until: row.valid_until,
|
valid_from: None,
|
||||||
is_promotional: row.is_promotional,
|
valid_until: None,
|
||||||
|
is_promotional: false,
|
||||||
is_active: row.is_active,
|
is_active: row.is_active,
|
||||||
features: row.features,
|
features: None,
|
||||||
created_at: row.created_at,
|
created_at: row.created_at,
|
||||||
updated_at: row.updated_at,
|
updated_at: row.created_at,
|
||||||
is_available,
|
is_available: row.is_active,
|
||||||
is_expired,
|
is_expired: false,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn normalize_role_key(role_key: Option<String>, applicable_roles: Option<Vec<String>>) -> Result<String, String> {
|
||||||
|
if let Some(role) = role_key {
|
||||||
|
let cleaned = role.trim().to_uppercase();
|
||||||
|
if !cleaned.is_empty() {
|
||||||
|
return Ok(cleaned);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(roles) = applicable_roles {
|
||||||
|
if let Some(role) = roles.into_iter().map(|role| role.trim().to_uppercase()).find(|role| !role.is_empty()) {
|
||||||
|
return Ok(role);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Err("role_key is required".to_string())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn package_query(base_where: &str, order_by: &str) -> String {
|
||||||
|
format!(
|
||||||
|
r#"
|
||||||
|
SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active, created_at
|
||||||
|
FROM pricing_packages
|
||||||
|
{base_where}
|
||||||
|
{order_by}
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(list_packages))
|
.route("/", get(list_packages))
|
||||||
|
|
@ -138,83 +155,67 @@ async fn list_packages(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(q): Query<PaginationQuery>,
|
Query(q): Query<PaginationQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let page = q.page.unwrap_or(1);
|
let page = q.page.unwrap_or(1).max(1);
|
||||||
let limit = q.limit.unwrap_or(20).min(100);
|
let limit = q.limit.unwrap_or(20).clamp(1, 100);
|
||||||
let offset = (page - 1) * limit;
|
let offset = (page - 1) * limit;
|
||||||
|
let search = q.search.unwrap_or_default().trim().to_string();
|
||||||
|
|
||||||
let search_filter = q.search
|
let rows = sqlx::query_as::<_, PricingPackageRow>(
|
||||||
.as_ref()
|
|
||||||
.map(|s| format!("AND (name ILIKE '%{}%' OR description ILIKE '%{}%')", s.replace('\'', "''"), s.replace('\'', "''")))
|
|
||||||
.unwrap_or_default();
|
|
||||||
|
|
||||||
let packages = sqlx::query_as::<_, PricingPackageRow>(
|
|
||||||
&format!(
|
&format!(
|
||||||
r#"
|
"{} LIMIT $2 OFFSET $3",
|
||||||
SELECT id, name, description, package_type, applicable_roles,
|
package_query(
|
||||||
tracecoins_amount, price, duration_days, valid_from, valid_until,
|
"WHERE ($1 = '' OR name ILIKE '%' || $1 || '%' OR COALESCE(description, '') ILIKE '%' || $1 || '%')",
|
||||||
is_promotional, is_active, features, created_at, updated_at
|
"ORDER BY created_at DESC"
|
||||||
FROM pricing_packages
|
|
||||||
WHERE 1=1 {}
|
|
||||||
ORDER BY created_at DESC
|
|
||||||
LIMIT {} OFFSET {}
|
|
||||||
"#,
|
|
||||||
search_filter, limit, offset
|
|
||||||
)
|
)
|
||||||
|
),
|
||||||
)
|
)
|
||||||
|
.bind(&search)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let packages = match packages {
|
let rows = match rows {
|
||||||
Ok(p) => p,
|
Ok(rows) => rows,
|
||||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let total: (i64,) = match sqlx::query_as(
|
let total: i64 = match sqlx::query_scalar(
|
||||||
&format!(
|
"SELECT COUNT(*) FROM pricing_packages WHERE ($1 = '' OR name ILIKE '%' || $1 || '%' OR COALESCE(description, '') ILIKE '%' || $1 || '%')",
|
||||||
"SELECT COUNT(*) FROM pricing_packages WHERE 1=1 {}",
|
|
||||||
search_filter
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
|
.bind(&search)
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(t) => t,
|
Ok(total) => total,
|
||||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let packages: Vec<PricingPackageResponse> = packages.into_iter().map(|p| p.into()).collect();
|
let packages: Vec<PricingPackageResponse> = rows.into_iter().map(Into::into).collect();
|
||||||
|
|
||||||
(StatusCode::OK, Json(serde_json::json!({
|
(StatusCode::OK, Json(serde_json::json!({
|
||||||
"data": packages,
|
"data": packages,
|
||||||
|
"packages": packages,
|
||||||
"pagination": {
|
"pagination": {
|
||||||
"page": page,
|
"page": page,
|
||||||
"limit": limit,
|
"limit": limit,
|
||||||
"total": total.0,
|
"total": total,
|
||||||
"pages": (total.0 as f64 / limit as f64).ceil() as i64
|
"pages": (total as f64 / limit as f64).ceil() as i64
|
||||||
}
|
}
|
||||||
}))).into_response()
|
})))
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_package(
|
async fn get_package(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
match sqlx::query_as::<_, PricingPackageRow>(
|
match sqlx::query_as::<_, PricingPackageRow>(&package_query("WHERE id = $1", ""))
|
||||||
r#"
|
|
||||||
SELECT id, name, description, package_type, applicable_roles,
|
|
||||||
tracecoins_amount, price, duration_days, valid_from, valid_until,
|
|
||||||
is_promotional, is_active, features, created_at, updated_at
|
|
||||||
FROM pricing_packages WHERE id = $1
|
|
||||||
"#
|
|
||||||
)
|
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(Some(pkg)) => {
|
Ok(Some(pkg)) => (StatusCode::OK, Json(PricingPackageResponse::from(pkg))).into_response(),
|
||||||
let response: PricingPackageResponse = pkg.into();
|
|
||||||
(StatusCode::OK, Json(response)).into_response()
|
|
||||||
}
|
|
||||||
Ok(None) => (StatusCode::NOT_FOUND, "Package not found").into_response(),
|
Ok(None) => (StatusCode::NOT_FOUND, "Package not found").into_response(),
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
}
|
}
|
||||||
|
|
@ -224,37 +225,31 @@ async fn create_package(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Json(payload): Json<CreatePackageRequest>,
|
Json(payload): Json<CreatePackageRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
|
let role_key = match normalize_role_key(payload.role_key, payload.applicable_roles) {
|
||||||
|
Ok(role_key) => role_key,
|
||||||
|
Err(message) => return (StatusCode::BAD_REQUEST, message).into_response(),
|
||||||
|
};
|
||||||
|
let price_inr = payload.price_inr.or(payload.price).unwrap_or(0);
|
||||||
|
|
||||||
let result = sqlx::query_as::<_, PricingPackageRow>(
|
let result = sqlx::query_as::<_, PricingPackageRow>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO pricing_packages (name, description, package_type, applicable_roles,
|
INSERT INTO pricing_packages (name, role_key, package_type, tracecoins_amount, price_inr, description, is_active)
|
||||||
tracecoins_amount, price, duration_days, valid_from, valid_until,
|
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
||||||
is_promotional, is_active, features)
|
RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active, created_at
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
|
"#,
|
||||||
RETURNING id, name, description, package_type, applicable_roles,
|
|
||||||
tracecoins_amount, price, duration_days, valid_from, valid_until,
|
|
||||||
is_promotional, is_active, features, created_at, updated_at
|
|
||||||
"#
|
|
||||||
)
|
)
|
||||||
.bind(&payload.name)
|
.bind(&payload.name)
|
||||||
.bind(&payload.description)
|
.bind(&role_key)
|
||||||
.bind(&payload.package_type)
|
.bind(&payload.package_type)
|
||||||
.bind(&payload.applicable_roles)
|
|
||||||
.bind(payload.tracecoins_amount)
|
.bind(payload.tracecoins_amount)
|
||||||
.bind(payload.price)
|
.bind(price_inr)
|
||||||
.bind(payload.duration_days)
|
.bind(&payload.description)
|
||||||
.bind(payload.valid_from)
|
|
||||||
.bind(payload.valid_until)
|
|
||||||
.bind(payload.is_promotional.unwrap_or(false))
|
|
||||||
.bind(payload.is_active.unwrap_or(true))
|
.bind(payload.is_active.unwrap_or(true))
|
||||||
.bind(payload.features)
|
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match result {
|
match result {
|
||||||
Ok(pkg) => {
|
Ok(pkg) => (StatusCode::CREATED, Json(PricingPackageResponse::from(pkg))).into_response(),
|
||||||
let response: PricingPackageResponse = pkg.into();
|
|
||||||
(StatusCode::CREATED, Json(response)).into_response()
|
|
||||||
}
|
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -264,58 +259,47 @@ async fn update_package(
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(payload): Json<UpdatePackageRequest>,
|
Json(payload): Json<UpdatePackageRequest>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let existing = sqlx::query_as::<_, PricingPackageRow>(
|
let current = match sqlx::query_as::<_, PricingPackageRow>(&package_query("WHERE id = $1", ""))
|
||||||
"SELECT * FROM pricing_packages WHERE id = $1"
|
|
||||||
)
|
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_optional(&state.pool)
|
.fetch_optional(&state.pool)
|
||||||
.await;
|
.await
|
||||||
|
{
|
||||||
let _existing = match existing {
|
Ok(Some(pkg)) => pkg,
|
||||||
Ok(Some(e)) => e,
|
|
||||||
Ok(None) => return (StatusCode::NOT_FOUND, "Package not found").into_response(),
|
Ok(None) => return (StatusCode::NOT_FOUND, "Package not found").into_response(),
|
||||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let role_key = match normalize_role_key(payload.role_key, payload.applicable_roles) {
|
||||||
|
Ok(role_key) => role_key,
|
||||||
|
Err(_) => current.role_key.clone(),
|
||||||
|
};
|
||||||
|
let price_inr = payload.price_inr.or(payload.price).unwrap_or(current.price_inr);
|
||||||
|
|
||||||
let updated = sqlx::query_as::<_, PricingPackageRow>(
|
let updated = sqlx::query_as::<_, PricingPackageRow>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE pricing_packages SET
|
UPDATE pricing_packages SET
|
||||||
name = COALESCE($2, name),
|
name = COALESCE($2, name),
|
||||||
description = COALESCE($3, description),
|
role_key = $3,
|
||||||
tracecoins_amount = COALESCE($4, tracecoins_amount),
|
description = COALESCE($4, description),
|
||||||
price = COALESCE($5, price),
|
tracecoins_amount = COALESCE($5, tracecoins_amount),
|
||||||
duration_days = COALESCE($6, duration_days),
|
price_inr = $6,
|
||||||
valid_from = COALESCE($7, valid_from),
|
is_active = COALESCE($7, is_active)
|
||||||
valid_until = COALESCE($8, valid_until),
|
|
||||||
is_promotional = COALESCE($9, is_promotional),
|
|
||||||
is_active = COALESCE($10, is_active),
|
|
||||||
features = COALESCE($11, features),
|
|
||||||
updated_at = NOW()
|
|
||||||
WHERE id = $1
|
WHERE id = $1
|
||||||
RETURNING id, name, description, package_type, applicable_roles,
|
RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active, created_at
|
||||||
tracecoins_amount, price, duration_days, valid_from, valid_until,
|
"#,
|
||||||
is_promotional, is_active, features, created_at, updated_at
|
|
||||||
"#
|
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.bind(&payload.name)
|
.bind(&payload.name)
|
||||||
|
.bind(&role_key)
|
||||||
.bind(&payload.description)
|
.bind(&payload.description)
|
||||||
.bind(payload.tracecoins_amount)
|
.bind(payload.tracecoins_amount)
|
||||||
.bind(payload.price)
|
.bind(price_inr)
|
||||||
.bind(payload.duration_days)
|
|
||||||
.bind(payload.valid_from)
|
|
||||||
.bind(payload.valid_until)
|
|
||||||
.bind(payload.is_promotional)
|
|
||||||
.bind(payload.is_active)
|
.bind(payload.is_active)
|
||||||
.bind(payload.features)
|
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
match updated {
|
match updated {
|
||||||
Ok(pkg) => {
|
Ok(pkg) => (StatusCode::OK, Json(PricingPackageResponse::from(pkg))).into_response(),
|
||||||
let response: PricingPackageResponse = pkg.into();
|
|
||||||
(StatusCode::OK, Json(response)).into_response()
|
|
||||||
}
|
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -329,7 +313,7 @@ async fn delete_package(
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
{
|
{
|
||||||
Ok(r) if r.rows_affected() > 0 => {
|
Ok(result) if result.rows_affected() > 0 => {
|
||||||
(StatusCode::OK, Json(serde_json::json!({"message": "Package deleted"}))).into_response()
|
(StatusCode::OK, Json(serde_json::json!({"message": "Package deleted"}))).into_response()
|
||||||
}
|
}
|
||||||
Ok(_) => (StatusCode::NOT_FOUND, "Package not found").into_response(),
|
Ok(_) => (StatusCode::NOT_FOUND, "Package not found").into_response(),
|
||||||
|
|
@ -341,78 +325,65 @@ async fn get_packages_by_type(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(q): Query<PackageTypeQuery>,
|
Query(q): Query<PackageTypeQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let package_type = q.package_type.as_deref().unwrap_or("TRACECOIN_BUNDLE");
|
let package_type = q.package_type.unwrap_or_else(|| "TRACECOIN_BUNDLE".to_string());
|
||||||
let now = chrono::Utc::now();
|
|
||||||
|
|
||||||
let packages = sqlx::query_as::<_, PricingPackageRow>(
|
let rows = sqlx::query_as::<_, PricingPackageRow>(
|
||||||
r#"
|
&package_query(
|
||||||
SELECT id, name, description, package_type, applicable_roles,
|
"WHERE package_type = $1 AND is_active = true",
|
||||||
tracecoins_amount, price, duration_days, valid_from, valid_until,
|
"ORDER BY price_inr ASC, created_at DESC",
|
||||||
is_promotional, is_active, features, created_at, updated_at
|
),
|
||||||
FROM pricing_packages
|
|
||||||
WHERE package_type = $1
|
|
||||||
AND is_active = true
|
|
||||||
AND (valid_from IS NULL OR valid_from <= $2)
|
|
||||||
AND (valid_until IS NULL OR valid_until > $2)
|
|
||||||
ORDER BY is_promotional DESC, price ASC
|
|
||||||
"#
|
|
||||||
)
|
)
|
||||||
.bind(package_type)
|
.bind(&package_type)
|
||||||
.bind(now)
|
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let packages = match packages {
|
let rows = match rows {
|
||||||
Ok(p) => p,
|
Ok(rows) => rows,
|
||||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let packages: Vec<PricingPackageResponse> = packages.into_iter().map(|p| p.into()).collect();
|
let packages: Vec<PricingPackageResponse> = rows.into_iter().map(Into::into).collect();
|
||||||
|
|
||||||
(StatusCode::OK, Json(serde_json::json!({
|
(StatusCode::OK, Json(serde_json::json!({
|
||||||
"data": packages,
|
"data": packages,
|
||||||
"package_type": package_type
|
"package_type": package_type
|
||||||
}))).into_response()
|
})))
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn get_packages_for_role(
|
async fn get_packages_for_role(
|
||||||
State(state): State<AppState>,
|
State(state): State<AppState>,
|
||||||
Query(q): Query<PackageTypeQuery>,
|
Query(q): Query<PackageTypeQuery>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let applicable_role = q.applicable_role.as_deref().unwrap_or("");
|
let role = q
|
||||||
|
.applicable_role
|
||||||
|
.or(q.role)
|
||||||
|
.unwrap_or_default()
|
||||||
|
.trim()
|
||||||
|
.to_uppercase();
|
||||||
let active_only = q.active_only.unwrap_or(true);
|
let active_only = q.active_only.unwrap_or(true);
|
||||||
let now = chrono::Utc::now();
|
|
||||||
|
|
||||||
let packages = sqlx::query_as::<_, PricingPackageRow>(
|
let rows = sqlx::query_as::<_, PricingPackageRow>(
|
||||||
&format!(
|
&package_query(
|
||||||
r#"
|
"WHERE ($1 = '' OR role_key = $1) AND ($2 = false OR is_active = true)",
|
||||||
SELECT id, name, description, package_type, applicable_roles,
|
"ORDER BY price_inr ASC, created_at DESC",
|
||||||
tracecoins_amount, price, duration_days, valid_from, valid_until,
|
),
|
||||||
is_promotional, is_active, features, created_at, updated_at
|
|
||||||
FROM pricing_packages
|
|
||||||
WHERE ($1 = '' OR $1 = ANY(applicable_roles))
|
|
||||||
AND (is_active = true OR {} = false)
|
|
||||||
AND (valid_from IS NULL OR valid_from <= $2)
|
|
||||||
AND (valid_until IS NULL OR valid_until > $2)
|
|
||||||
ORDER BY is_promotional DESC, price ASC
|
|
||||||
"#,
|
|
||||||
if active_only { "true" } else { "false" }
|
|
||||||
)
|
)
|
||||||
)
|
.bind(&role)
|
||||||
.bind(applicable_role)
|
.bind(active_only)
|
||||||
.bind(now)
|
|
||||||
.fetch_all(&state.pool)
|
.fetch_all(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let packages = match packages {
|
let rows = match rows {
|
||||||
Ok(p) => p,
|
Ok(rows) => rows,
|
||||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let packages: Vec<PricingPackageResponse> = packages.into_iter().map(|p| p.into()).collect();
|
let packages: Vec<PricingPackageResponse> = rows.into_iter().map(Into::into).collect();
|
||||||
|
|
||||||
(StatusCode::OK, Json(serde_json::json!({
|
(StatusCode::OK, Json(serde_json::json!({
|
||||||
"data": packages,
|
"data": packages,
|
||||||
"applicable_role": applicable_role
|
"applicable_role": role
|
||||||
}))).into_response()
|
})))
|
||||||
|
.into_response()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -165,6 +165,13 @@ fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str>
|
||||||
vec![]
|
vec![]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_dummy_account_email(email: &str) -> bool {
|
||||||
|
email.ends_with("@demo.com")
|
||||||
|
|| email == "paymentgateway@demo.com"
|
||||||
|
|| email.contains("+dummy@")
|
||||||
|
|| email.starts_with("dummy+")
|
||||||
|
}
|
||||||
|
|
||||||
fn role_display_name_from_code(code: &str) -> String {
|
fn role_display_name_from_code(code: &str) -> String {
|
||||||
code
|
code
|
||||||
.split('_')
|
.split('_')
|
||||||
|
|
@ -296,6 +303,9 @@ async fn register(
|
||||||
}
|
}
|
||||||
})?;
|
})?;
|
||||||
|
|
||||||
|
// Check if this is a demo account (payment gateway integration)
|
||||||
|
let is_demo_account = is_dummy_account_email(&email);
|
||||||
|
|
||||||
// Assign signup role immediately (intent-driven). Email verification is still required for login.
|
// Assign signup role immediately (intent-driven). Email verification is still required for login.
|
||||||
let role_candidates = resolve_signup_role_candidates(
|
let role_candidates = resolve_signup_role_candidates(
|
||||||
payload.intent.as_deref(),
|
payload.intent.as_deref(),
|
||||||
|
|
@ -304,22 +314,25 @@ async fn register(
|
||||||
for role_key in role_candidates {
|
for role_key in role_candidates {
|
||||||
let role_id = ensure_role_exists(&state.pool, &role_key).await;
|
let role_id = ensure_role_exists(&state.pool, &role_key).await;
|
||||||
if let Some(role_id) = role_id {
|
if let Some(role_id) = role_id {
|
||||||
|
// For demo accounts, auto-approve the role immediately
|
||||||
|
let status = if is_demo_account { "APPROVED" } else { "PENDING" };
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
UPDATE user_role_assignments
|
UPDATE user_role_assignments
|
||||||
SET status = 'APPROVED'
|
SET status = $3
|
||||||
WHERE user_id = $1 AND role_id = $2
|
WHERE user_id = $1 AND role_id = $2
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.bind(role_id)
|
.bind(role_id)
|
||||||
|
.bind(status)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
let _ = sqlx::query(
|
let _ = sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO user_role_assignments (user_id, role_id, status)
|
INSERT INTO user_role_assignments (user_id, role_id, status)
|
||||||
SELECT $1, $2, 'APPROVED'
|
SELECT $1, $2, $3
|
||||||
WHERE NOT EXISTS (
|
WHERE NOT EXISTS (
|
||||||
SELECT 1 FROM user_role_assignments WHERE user_id = $1 AND role_id = $2
|
SELECT 1 FROM user_role_assignments WHERE user_id = $1 AND role_id = $2
|
||||||
)
|
)
|
||||||
|
|
@ -327,12 +340,37 @@ async fn register(
|
||||||
)
|
)
|
||||||
.bind(user.id)
|
.bind(user.id)
|
||||||
.bind(role_id)
|
.bind(role_id)
|
||||||
|
.bind(status)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// For demo accounts: auto-verify email and skip OTP
|
||||||
|
if is_demo_account {
|
||||||
|
tracing::info!(email = %email, "Demo account auto-verified");
|
||||||
|
let _ = sqlx::query(
|
||||||
|
"UPDATE users SET email_verified = true, status = 'ACTIVE' WHERE id = $1"
|
||||||
|
)
|
||||||
|
.bind(user.id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
// Return success with demo flag
|
||||||
|
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
|
||||||
|
return Ok((StatusCode::CREATED, Json(RegisterResponse {
|
||||||
|
user_id: user.id.to_string(),
|
||||||
|
email: user.email,
|
||||||
|
phone: None,
|
||||||
|
name: user_name,
|
||||||
|
status: "ACTIVE".to_string(),
|
||||||
|
email_verified: true,
|
||||||
|
created_at: user.created_at.to_rfc3339(),
|
||||||
|
otp: Some("DEMO".to_string()), // Return dummy OTP for demo
|
||||||
|
})));
|
||||||
|
}
|
||||||
|
|
||||||
// Store OTP in Redis (15-min TTL, keyed by code → user_id)
|
// Store OTP in Redis (15-min TTL, keyed by code → user_id)
|
||||||
let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
|
let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
|
||||||
tracing::info!(otp = %otp, email = %email, "OTP generated for registration");
|
tracing::info!(otp = %otp, email = %email, "OTP generated for registration");
|
||||||
|
|
@ -384,7 +422,10 @@ async fn login(
|
||||||
if user.status == "SUSPENDED" {
|
if user.status == "SUSPENDED" {
|
||||||
return Err(err(StatusCode::FORBIDDEN, "Account suspended", "ACCOUNT_SUSPENDED"));
|
return Err(err(StatusCode::FORBIDDEN, "Account suspended", "ACCOUNT_SUSPENDED"));
|
||||||
}
|
}
|
||||||
if !user.email_verified {
|
|
||||||
|
// Allow demo accounts to login without email verification
|
||||||
|
let is_demo_account = is_dummy_account_email(&email);
|
||||||
|
if !user.email_verified && !is_demo_account {
|
||||||
return Err(err(StatusCode::UNAUTHORIZED, "Email not verified. Check your inbox.", "EMAIL_NOT_VERIFIED"));
|
return Err(err(StatusCode::UNAUTHORIZED, "Email not verified. Check your inbox.", "EMAIL_NOT_VERIFIED"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ use axum::{
|
||||||
extract::{Path, Query, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::get,
|
routing::{get, patch},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
@ -14,6 +14,8 @@ use contracts::auth_middleware::{AuthUser, require_admin};
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
.route("/", get(list_external_roles).post(create_external_role))
|
.route("/", get(list_external_roles).post(create_external_role))
|
||||||
|
.route("/by-key/{role_key}", get(get_external_role_by_key))
|
||||||
|
.route("/by-key/{role_key}", patch(update_external_role_by_key))
|
||||||
.route("/{id}", get(get_external_role).put(update_external_role).delete(delete_external_role))
|
.route("/{id}", get(get_external_role).put(update_external_role).delete(delete_external_role))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -247,6 +249,60 @@ async fn get_external_role(
|
||||||
}))
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async fn get_external_role_by_key(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(role_key): Path<String>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
if let Err(_e) = require_admin(&auth) {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
||||||
|
}
|
||||||
|
let row = sqlx::query_as::<_, ExternalRoleDetailRow>(
|
||||||
|
r#"
|
||||||
|
SELECT r.id, r.name, r.key as code, r.audience, r.is_active, r.created_at,
|
||||||
|
rc.updated_at as updated_at, rc.config_json as config_json
|
||||||
|
FROM roles r
|
||||||
|
LEFT JOIN role_runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
|
||||||
|
WHERE r.key = $1 AND r.audience = 'EXTERNAL'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(&role_key)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "External role not found".to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(ExternalRoleDetail {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
code: row.code,
|
||||||
|
audience: row.audience,
|
||||||
|
is_active: row.is_active,
|
||||||
|
runtime: row.config_json.unwrap_or_else(|| serde_json::json!({})),
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_external_role_by_key(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(role_key): Path<String>,
|
||||||
|
Json(payload): Json<UpdateExternalRolePayload>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
if let Err(_e) = require_admin(&auth) {
|
||||||
|
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
||||||
|
}
|
||||||
|
let row: (Uuid,) = sqlx::query_as("SELECT id FROM roles WHERE key = $1 AND audience = 'EXTERNAL'")
|
||||||
|
.bind(&role_key)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "External role not found".to_string()))?;
|
||||||
|
|
||||||
|
update_external_role_impl(&state, row.0, payload).await
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct CreateExternalRolePayload {
|
struct CreateExternalRolePayload {
|
||||||
name: String,
|
name: String,
|
||||||
|
|
@ -340,6 +396,14 @@ async fn update_external_role(
|
||||||
if let Err(_e) = require_admin(&auth) {
|
if let Err(_e) = require_admin(&auth) {
|
||||||
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
|
||||||
}
|
}
|
||||||
|
update_external_role_impl(&state, id, payload).await
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn update_external_role_impl(
|
||||||
|
state: &AppState,
|
||||||
|
role_id: Uuid,
|
||||||
|
payload: UpdateExternalRolePayload,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
if payload.name.is_some() || payload.is_active.is_some() {
|
if payload.name.is_some() || payload.is_active.is_some() {
|
||||||
sqlx::query(
|
sqlx::query(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -351,7 +415,7 @@ async fn update_external_role(
|
||||||
)
|
)
|
||||||
.bind(payload.name)
|
.bind(payload.name)
|
||||||
.bind(payload.is_active)
|
.bind(payload.is_active)
|
||||||
.bind(id)
|
.bind(role_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
|
|
@ -364,7 +428,7 @@ async fn update_external_role(
|
||||||
WHERE role_id = $1 AND is_active = true
|
WHERE role_id = $1 AND is_active = true
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(role_id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.ok();
|
.ok();
|
||||||
|
|
@ -379,13 +443,38 @@ async fn update_external_role(
|
||||||
)
|
)
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(role_id)
|
||||||
.bind(runtime)
|
.bind(runtime)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
|
||||||
}
|
}
|
||||||
get_external_role(auth, State(state), Path(id)).await
|
// Return the updated role detail
|
||||||
|
let row = sqlx::query_as::<_, ExternalRoleDetailRow>(
|
||||||
|
r#"
|
||||||
|
SELECT r.id, r.name, r.key as code, r.audience, r.is_active, r.created_at,
|
||||||
|
rc.updated_at as updated_at, rc.config_json as config_json
|
||||||
|
FROM roles r
|
||||||
|
LEFT JOIN role_runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
|
||||||
|
WHERE r.id = $1 AND r.audience = 'EXTERNAL'
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(role_id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await
|
||||||
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
|
||||||
|
.ok_or((StatusCode::NOT_FOUND, "External role not found".to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(ExternalRoleDetail {
|
||||||
|
id: row.id,
|
||||||
|
name: row.name,
|
||||||
|
code: row.code,
|
||||||
|
audience: row.audience,
|
||||||
|
is_active: row.is_active,
|
||||||
|
runtime: row.config_json.unwrap_or_else(|| serde_json::json!({})),
|
||||||
|
created_at: row.created_at,
|
||||||
|
updated_at: row.updated_at,
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn delete_external_role(
|
async fn delete_external_role(
|
||||||
|
|
|
||||||
|
|
@ -18,6 +18,7 @@ pub fn public_router() -> Router<AppState> {
|
||||||
.route("/categories", get(public_list_categories))
|
.route("/categories", get(public_list_categories))
|
||||||
.route("/articles", get(public_list_articles))
|
.route("/articles", get(public_list_articles))
|
||||||
.route("/articles/{slug}", get(public_get_article))
|
.route("/articles/{slug}", get(public_get_article))
|
||||||
|
.route("/articles/id/{id}", get(public_get_article_by_id))
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Admin CRUD routes
|
/// Admin CRUD routes
|
||||||
|
|
@ -346,6 +347,68 @@ async fn public_get_article(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Public: single article by ID ───────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn public_get_article_by_id(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(id): Path<Uuid>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let row = sqlx::query_as::<_, PublicArticleRow>(
|
||||||
|
r#"
|
||||||
|
SELECT
|
||||||
|
a.id, a.title, a.slug, a.summary, a.body, a.target_roles, a.tags,
|
||||||
|
a.updated_at,
|
||||||
|
c.name AS category_name, c.slug AS category_slug
|
||||||
|
FROM kb_articles a
|
||||||
|
JOIN kb_categories c ON c.id = a.category_id
|
||||||
|
WHERE a.id = $1 AND a.status = 'PUBLISHED' AND c.is_active = true
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(id)
|
||||||
|
.fetch_optional(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
let pool = state.pool.clone();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
let _ = sqlx::query("UPDATE kb_articles SET views = views + 1 WHERE id = $1")
|
||||||
|
.bind(id)
|
||||||
|
.execute(&pool)
|
||||||
|
.await;
|
||||||
|
});
|
||||||
|
|
||||||
|
match row {
|
||||||
|
Ok(Some(r)) => {
|
||||||
|
let role = derive_role(r.target_roles.as_deref().unwrap_or(&[]));
|
||||||
|
let dto = PublicArticleDto {
|
||||||
|
id: r.id,
|
||||||
|
slug: r.slug,
|
||||||
|
title: r.title,
|
||||||
|
summary: r.summary,
|
||||||
|
category_key: r.category_slug,
|
||||||
|
category: r.category_name,
|
||||||
|
role,
|
||||||
|
tags: r.tags,
|
||||||
|
updated_at: r.updated_at.to_rfc3339(),
|
||||||
|
content: r.body,
|
||||||
|
};
|
||||||
|
(StatusCode::OK, Json(dto)).into_response()
|
||||||
|
}
|
||||||
|
Ok(None) => (
|
||||||
|
StatusCode::NOT_FOUND,
|
||||||
|
Json(serde_json::json!({ "error": "Article not found" })),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to fetch KB article by id {}: {}", id, e);
|
||||||
|
(
|
||||||
|
StatusCode::INTERNAL_SERVER_ERROR,
|
||||||
|
Json(serde_json::json!({ "error": "Failed to fetch article" })),
|
||||||
|
)
|
||||||
|
.into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// ── Admin: categories ─────────────────────────────────────────────────────────
|
// ── Admin: categories ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
async fn admin_list_categories(
|
async fn admin_list_categories(
|
||||||
|
|
|
||||||
|
|
@ -173,8 +173,8 @@ async fn submit(
|
||||||
|
|
||||||
let query = format!(
|
let query = format!(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO {} (id, custom_data, status, updated_at)
|
INSERT INTO {} (id, user_id, custom_data, status, updated_at)
|
||||||
VALUES ($1, $2, 'PENDING', NOW())
|
VALUES ($1, $2, $3, 'PENDING', NOW())
|
||||||
ON CONFLICT (id) DO UPDATE SET
|
ON CONFLICT (id) DO UPDATE SET
|
||||||
custom_data = EXCLUDED.custom_data,
|
custom_data = EXCLUDED.custom_data,
|
||||||
status = 'PENDING',
|
status = 'PENDING',
|
||||||
|
|
@ -185,6 +185,7 @@ async fn submit(
|
||||||
|
|
||||||
sqlx::query(&query)
|
sqlx::query(&query)
|
||||||
.bind(user_role_profile_id)
|
.bind(user_role_profile_id)
|
||||||
|
.bind(auth.user_id)
|
||||||
.bind(&progress)
|
.bind(&progress)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await
|
.await
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ use axum::{
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use contracts::auth_middleware::AuthUser;
|
use contracts::auth_middleware::AuthUser;
|
||||||
use db::models::{role::RoleRepository, verification::VerificationRepository};
|
use db::models::{role::RoleRepository, user::UserRepository, verification::VerificationRepository};
|
||||||
use serde::Deserialize;
|
use serde::Deserialize;
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
|
|
@ -19,8 +19,9 @@ pub fn router() -> Router<AppState> {
|
||||||
.route("/submit-for-verification", post(submit_for_verification))
|
.route("/submit-for-verification", post(submit_for_verification))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn me_verification_router() -> Router<AppState> {
|
pub fn me_router() -> Router<AppState> {
|
||||||
Router::new()
|
Router::new()
|
||||||
|
.route("/", get(get_me))
|
||||||
.route("/verification-status", get(verification_status))
|
.route("/verification-status", get(verification_status))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -68,6 +69,13 @@ fn role_to_table(role_key: &str) -> Option<&'static str> {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn is_dummy_account_email(email: &str) -> bool {
|
||||||
|
email.ends_with("@demo.com")
|
||||||
|
|| email == "paymentgateway@demo.com"
|
||||||
|
|| email.contains("+dummy@")
|
||||||
|
|| email.starts_with("dummy+")
|
||||||
|
}
|
||||||
|
|
||||||
fn extract_documents(profile_data: &serde_json::Value) -> serde_json::Value {
|
fn extract_documents(profile_data: &serde_json::Value) -> serde_json::Value {
|
||||||
let doc_keys = [
|
let doc_keys = [
|
||||||
"aadhar_doc",
|
"aadhar_doc",
|
||||||
|
|
@ -304,6 +312,67 @@ async fn submit_for_verification(
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let role_key = input.role_key.to_uppercase();
|
let role_key = input.role_key.to_uppercase();
|
||||||
|
|
||||||
|
// Check if user is a demo account
|
||||||
|
let is_demo = sqlx::query_scalar::<_, String>("SELECT email FROM users WHERE id = $1")
|
||||||
|
.bind(auth.user_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.map(|email| is_dummy_account_email(&email))
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
// For demo accounts: auto-approve verification
|
||||||
|
if is_demo {
|
||||||
|
tracing::info!(user_id = %auth.user_id, role_key = %role_key, "Demo account auto-approved for verification");
|
||||||
|
|
||||||
|
// Update role assignment to APPROVED
|
||||||
|
if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await {
|
||||||
|
sqlx::query(
|
||||||
|
"UPDATE user_role_assignments SET status = 'APPROVED' WHERE user_id = $1 AND role_id = $2",
|
||||||
|
)
|
||||||
|
.bind(auth.user_id)
|
||||||
|
.bind(role.id)
|
||||||
|
.execute(&state.pool)
|
||||||
|
.await
|
||||||
|
.ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark profile as VERIFIED
|
||||||
|
set_profile_status(&state, auth.user_id, &role_key, "VERIFIED").await;
|
||||||
|
|
||||||
|
// Create a verification record with APPROVED status
|
||||||
|
let profile_data = input.profile_data.unwrap_or_else(|| {
|
||||||
|
serde_json::json!({
|
||||||
|
"company_name": "Payment Gateway Demo Company",
|
||||||
|
"company_description": "Demo account for reviewing packages",
|
||||||
|
"industry": "Technology",
|
||||||
|
"location": "India"
|
||||||
|
})
|
||||||
|
});
|
||||||
|
let documents = extract_documents(&profile_data);
|
||||||
|
|
||||||
|
match VerificationRepository::create_approved(
|
||||||
|
&state.pool,
|
||||||
|
auth.user_id,
|
||||||
|
&role_key,
|
||||||
|
"PROFILE_VERIFICATION",
|
||||||
|
profile_data,
|
||||||
|
documents,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
|
Ok(v) => (
|
||||||
|
StatusCode::CREATED,
|
||||||
|
Json(serde_json::json!({
|
||||||
|
"verification_id": v.id,
|
||||||
|
"status": "APPROVED",
|
||||||
|
"message": "Your profile has been auto-approved for demo access."
|
||||||
|
})),
|
||||||
|
)
|
||||||
|
.into_response(),
|
||||||
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Regular verification flow for non-demo accounts
|
||||||
// Guard: reject if an active verification already exists
|
// Guard: reject if an active verification already exists
|
||||||
let existing: Result<Option<Uuid>, sqlx::Error> = sqlx::query_scalar(
|
let existing: Result<Option<Uuid>, sqlx::Error> = sqlx::query_scalar(
|
||||||
r#"
|
r#"
|
||||||
|
|
@ -375,6 +444,7 @@ async fn submit_for_verification(
|
||||||
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// GET /api/me/verification-status?roleKey=PHOTOGRAPHER
|
/// GET /api/me/verification-status?roleKey=PHOTOGRAPHER
|
||||||
pub async fn verification_status(
|
pub async fn verification_status(
|
||||||
|
|
@ -557,3 +627,22 @@ async fn fetch_saved_profile_by_urp_id(
|
||||||
}
|
}
|
||||||
serde_json::Value::Object(Default::default())
|
serde_json::Value::Object(Default::default())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// GET /api/me — returns the authenticated user's basic info
|
||||||
|
pub async fn get_me(
|
||||||
|
auth: AuthUser,
|
||||||
|
State(state): State<AppState>,
|
||||||
|
) -> Result<impl IntoResponse, (StatusCode, String)> {
|
||||||
|
let user = UserRepository::get_by_id(&state.pool, auth.user_id)
|
||||||
|
.await
|
||||||
|
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
|
||||||
|
|
||||||
|
Ok(Json(serde_json::json!({
|
||||||
|
"id": user.id,
|
||||||
|
"email": user.email,
|
||||||
|
"firstName": user.first_name,
|
||||||
|
"lastName": user.last_name,
|
||||||
|
"activeRole": auth.claims.active_role,
|
||||||
|
"emailVerified": user.email_verified,
|
||||||
|
})))
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
use axum::{
|
use axum::{
|
||||||
extract::{Path, State},
|
extract::{Path, Query, State},
|
||||||
http::StatusCode,
|
http::StatusCode,
|
||||||
response::IntoResponse,
|
response::IntoResponse,
|
||||||
routing::get,
|
routing::{get, post},
|
||||||
Json, Router,
|
Json, Router,
|
||||||
};
|
};
|
||||||
use contracts::auth_middleware::AuthUser;
|
use contracts::auth_middleware::AuthUser;
|
||||||
|
|
@ -18,35 +18,50 @@ pub fn admin_router() -> Router<AppState> {
|
||||||
.route("/{id}", axum::routing::patch(admin_update_review).delete(admin_delete_review))
|
.route("/{id}", axum::routing::patch(admin_update_review).delete(admin_delete_review))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn public_router() -> Router<AppState> {
|
||||||
|
Router::new()
|
||||||
|
.route("/", get(list_reviews))
|
||||||
|
.route("/professional/{professional_id}", get(list_reviews_by_professional))
|
||||||
|
}
|
||||||
|
|
||||||
// ── DTOs ──────────────────────────────────────────────────────────────────────
|
// ── DTOs ──────────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
#[derive(Serialize)]
|
#[derive(Serialize)]
|
||||||
struct ReviewDto {
|
struct ReviewDto {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
subject_type: String,
|
professional_id: Uuid,
|
||||||
subject_id: Option<String>,
|
customer_id: Uuid,
|
||||||
reviewer_name: Option<String>,
|
|
||||||
reviewer_id: Option<Uuid>,
|
|
||||||
rating: i16,
|
rating: i16,
|
||||||
title: Option<String>,
|
|
||||||
comment: Option<String>,
|
comment: Option<String>,
|
||||||
status: String,
|
is_published: bool,
|
||||||
created_at: chrono::DateTime<chrono::Utc>,
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
struct PublicReviewDto {
|
||||||
|
id: Uuid,
|
||||||
|
professional_id: Uuid,
|
||||||
|
rating: i16,
|
||||||
|
comment: Option<String>,
|
||||||
|
created_at: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct CreateReviewBody {
|
struct CreateReviewBody {
|
||||||
subject_type: Option<String>,
|
lead_request_id: Uuid,
|
||||||
subject_id: Option<String>,
|
|
||||||
reviewer_name: Option<String>,
|
|
||||||
rating: i16,
|
rating: i16,
|
||||||
title: Option<String>,
|
|
||||||
comment: Option<String>,
|
comment: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
struct PatchReviewBody {
|
struct PatchReviewBody {
|
||||||
status: Option<String>,
|
is_published: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Deserialize)]
|
||||||
|
struct PublicListQuery {
|
||||||
|
page: Option<i64>,
|
||||||
|
limit: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
// ── FromRow structs ──────────────────────────────────────────────────────────
|
// ── FromRow structs ──────────────────────────────────────────────────────────
|
||||||
|
|
@ -54,14 +69,12 @@ struct PatchReviewBody {
|
||||||
#[derive(sqlx::FromRow)]
|
#[derive(sqlx::FromRow)]
|
||||||
struct ReviewRow {
|
struct ReviewRow {
|
||||||
id: Uuid,
|
id: Uuid,
|
||||||
subject_type: String,
|
lead_request_id: Uuid,
|
||||||
subject_id: Option<String>,
|
customer_id: Uuid,
|
||||||
reviewer_name: Option<String>,
|
professional_id: Uuid,
|
||||||
reviewer_id: Option<Uuid>,
|
|
||||||
rating: i16,
|
rating: i16,
|
||||||
title: Option<String>,
|
|
||||||
comment: Option<String>,
|
comment: Option<String>,
|
||||||
status: String,
|
is_published: bool,
|
||||||
created_at: chrono::DateTime<chrono::Utc>,
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -75,14 +88,12 @@ async fn admin_list_reviews(
|
||||||
r#"
|
r#"
|
||||||
SELECT
|
SELECT
|
||||||
r.id,
|
r.id,
|
||||||
r.subject_type,
|
r.lead_request_id,
|
||||||
r.subject_id,
|
r.customer_id,
|
||||||
r.reviewer_name,
|
r.professional_id,
|
||||||
r.reviewer_user_id AS reviewer_id,
|
|
||||||
r.rating,
|
r.rating,
|
||||||
r.title,
|
|
||||||
r.comment,
|
r.comment,
|
||||||
r.status,
|
r.is_published,
|
||||||
r.created_at
|
r.created_at
|
||||||
FROM reviews r
|
FROM reviews r
|
||||||
ORDER BY r.created_at DESC
|
ORDER BY r.created_at DESC
|
||||||
|
|
@ -97,14 +108,11 @@ async fn admin_list_reviews(
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.map(|r| ReviewDto {
|
.map(|r| ReviewDto {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
subject_type: r.subject_type,
|
professional_id: r.professional_id,
|
||||||
subject_id: r.subject_id,
|
customer_id: r.customer_id,
|
||||||
reviewer_name: r.reviewer_name,
|
|
||||||
reviewer_id: r.reviewer_id,
|
|
||||||
rating: r.rating,
|
rating: r.rating,
|
||||||
title: r.title,
|
|
||||||
comment: r.comment,
|
comment: r.comment,
|
||||||
status: r.status,
|
is_published: r.is_published,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
})
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
@ -126,24 +134,19 @@ async fn admin_create_review(
|
||||||
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Rating must be 1-5" }))).into_response();
|
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Rating must be 1-5" }))).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let subject_type = body.subject_type.unwrap_or_else(|| "PLATFORM".to_string());
|
|
||||||
let status = "PUBLISHED".to_string();
|
|
||||||
|
|
||||||
let row = sqlx::query_as::<_, ReviewRow>(
|
let row = sqlx::query_as::<_, ReviewRow>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO reviews (subject_type, subject_id, reviewer_name, rating, title, comment, status)
|
INSERT INTO reviews (lead_request_id, customer_id, professional_id, rating, comment, is_published)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7)
|
SELECT $1,
|
||||||
RETURNING id, subject_type, subject_id, reviewer_name, reviewer_user_id AS reviewer_id,
|
(SELECT id FROM customer_profiles WHERE user_id = (SELECT user_id FROM lead_requests WHERE id = $1)),
|
||||||
rating, title, comment, status, created_at
|
(SELECT user_role_profile_id FROM lead_requests WHERE id = $1),
|
||||||
|
$2, $3, true
|
||||||
|
RETURNING id, lead_request_id, customer_id, professional_id, rating, comment, is_published, created_at
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&subject_type)
|
.bind(body.lead_request_id)
|
||||||
.bind(&body.subject_id)
|
|
||||||
.bind(&body.reviewer_name)
|
|
||||||
.bind(body.rating)
|
.bind(body.rating)
|
||||||
.bind(&body.title)
|
|
||||||
.bind(&body.comment)
|
.bind(&body.comment)
|
||||||
.bind(&status)
|
|
||||||
.fetch_one(&state.pool)
|
.fetch_one(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
||||||
|
|
@ -151,14 +154,11 @@ async fn admin_create_review(
|
||||||
Ok(r) => {
|
Ok(r) => {
|
||||||
let dto = ReviewDto {
|
let dto = ReviewDto {
|
||||||
id: r.id,
|
id: r.id,
|
||||||
subject_type: r.subject_type,
|
professional_id: r.professional_id,
|
||||||
subject_id: r.subject_id,
|
customer_id: r.customer_id,
|
||||||
reviewer_name: r.reviewer_name,
|
|
||||||
reviewer_id: r.reviewer_id,
|
|
||||||
rating: r.rating,
|
rating: r.rating,
|
||||||
title: r.title,
|
|
||||||
comment: r.comment,
|
comment: r.comment,
|
||||||
status: r.status,
|
is_published: r.is_published,
|
||||||
created_at: r.created_at,
|
created_at: r.created_at,
|
||||||
};
|
};
|
||||||
(StatusCode::CREATED, Json(serde_json::json!(dto))).into_response()
|
(StatusCode::CREATED, Json(serde_json::json!(dto))).into_response()
|
||||||
|
|
@ -176,13 +176,12 @@ async fn admin_update_review(
|
||||||
Path(id): Path<Uuid>,
|
Path(id): Path<Uuid>,
|
||||||
Json(body): Json<PatchReviewBody>,
|
Json(body): Json<PatchReviewBody>,
|
||||||
) -> impl IntoResponse {
|
) -> impl IntoResponse {
|
||||||
let status = body.status.as_deref().unwrap_or("PUBLISHED").to_string();
|
let is_published = body.is_published.unwrap_or(true);
|
||||||
|
|
||||||
let result = sqlx::query(
|
let result = sqlx::query(
|
||||||
"UPDATE reviews SET status = $1, updated_at = NOW() WHERE id = $2",
|
"UPDATE reviews SET is_published = $1, updated_at = NOW() WHERE id = $2",
|
||||||
)
|
)
|
||||||
.bind(&status)
|
.bind(is_published)
|
||||||
.bind(id)
|
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.execute(&state.pool)
|
.execute(&state.pool)
|
||||||
.await;
|
.await;
|
||||||
|
|
@ -220,3 +219,107 @@ async fn admin_delete_review(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Public handlers ────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
async fn list_reviews(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Query(q): Query<PublicListQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let page = q.page.unwrap_or(1).max(1);
|
||||||
|
let limit = q.limit.unwrap_or(20).clamp(1, 100);
|
||||||
|
let offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, ReviewRow>(
|
||||||
|
r#"
|
||||||
|
SELECT id, lead_request_id, customer_id, professional_id, rating, comment, is_published, created_at
|
||||||
|
FROM reviews
|
||||||
|
WHERE is_published = true
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $1 OFFSET $2
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match rows {
|
||||||
|
Ok(rows) => {
|
||||||
|
let dtos: Vec<PublicReviewDto> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| PublicReviewDto {
|
||||||
|
id: r.id,
|
||||||
|
professional_id: r.professional_id,
|
||||||
|
rating: r.rating,
|
||||||
|
comment: r.comment,
|
||||||
|
created_at: r.created_at.to_rfc3339(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(StatusCode::OK, Json(serde_json::json!({ "reviews": dtos }))).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to list public reviews: {e}");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn list_reviews_by_professional(
|
||||||
|
State(state): State<AppState>,
|
||||||
|
Path(professional_id): Path<Uuid>,
|
||||||
|
Query(q): Query<PublicListQuery>,
|
||||||
|
) -> impl IntoResponse {
|
||||||
|
let page = q.page.unwrap_or(1).max(1);
|
||||||
|
let limit = q.limit.unwrap_or(20).clamp(1, 100);
|
||||||
|
let offset = (page - 1) * limit;
|
||||||
|
|
||||||
|
let rows = sqlx::query_as::<_, ReviewRow>(
|
||||||
|
r#"
|
||||||
|
SELECT id, lead_request_id, customer_id, professional_id, rating, comment, is_published, created_at
|
||||||
|
FROM reviews
|
||||||
|
WHERE professional_id = $1 AND is_published = true
|
||||||
|
ORDER BY created_at DESC
|
||||||
|
LIMIT $2 OFFSET $3
|
||||||
|
"#,
|
||||||
|
)
|
||||||
|
.bind(professional_id)
|
||||||
|
.bind(limit)
|
||||||
|
.bind(offset)
|
||||||
|
.fetch_all(&state.pool)
|
||||||
|
.await;
|
||||||
|
|
||||||
|
match rows {
|
||||||
|
Ok(rows) => {
|
||||||
|
let avg: (f64,) = sqlx::query_as("SELECT COALESCE(AVG(rating), 0)::float FROM reviews WHERE professional_id = $1 AND is_published = true")
|
||||||
|
.bind(professional_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or((0.0,));
|
||||||
|
let count: (i64,) = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE professional_id = $1 AND is_published = true")
|
||||||
|
.bind(professional_id)
|
||||||
|
.fetch_one(&state.pool)
|
||||||
|
.await
|
||||||
|
.unwrap_or((0,));
|
||||||
|
let dtos: Vec<PublicReviewDto> = rows
|
||||||
|
.into_iter()
|
||||||
|
.map(|r| PublicReviewDto {
|
||||||
|
id: r.id,
|
||||||
|
professional_id: r.professional_id,
|
||||||
|
rating: r.rating,
|
||||||
|
comment: r.comment,
|
||||||
|
created_at: r.created_at.to_rfc3339(),
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
(StatusCode::OK, Json(serde_json::json!({
|
||||||
|
"reviews": dtos,
|
||||||
|
"averageRating": avg.0,
|
||||||
|
"totalCount": count.0
|
||||||
|
}))).into_response()
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::error!("Failed to list reviews for professional {professional_id}: {e}");
|
||||||
|
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -479,7 +479,7 @@ struct AdminTicketRow {
|
||||||
created_at: chrono::DateTime<chrono::Utc>,
|
created_at: chrono::DateTime<chrono::Utc>,
|
||||||
updated_at: chrono::DateTime<chrono::Utc>,
|
updated_at: chrono::DateTime<chrono::Utc>,
|
||||||
user_name: Option<String>,
|
user_name: Option<String>,
|
||||||
user_email: String,
|
user_email: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn admin_list_cases(
|
async fn admin_list_cases(
|
||||||
|
|
@ -503,7 +503,11 @@ async fn admin_list_cases(
|
||||||
CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email
|
CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email
|
||||||
FROM support_tickets t
|
FROM support_tickets t
|
||||||
LEFT JOIN users u ON u.id = t.user_id
|
LEFT JOIN users u ON u.id = t.user_id
|
||||||
WHERE t.id = $1
|
WHERE ($1 = '' OR t.status = $1)
|
||||||
|
AND ($2 = '' OR t.priority = $2)
|
||||||
|
AND ($3 = '' OR t.category = $3)
|
||||||
|
ORDER BY t.updated_at DESC
|
||||||
|
LIMIT $4 OFFSET $5
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(&status_filter)
|
.bind(&status_filter)
|
||||||
|
|
@ -526,7 +530,7 @@ async fn admin_list_cases(
|
||||||
.map(|r| {
|
.map(|r| {
|
||||||
// Use user info if available, fall back to requester fields
|
// Use user info if available, fall back to requester fields
|
||||||
let requester_name = r.requester_name.or(r.user_name);
|
let requester_name = r.requester_name.or(r.user_name);
|
||||||
let requester_email = r.requester_email.or(Some(r.user_email));
|
let requester_email = r.requester_email.or(r.user_email);
|
||||||
serde_json::json!({
|
serde_json::json!({
|
||||||
"id": r.id,
|
"id": r.id,
|
||||||
"title": r.subject,
|
"title": r.subject,
|
||||||
|
|
@ -642,11 +646,7 @@ async fn admin_get_case(
|
||||||
CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email
|
CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email
|
||||||
FROM support_tickets t
|
FROM support_tickets t
|
||||||
LEFT JOIN users u ON u.id = t.user_id
|
LEFT JOIN users u ON u.id = t.user_id
|
||||||
WHERE ($1 = '' OR t.status = $1)
|
WHERE t.id = $1
|
||||||
AND ($2 = '' OR t.priority = $2)
|
|
||||||
AND ($3 = '' OR t.category = $3)
|
|
||||||
ORDER BY t.updated_at DESC
|
|
||||||
LIMIT $4 OFFSET $5
|
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
|
@ -681,7 +681,7 @@ async fn admin_get_case(
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let requester_name = t.requester_name.or(t.user_name);
|
let requester_name = t.requester_name.or(t.user_name);
|
||||||
let requester_email = t.requester_email.or(Some(t.user_email));
|
let requester_email = t.requester_email.or(t.user_email);
|
||||||
|
|
||||||
(StatusCode::OK, Json(serde_json::json!({
|
(StatusCode::OK, Json(serde_json::json!({
|
||||||
"ticket": {
|
"ticket": {
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ use axum::{
|
||||||
};
|
};
|
||||||
use contracts::auth_middleware::AuthUser;
|
use contracts::auth_middleware::AuthUser;
|
||||||
use db::models::role::RoleRepository;
|
use db::models::role::RoleRepository;
|
||||||
|
use db::models::user_role_profile::UserRoleProfileRepository;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
pub fn router() -> Router<AppState> {
|
pub fn router() -> Router<AppState> {
|
||||||
|
|
@ -42,6 +43,7 @@ fn is_professional_role(role_key: &str) -> bool {
|
||||||
| "SOCIAL_MEDIA_MANAGER"
|
| "SOCIAL_MEDIA_MANAGER"
|
||||||
| "FITNESS_TRAINER"
|
| "FITNESS_TRAINER"
|
||||||
| "CATERING_SERVICES"
|
| "CATERING_SERVICES"
|
||||||
|
| "UGC_CONTENT_CREATOR"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -112,7 +114,14 @@ async fn register_role(
|
||||||
.await
|
.await
|
||||||
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
|
||||||
|
|
||||||
// Note: Professional profile creation is now handled upon successful submission of onboarding data.
|
// For professional/external roles, also create the user_role_profiles entry so
|
||||||
|
// downstream services (e.g. leads) can find the professional's profile.
|
||||||
|
if is_professional_role(&role_key) {
|
||||||
|
if let Err(e) = UserRoleProfileRepository::create(&state.pool, auth.user_id, &role_key).await {
|
||||||
|
tracing::warn!("Failed to create user_role_profiles entry for {}: {}", role_key, e);
|
||||||
|
// Non-fatal — the assignment is created; the profile row can be backfilled later.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Ok((
|
Ok((
|
||||||
StatusCode::OK,
|
StatusCode::OK,
|
||||||
|
|
|
||||||
|
|
@ -72,8 +72,7 @@ async fn main() {
|
||||||
.nest("/api/admin/approvals", handlers::approvals::router())
|
.nest("/api/admin/approvals", handlers::approvals::router())
|
||||||
.nest("/api/admin/verifications", handlers::verifications::router())
|
.nest("/api/admin/verifications", handlers::verifications::router())
|
||||||
// ── Me: Profile Status + Verification Status ──────────────────────
|
// ── Me: Profile Status + Verification Status ──────────────────────
|
||||||
.nest("/api/me", handlers::onboarding::me_router())
|
.nest("/api/me", handlers::profile::me_router())
|
||||||
.nest("/api/me", handlers::profile::me_verification_router())
|
|
||||||
// ── Profile (save + submit-for-verification) ──────────────────────
|
// ── Profile (save + submit-for-verification) ──────────────────────
|
||||||
.nest("/api/profile", handlers::profile::router())
|
.nest("/api/profile", handlers::profile::router())
|
||||||
// ── Onboarding State (legacy, kept for compatibility) ────────────
|
// ── Onboarding State (legacy, kept for compatibility) ────────────
|
||||||
|
|
@ -97,7 +96,8 @@ async fn main() {
|
||||||
.nest("/api/support/tickets", handlers::support::user_router())
|
.nest("/api/support/tickets", handlers::support::user_router())
|
||||||
// ── Support Tickets (admin) ───────────────────────────────────────
|
// ── Support Tickets (admin) ───────────────────────────────────────
|
||||||
.nest("/api/admin/support-cases", handlers::support::admin_router())
|
.nest("/api/admin/support-cases", handlers::support::admin_router())
|
||||||
// ── Reviews (admin) ───────────────────────────────────────────────
|
// ── Reviews (public + admin) ─────────────────────────────────────
|
||||||
|
.nest("/api/reviews", handlers::reviews::public_router())
|
||||||
.nest("/api/admin/reviews", handlers::reviews::admin_router())
|
.nest("/api/admin/reviews", handlers::reviews::admin_router())
|
||||||
// ── Coupons & Discounts (admin) ───────────────────────────────────
|
// ── Coupons & Discounts (admin) ───────────────────────────────────
|
||||||
.nest("/api/admin/coupons", handlers::coupons::coupons_router())
|
.nest("/api/admin/coupons", handlers::coupons::coupons_router())
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
|
|
||||||
[dependencies]
|
[dependencies]
|
||||||
jsonwebtoken = "10.3"
|
jsonwebtoken = { version = "10.3", features = ["rust_crypto"] }
|
||||||
argon2 = "0.5"
|
argon2 = "0.5"
|
||||||
rand_core = { version = "0.6", features = ["std"] }
|
rand_core = { version = "0.6", features = ["std"] }
|
||||||
serde = { workspace = true }
|
serde = { workspace = true }
|
||||||
|
|
|
||||||
|
|
@ -13,7 +13,7 @@ chrono = { workspace = true }
|
||||||
anyhow = { workspace = true }
|
anyhow = { workspace = true }
|
||||||
sqlx = { workspace = true }
|
sqlx = { workspace = true }
|
||||||
async-trait = { workspace = true }
|
async-trait = { workspace = true }
|
||||||
jsonwebtoken = "10.3"
|
jsonwebtoken = { version = "10.3", features = ["rust_crypto"] }
|
||||||
db = { path = "../db" }
|
db = { path = "../db" }
|
||||||
cache = { path = "../cache" }
|
cache = { path = "../cache" }
|
||||||
storage = { path = "../storage" }
|
storage = { path = "../storage" }
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,8 @@ pub struct PaginationQuery {
|
||||||
#[derive(Deserialize)]
|
#[derive(Deserialize)]
|
||||||
pub struct LeadRequestPayload {
|
pub struct LeadRequestPayload {
|
||||||
pub requirement_id: Uuid,
|
pub requirement_id: Uuid,
|
||||||
|
#[serde(default)]
|
||||||
|
pub message: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Build the shared Router that every profession service merges into its own Router.
|
/// Build the shared Router that every profession service merges into its own Router.
|
||||||
|
|
@ -189,10 +191,12 @@ async fn send_lead_request(
|
||||||
return (StatusCode::PAYMENT_REQUIRED, "Insufficient Tracecoin balance").into_response();
|
return (StatusCode::PAYMENT_REQUIRED, "Insufficient Tracecoin balance").into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
let db_payload = CreateLeadRequestPayload {
|
let db_payload = CreateLeadRequestPayload::new(
|
||||||
user_role_profile_id: user_role_profile.id,
|
req.id,
|
||||||
expires_at: Utc::now() + chrono::Duration::hours(24),
|
user_role_profile.id,
|
||||||
};
|
auth.user_id,
|
||||||
|
payload.message.clone(),
|
||||||
|
);
|
||||||
|
|
||||||
match LeadRequestRepository::create(&state.pool, db_payload).await {
|
match LeadRequestRepository::create(&state.pool, db_payload).await {
|
||||||
Ok(lead) => {
|
Ok(lead) => {
|
||||||
|
|
@ -456,7 +460,7 @@ async fn cancel_request(
|
||||||
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(),
|
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(),
|
||||||
};
|
};
|
||||||
|
|
||||||
if lead.user_role_profile_id != user_role_profile.id {
|
if lead.user_role_profile_id != Some(user_role_profile.id) {
|
||||||
return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Access denied" }))).into_response();
|
return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Access denied" }))).into_response();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS leads;
|
||||||
27
crates/db/migrations/20260610003321_create_leads.up.sql
Normal file
27
crates/db/migrations/20260610003321_create_leads.up.sql
Normal file
|
|
@ -0,0 +1,27 @@
|
||||||
|
-- Create the leads table (also called "requirements" in some contexts)
|
||||||
|
-- Required by RequirementRepository in crates/db/src/models/requirement.rs
|
||||||
|
CREATE TABLE IF NOT EXISTS leads (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
profession_key VARCHAR(100) NOT NULL,
|
||||||
|
title VARCHAR(255) NOT NULL,
|
||||||
|
description TEXT NOT NULL DEFAULT '',
|
||||||
|
location VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
|
budget_inr INT,
|
||||||
|
required_date DATE,
|
||||||
|
extra_data_json JSONB,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'DRAFT',
|
||||||
|
rejection_reason TEXT,
|
||||||
|
request_count INT NOT NULL DEFAULT 0,
|
||||||
|
accepted_count INT NOT NULL DEFAULT 0,
|
||||||
|
expires_at TIMESTAMPTZ,
|
||||||
|
approved_at TIMESTAMPTZ,
|
||||||
|
approved_by UUID,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
created_by_user_id UUID,
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_leads_status ON leads(status);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_leads_profession_key ON leads(profession_key);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_leads_created_by_user_id ON leads(created_by_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_leads_status_profession ON leads(status, profession_key);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
DROP TABLE IF EXISTS lead_requests;
|
||||||
|
|
@ -0,0 +1,21 @@
|
||||||
|
-- Create the lead_requests table for professional responses to leads
|
||||||
|
CREATE TABLE IF NOT EXISTS lead_requests (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE,
|
||||||
|
user_role_profile_id UUID NOT NULL,
|
||||||
|
customer_user_id UUID NOT NULL,
|
||||||
|
status VARCHAR(50) NOT NULL DEFAULT 'PENDING',
|
||||||
|
tracecoins_reserved INT NOT NULL DEFAULT 0,
|
||||||
|
message TEXT,
|
||||||
|
expires_at TIMESTAMPTZ NOT NULL,
|
||||||
|
accepted_at TIMESTAMPTZ,
|
||||||
|
rejected_at TIMESTAMPTZ,
|
||||||
|
rejected_reason TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
|
||||||
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
|
||||||
|
);
|
||||||
|
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lead_requests_lead_id ON lead_requests(lead_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lead_requests_user_role_profile_id ON lead_requests(user_role_profile_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lead_requests_customer_user_id ON lead_requests(customer_user_id);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_lead_requests_status ON lead_requests(status);
|
||||||
|
|
@ -0,0 +1 @@
|
||||||
|
ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS niche_tags;
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
-- Fix UGC Content Creator schema: rename content_niches→niche_tags (matches Rust model)
|
||||||
|
ALTER TABLE ugc_content_creator_profiles
|
||||||
|
ADD COLUMN IF NOT EXISTS niche_tags TEXT[] DEFAULT '{}';
|
||||||
|
|
||||||
|
UPDATE ugc_content_creator_profiles
|
||||||
|
SET niche_tags = COALESCE(content_niches, '{}')
|
||||||
|
WHERE niche_tags IS NULL;
|
||||||
|
|
@ -52,6 +52,20 @@ pub struct UpsertCompanyProfilePayload {
|
||||||
pub struct CompanyRepository;
|
pub struct CompanyRepository;
|
||||||
|
|
||||||
impl CompanyRepository {
|
impl CompanyRepository {
|
||||||
|
async fn is_dummy_account(pool: &PgPool, user_id: Uuid) -> Result<bool, sqlx::Error> {
|
||||||
|
let email = sqlx::query_scalar::<_, String>("SELECT email FROM users WHERE id = $1")
|
||||||
|
.bind(user_id)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await?;
|
||||||
|
|
||||||
|
Ok(
|
||||||
|
email.ends_with("@demo.com")
|
||||||
|
|| email == "paymentgateway@demo.com"
|
||||||
|
|| email.contains("+dummy@")
|
||||||
|
|| email.starts_with("dummy+"),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_by_user_id(
|
pub async fn get_by_user_id(
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
|
|
@ -81,6 +95,9 @@ impl CompanyRepository {
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
payload: UpsertCompanyProfilePayload,
|
payload: UpsertCompanyProfilePayload,
|
||||||
) -> Result<CompanyProfile, sqlx::Error> {
|
) -> Result<CompanyProfile, sqlx::Error> {
|
||||||
|
let is_dummy_account = Self::is_dummy_account(pool, user_id).await?;
|
||||||
|
let default_status = if is_dummy_account { "APPROVED" } else { "PENDING" };
|
||||||
|
|
||||||
let profile = sqlx::query_as::<_, CompanyProfile>(
|
let profile = sqlx::query_as::<_, CompanyProfile>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO company_profiles (
|
INSERT INTO company_profiles (
|
||||||
|
|
@ -88,7 +105,7 @@ impl CompanyRepository {
|
||||||
employee_count, business_type, gst_number, contact_name,
|
employee_count, business_type, gst_number, contact_name,
|
||||||
contact_email, contact_phone, address_line1, city, state, postal_code, status
|
contact_email, contact_phone, address_line1, city, state, postal_code, status
|
||||||
)
|
)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 'PENDING')
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
|
||||||
ON CONFLICT (user_id) DO UPDATE SET
|
ON CONFLICT (user_id) DO UPDATE SET
|
||||||
company_name = EXCLUDED.company_name,
|
company_name = EXCLUDED.company_name,
|
||||||
registration_number = EXCLUDED.registration_number,
|
registration_number = EXCLUDED.registration_number,
|
||||||
|
|
@ -105,6 +122,7 @@ impl CompanyRepository {
|
||||||
state = EXCLUDED.state,
|
state = EXCLUDED.state,
|
||||||
postal_code = EXCLUDED.postal_code,
|
postal_code = EXCLUDED.postal_code,
|
||||||
status = CASE
|
status = CASE
|
||||||
|
WHEN $17 THEN 'APPROVED'
|
||||||
WHEN company_profiles.status = 'APPROVED' THEN 'APPROVED'
|
WHEN company_profiles.status = 'APPROVED' THEN 'APPROVED'
|
||||||
ELSE 'PENDING'
|
ELSE 'PENDING'
|
||||||
END,
|
END,
|
||||||
|
|
@ -133,6 +151,8 @@ impl CompanyRepository {
|
||||||
.bind(payload.city)
|
.bind(payload.city)
|
||||||
.bind(payload.state)
|
.bind(payload.state)
|
||||||
.bind(payload.postal_code)
|
.bind(payload.postal_code)
|
||||||
|
.bind(default_status)
|
||||||
|
.bind(is_dummy_account)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -143,10 +163,13 @@ impl CompanyRepository {
|
||||||
pool: &PgPool,
|
pool: &PgPool,
|
||||||
user_id: Uuid,
|
user_id: Uuid,
|
||||||
) -> Result<CompanyProfile, sqlx::Error> {
|
) -> Result<CompanyProfile, sqlx::Error> {
|
||||||
|
let is_dummy_account = Self::is_dummy_account(pool, user_id).await?;
|
||||||
|
let next_status = if is_dummy_account { "APPROVED" } else { "PENDING_REVIEW" };
|
||||||
|
|
||||||
let profile = sqlx::query_as::<_, CompanyProfile>(
|
let profile = sqlx::query_as::<_, CompanyProfile>(
|
||||||
r#"
|
r#"
|
||||||
UPDATE company_profiles
|
UPDATE company_profiles
|
||||||
SET status = 'PENDING_REVIEW', updated_at = NOW()
|
SET status = $2, updated_at = NOW()
|
||||||
WHERE user_id = $1
|
WHERE user_id = $1
|
||||||
RETURNING
|
RETURNING
|
||||||
id, user_id, company_name, registration_number, industry,
|
id, user_id, company_name, registration_number, industry,
|
||||||
|
|
@ -158,6 +181,7 @@ impl CompanyRepository {
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
.bind(user_id)
|
.bind(user_id)
|
||||||
|
.bind(next_status)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,20 +6,41 @@ use uuid::Uuid;
|
||||||
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
#[derive(Debug, Serialize, Deserialize, FromRow)]
|
||||||
pub struct LeadRequest {
|
pub struct LeadRequest {
|
||||||
pub id: Uuid,
|
pub id: Uuid,
|
||||||
pub user_role_profile_id: Uuid,
|
pub lead_id: Option<Uuid>,
|
||||||
|
pub user_role_profile_id: Option<Uuid>,
|
||||||
|
pub professional_user_id: Option<Uuid>,
|
||||||
pub status: String,
|
pub status: String,
|
||||||
pub tracecoins_reserved: i32,
|
pub tracecoins_reserved: i32,
|
||||||
|
pub remarks: Option<String>,
|
||||||
pub expires_at: DateTime<Utc>,
|
pub expires_at: DateTime<Utc>,
|
||||||
pub requested_at: DateTime<Utc>,
|
pub requested_at: DateTime<Utc>,
|
||||||
pub resolved_at: Option<DateTime<Utc>>,
|
pub resolved_at: Option<DateTime<Utc>>,
|
||||||
pub remarks: Option<String>,
|
pub created_at: DateTime<Utc>,
|
||||||
pub updated_at: DateTime<Utc>,
|
pub updated_at: DateTime<Utc>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Serialize, Deserialize)]
|
#[derive(Debug, Serialize, Deserialize)]
|
||||||
pub struct CreateLeadRequestPayload {
|
pub struct CreateLeadRequestPayload {
|
||||||
|
pub lead_id: Uuid,
|
||||||
pub user_role_profile_id: Uuid,
|
pub user_role_profile_id: Uuid,
|
||||||
pub expires_at: DateTime<Utc>,
|
pub professional_user_id: Uuid,
|
||||||
|
pub remarks: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CreateLeadRequestPayload {
|
||||||
|
pub fn new(
|
||||||
|
lead_id: Uuid,
|
||||||
|
user_role_profile_id: Uuid,
|
||||||
|
professional_user_id: Uuid,
|
||||||
|
remarks: Option<String>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
lead_id,
|
||||||
|
user_role_profile_id,
|
||||||
|
professional_user_id,
|
||||||
|
remarks,
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct LeadRequestRepository;
|
pub struct LeadRequestRepository;
|
||||||
|
|
@ -31,13 +52,15 @@ impl LeadRequestRepository {
|
||||||
) -> Result<LeadRequest, sqlx::Error> {
|
) -> Result<LeadRequest, sqlx::Error> {
|
||||||
let req = sqlx::query_as::<_, LeadRequest>(
|
let req = sqlx::query_as::<_, LeadRequest>(
|
||||||
r#"
|
r#"
|
||||||
INSERT INTO lead_requests (user_role_profile_id, expires_at)
|
INSERT INTO lead_requests (lead_id, user_role_profile_id, professional_user_id, remarks)
|
||||||
VALUES ($1, $2)
|
VALUES ($1, $2, $3, $4)
|
||||||
RETURNING *
|
RETURNING *
|
||||||
"#,
|
"#,
|
||||||
)
|
)
|
||||||
|
.bind(payload.lead_id)
|
||||||
.bind(payload.user_role_profile_id)
|
.bind(payload.user_role_profile_id)
|
||||||
.bind(payload.expires_at)
|
.bind(payload.professional_user_id)
|
||||||
|
.bind(&payload.remarks)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
|
@ -92,6 +115,7 @@ impl LeadRequestRepository {
|
||||||
.bind(id)
|
.bind(id)
|
||||||
.fetch_one(pool)
|
.fetch_one(pool)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
Ok(req)
|
Ok(req)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,6 +61,30 @@ impl VerificationRepository {
|
||||||
.await
|
.await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub async fn create_approved(
|
||||||
|
pool: &PgPool,
|
||||||
|
user_id: Uuid,
|
||||||
|
role_key: &str,
|
||||||
|
case_type: &str,
|
||||||
|
payload: serde_json::Value,
|
||||||
|
documents: serde_json::Value,
|
||||||
|
) -> Result<Verification, sqlx::Error> {
|
||||||
|
sqlx::query_as::<_, Verification>(
|
||||||
|
r#"
|
||||||
|
INSERT INTO verifications (user_id, role_key, case_type, priority, status, payload, documents, reviewed_at, reviewer_notes)
|
||||||
|
VALUES ($1, $2, $3, 'MEDIUM', 'APPROVED', $4, $5, NOW(), 'Auto-approved for demo account')
|
||||||
|
RETURNING *
|
||||||
|
"#
|
||||||
|
)
|
||||||
|
.bind(user_id)
|
||||||
|
.bind(role_key)
|
||||||
|
.bind(case_type)
|
||||||
|
.bind(payload)
|
||||||
|
.bind(documents)
|
||||||
|
.fetch_one(pool)
|
||||||
|
.await
|
||||||
|
}
|
||||||
|
|
||||||
pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Verification>, sqlx::Error> {
|
pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Verification>, sqlx::Error> {
|
||||||
sqlx::query_as::<_, Verification>("SELECT * FROM verifications WHERE id = $1")
|
sqlx::query_as::<_, Verification>("SELECT * FROM verifications WHERE id = $1")
|
||||||
.bind(id)
|
.bind(id)
|
||||||
|
|
|
||||||
113
scripts/build-all.sh
Executable file
113
scripts/build-all.sh
Executable file
|
|
@ -0,0 +1,113 @@
|
||||||
|
#!/bin/bash
|
||||||
|
# build-all.sh - Build all nxtgauge backend services and push to registry
|
||||||
|
# Usage: ./scripts/build-all.sh [TAG]
|
||||||
|
|
||||||
|
set -e
|
||||||
|
|
||||||
|
REGISTRY="registry.nxtgauge.com"
|
||||||
|
TAG=${1:-$(git rev-parse --short HEAD)}
|
||||||
|
DOCKERFILE="Dockerfile.simple"
|
||||||
|
|
||||||
|
echo "============================================"
|
||||||
|
echo "🚀 NXTGAUGE Full Rebuild Script"
|
||||||
|
echo "============================================"
|
||||||
|
echo "Registry: ${REGISTRY}"
|
||||||
|
echo "Tag: ${TAG}"
|
||||||
|
echo "Dockerfile: ${DOCKERFILE}"
|
||||||
|
echo ""
|
||||||
|
|
||||||
|
# Services list with underscores (matching Cargo.toml bin names)
|
||||||
|
SERVICES=(
|
||||||
|
"gateway"
|
||||||
|
"users"
|
||||||
|
"companies"
|
||||||
|
"job_seekers"
|
||||||
|
"customers"
|
||||||
|
"photographers"
|
||||||
|
"makeup_artists"
|
||||||
|
"tutors"
|
||||||
|
"developers"
|
||||||
|
"video_editors"
|
||||||
|
"graphic_designers"
|
||||||
|
"social_media_managers"
|
||||||
|
"fitness_trainers"
|
||||||
|
"catering_services"
|
||||||
|
"ugc_content_creators"
|
||||||
|
"employees"
|
||||||
|
"payments"
|
||||||
|
"jobs"
|
||||||
|
"leads"
|
||||||
|
"cron"
|
||||||
|
)
|
||||||
|
|
||||||
|
# Also need service names with hyphens for image names
|
||||||
|
declare -A SERVICE_IMAGE_NAMES=(
|
||||||
|
["gateway"]="nxtgauge-rust-gateway"
|
||||||
|
["users"]="nxtgauge-rust-users"
|
||||||
|
["companies"]="nxtgauge-rust-companies"
|
||||||
|
["job_seekers"]="nxtgauge-rust-job-seekers"
|
||||||
|
["customers"]="nxtgauge-rust-customers"
|
||||||
|
["photographers"]="nxtgauge-rust-photographers"
|
||||||
|
["makeup_artists"]="nxtgauge-rust-makeup-artists"
|
||||||
|
["tutors"]="nxtgauge-rust-tutors"
|
||||||
|
["developers"]="nxtgauge-rust-developers"
|
||||||
|
["video_editors"]="nxtgauge-rust-video-editors"
|
||||||
|
["graphic_designers"]="nxtgauge-rust-graphic-designers"
|
||||||
|
["social_media_managers"]="nxtgauge-rust-social-media-managers"
|
||||||
|
["fitness_trainers"]="nxtgauge-rust-fitness-trainers"
|
||||||
|
["catering_services"]="nxtgauge-rust-catering-services"
|
||||||
|
["ugc_content_creators"]="nxtgauge-rust-ugc-content-creators"
|
||||||
|
["employees"]="nxtgauge-rust-employees"
|
||||||
|
["payments"]="nxtgauge-rust-payments"
|
||||||
|
["jobs"]="nxtgauge-rust-jobs"
|
||||||
|
["leads"]="nxtgauge-rust-leads"
|
||||||
|
["cron"]="nxtgauge-rust-cron"
|
||||||
|
)
|
||||||
|
|
||||||
|
TOTAL=${#SERVICES[@]}
|
||||||
|
CURRENT=0
|
||||||
|
|
||||||
|
build_service() {
|
||||||
|
local service=$1
|
||||||
|
local image_name=${SERVICE_IMAGE_NAMES[$service]}
|
||||||
|
local tag="${REGISTRY}/${image_name}:${TAG}"
|
||||||
|
local latest="${REGISTRY}/${image_name}:latest"
|
||||||
|
|
||||||
|
CURRENT=$((CURRENT + 1))
|
||||||
|
echo ""
|
||||||
|
echo "[$CURRENT/$TOTAL] Building ${image_name}..."
|
||||||
|
echo " Service: ${service}"
|
||||||
|
echo " Tag: ${tag}"
|
||||||
|
|
||||||
|
# Build
|
||||||
|
docker build \
|
||||||
|
--build-arg SERVICE_NAME=${service} \
|
||||||
|
-f ${DOCKERFILE} \
|
||||||
|
-t ${tag} \
|
||||||
|
-t ${latest} \
|
||||||
|
. 2>&1 | tail -5
|
||||||
|
|
||||||
|
# Push
|
||||||
|
echo " Pushing..."
|
||||||
|
docker push ${tag}
|
||||||
|
docker push ${latest}
|
||||||
|
|
||||||
|
echo " ✅ ${image_name} complete"
|
||||||
|
}
|
||||||
|
|
||||||
|
# Build all services
|
||||||
|
for service in "${SERVICES[@]}"; do
|
||||||
|
build_service "${service}"
|
||||||
|
done
|
||||||
|
|
||||||
|
echo ""
|
||||||
|
echo "============================================"
|
||||||
|
echo "🎉 All services built and pushed!"
|
||||||
|
echo "============================================"
|
||||||
|
echo "Tag used: ${TAG}"
|
||||||
|
echo ""
|
||||||
|
echo "Update kustomization.yaml with:"
|
||||||
|
for service in "${SERVICES[@]}"; do
|
||||||
|
local image_name=${SERVICE_IMAGE_NAMES[$service]}
|
||||||
|
echo " ${image_name}: ${TAG}"
|
||||||
|
done
|
||||||
Loading…
Add table
Reference in a new issue