Compare commits

...
Sign in to create a new pull request.

24 commits

Author SHA1 Message Date
Ashwin Kumar Sivakumar
189f4bca60 ci: test single job with ubuntu-latest
Some checks failed
build-all / build-cron (push) Failing after 1s
build-and-push / detect-changes (push) Successful in 4s
build-all / build-users (push) Failing after 1s
build-gateway / build (push) Failing after 1s
build-services / build-gateway (push) Failing after 2s
build-services / build-users (push) Failing after 3s
build-services / build-jobs (push) Failing after 3s
build-services / build-cron (push) Failing after 2s
build-services / build-leads (push) Failing after 4s
build-all-services / build (push) Failing after 2s
build-and-push / build (catering-services) (push) Failing after 2s
build-and-push / build (companies) (push) Failing after 4s
build-and-push / build (cron) (push) Failing after 3s
build-and-push / build (customers) (push) Failing after 4s
build-and-push / build (developers) (push) Failing after 3s
build-and-push / build (fitness-trainers) (push) Failing after 3s
build-and-push / build (employees) (push) Failing after 4s
build-and-push / build (gateway) (push) Failing after 3s
build-and-push / build (graphic-designers) (push) Failing after 3s
build-and-push / build (job-seekers) (push) Failing after 3s
build-and-push / build (jobs) (push) Failing after 3s
build-and-push / build (leads) (push) Failing after 3s
build-and-push / build (payments) (push) Failing after 3s
build-and-push / build (makeup-artists) (push) Failing after 4s
build-and-push / build (photographers) (push) Failing after 2s
build-and-push / build (social-media-managers) (push) Failing after 3s
build-and-push / build (tutors) (push) Failing after 2s
build-and-push / build (ugc-content-creators) (push) Failing after 3s
build-and-push / build (users) (push) Failing after 2s
build-and-push / build (video-editors) (push) Failing after 3s
2026-06-13 01:44:07 +05:30
Ashwin Kumar Sivakumar
1956acdcf3 ci: minimal workflow without external actions
Some checks failed
build-all / build-users (push) Failing after 1s
build-services / build-gateway (push) Failing after 2s
build-services / build-users (push) Failing after 4s
build-services / build-jobs (push) Failing after 2s
build-services / build-leads (push) Failing after 4s
build-all / build-gateway (push) Failing after 2s
build-all / build-cron (push) Failing after 2s
build-services / build-cron (push) Failing after 2s
build-all-services / build (push) Failing after 2s
build-and-push / detect-changes (push) Successful in 4s
build-and-push / build (catering-services) (push) Failing after 2s
build-and-push / build (companies) (push) Failing after 2s
build-and-push / build (cron) (push) Failing after 2s
build-and-push / build (customers) (push) Failing after 2s
build-and-push / build (developers) (push) Failing after 2s
build-and-push / build (employees) (push) Failing after 2s
build-and-push / build (fitness-trainers) (push) Failing after 3s
build-and-push / build (gateway) (push) Failing after 3s
build-and-push / build (graphic-designers) (push) Failing after 3s
build-and-push / build (job-seekers) (push) Failing after 3s
build-and-push / build (jobs) (push) Failing after 2s
build-and-push / build (leads) (push) Failing after 2s
build-and-push / build (makeup-artists) (push) Failing after 3s
build-and-push / build (payments) (push) Failing after 3s
build-and-push / build (photographers) (push) Failing after 3s
build-and-push / build (social-media-managers) (push) Failing after 3s
build-and-push / build (tutors) (push) Failing after 2s
build-and-push / build (ugc-content-creators) (push) Failing after 2s
build-and-push / build (users) (push) Failing after 2s
build-and-push / build (video-editors) (push) Failing after 2s
2026-06-13 01:41:32 +05:30
Ashwin Kumar Sivakumar
ec41f6dad2 ci: simpler workflow with self-hosted runners
Some checks failed
build-services / build-jobs (push) Failing after 2s
build-services / build-leads (push) Failing after 3s
build-services / build-cron (push) Failing after 3s
build-services / build-users (push) Failing after 2s
build-services / build-gateway (push) Failing after 4s
build-and-push / detect-changes (push) Successful in 2s
build-all-services / build (push) Failing after 3s
build-and-push / build (catering-services) (push) Failing after 2s
build-and-push / build (companies) (push) Failing after 4s
build-and-push / build (cron) (push) Failing after 3s
build-and-push / build (customers) (push) Failing after 4s
build-and-push / build (developers) (push) Failing after 3s
build-and-push / build (fitness-trainers) (push) Failing after 3s
build-and-push / build (employees) (push) Failing after 4s
build-and-push / build (gateway) (push) Failing after 3s
build-and-push / build (graphic-designers) (push) Failing after 4s
build-and-push / build (job-seekers) (push) Failing after 2s
build-and-push / build (jobs) (push) Failing after 4s
build-and-push / build (leads) (push) Failing after 2s
build-and-push / build (payments) (push) Failing after 2s
build-and-push / build (makeup-artists) (push) Failing after 4s
build-and-push / build (photographers) (push) Failing after 2s
build-and-push / build (social-media-managers) (push) Failing after 4s
build-and-push / build (tutors) (push) Failing after 2s
build-and-push / build (users) (push) Failing after 2s
build-and-push / build (ugc-content-creators) (push) Failing after 4s
build-and-push / build (video-editors) (push) Failing after 2s
2026-06-13 01:38:16 +05:30
Ashwin Kumar Sivakumar
f67f6c2514 trigger build: test with registry auth
Some checks failed
build-and-push / build (catering-services) (push) Failing after 2s
build-and-push / detect-changes (push) Successful in 18s
build-and-push / build (companies) (push) Failing after 3s
build-all-services / build (push) Failing after 29s
build-and-push / build (cron) (push) Failing after 2s
build-and-push / build (developers) (push) Failing after 2s
build-and-push / build (customers) (push) Failing after 3s
build-and-push / build (employees) (push) Failing after 2s
build-and-push / build (fitness-trainers) (push) Failing after 4s
build-and-push / build (gateway) (push) Failing after 3s
build-and-push / build (graphic-designers) (push) Failing after 4s
build-and-push / build (job-seekers) (push) Failing after 3s
build-and-push / build (leads) (push) Failing after 2s
build-and-push / build (jobs) (push) Failing after 4s
build-and-push / build (makeup-artists) (push) Failing after 2s
build-and-push / build (payments) (push) Failing after 3s
build-and-push / build (photographers) (push) Failing after 3s
build-and-push / build (social-media-managers) (push) Failing after 4s
build-and-push / build (tutors) (push) Failing after 3s
build-and-push / build (ugc-content-creators) (push) Failing after 3s
build-and-push / build (users) (push) Failing after 4s
build-and-push / build (video-editors) (push) Failing after 3s
2026-06-13 01:36:34 +05:30
Ashwin Kumar Sivakumar
051a980f14 ci: add simple build workflow with self-hosted runners
Some checks failed
build-all-services / build (push) Failing after 2s
build-and-push / detect-changes (push) Successful in 4s
build-and-push / build (companies) (push) Failing after 3s
build-and-push / build (catering-services) (push) Failing after 4s
build-and-push / build (cron) (push) Failing after 3s
build-and-push / build (customers) (push) Failing after 3s
build-and-push / build (developers) (push) Failing after 3s
build-and-push / build (employees) (push) Failing after 3s
build-and-push / build (fitness-trainers) (push) Failing after 3s
build-and-push / build (graphic-designers) (push) Failing after 3s
build-and-push / build (gateway) (push) Failing after 4s
build-and-push / build (job-seekers) (push) Failing after 3s
build-and-push / build (jobs) (push) Failing after 4s
build-and-push / build (leads) (push) Failing after 3s
build-and-push / build (makeup-artists) (push) Failing after 3s
build-and-push / build (payments) (push) Failing after 3s
build-and-push / build (social-media-managers) (push) Failing after 3s
build-and-push / build (photographers) (push) Failing after 3s
build-and-push / build (tutors) (push) Failing after 3s
build-and-push / build (ugc-content-creators) (push) Failing after 3s
build-and-push / build (users) (push) Failing after 2s
build-and-push / build (video-editors) (push) Failing after 3s
2026-06-13 01:29:21 +05:30
Ashwin Kumar Sivakumar
0d35bf5649 trigger build: rebuild all services
Some checks failed
build-and-push / detect-changes (push) Successful in 2s
build-and-push / build (catering-services) (push) Failing after 2s
build-and-push / build (companies) (push) Failing after 4s
build-and-push / build (cron) (push) Failing after 3s
build-and-push / build (developers) (push) Failing after 3s
build-and-push / build (customers) (push) Failing after 4s
build-and-push / build (employees) (push) Failing after 3s
build-and-push / build (fitness-trainers) (push) Failing after 4s
build-and-push / build (gateway) (push) Failing after 3s
build-and-push / build (job-seekers) (push) Failing after 3s
build-and-push / build (graphic-designers) (push) Failing after 4s
build-and-push / build (jobs) (push) Failing after 2s
build-and-push / build (leads) (push) Failing after 4s
build-and-push / build (makeup-artists) (push) Failing after 3s
build-and-push / build (payments) (push) Failing after 3s
build-and-push / build (photographers) (push) Failing after 3s
build-and-push / build (tutors) (push) Failing after 3s
build-and-push / build (social-media-managers) (push) Failing after 4s
build-and-push / build (ugc-content-creators) (push) Failing after 3s
build-and-push / build (users) (push) Failing after 4s
build-and-push / build (video-editors) (push) Failing after 3s
2026-06-13 01:23:22 +05:30
Ashwin Kumar Sivakumar
123a157e04 ci: add GitHub Actions workflow to build and push images
Build all services and push to registry.nxtgauge.com
Using Dockerfile.simple for fast builds
2026-06-13 01:22:31 +05:30
Ashwin Kumar Sivakumar
d0b10eac8f trigger forgejo pipeline: rebuild all services
Some checks failed
build-and-push / detect-changes (push) Successful in 4s
build-and-push / build (catering-services) (push) Failing after 3s
build-and-push / build (companies) (push) Failing after 3s
build-and-push / build (cron) (push) Failing after 2s
build-and-push / build (customers) (push) Failing after 4s
build-and-push / build (developers) (push) Failing after 2s
build-and-push / build (fitness-trainers) (push) Failing after 3s
build-and-push / build (employees) (push) Failing after 4s
build-and-push / build (gateway) (push) Failing after 3s
build-and-push / build (graphic-designers) (push) Failing after 3s
build-and-push / build (job-seekers) (push) Failing after 3s
build-and-push / build (jobs) (push) Failing after 3s
build-and-push / build (leads) (push) Failing after 3s
build-and-push / build (makeup-artists) (push) Failing after 3s
build-and-push / build (payments) (push) Failing after 3s
build-and-push / build (social-media-managers) (push) Failing after 3s
build-and-push / build (photographers) (push) Failing after 4s
build-and-push / build (tutors) (push) Failing after 3s
build-and-push / build (ugc-content-creators) (push) Failing after 3s
build-and-push / build (users) (push) Failing after 3s
build-and-push / build (video-editors) (push) Failing after 4s
build-and-push / cleanup-after-build (push) Failing after 0s
Retention script was too aggressive and deleted most images.
Increasing keep count from 2 to 10 SHA tags.
2026-06-12 23:55:01 +05:30
Ashwin Kumar Sivakumar
418da25d37 feat: auto-approve dummy company accounts 2026-06-12 06:02:20 +05:30
Ashwin Kumar Sivakumar
b2c93f4e33 feat: auto-verify demo accounts for payment gateway integration
- Auto-verifies emails for accounts ending with @demo.com
- Auto-approves COMPANY role for demo accounts
- Skips email verification and OTP for demo accounts
- Auto-approves profile verification for demo accounts
- Allows login without email verification for demo accounts

This enables payment gateway companies to login directly and view packages.
2026-06-12 05:51:19 +05:30
Ashwin Kumar Sivakumar
0bda2b2f10 remove: delete high-performance-latest tag from builds - use SHA tags only 2026-06-12 04:40:36 +05:30
Ashwin Kumar Sivakumar
8adc84699e fix: keep only 2 SHA tags (current + 1 previous) to save disk space 2026-06-12 04:12:15 +05:30
Ashwin Kumar Sivakumar
758f0699ff fix: move image cleanup to post-build job after all builds complete 2026-06-12 04:10:52 +05:30
Ashwin Kumar Sivakumar
d0b768d602 trigger: force build all services with high-performance-latest tag 2026-06-12 03:45:06 +05:30
Ashwin Kumar Sivakumar
30346b02d1 fix: remove push preflight from forgejo mirror sync 2026-06-11 19:29:41 +05:30
Ashwin Kumar Sivakumar
30df37b127 fix: trigger forgejo mirror sync via api 2026-06-11 19:14:46 +05:30
Ashwin Kumar Sivakumar
e428fe268c fix: use basic auth for forgejo sync 2026-06-11 18:56:39 +05:30
Ashwin Kumar Sivakumar
d79aa50c77 fix: use existing forgejo mirror secrets 2026-06-11 18:19:10 +05:30
Ashwin Kumar Sivakumar
fc772c2acb fix: point forgejo sync to ashwin namespace 2026-06-11 18:00:03 +05:30
Ashwin Kumar Sivakumar
1b1d98ebee chore: migrate ci naming to forgejo 2026-06-11 17:17:42 +05:30
Ashwin Kumar Sivakumar
c7fe1b7ad3 chore: trigger rebuild all 2026-06-11 02:52:14 +05:30
Tracewebstudio Dev
319b384f0a fix(session1): customer list_requests path arg, external-role by-key endpoint, RuntimeRoleDetail type 2026-06-10 16:19:46 +02:00
Tracewebstudio Dev
2c6d102205 fix(e2e): 14 bug fixes across users, leads, gateway, KB, and reviews
DB:
- Add niche_tags column to ugc_content_creator_profiles (was blocking UGC service)
- Add turnaround_days and fix user_role_profile_id NOT NULL for UGC
- leads/lead_requests tables (already created in session 1)

Code:
- Add UGC_CONTENT_CREATOR to is_professional_role() to auto-create user_role_profiles
- Fix onboarding INSERT to include user_id for photographer_profiles
- Fix send_lead_request_ai to use correct customer_user_id (was self-notifying)
- Add PATCH /api/leads/:id support + mount leads at /api/* for gateway compatibility
- Fix admin_list_cases query (WHERE was using wrong params)
- Fix admin_get_case query (was using list query instead of fetch-by-id)
- Add GET /api/me in profile.rs (moved from onboarding)
- Add KB articles by ID route /api/kb/articles/id/{id}
- Rewrite reviews handlers to match actual reviews table schema
- Add public reviews router GET /api/reviews

Gateway:
- Add /api/reviews route to users service
2026-06-10 16:17:10 +02:00
Tracewebstudio Dev
52e30a1b4b fix payments runtime and jwt backend 2026-06-09 22:52:30 +02:00
41 changed files with 1627 additions and 680 deletions

View file

@ -11,7 +11,7 @@ Usage:
This script:
1. Updates the newTag for the specified service to the SHA
2. Commits and pushes to the gitops repo
3. ArgoCD detects the change and deploys
3. Flux detects the change and deploys
"""
import argparse
@ -98,10 +98,14 @@ def main():
image_name = f"nxtgauge-{args.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")
if not os.path.exists(kustomization_path):
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:
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-ai-assistant/overlays/prod/kustomization.yaml")
if not os.path.exists(kustomization_path):

View file

@ -0,0 +1,38 @@
name: build-all
on:
push:
branches:
- main
- high-performance
jobs:
build-gateway:
runs-on: self-hosted
steps:
- run: |
git clone --depth 1 http://forgejo-http.forgejo.svc.cluster.local:3000/ashwin/nxtgauge-backend-rust.git /tmp/repo
cd /tmp/repo
docker build -f Dockerfile.simple --build-arg SERVICE_NAME=gateway -t registry.nxtgauge.com/nxtgauge-rust-gateway:latest .
docker push registry.nxtgauge.com/nxtgauge-rust-gateway:latest
rm -rf /tmp/repo
build-users:
runs-on: self-hosted
steps:
- run: |
git clone --depth 1 http://forgejo-http.forgejo.svc.cluster.local:3000/ashwin/nxtgauge-backend-rust.git /tmp/repo
cd /tmp/repo
docker build -f Dockerfile.simple --build-arg SERVICE_NAME=users -t registry.nxtgauge.com/nxtgauge-rust-users:latest .
docker push registry.nxtgauge.com/nxtgauge-rust-users:latest
rm -rf /tmp/repo
build-cron:
runs-on: self-hosted
steps:
- run: |
git clone --depth 1 http://forgejo-http.forgejo.svc.cluster.local:3000/ashwin/nxtgauge-backend-rust.git /tmp/repo
cd /tmp/repo
docker build -f Dockerfile.simple --build-arg SERVICE_NAME=cron -t registry.nxtgauge.com/nxtgauge-rust-cron:latest .
docker push registry.nxtgauge.com/nxtgauge-rust-cron:latest
rm -rf /tmp/repo

View file

@ -0,0 +1,18 @@
name: build-gateway
on:
push:
branches:
- high-performance
jobs:
build:
runs-on: ubuntu-latest
steps:
- name: Build
run: |
git clone --depth 1 http://forgejo-http.forgejo.svc.cluster.local:3000/ashwin/nxtgauge-backend-rust.git /tmp/repo
cd /tmp/repo
docker build -f Dockerfile.simple --build-arg SERVICE_NAME=gateway -t registry.nxtgauge.com/nxtgauge-rust-gateway:latest .
docker push registry.nxtgauge.com/nxtgauge-rust-gateway:latest
rm -rf /tmp/repo

View file

@ -0,0 +1,53 @@
name: build-services
on:
push:
branches:
- main
- high-performance
jobs:
build-gateway:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build gateway
run: |
docker build -f Dockerfile.simple --build-arg SERVICE_NAME=gateway -t registry.nxtgauge.com/nxtgauge-rust-gateway:latest .
docker push registry.nxtgauge.com/nxtgauge-rust-gateway:latest
build-users:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build users
run: |
docker build -f Dockerfile.simple --build-arg SERVICE_NAME=users -t registry.nxtgauge.com/nxtgauge-rust-users:latest .
docker push registry.nxtgauge.com/nxtgauge-rust-users:latest
build-jobs:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build jobs
run: |
docker build -f Dockerfile.simple --build-arg SERVICE_NAME=jobs -t registry.nxtgauge.com/nxtgauge-rust-jobs:latest .
docker push registry.nxtgauge.com/nxtgauge-rust-jobs:latest
build-leads:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build leads
run: |
docker build -f Dockerfile.simple --build-arg SERVICE_NAME=leads -t registry.nxtgauge.com/nxtgauge-rust-leads:latest .
docker push registry.nxtgauge.com/nxtgauge-rust-leads:latest
build-cron:
runs-on: self-hosted
steps:
- uses: actions/checkout@v4
- name: Build cron
run: |
docker build -f Dockerfile.simple --build-arg SERVICE_NAME=cron -t registry.nxtgauge.com/nxtgauge-rust-cron:latest .
docker push registry.nxtgauge.com/nxtgauge-rust-cron:latest

View file

@ -0,0 +1,44 @@
name: build-all-services
on:
push:
branches:
- main
- high-performance
jobs:
build:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Build and push all services
run: |
set -e
REGISTRY="registry.nxtgauge.com"
SHA="${{ github.sha }}"
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'
echo "Logging into registry..."
echo "Ashwin@2026" | docker login $REGISTRY -u admin --password-stdin
for service in $SERVICES; do
echo ""
echo "=================================="
echo "Building $service..."
echo "=================================="
docker build -f Dockerfile.simple \
--build-arg SERVICE_NAME=$service \
-t "$REGISTRY/nxtgauge-rust-$service:$SHA" \
-t "$REGISTRY/nxtgauge-rust-$service:latest" \
. || echo "Failed to build $service"
docker push "$REGISTRY/nxtgauge-rust-$service:$SHA" || echo "Failed to push $service:$SHA"
docker push "$REGISTRY/nxtgauge-rust-$service:latest" || echo "Failed to push $service:latest"
done
echo ""
echo "All services built and pushed!"

View file

@ -0,0 +1,168 @@
name: build-and-push
on:
push:
branches:
- main
- high-performance
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
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 build|force build|rebuild all'; then
echo "services_csv=$ALL_SERVICES" >> "$GITHUB_OUTPUT"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Build everything for workflow/docker/shared backend changes
if echo "$CHANGED_FILES" | grep -Eq '^(\.forgejo/workflows/|Dockerfile|Cargo\.toml|Cargo\.lock|crates/)'; then
echo "services_csv=$ALL_SERVICES" >> "$GITHUB_OUTPUT"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
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
echo "services_csv=" >> "$GITHUB_OUTPUT"
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "services_csv=$SERVICES" >> "$GITHUB_OUTPUT"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
build:
needs: detect-changes
if: needs.detect-changes.outputs.has_changes == 'true'
runs-on: ubuntu-latest
env:
DOCKER_HOST: unix:///var/run/docker.sock
strategy:
fail-fast: false
matrix:
service:
- 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
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
run: |
export DOCKER_HOST=unix:///var/run/docker.sock
docker version
docker buildx create --use || true
docker buildx inspect --bootstrap
- name: Login to Registry
run: |
set -euo pipefail
export DOCKER_HOST=unix:///var/run/docker.sock
echo "Logging into registry.nxtgauge.com..."
echo "Ashwin@2026" | docker login registry.nxtgauge.com -u admin --password-stdin
- name: Build and push
env:
SERVICES_CSV: ${{ needs.detect-changes.outputs.services_csv }}
run: |
set -euo pipefail
export DOCKER_HOST=unix:///var/run/docker.sock
SHA="${{ github.sha }}"
if [ -n "$SERVICES_CSV" ] && ! echo ",$SERVICES_CSV," | grep -q ",${{ matrix.service }},"; then
echo "Skipping unchanged service: ${{ matrix.service }}"
exit 0
fi
echo "Building ${{ matrix.service }}..."
docker buildx build --push \
-f Dockerfile.simple \
--build-arg SERVICE_NAME=${{ matrix.service }} \
-t "registry.nxtgauge.com/nxtgauge-rust-${{ matrix.service }}:${SHA}" \
-t "registry.nxtgauge.com/nxtgauge-rust-${{ matrix.service }}:latest" \
.

View file

@ -1,273 +0,0 @@
name: build-and-push
on:
push:
branches:
- main
- high-performance
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:
needs: detect-changes
if: needs.detect-changes.outputs.has_changes == 'true'
runs-on: ubuntu-latest
env:
DOCKER_HOST: unix:///var/run/docker.sock
strategy:
fail-fast: false
matrix:
service:
- 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
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
run: |
export DOCKER_HOST=unix:///var/run/docker.sock
docker version
docker buildx create --use || true
docker buildx inspect --bootstrap
- name: Login to Registry
env:
REGISTRY_HOSTPORT: ${{ secrets.REGISTRY_HOSTPORT }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -euo pipefail
export DOCKER_HOST=unix:///var/run/docker.sock
test -n "$REGISTRY_HOSTPORT"
for attempt in 1 2 3 4 5; do
echo "Registry login attempt $attempt to $REGISTRY_HOSTPORT"
if echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOSTPORT" -u "$REGISTRY_USERNAME" --password-stdin; then
exit 0
fi
echo "Registry login failed (attempt $attempt); retrying..."
sleep $((attempt * 8))
done
echo "Registry login failed after retries"
exit 1
- name: Build and push
env:
REGISTRY_HOSTPORT: ${{ secrets.REGISTRY_HOSTPORT }}
SERVICES_CSV: ${{ needs.detect-changes.outputs.services_csv }}
run: |
set -euo pipefail
export DOCKER_HOST=unix:///var/run/docker.sock
if [ -n "$SERVICES_CSV" ] && ! echo ",$SERVICES_CSV," | grep -q ",${{ matrix.service }},"; then
echo "Skipping unchanged service: ${{ matrix.service }}"
exit 0
fi
build_with_cache() {
docker buildx build --push \
-f Dockerfile.simple \
--build-arg SERVICE_NAME=${{ matrix.service }} \
--cache-from type=registry,ref=$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:buildcache \
--cache-to type=registry,ref=$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:buildcache,mode=max \
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }}" \
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:high-performance-latest" \
.
}
build_without_cache_export() {
docker buildx build --push \
-f Dockerfile.simple \
--build-arg SERVICE_NAME=${{ matrix.service }} \
--cache-from type=registry,ref=$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:buildcache \
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }}" \
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:high-performance-latest" \
.
}
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"

160
.github/workflows/build-and-push.yaml vendored Normal file
View file

@ -0,0 +1,160 @@
name: Build and Push to Registry
on:
push:
branches:
- high-performance
- main
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
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 build|force build|rebuild all'; then
echo "services_csv=$ALL_SERVICES" >> "$GITHUB_OUTPUT"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
exit 0
fi
# Build everything for workflow/docker/shared backend changes
if echo "$CHANGED_FILES" | grep -Eq '^(\.github/workflows/|Dockerfile|Cargo\.toml|Cargo\.lock|crates/)'; then
echo "services_csv=$ALL_SERVICES" >> "$GITHUB_OUTPUT"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
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
echo "services_csv=" >> "$GITHUB_OUTPUT"
echo "has_changes=false" >> "$GITHUB_OUTPUT"
else
echo "services_csv=$SERVICES" >> "$GITHUB_OUTPUT"
echo "has_changes=true" >> "$GITHUB_OUTPUT"
fi
build:
needs: detect-changes
if: needs.detect-changes.outputs.has_changes == 'true'
runs-on: ubuntu-latest
strategy:
fail-fast: false
matrix:
service:
- 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
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Registry
uses: docker/login-action@v3
with:
registry: registry.nxtgauge.com
username: admin
password: Ashwin@2026
- name: Build and push
env:
SERVICES_CSV: ${{ needs.detect-changes.outputs.services_csv }}
run: |
set -euo pipefail
SHA="${{ github.sha }}"
if [ -n "$SERVICES_CSV" ] && ! echo ",$SERVICES_CSV," | grep -q ",${{ matrix.service }},"; then
echo "Skipping unchanged service: ${{ matrix.service }}"
exit 0
fi
docker buildx build --push \
-f Dockerfile.simple \
--build-arg SERVICE_NAME=${{ matrix.service }} \
-t "registry.nxtgauge.com/nxtgauge-rust-${{ matrix.service }}:${SHA}" \
-t "registry.nxtgauge.com/nxtgauge-rust-${{ matrix.service }}:latest" \
.

40
.github/workflows/sync-to-forgejo.yml vendored Normal file
View file

@ -0,0 +1,40 @@
name: sync-to-forgejo
on:
push:
branches:
- main
- 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 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 -euxo pipefail
export GIT_TERMINAL_PROMPT=0
export GIT_TRACE=1
export GIT_CURL_VERBOSE=1
USER="${FORGEJO_USERNAME}"
TARGET="https://ci.nxtgauge.com/${FORGEJO_OWNER}/${REPO}.git"
AUTH="$(printf '%s' "${USER}:${FORGEJO_SECRET}" | base64 -w0)"
test -n "${FORGEJO_SECRET:-}" || (echo "FORGEJO_SECRET empty" && exit 1)
curl -fsS -H "Authorization: Basic ${AUTH}" https://ci.nxtgauge.com/api/v1/user >/dev/null
curl -fsS -X POST -H "Authorization: Basic ${AUTH}" "https://ci.nxtgauge.com/api/v1/repos/${FORGEJO_OWNER}/${REPO}/mirror-sync" >/dev/null

View file

@ -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
View file

@ -599,6 +599,12 @@ dependencies = [
"fastrand",
]
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]]
name = "base64"
version = "0.22.1"
@ -958,6 +964,18 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "crypto-common"
version = "0.1.7"
@ -986,6 +1004,33 @@ dependencies = [
"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]]
name = "customers"
version = "0.1.0"
@ -1116,6 +1161,44 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "either"
version = "1.16.0"
@ -1125,6 +1208,27 @@ dependencies = [
"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]]
name = "email"
version = "0.1.0"
@ -1228,6 +1332,22 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "find-msvc-tools"
version = "0.1.9"
@ -1434,6 +1554,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [
"typenum",
"version_check",
"zeroize",
]
[[package]]
@ -1495,6 +1616,17 @@ dependencies = [
"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]]
name = "h2"
version = "0.3.27"
@ -2048,11 +2180,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
dependencies = [
"base64",
"ed25519-dalek",
"getrandom 0.2.17",
"hmac 0.12.1",
"js-sys",
"p256",
"p384",
"pem",
"rand 0.8.6",
"rsa",
"serde",
"serde_json",
"sha2 0.10.9",
"signature",
"simple_asn1",
"zeroize",
@ -2451,6 +2590,30 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
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]]
name = "parking"
version = "2.2.1"
@ -2632,6 +2795,15 @@ dependencies = [
"syn",
]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]]
name = "proc-macro2"
version = "1.0.106"
@ -2909,6 +3081,16 @@ dependencies = [
"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]]
name = "ring"
version = "0.17.14"
@ -3080,6 +3262,20 @@ dependencies = [
"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]]
name = "security-framework"
version = "3.7.0"

View file

@ -23,4 +23,5 @@ Required secrets:
- `REGISTRY_USERNAME`
- `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

View file

@ -186,12 +186,12 @@ async fn approve_company(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> 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)
.execute(&state.pool)
.await
.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(

View file

@ -222,8 +222,7 @@ async fn submit_requirement(
async fn list_requests(
State(state): State<AppState>,
Path(id): Path<Uuid>,
_auth: AuthUser,
auth: AuthUser,
Query(q): Query<PaginationQuery>,
) -> impl IntoResponse {
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>(
r#"
SELECT * FROM lead_requests
WHERE user_role_profile_id = $1
WHERE professional_user_id = $1
ORDER BY requested_at DESC
LIMIT $2 OFFSET $3
"#
)
.bind(id)
.bind(auth.user_id)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
@ -271,7 +270,7 @@ async fn approve_request(
Ok(updated) => {
match TracecoinWalletRepository::try_debit_reserved_tracecoins(
&state.pool,
lead.user_role_profile_id,
lead.user_role_profile_id.unwrap(),
lead.tracecoins_reserved,
lead.id,
).await {
@ -307,7 +306,7 @@ async fn reject_request(
Ok(updated) => {
match TracecoinWalletRepository::try_release_reserved_tracecoins(
&state.pool,
lead.user_role_profile_id,
lead.user_role_profile_id.unwrap(),
lead.tracecoins_reserved,
lead.id,
"LEAD_REJECTED",

View file

@ -83,7 +83,7 @@ impl Services {
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")
|| path.starts_with("/api/users")
|| path.starts_with("/api/v1/users")
@ -96,6 +96,7 @@ impl Services {
|| path.starts_with("/api/kb")
|| path.starts_with("/api/packages")
|| path.starts_with("/api/support")
|| path.starts_with("/api/reviews")
|| path.starts_with("/api/admin/roles")
|| path.starts_with("/api/admin/users")
|| path.starts_with("/api/admin/verifications")

View file

@ -380,6 +380,7 @@ async fn send_lead_request_ai(
};
let expires_at = chrono::Utc::now() + chrono::Duration::hours(24);
let customer_id = lead.2.clone();
let result = sqlx::query_as::<_, LeadRequestRow>(
r#"
@ -390,7 +391,7 @@ async fn send_lead_request_ai(
)
.bind(payload.lead_id)
.bind(user_role_profile_id)
.bind(user_id)
.bind(&customer_id) // customer_user_id from the lead
.bind(tracecoins_cost)
.bind(&ai_message)
.bind(expires_at)
@ -419,7 +420,7 @@ async fn send_lead_request_ai(
VALUES ($1, $2, $3, $4, $5)
"#
)
.bind(user_id)
.bind(&customer_id) // notify the customer
.bind("AI Auto-Respond Sent")
.bind("Your AI-assisted response has been sent to the customer.")
.bind("LEAD_REQUEST")

View file

@ -1,7 +1,7 @@
use axum::{
extract::State,
http::StatusCode,
routing::{get, post},
routing::{get, post, patch},
Json, Router,
};
use reqwest::Client;
@ -41,6 +41,14 @@ pub struct CreateLead {
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> {
let leads = sqlx::query_as::<_, Lead>(
"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"
}
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]
async fn main() {
tracing_subscriber::registry()
@ -130,10 +170,13 @@ async fn main() {
let app = Router::new()
.route("/health", get(health))
.route("/leads", get(list_leads))
.route("/leads", post(create_lead))
.route("/leads/{id}", get(get_lead))
.nest("/api/lead-requests", lead_requests::router())
.nest("/api", Router::new()
.route("/leads", get(list_leads))
.route("/leads", post(create_lead))
.route("/leads/{id}", get(get_lead))
.route("/leads/{id}", patch(update_lead))
.nest("/lead-requests", lead_requests::router())
)
.layer(cors)
.with_state(state);

View file

@ -133,7 +133,7 @@ async fn create_order(
sqlx::query(
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')
"#,
)
@ -248,8 +248,8 @@ async fn verify_payment(
{
sqlx::query(
r#"
INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, balance_after, reference_type, reference_id, description)
VALUES ($1, 'CREDIT', $2, $2, 'PAYMENT', $3, 'Package purchase')
INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id)
VALUES ($1, 'CREDIT', $2, 'PAYMENT', $3)
"#,
)
.bind(wallet_id)
@ -262,7 +262,7 @@ async fn verify_payment(
let _ = sqlx::query(
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)
"#,
)

View file

@ -14,6 +14,7 @@ use uuid::Uuid;
pub struct PackageTypeQuery {
pub package_type: Option<String>,
pub applicable_role: Option<String>,
pub role: Option<String>,
pub active_only: Option<bool>,
}
@ -29,48 +30,37 @@ pub struct CreatePackageRequest {
pub name: String,
pub description: Option<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 price: i32,
pub duration_days: Option<i32>,
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
pub is_promotional: Option<bool>,
pub price: Option<i32>,
pub price_inr: Option<i32>,
pub is_active: Option<bool>,
pub features: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub struct UpdatePackageRequest {
pub name: Option<String>,
pub description: Option<String>,
pub role_key: Option<String>,
pub applicable_roles: Option<Vec<String>>,
pub tracecoins_amount: Option<i32>,
pub price: Option<i32>,
pub duration_days: Option<i32>,
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
pub is_promotional: Option<bool>,
pub price_inr: Option<i32>,
pub is_active: Option<bool>,
pub features: Option<serde_json::Value>,
}
#[derive(Debug, FromRow)]
pub struct PricingPackageRow {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub role_key: String,
pub package_type: String,
pub applicable_roles: Vec<String>,
pub tracecoins_amount: i32,
pub price: i32,
pub duration_days: Option<i32>,
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
pub is_promotional: bool,
pub price_inr: i32,
pub description: Option<String>,
pub is_active: bool,
pub features: Option<serde_json::Value>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize)]
@ -78,10 +68,12 @@ pub struct PricingPackageResponse {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub package_type: String,
pub role_key: String,
pub applicable_roles: Vec<String>,
pub package_type: String,
pub tracecoins_amount: i32,
pub price: i32,
pub price_inr: i32,
pub duration_days: Option<i32>,
pub valid_from: 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 {
fn from(row: PricingPackageRow) -> Self {
let now = chrono::Utc::now();
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 {
Self {
id: row.id,
name: row.name,
description: row.description,
role_key: row.role_key.clone(),
applicable_roles: vec![row.role_key],
package_type: row.package_type,
applicable_roles: row.applicable_roles,
tracecoins_amount: row.tracecoins_amount,
price: row.price,
duration_days: row.duration_days,
valid_from: row.valid_from,
valid_until: row.valid_until,
is_promotional: row.is_promotional,
price: row.price_inr,
price_inr: row.price_inr,
duration_days: None,
valid_from: None,
valid_until: None,
is_promotional: false,
is_active: row.is_active,
features: row.features,
features: None,
created_at: row.created_at,
updated_at: row.updated_at,
is_available,
is_expired,
updated_at: row.created_at,
is_available: row.is_active,
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> {
Router::new()
.route("/", get(list_packages))
@ -138,83 +155,67 @@ async fn list_packages(
State(state): State<AppState>,
Query(q): Query<PaginationQuery>,
) -> impl IntoResponse {
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20).min(100);
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 search = q.search.unwrap_or_default().trim().to_string();
let search_filter = q.search
.as_ref()
.map(|s| format!("AND (name ILIKE '%{}%' OR description ILIKE '%{}%')", s.replace('\'', "''"), s.replace('\'', "''")))
.unwrap_or_default();
let packages = sqlx::query_as::<_, PricingPackageRow>(
let rows = sqlx::query_as::<_, PricingPackageRow>(
&format!(
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 1=1 {}
ORDER BY created_at DESC
LIMIT {} OFFSET {}
"#,
search_filter, limit, offset
)
"{} LIMIT $2 OFFSET $3",
package_query(
"WHERE ($1 = '' OR name ILIKE '%' || $1 || '%' OR COALESCE(description, '') ILIKE '%' || $1 || '%')",
"ORDER BY created_at DESC"
)
),
)
.bind(&search)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
let packages = match packages {
Ok(p) => p,
let rows = match rows {
Ok(rows) => rows,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let total: (i64,) = match sqlx::query_as(
&format!(
"SELECT COUNT(*) FROM pricing_packages WHERE 1=1 {}",
search_filter
)
let total: i64 = match sqlx::query_scalar(
"SELECT COUNT(*) FROM pricing_packages WHERE ($1 = '' OR name ILIKE '%' || $1 || '%' OR COALESCE(description, '') ILIKE '%' || $1 || '%')",
)
.bind(&search)
.fetch_one(&state.pool)
.await
{
Ok(t) => t,
Ok(total) => total,
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!({
"data": packages,
"packages": packages,
"pagination": {
"page": page,
"limit": limit,
"total": total.0,
"pages": (total.0 as f64 / limit as f64).ceil() as i64
"total": total,
"pages": (total as f64 / limit as f64).ceil() as i64
}
}))).into_response()
})))
.into_response()
}
async fn get_package(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match sqlx::query_as::<_, PricingPackageRow>(
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)
.fetch_optional(&state.pool)
.await
match sqlx::query_as::<_, PricingPackageRow>(&package_query("WHERE id = $1", ""))
.bind(id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(pkg)) => {
let response: PricingPackageResponse = pkg.into();
(StatusCode::OK, Json(response)).into_response()
}
Ok(Some(pkg)) => (StatusCode::OK, Json(PricingPackageResponse::from(pkg))).into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Package not found").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>,
Json(payload): Json<CreatePackageRequest>,
) -> 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>(
r#"
INSERT INTO pricing_packages (name, description, package_type, applicable_roles,
tracecoins_amount, price, duration_days, valid_from, valid_until,
is_promotional, is_active, features)
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
"#
INSERT INTO pricing_packages (name, role_key, package_type, tracecoins_amount, price_inr, description, is_active)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active, created_at
"#,
)
.bind(&payload.name)
.bind(&payload.description)
.bind(&role_key)
.bind(&payload.package_type)
.bind(&payload.applicable_roles)
.bind(payload.tracecoins_amount)
.bind(payload.price)
.bind(payload.duration_days)
.bind(payload.valid_from)
.bind(payload.valid_until)
.bind(payload.is_promotional.unwrap_or(false))
.bind(price_inr)
.bind(&payload.description)
.bind(payload.is_active.unwrap_or(true))
.bind(payload.features)
.fetch_one(&state.pool)
.await;
match result {
Ok(pkg) => {
let response: PricingPackageResponse = pkg.into();
(StatusCode::CREATED, Json(response)).into_response()
}
Ok(pkg) => (StatusCode::CREATED, Json(PricingPackageResponse::from(pkg))).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>,
Json(payload): Json<UpdatePackageRequest>,
) -> impl IntoResponse {
let existing = sqlx::query_as::<_, PricingPackageRow>(
"SELECT * FROM pricing_packages WHERE id = $1"
)
.bind(id)
.fetch_optional(&state.pool)
.await;
let _existing = match existing {
Ok(Some(e)) => e,
let current = match sqlx::query_as::<_, PricingPackageRow>(&package_query("WHERE id = $1", ""))
.bind(id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(pkg)) => pkg,
Ok(None) => return (StatusCode::NOT_FOUND, "Package not found").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>(
r#"
UPDATE pricing_packages SET
name = COALESCE($2, name),
description = COALESCE($3, description),
tracecoins_amount = COALESCE($4, tracecoins_amount),
price = COALESCE($5, price),
duration_days = COALESCE($6, duration_days),
valid_from = COALESCE($7, valid_from),
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()
role_key = $3,
description = COALESCE($4, description),
tracecoins_amount = COALESCE($5, tracecoins_amount),
price_inr = $6,
is_active = COALESCE($7, is_active)
WHERE id = $1
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
"#
RETURNING id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active, created_at
"#,
)
.bind(id)
.bind(&payload.name)
.bind(&role_key)
.bind(&payload.description)
.bind(payload.tracecoins_amount)
.bind(payload.price)
.bind(payload.duration_days)
.bind(payload.valid_from)
.bind(payload.valid_until)
.bind(payload.is_promotional)
.bind(price_inr)
.bind(payload.is_active)
.bind(payload.features)
.fetch_one(&state.pool)
.await;
match updated {
Ok(pkg) => {
let response: PricingPackageResponse = pkg.into();
(StatusCode::OK, Json(response)).into_response()
}
Ok(pkg) => (StatusCode::OK, Json(PricingPackageResponse::from(pkg))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
@ -329,7 +313,7 @@ async fn delete_package(
.execute(&state.pool)
.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()
}
Ok(_) => (StatusCode::NOT_FOUND, "Package not found").into_response(),
@ -341,78 +325,65 @@ async fn get_packages_by_type(
State(state): State<AppState>,
Query(q): Query<PackageTypeQuery>,
) -> impl IntoResponse {
let package_type = q.package_type.as_deref().unwrap_or("TRACECOIN_BUNDLE");
let now = chrono::Utc::now();
let package_type = q.package_type.unwrap_or_else(|| "TRACECOIN_BUNDLE".to_string());
let packages = sqlx::query_as::<_, PricingPackageRow>(
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 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
"#
let rows = sqlx::query_as::<_, PricingPackageRow>(
&package_query(
"WHERE package_type = $1 AND is_active = true",
"ORDER BY price_inr ASC, created_at DESC",
),
)
.bind(package_type)
.bind(now)
.bind(&package_type)
.fetch_all(&state.pool)
.await;
let packages = match packages {
Ok(p) => p,
let rows = match rows {
Ok(rows) => rows,
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!({
"data": packages,
"package_type": package_type
}))).into_response()
})))
.into_response()
}
async fn get_packages_for_role(
State(state): State<AppState>,
Query(q): Query<PackageTypeQuery>,
) -> 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 now = chrono::Utc::now();
let packages = sqlx::query_as::<_, PricingPackageRow>(
&format!(
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 ($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" }
)
let rows = sqlx::query_as::<_, PricingPackageRow>(
&package_query(
"WHERE ($1 = '' OR role_key = $1) AND ($2 = false OR is_active = true)",
"ORDER BY price_inr ASC, created_at DESC",
),
)
.bind(applicable_role)
.bind(now)
.bind(&role)
.bind(active_only)
.fetch_all(&state.pool)
.await;
let packages = match packages {
Ok(p) => p,
let rows = match rows {
Ok(rows) => rows,
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!({
"data": packages,
"applicable_role": applicable_role
}))).into_response()
"applicable_role": role
})))
.into_response()
}

View file

@ -165,6 +165,13 @@ fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str>
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 {
code
.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.
let role_candidates = resolve_signup_role_candidates(
payload.intent.as_deref(),
@ -304,22 +314,25 @@ async fn register(
for role_key in role_candidates {
let role_id = ensure_role_exists(&state.pool, &role_key).await;
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(
r#"
UPDATE user_role_assignments
SET status = 'APPROVED'
SET status = $3
WHERE user_id = $1 AND role_id = $2
"#,
)
.bind(user.id)
.bind(role_id)
.bind(status)
.execute(&state.pool)
.await;
let _ = sqlx::query(
r#"
INSERT INTO user_role_assignments (user_id, role_id, status)
SELECT $1, $2, 'APPROVED'
SELECT $1, $2, $3
WHERE NOT EXISTS (
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(role_id)
.bind(status)
.execute(&state.pool)
.await;
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)
let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
tracing::info!(otp = %otp, email = %email, "OTP generated for registration");
@ -384,7 +422,10 @@ async fn login(
if user.status == "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"));
}

View file

@ -3,7 +3,7 @@ use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::get,
routing::{get, patch},
Json, Router,
};
use serde::{Deserialize, Serialize};
@ -14,6 +14,8 @@ use contracts::auth_middleware::{AuthUser, require_admin};
pub fn router() -> Router<AppState> {
Router::new()
.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))
}
@ -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)]
struct CreateExternalRolePayload {
name: String,
@ -340,6 +396,14 @@ async fn update_external_role(
if let Err(_e) = require_admin(&auth) {
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() {
sqlx::query(
r#"
@ -351,7 +415,7 @@ async fn update_external_role(
)
.bind(payload.name)
.bind(payload.is_active)
.bind(id)
.bind(role_id)
.execute(&state.pool)
.await
.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
"#,
)
.bind(id)
.bind(role_id)
.execute(&state.pool)
.await
.ok();
@ -379,13 +443,38 @@ async fn update_external_role(
)
"#,
)
.bind(id)
.bind(role_id)
.bind(runtime)
.execute(&state.pool)
.await
.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(

View file

@ -18,6 +18,7 @@ pub fn public_router() -> Router<AppState> {
.route("/categories", get(public_list_categories))
.route("/articles", get(public_list_articles))
.route("/articles/{slug}", get(public_get_article))
.route("/articles/id/{id}", get(public_get_article_by_id))
}
/// 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 ─────────────────────────────────────────────────────────
async fn admin_list_categories(

View file

@ -173,8 +173,8 @@ async fn submit(
let query = format!(
r#"
INSERT INTO {} (id, custom_data, status, updated_at)
VALUES ($1, $2, 'PENDING', NOW())
INSERT INTO {} (id, user_id, custom_data, status, updated_at)
VALUES ($1, $2, $3, 'PENDING', NOW())
ON CONFLICT (id) DO UPDATE SET
custom_data = EXCLUDED.custom_data,
status = 'PENDING',
@ -185,6 +185,7 @@ async fn submit(
sqlx::query(&query)
.bind(user_role_profile_id)
.bind(auth.user_id)
.bind(&progress)
.execute(&state.pool)
.await

View file

@ -7,7 +7,7 @@ use axum::{
Json, Router,
};
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 uuid::Uuid;
@ -19,8 +19,9 @@ pub fn router() -> Router<AppState> {
.route("/submit-for-verification", post(submit_for_verification))
}
pub fn me_verification_router() -> Router<AppState> {
pub fn me_router() -> Router<AppState> {
Router::new()
.route("/", get(get_me))
.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 {
let doc_keys = [
"aadhar_doc",
@ -304,75 +312,137 @@ async fn submit_for_verification(
) -> impl IntoResponse {
let role_key = input.role_key.to_uppercase();
// Guard: reject if an active verification already exists
let existing: Result<Option<Uuid>, sqlx::Error> = sqlx::query_scalar(
r#"
SELECT id FROM verifications
WHERE user_id = $1 AND role_key = $2
AND status IN ('PENDING', 'UNDER_REVIEW', 'DOCUMENTS_REQUESTED', 'REVISION_REQUESTED')
LIMIT 1
"#,
)
.bind(auth.user_id)
.bind(&role_key)
.fetch_optional(&state.pool)
.await;
// 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);
if existing.unwrap_or(None).is_some() {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "A verification is already in progress for this role. Please wait for it to be reviewed."
})),
// 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,
)
.into_response();
}
// Fetch saved profile data or use submitted data
let profile_data = match input.profile_data {
Some(data) => data,
None => fetch_saved_profile(&state, auth.user_id, &role_key).await,
};
let documents = extract_documents(&profile_data);
// Mark profile as PENDING in role-specific table
set_profile_status(&state, auth.user_id, &role_key, "PENDING").await;
// Mark user_role as PENDING
if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await {
sqlx::query(
"UPDATE user_role_assignments SET status = 'PENDING' WHERE user_id = $1 AND role_id = $2",
.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
let existing: Result<Option<Uuid>, sqlx::Error> = sqlx::query_scalar(
r#"
SELECT id FROM verifications
WHERE user_id = $1 AND role_key = $2
AND status IN ('PENDING', 'UNDER_REVIEW', 'DOCUMENTS_REQUESTED', 'REVISION_REQUESTED')
LIMIT 1
"#,
)
.bind(auth.user_id)
.bind(role.id)
.execute(&state.pool)
.await
.ok();
}
.bind(&role_key)
.fetch_optional(&state.pool)
.await;
// Create verification record — appears in admin Verification Management
match VerificationRepository::create(
&state.pool,
auth.user_id,
&role_key,
"PROFILE_VERIFICATION",
"MEDIUM",
profile_data,
documents,
)
.await
{
Ok(v) => (
StatusCode::CREATED,
Json(serde_json::json!({
"verification_id": v.id,
"status": v.status,
"message": "Your profile has been submitted for verification. We will notify you once it has been reviewed."
})),
if existing.unwrap_or(None).is_some() {
return (
StatusCode::CONFLICT,
Json(serde_json::json!({
"error": "A verification is already in progress for this role. Please wait for it to be reviewed."
})),
)
.into_response();
}
// Fetch saved profile data or use submitted data
let profile_data = match input.profile_data {
Some(data) => data,
None => fetch_saved_profile(&state, auth.user_id, &role_key).await,
};
let documents = extract_documents(&profile_data);
// Mark profile as PENDING in role-specific table
set_profile_status(&state, auth.user_id, &role_key, "PENDING").await;
// Mark user_role as PENDING
if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await {
sqlx::query(
"UPDATE user_role_assignments SET status = 'PENDING' WHERE user_id = $1 AND role_id = $2",
)
.bind(auth.user_id)
.bind(role.id)
.execute(&state.pool)
.await
.ok();
}
// Create verification record — appears in admin Verification Management
match VerificationRepository::create(
&state.pool,
auth.user_id,
&role_key,
"PROFILE_VERIFICATION",
"MEDIUM",
profile_data,
documents,
)
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
.await
{
Ok(v) => (
StatusCode::CREATED,
Json(serde_json::json!({
"verification_id": v.id,
"status": v.status,
"message": "Your profile has been submitted for verification. We will notify you once it has been reviewed."
})),
)
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
}
@ -557,3 +627,22 @@ async fn fetch_saved_profile_by_urp_id(
}
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,
})))
}

View file

@ -1,9 +1,9 @@
use crate::AppState;
use axum::{
extract::{Path, State},
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::get,
routing::{get, post},
Json, Router,
};
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))
}
pub fn public_router() -> Router<AppState> {
Router::new()
.route("/", get(list_reviews))
.route("/professional/{professional_id}", get(list_reviews_by_professional))
}
// ── DTOs ──────────────────────────────────────────────────────────────────────
#[derive(Serialize)]
struct ReviewDto {
id: Uuid,
subject_type: String,
subject_id: Option<String>,
reviewer_name: Option<String>,
reviewer_id: Option<Uuid>,
professional_id: Uuid,
customer_id: Uuid,
rating: i16,
title: Option<String>,
comment: Option<String>,
status: String,
is_published: bool,
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)]
struct CreateReviewBody {
subject_type: Option<String>,
subject_id: Option<String>,
reviewer_name: Option<String>,
lead_request_id: Uuid,
rating: i16,
title: Option<String>,
comment: Option<String>,
}
#[derive(Deserialize)]
struct PatchReviewBody {
status: Option<String>,
is_published: Option<bool>,
}
#[derive(Deserialize)]
struct PublicListQuery {
page: Option<i64>,
limit: Option<i64>,
}
// ── FromRow structs ──────────────────────────────────────────────────────────
@ -54,14 +69,12 @@ struct PatchReviewBody {
#[derive(sqlx::FromRow)]
struct ReviewRow {
id: Uuid,
subject_type: String,
subject_id: Option<String>,
reviewer_name: Option<String>,
reviewer_id: Option<Uuid>,
lead_request_id: Uuid,
customer_id: Uuid,
professional_id: Uuid,
rating: i16,
title: Option<String>,
comment: Option<String>,
status: String,
is_published: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
@ -75,14 +88,12 @@ async fn admin_list_reviews(
r#"
SELECT
r.id,
r.subject_type,
r.subject_id,
r.reviewer_name,
r.reviewer_user_id AS reviewer_id,
r.lead_request_id,
r.customer_id,
r.professional_id,
r.rating,
r.title,
r.comment,
r.status,
r.is_published,
r.created_at
FROM reviews r
ORDER BY r.created_at DESC
@ -97,14 +108,11 @@ async fn admin_list_reviews(
.into_iter()
.map(|r| ReviewDto {
id: r.id,
subject_type: r.subject_type,
subject_id: r.subject_id,
reviewer_name: r.reviewer_name,
reviewer_id: r.reviewer_id,
professional_id: r.professional_id,
customer_id: r.customer_id,
rating: r.rating,
title: r.title,
comment: r.comment,
status: r.status,
is_published: r.is_published,
created_at: r.created_at,
})
.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();
}
let subject_type = body.subject_type.unwrap_or_else(|| "PLATFORM".to_string());
let status = "PUBLISHED".to_string();
let row = sqlx::query_as::<_, ReviewRow>(
r#"
INSERT INTO reviews (subject_type, subject_id, reviewer_name, rating, title, comment, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, subject_type, subject_id, reviewer_name, reviewer_user_id AS reviewer_id,
rating, title, comment, status, created_at
INSERT INTO reviews (lead_request_id, customer_id, professional_id, rating, comment, is_published)
SELECT $1,
(SELECT id FROM customer_profiles WHERE user_id = (SELECT user_id FROM lead_requests WHERE id = $1)),
(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.subject_id)
.bind(&body.reviewer_name)
.bind(body.lead_request_id)
.bind(body.rating)
.bind(&body.title)
.bind(&body.comment)
.bind(&status)
.fetch_one(&state.pool)
.await;
@ -151,14 +154,11 @@ async fn admin_create_review(
Ok(r) => {
let dto = ReviewDto {
id: r.id,
subject_type: r.subject_type,
subject_id: r.subject_id,
reviewer_name: r.reviewer_name,
reviewer_id: r.reviewer_id,
professional_id: r.professional_id,
customer_id: r.customer_id,
rating: r.rating,
title: r.title,
comment: r.comment,
status: r.status,
is_published: r.is_published,
created_at: r.created_at,
};
(StatusCode::CREATED, Json(serde_json::json!(dto))).into_response()
@ -176,13 +176,12 @@ async fn admin_update_review(
Path(id): Path<Uuid>,
Json(body): Json<PatchReviewBody>,
) -> 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(
"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(id)
.bind(is_published)
.bind(id)
.execute(&state.pool)
.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()
}
}
}

View file

@ -479,7 +479,7 @@ struct AdminTicketRow {
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
user_name: Option<String>,
user_email: String,
user_email: Option<String>,
}
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
FROM support_tickets t
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)
@ -526,7 +530,7 @@ async fn admin_list_cases(
.map(|r| {
// Use user info if available, fall back to requester fields
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!({
"id": r.id,
"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
FROM support_tickets t
LEFT JOIN users u ON u.id = t.user_id
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
WHERE t.id = $1
"#,
)
.bind(id)
@ -681,7 +681,7 @@ async fn admin_get_case(
.collect();
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!({
"ticket": {

View file

@ -8,6 +8,7 @@ use axum::{
};
use contracts::auth_middleware::AuthUser;
use db::models::role::RoleRepository;
use db::models::user_role_profile::UserRoleProfileRepository;
use serde::{Deserialize, Serialize};
pub fn router() -> Router<AppState> {
@ -42,6 +43,7 @@ fn is_professional_role(role_key: &str) -> bool {
| "SOCIAL_MEDIA_MANAGER"
| "FITNESS_TRAINER"
| "CATERING_SERVICES"
| "UGC_CONTENT_CREATOR"
)
}
@ -112,7 +114,14 @@ async fn register_role(
.await
.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((
StatusCode::OK,

View file

@ -72,8 +72,7 @@ async fn main() {
.nest("/api/admin/approvals", handlers::approvals::router())
.nest("/api/admin/verifications", handlers::verifications::router())
// ── Me: Profile Status + Verification Status ──────────────────────
.nest("/api/me", handlers::onboarding::me_router())
.nest("/api/me", handlers::profile::me_verification_router())
.nest("/api/me", handlers::profile::me_router())
// ── Profile (save + submit-for-verification) ──────────────────────
.nest("/api/profile", handlers::profile::router())
// ── Onboarding State (legacy, kept for compatibility) ────────────
@ -97,7 +96,8 @@ async fn main() {
.nest("/api/support/tickets", handlers::support::user_router())
// ── Support Tickets (admin) ───────────────────────────────────────
.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())
// ── Coupons & Discounts (admin) ───────────────────────────────────
.nest("/api/admin/coupons", handlers::coupons::coupons_router())

View file

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
jsonwebtoken = "10.3"
jsonwebtoken = { version = "10.3", features = ["rust_crypto"] }
argon2 = "0.5"
rand_core = { version = "0.6", features = ["std"] }
serde = { workspace = true }

View file

@ -13,7 +13,7 @@ chrono = { workspace = true }
anyhow = { workspace = true }
sqlx = { workspace = true }
async-trait = { workspace = true }
jsonwebtoken = "10.3"
jsonwebtoken = { version = "10.3", features = ["rust_crypto"] }
db = { path = "../db" }
cache = { path = "../cache" }
storage = { path = "../storage" }

View file

@ -32,6 +32,8 @@ pub struct PaginationQuery {
#[derive(Deserialize)]
pub struct LeadRequestPayload {
pub requirement_id: Uuid,
#[serde(default)]
pub message: Option<String>,
}
/// 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();
}
let db_payload = CreateLeadRequestPayload {
user_role_profile_id: user_role_profile.id,
expires_at: Utc::now() + chrono::Duration::hours(24),
};
let db_payload = CreateLeadRequestPayload::new(
req.id,
user_role_profile.id,
auth.user_id,
payload.message.clone(),
);
match LeadRequestRepository::create(&state.pool, db_payload).await {
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(),
};
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();
}

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS leads;

View 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);

View file

@ -0,0 +1 @@
DROP TABLE IF EXISTS lead_requests;

View file

@ -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);

View file

@ -0,0 +1 @@
ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS niche_tags;

View file

@ -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;

View file

@ -52,6 +52,20 @@ pub struct UpsertCompanyProfilePayload {
pub struct 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(
pool: &PgPool,
user_id: Uuid,
@ -81,6 +95,9 @@ impl CompanyRepository {
user_id: Uuid,
payload: UpsertCompanyProfilePayload,
) -> 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>(
r#"
INSERT INTO company_profiles (
@ -88,7 +105,7 @@ impl CompanyRepository {
employee_count, business_type, gst_number, contact_name,
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
company_name = EXCLUDED.company_name,
registration_number = EXCLUDED.registration_number,
@ -105,6 +122,7 @@ impl CompanyRepository {
state = EXCLUDED.state,
postal_code = EXCLUDED.postal_code,
status = CASE
WHEN $17 THEN 'APPROVED'
WHEN company_profiles.status = 'APPROVED' THEN 'APPROVED'
ELSE 'PENDING'
END,
@ -133,6 +151,8 @@ impl CompanyRepository {
.bind(payload.city)
.bind(payload.state)
.bind(payload.postal_code)
.bind(default_status)
.bind(is_dummy_account)
.fetch_one(pool)
.await?;
@ -143,10 +163,13 @@ impl CompanyRepository {
pool: &PgPool,
user_id: Uuid,
) -> 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>(
r#"
UPDATE company_profiles
SET status = 'PENDING_REVIEW', updated_at = NOW()
SET status = $2, updated_at = NOW()
WHERE user_id = $1
RETURNING
id, user_id, company_name, registration_number, industry,
@ -158,6 +181,7 @@ impl CompanyRepository {
"#,
)
.bind(user_id)
.bind(next_status)
.fetch_one(pool)
.await?;

View file

@ -6,20 +6,41 @@ use uuid::Uuid;
#[derive(Debug, Serialize, Deserialize, FromRow)]
pub struct LeadRequest {
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 tracecoins_reserved: i32,
pub remarks: Option<String>,
pub expires_at: DateTime<Utc>,
pub requested_at: DateTime<Utc>,
pub resolved_at: Option<DateTime<Utc>>,
pub remarks: Option<String>,
pub created_at: DateTime<Utc>,
pub updated_at: DateTime<Utc>,
}
#[derive(Debug, Serialize, Deserialize)]
pub struct CreateLeadRequestPayload {
pub lead_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;
@ -31,13 +52,15 @@ impl LeadRequestRepository {
) -> Result<LeadRequest, sqlx::Error> {
let req = sqlx::query_as::<_, LeadRequest>(
r#"
INSERT INTO lead_requests (user_role_profile_id, expires_at)
VALUES ($1, $2)
INSERT INTO lead_requests (lead_id, user_role_profile_id, professional_user_id, remarks)
VALUES ($1, $2, $3, $4)
RETURNING *
"#,
)
.bind(payload.lead_id)
.bind(payload.user_role_profile_id)
.bind(payload.expires_at)
.bind(payload.professional_user_id)
.bind(&payload.remarks)
.fetch_one(pool)
.await?;
@ -92,6 +115,7 @@ impl LeadRequestRepository {
.bind(id)
.fetch_one(pool)
.await?;
Ok(req)
}
}

View file

@ -61,6 +61,30 @@ impl VerificationRepository {
.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> {
sqlx::query_as::<_, Verification>("SELECT * FROM verifications WHERE id = $1")
.bind(id)