Compare commits

..

29 commits

Author SHA1 Message Date
Ashwin Kumar Sivakumar
349673b7f8 fix(ci): unify backend build flow through forgejo 2026-06-14 01:20:24 +05:30
Ashwin Kumar Sivakumar
de1b6fe828 chore: trigger build
Some checks failed
build-all / build-users (push) Failing after 2s
build-all / build-cron (push) Failing after 2s
build-services / build-gateway (push) Failing after 4s
build-services / build-users (push) Failing after 3s
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-all-services / build (push) Failing after 2s
build-and-push / detect-changes (push) Successful in 32s
build-and-push / build (developers) (push) Failing after 2s
build-gateway / build (push) Failing after 30s
build-and-push / build (catering-services) (push) Failing after 2s
build-and-push / build (companies) (push) Failing after 3s
build-and-push / build (employees) (push) Failing after 3s
build-and-push / build (fitness-trainers) (push) Failing after 2s
build-and-push / build (gateway) (push) Failing after 2s
build-and-push / build (graphic-designers) (push) Failing after 3s
build-and-push / build (job-seekers) (push) Failing after 2s
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 2s
build-and-push / build (payments) (push) Failing after 2s
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 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 3s
build-and-push / build (cron) (push) Successful in 6m15s
build-and-push / build (customers) (push) Successful in 9m40s
2026-06-13 23:29:43 +05:30
Ashwin Kumar Sivakumar
6dfb7a1a2e chore: rebuild all services 2026-06-13 21:33:02 +05:30
Ashwin Kumar Sivakumar
a7501eafc8 chore: rebuild all - clean build 2026-06-13 20:41:38 +05:30
Ashwin Kumar Sivakumar
1c9b4848a9 feat: Add Dockerfiles for jobs/leads services and build scripts
Some checks failed
build-all / build-users (push) Failing after 1s
build-all / build-cron (push) Failing after 2s
build-gateway / build (push) Failing after 4s
build-services / build-gateway (push) Failing after 3s
build-services / build-users (push) Failing after 5s
build-services / build-jobs (push) Failing after 3s
build-and-push / detect-changes (push) Successful in 8s
build-services / build-cron (push) Failing after 2s
build-services / build-leads (push) Failing after 4s
build-all-services / build (push) Failing after 3s
build-and-push / build (cron) (push) Failing after 5s
build-and-push / build (customers) (push) Failing after 3s
build-and-push / build (employees) (push) Failing after 3s
build-and-push / build (developers) (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 (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 2s
build-and-push / build (payments) (push) Failing after 2s
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 3s
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
build-and-push / build (companies) (push) Failing after 10m52s
build-and-push / build (catering-services) (push) Successful in 11m29s
Add build-images.yaml for Forgejo CI
tmp build.yaml
Add Dockerfiles for jobs and leads services
Add build-all.sh script for batch building
2026-06-13 18:31:34 +05:30
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
39 changed files with 1568 additions and 653 deletions

View file

@ -11,7 +11,7 @@ Usage:
This script: This script:
1. Updates the newTag for the specified service to the SHA 1. Updates the newTag for the specified service to the SHA
2. Commits and pushes to the gitops repo 2. Commits and pushes to the gitops repo
3. ArgoCD detects the change and deploys 3. Flux detects the change and deploys
""" """
import argparse import argparse
@ -98,10 +98,14 @@ def main():
image_name = f"nxtgauge-{args.service}" image_name = f"nxtgauge-{args.service}"
# Find the right kustomization file based on service # Find the right kustomization file based on service
if "frontend" in args.service or "admin" in args.service: if "frontend" in args.service:
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-frontend-solid/overlays/prod/kustomization.yaml") kustomization_path = os.path.join(args.repo, "apps/nxtgauge-frontend-solid/overlays/prod/kustomization.yaml")
if not os.path.exists(kustomization_path): if not os.path.exists(kustomization_path):
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-frontend-solid/base/kustomization.yaml") kustomization_path = os.path.join(args.repo, "apps/nxtgauge-frontend-solid/base/kustomization.yaml")
elif "admin" in args.service:
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-admin-solid/overlays/prod/kustomization.yaml")
if not os.path.exists(kustomization_path):
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-admin-solid/base/kustomization.yaml")
elif "ai-assistant" in args.service: elif "ai-assistant" in args.service:
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-ai-assistant/overlays/prod/kustomization.yaml") kustomization_path = os.path.join(args.repo, "apps/nxtgauge-ai-assistant/overlays/prod/kustomization.yaml")
if not os.path.exists(kustomization_path): if not os.path.exists(kustomization_path):

View file

@ -0,0 +1,164 @@
name: build-and-release
on:
push:
branches:
- main
- high-performance
concurrency:
group: backend-build-${{ github.ref }}
cancel-in-progress: true
jobs:
build:
runs-on: self-hosted
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed services
run: |
set -euo pipefail
ALL_SERVICES='gateway users companies jobs leads job-seekers customers payments employees photographers makeup-artists tutors developers video-editors graphic-designers social-media-managers fitness-trainers catering-services ugc-content-creators cron'
if git rev-parse --verify HEAD^ >/dev/null 2>&1; then
CHANGED_FILES="$(git diff --name-only HEAD^ HEAD)"
else
CHANGED_FILES="$(git ls-files)"
fi
LAST_COMMIT_MSG="$(git log -1 --pretty=%B | tr '\n' ' ')"
force_full_build=false
if echo "$LAST_COMMIT_MSG" | grep -Eiq 'trigger build|force build|rebuild all'; then
force_full_build=true
elif echo "$CHANGED_FILES" | grep -Eq '^(\.forgejo/workflows/|Dockerfile|Cargo\.toml|Cargo\.lock|crates/|scripts/)'; then
force_full_build=true
fi
if [ "$force_full_build" = true ]; then
printf '%s\n' $ALL_SERVICES > /tmp/changed-services.txt
exit 0
fi
: > /tmp/changed-services.txt
while IFS= read -r f; do
case "$f" in
apps/gateway/*) echo gateway >> /tmp/changed-services.txt ;;
apps/users/*) echo users >> /tmp/changed-services.txt ;;
apps/companies/*) echo companies >> /tmp/changed-services.txt ;;
apps/jobs/*) echo jobs >> /tmp/changed-services.txt ;;
apps/leads/*) echo leads >> /tmp/changed-services.txt ;;
apps/job_seekers/*) echo job-seekers >> /tmp/changed-services.txt ;;
apps/customers/*) echo customers >> /tmp/changed-services.txt ;;
apps/payments/*) echo payments >> /tmp/changed-services.txt ;;
apps/employees/*) echo employees >> /tmp/changed-services.txt ;;
apps/photographers/*) echo photographers >> /tmp/changed-services.txt ;;
apps/makeup_artists/*) echo makeup-artists >> /tmp/changed-services.txt ;;
apps/tutors/*) echo tutors >> /tmp/changed-services.txt ;;
apps/developers/*) echo developers >> /tmp/changed-services.txt ;;
apps/video_editors/*) echo video-editors >> /tmp/changed-services.txt ;;
apps/graphic_designers/*) echo graphic-designers >> /tmp/changed-services.txt ;;
apps/social_media_managers/*) echo social-media-managers >> /tmp/changed-services.txt ;;
apps/fitness_trainers/*) echo fitness-trainers >> /tmp/changed-services.txt ;;
apps/catering_services/*) echo catering-services >> /tmp/changed-services.txt ;;
apps/ugc_content_creators/*) echo ugc-content-creators >> /tmp/changed-services.txt ;;
apps/cron/*) echo cron >> /tmp/changed-services.txt ;;
esac
done <<EOF2
$CHANGED_FILES
EOF2
sort -u /tmp/changed-services.txt -o /tmp/changed-services.txt
- name: Stop if nothing changed
run: |
set -euo pipefail
if [ ! -s /tmp/changed-services.txt ]; then
echo "No backend service changes detected."
exit 0
fi
- name: Set up Docker Buildx
run: |
set -euo pipefail
[ -s /tmp/changed-services.txt ] || exit 0
docker version
docker buildx create --use --name nxtgauge-builder || docker buildx use nxtgauge-builder
docker buildx inspect --bootstrap
- name: Login to registry
env:
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME || 'admin' }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD || 'Ashwin@2026' }}
run: |
set -euo pipefail
[ -s /tmp/changed-services.txt ] || exit 0
printf '%s' "$REGISTRY_PASSWORD" | docker login registry.nxtgauge.com -u "$REGISTRY_USERNAME" --password-stdin
- name: Build changed services
env:
SHA: ${{ github.sha }}
run: |
set -euo pipefail
[ -s /tmp/changed-services.txt ] || exit 0
: > /tmp/built-services.tsv
while IFS= read -r service; do
[ -n "$service" ] || continue
metadata_file="/tmp/${service}-metadata.json"
image_ref="registry.nxtgauge.com/nxtgauge-rust-${service}:${SHA}"
docker buildx build --push \
--metadata-file "$metadata_file" \
-f Dockerfile.simple \
--build-arg SERVICE_NAME="$service" \
-t "$image_ref" \
.
digest="$(grep -o '"containerimage.digest":"sha256:[^"]*"' "$metadata_file" | cut -d'"' -f4)"
if [ -z "$digest" ]; then
echo "Failed to determine digest for $service" >&2
exit 1
fi
printf '%s\t%s\n' "$service" "$digest" >> /tmp/built-services.tsv
done < /tmp/changed-services.txt
- name: Update GitOps release state
env:
GITOPS_OWNER: ${{ secrets.GITOPS_OWNER || 'Traceworks2023' }}
GITOPS_REPO: ${{ secrets.GITOPS_REPO || 'nxtgauge-gitops' }}
GITOPS_BRANCH: ${{ secrets.GITOPS_BRANCH || 'main' }}
GITOPS_PUSH_USERNAME: ${{ secrets.GITOPS_PUSH_USERNAME || 'Traceworks2023' }}
GITOPS_PUSH_TOKEN: ${{ secrets.GITOPS_PUSH_TOKEN || secrets.GITHUB_PAT }}
SHA: ${{ github.sha }}
run: |
set -euo pipefail
[ -s /tmp/built-services.tsv ] || exit 0
test -n "${GITOPS_PUSH_TOKEN:-}" || { echo "GITOPS_PUSH_TOKEN is empty"; exit 1; }
git clone "https://${GITOPS_PUSH_USERNAME}:${GITOPS_PUSH_TOKEN}@github.com/${GITOPS_OWNER}/${GITOPS_REPO}.git" /tmp/nxtgauge-gitops
cd /tmp/nxtgauge-gitops
git checkout "$GITOPS_BRANCH"
while IFS=$'\t' read -r service digest; do
[ -n "$service" ] || continue
./scripts/set-backend-rust-release.sh "$service" "$digest"
done < /tmp/built-services.tsv
if git diff --quiet; then
echo "GitOps repo already up to date."
exit 0
fi
git config user.name "forgejo-actions[bot]"
git config user.email "forgejo-actions@ci.nxtgauge.com"
git add \
apps/nxtgauge-backend-rust/overlays/prod/backend-release-state.tsv \
apps/nxtgauge-backend-rust/overlays/prod/release-patches.yaml \
apps/nxtgauge-backend-rust/overlays/prod/disabled-deployments.yaml
git commit -m "chore(gitops): update backend rust images for ${SHA}"
git push origin "HEAD:${GITOPS_BRANCH}"

View file

@ -1,4 +1,4 @@
name: build-and-push name: Build Backend And Update GitOps
on: on:
push: push:
@ -7,267 +7,110 @@ on:
- high-performance - high-performance
jobs: jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
services_csv: ${{ steps.detect.outputs.services_csv }}
has_changes: ${{ steps.detect.outputs.has_changes }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed services
id: detect
run: |
set -euo pipefail
set_output() {
local key="$1"
local value="$2"
if [ -n "${GITHUB_OUTPUT:-}" ]; then
echo "$key=$value" >> "$GITHUB_OUTPUT"
fi
echo "::set-output name=$key::$value"
}
if git rev-parse --verify HEAD^ >/dev/null 2>&1; then
CHANGED_FILES=$(git diff --name-only HEAD^ HEAD)
else
CHANGED_FILES=$(git ls-files)
fi
LAST_COMMIT_MSG=$(git log -1 --pretty=%B | tr '\n' ' ')
echo "Changed files:"
echo "$CHANGED_FILES"
ALL_SERVICES='gateway,users,companies,jobs,leads,job-seekers,customers,payments,employees,photographers,makeup-artists,tutors,developers,video-editors,graphic-designers,social-media-managers,fitness-trainers,catering-services,ugc-content-creators,cron'
# Force full build for explicit trigger commits.
if echo "$LAST_COMMIT_MSG" | grep -Eiq 'trigger gitea pipeline|force build|rebuild all'; then
set_output "services_csv" "$ALL_SERVICES"
set_output "has_changes" "true"
exit 0
fi
# Build everything for workflow/docker/shared backend changes.
if echo "$CHANGED_FILES" | grep -Eq '^(\.gitea/workflows/|Dockerfile|Dockerfile\.|Cargo\.toml|Cargo\.lock|crates/|scripts/)'; then
set_output "services_csv" "$ALL_SERVICES"
set_output "has_changes" "true"
exit 0
fi
SERVICES=''
add_service() {
local svc="$1"
case ",${SERVICES}," in
*",${svc},"*) ;;
*)
if [ -z "$SERVICES" ]; then
SERVICES="$svc"
else
SERVICES="$SERVICES,$svc"
fi
;;
esac
}
while IFS= read -r f; do
case "$f" in
apps/gateway/*) add_service "gateway" ;;
apps/users/*) add_service "users" ;;
apps/companies/*) add_service "companies" ;;
apps/jobs/*) add_service "jobs" ;;
apps/leads/*) add_service "leads" ;;
apps/job_seekers/*) add_service "job-seekers" ;;
apps/customers/*) add_service "customers" ;;
apps/payments/*) add_service "payments" ;;
apps/employees/*) add_service "employees" ;;
apps/photographers/*) add_service "photographers" ;;
apps/makeup_artists/*) add_service "makeup-artists" ;;
apps/tutors/*) add_service "tutors" ;;
apps/developers/*) add_service "developers" ;;
apps/video_editors/*) add_service "video-editors" ;;
apps/graphic_designers/*) add_service "graphic-designers" ;;
apps/social_media_managers/*) add_service "social-media-managers" ;;
apps/fitness_trainers/*) add_service "fitness-trainers" ;;
apps/catering_services/*) add_service "catering-services" ;;
apps/ugc_content_creators/*) add_service "ugc-content-creators" ;;
apps/cron/*) add_service "cron" ;;
esac
done <<< "$CHANGED_FILES"
if [ -z "$SERVICES" ]; then
set_output "services_csv" ""
set_output "has_changes" "false"
else
set_output "services_csv" "$SERVICES"
set_output "has_changes" "true"
fi
build: build:
needs: detect-changes
if: needs.detect-changes.outputs.has_changes == 'true'
runs-on: ubuntu-latest runs-on: ubuntu-latest
env: env:
DOCKER_HOST: unix:///var/run/docker.sock DOCKER_HOST: unix:///var/run/docker.sock
strategy: strategy:
fail-fast: false fail-fast: false
matrix: matrix:
service: include:
- gateway - app_dir: apps/catering_services
- users image: registry.nxtgauge.com/nxtgauge-rust-catering-services
- companies - app_dir: apps/companies
- jobs image: registry.nxtgauge.com/nxtgauge-rust-companies
- leads - app_dir: apps/cron
- job-seekers image: registry.nxtgauge.com/nxtgauge-rust-cron
- customers - app_dir: apps/customers
- payments image: registry.nxtgauge.com/nxtgauge-rust-customers
- employees - app_dir: apps/developers
- photographers image: registry.nxtgauge.com/nxtgauge-rust-developers
- makeup-artists - app_dir: apps/employees
- tutors image: registry.nxtgauge.com/nxtgauge-rust-employees
- developers - app_dir: apps/fitness_trainers
- video-editors image: registry.nxtgauge.com/nxtgauge-rust-fitness-trainers
- graphic-designers - app_dir: apps/gateway
- social-media-managers image: registry.nxtgauge.com/nxtgauge-rust-gateway
- fitness-trainers - app_dir: apps/graphic_designers
- catering-services image: registry.nxtgauge.com/nxtgauge-rust-graphic-designers
- ugc-content-creators - app_dir: apps/jobs
- cron image: registry.nxtgauge.com/nxtgauge-rust-jobs
- app_dir: apps/job_seekers
image: registry.nxtgauge.com/nxtgauge-rust-job-seekers
- app_dir: apps/leads
image: registry.nxtgauge.com/nxtgauge-rust-leads
- app_dir: apps/makeup_artists
image: registry.nxtgauge.com/nxtgauge-rust-makeup-artists
- app_dir: apps/payments
image: registry.nxtgauge.com/nxtgauge-rust-payments
- app_dir: apps/photographers
image: registry.nxtgauge.com/nxtgauge-rust-photographers
- app_dir: apps/social_media_managers
image: registry.nxtgauge.com/nxtgauge-rust-social-media-managers
- app_dir: apps/tutors
image: registry.nxtgauge.com/nxtgauge-rust-tutors
- app_dir: apps/ugc_content_creators
image: registry.nxtgauge.com/nxtgauge-rust-ugc-content-creators
- app_dir: apps/users
image: registry.nxtgauge.com/nxtgauge-rust-users
- app_dir: apps/video_editors
image: registry.nxtgauge.com/nxtgauge-rust-video-editors
steps: steps:
- name: Checkout - name: Checkout
uses: actions/checkout@v4 uses: actions/checkout@v4
- name: Set up Docker Buildx - name: Install Docker CLI
run: | run: |
export DOCKER_HOST=unix:///var/run/docker.sock apt-get update
docker version apt-get install -y docker.io
docker buildx create --use || true
docker buildx inspect --bootstrap
- name: Login to Registry - name: Log in to registry
env: run: |
REGISTRY_HOSTPORT: ${{ secrets.REGISTRY_HOSTPORT }} echo "${{ secrets.REGISTRY_PASSWORD }}" | docker login registry.nxtgauge.com -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} - name: Build and push backend service image
run: | run: |
set -euo pipefail set -euo pipefail
export DOCKER_HOST=unix:///var/run/docker.sock IMAGE_SHA="${{ matrix.image }}:${{ github.sha }}"
test -n "$REGISTRY_HOSTPORT" IMAGE_LATEST="${{ matrix.image }}:latest"
for attempt in 1 2 3 4 5; do docker build -f "${{ matrix.app_dir }}/Dockerfile" -t "${IMAGE_SHA}" -t "${IMAGE_LATEST}" .
echo "Registry login attempt $attempt to $REGISTRY_HOSTPORT" docker push "${IMAGE_SHA}"
if echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOSTPORT" -u "$REGISTRY_USERNAME" --password-stdin; then docker push "${IMAGE_LATEST}"
exit 0
fi
echo "Registry login failed (attempt $attempt); retrying..."
sleep $((attempt * 8))
done
echo "Registry login failed after retries" update-gitops:
exit 1 needs: build
runs-on: ubuntu-latest
- name: Build and push steps:
- name: Update GitOps backend tags
env: env:
REGISTRY_HOSTPORT: ${{ secrets.REGISTRY_HOSTPORT }} GITOPS_USERNAME: ${{ secrets.GITOPS_GITHUB_USERNAME || 'Traceworks2023' }}
SERVICES_CSV: ${{ needs.detect-changes.outputs.services_csv }} GITOPS_PASSWORD: ${{ secrets.GITOPS_GITHUB_TOKEN || secrets.GITOPS_PAT }}
GITOPS_REPO: https://github.com/Traceworks2023/nxtgauge-gitops.git
IMAGE_TAG: ${{ github.sha }}
run: | run: |
set -euo pipefail set -euo pipefail
export DOCKER_HOST=unix:///var/run/docker.sock test -n "${GITOPS_PASSWORD:-}" || { echo "GITOPS_PASSWORD is empty"; exit 1; }
if [ -n "$SERVICES_CSV" ] && ! echo ",$SERVICES_CSV," | grep -q ",${{ matrix.service }},"; then AUTH="$(printf '%s' "${GITOPS_USERNAME}:${GITOPS_PASSWORD}" | base64 -w0)"
echo "Skipping unchanged service: ${{ matrix.service }}" TMP_DIR="$(mktemp -d)"
exit 0 git -c http.extraHeader="AUTHORIZATION: basic ${AUTH}" clone --branch main "${GITOPS_REPO}" "${TMP_DIR}"
fi cd "${TMP_DIR}"
python3 - <<'PY'
build_with_cache() { from pathlib import Path
docker buildx build --push \ import os
-f Dockerfile.simple \ path = Path('apps/nxtgauge-backend-rust/overlays/prod/kustomization.yaml')
--build-arg SERVICE_NAME=${{ matrix.service }} \ lines = path.read_text().splitlines()
--cache-from type=registry,ref=$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:buildcache \ out = []
--cache-to type=registry,ref=$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:buildcache,mode=max \ for line in lines:
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }}" \ if line.strip().startswith('newTag:'):
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:high-performance-latest" \ indent = line[:len(line) - len(line.lstrip())]
. out.append(f"{indent}newTag: {os.environ['IMAGE_TAG']}")
} else:
out.append(line)
build_without_cache_export() { path.write_text('\n'.join(out) + '\n')
docker buildx build --push \ PY
-f Dockerfile.simple \ git config user.name "forgejo-actions"
--build-arg SERVICE_NAME=${{ matrix.service }} \ git config user.email "forgejo-actions@nxtgauge.com"
--cache-from type=registry,ref=$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:buildcache \ git add apps/nxtgauge-backend-rust/overlays/prod/kustomization.yaml
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }}" \ git diff --cached --quiet && exit 0
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:high-performance-latest" \ git commit -m "chore(gitops): update backend images to ${IMAGE_TAG}"
. git -c http.extraHeader="AUTHORIZATION: basic ${AUTH}" push origin main
}
for attempt in 1 2 3; do
echo "Build attempt $attempt with cache export for ${{ matrix.service }}"
if build_with_cache; then
exit 0
fi
echo "Attempt $attempt failed; retrying after backoff"
sleep $((attempt * 10))
done
echo "Falling back to build without cache export for ${{ matrix.service }}"
if ! build_without_cache_export; then
echo "Final fallback: push tags without cache"
docker buildx build --push \
-f Dockerfile.simple \
--build-arg SERVICE_NAME=${{ matrix.service }} \
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }}" \
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:high-performance-latest" \
.
fi
- name: Prune old image tags (keep latest 1 SHA)
if: success()
continue-on-error: true
env:
REGISTRY_HOST: ${{ secrets.REGISTRY_HOSTPORT }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -euo pipefail
python3 .gitea/scripts/registry_prune.py \
--registry "$REGISTRY_HOST" \
--repo "nxtgauge-rust-${{ matrix.service }}" \
--username "$REGISTRY_USERNAME" \
--password "$REGISTRY_PASSWORD" \
--keep 1
- name: Update GitOps and trigger deployment
if: always()
continue-on-error: true
env:
GITEOPS_REPO: ${{ secrets.GITEOPS_REPO }}
GITEOPS_SSH_KEY: ${{ secrets.GITEOPS_SSH_KEY }}
run: |
set -euo pipefail
if [ -z "$GITEOPS_REPO" ]; then
echo "GITEOPS_REPO secret not set, skipping GitOps update"
exit 0
fi
# Clone gitops repo
GITEOPS_DIR=$(mktemp -d)
git clone "$GITEOPS_REPO" "$GITEOPS_DIR"
cd "$GITEOPS_DIR"
# Set up SSH key for push
mkdir -p ~/.ssh
echo "$GITEOPS_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
# Update gitops with new SHA
python3 .gitea/scripts/update-gitops.py \
--repo "$GITEOPS_DIR" \
--service "${{ matrix.service }}" \
--sha "${{ gitea.sha }}" \
--message "chore: deploy ${{ matrix.service }}@${{ gitea.sha }}"
rm -rf "$GITEOPS_DIR"

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

@ -0,0 +1,40 @@
name: sync-to-forgejo
on:
push:
branches:
- main
- high-performance
jobs:
sync:
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Push branch to Forgejo
env:
FORGEJO_SECRET: ${{ secrets.FORGEJO_SECRET || secrets.GITEA_SECRET }}
FORGEJO_OWNER: ${{ secrets.FORGEJO_OWNER || 'ashwin' }}
FORGEJO_USERNAME: ${{ secrets.FORGEJO_USERNAME || secrets.GITEA_USERNAME || 'ashwin' }}
REPO: ${{ github.event.repository.name }}
BRANCH: ${{ github.ref_name }}
run: |
set -euo pipefail
test -n "${FORGEJO_SECRET:-}" || { echo "FORGEJO_SECRET is empty"; exit 1; }
AUTH="$(printf '%s' "${FORGEJO_USERNAME}:${FORGEJO_SECRET}" | base64 -w0)"
TARGET="https://ci.nxtgauge.com/${FORGEJO_OWNER}/${REPO}.git"
git config user.name "github-actions[bot]"
git config user.email "41898282+github-actions[bot]@users.noreply.github.com"
git remote remove forgejo 2>/dev/null || true
git remote add forgejo "${TARGET}"
git -c http.extraHeader="AUTHORIZATION: basic ${AUTH}" push forgejo "HEAD:${BRANCH}" --force
git -c http.extraHeader="AUTHORIZATION: basic ${AUTH}" push forgejo --tags --force

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", "fastrand",
] ]
[[package]]
name = "base16ct"
version = "0.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "4c7f02d4ea65f2c1853089ffd8d2787bdbc63de2f0d29dedbcf8ccdfa0ccd4cf"
[[package]] [[package]]
name = "base64" name = "base64"
version = "0.22.1" version = "0.22.1"
@ -958,6 +964,18 @@ version = "0.8.21"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28" checksum = "d0a5c400df2834b80a4c3327b3aad3a4c4cd4de0629063962b03235697506a28"
[[package]]
name = "crypto-bigint"
version = "0.5.5"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "0dc92fb57ca44df6db8059111ab3af99a63d5d0f8375d9972e319a379c6bab76"
dependencies = [
"generic-array",
"rand_core 0.6.4",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "crypto-common" name = "crypto-common"
version = "0.1.7" version = "0.1.7"
@ -986,6 +1004,33 @@ dependencies = [
"cmov", "cmov",
] ]
[[package]]
name = "curve25519-dalek"
version = "4.1.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "97fb8b7c4503de7d6ae7b42ab72a5a59857b4c937ec27a3d4539dba95b5ab2be"
dependencies = [
"cfg-if",
"cpufeatures 0.2.17",
"curve25519-dalek-derive",
"digest 0.10.7",
"fiat-crypto",
"rustc_version",
"subtle",
"zeroize",
]
[[package]]
name = "curve25519-dalek-derive"
version = "0.1.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f46882e17999c6cc590af592290432be3bce0428cb0d5f8b6715e4dc7b383eb3"
dependencies = [
"proc-macro2",
"quote",
"syn",
]
[[package]] [[package]]
name = "customers" name = "customers"
version = "0.1.0" version = "0.1.0"
@ -1116,6 +1161,44 @@ version = "1.0.5"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813" checksum = "92773504d58c093f6de2459af4af33faa518c13451eb8f2b5698ed3d36e7c813"
[[package]]
name = "ecdsa"
version = "0.16.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "ee27f32b5c5292967d2d4a9d7f1e0b0aed2c15daded5a60300e4abb9d8020bca"
dependencies = [
"der",
"digest 0.10.7",
"elliptic-curve",
"rfc6979",
"signature",
"spki",
]
[[package]]
name = "ed25519"
version = "2.2.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "115531babc129696a58c64a4fef0a8bf9e9698629fb97e9e40767d235cfbcd53"
dependencies = [
"pkcs8",
"signature",
]
[[package]]
name = "ed25519-dalek"
version = "2.2.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "70e796c081cee67dc755e1a36a0a172b897fab85fc3f6bc48307991f64e4eca9"
dependencies = [
"curve25519-dalek",
"ed25519",
"serde",
"sha2 0.10.9",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "either" name = "either"
version = "1.16.0" version = "1.16.0"
@ -1125,6 +1208,27 @@ dependencies = [
"serde", "serde",
] ]
[[package]]
name = "elliptic-curve"
version = "0.13.8"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "b5e6043086bf7973472e0c7dff2142ea0b680d30e18d9cc40f267efbf222bd47"
dependencies = [
"base16ct",
"crypto-bigint",
"digest 0.10.7",
"ff",
"generic-array",
"group",
"hkdf",
"pem-rfc7468",
"pkcs8",
"rand_core 0.6.4",
"sec1",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "email" name = "email"
version = "0.1.0" version = "0.1.0"
@ -1228,6 +1332,22 @@ version = "2.4.1"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6" checksum = "9f1f227452a390804cdb637b74a86990f2a7d7ba4b7d5693aac9b4dd6defd8d6"
[[package]]
name = "ff"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c0b50bfb653653f9ca9095b427bed08ab8d75a137839d9ad64eb11810d5b6393"
dependencies = [
"rand_core 0.6.4",
"subtle",
]
[[package]]
name = "fiat-crypto"
version = "0.2.9"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "28dea519a9695b9977216879a3ebfddf92f1c08c05d984f8996aecd6ecdc811d"
[[package]] [[package]]
name = "find-msvc-tools" name = "find-msvc-tools"
version = "0.1.9" version = "0.1.9"
@ -1434,6 +1554,7 @@ checksum = "85649ca51fd72272d7821adaf274ad91c288277713d9c18820d8499a7ff69e9a"
dependencies = [ dependencies = [
"typenum", "typenum",
"version_check", "version_check",
"zeroize",
] ]
[[package]] [[package]]
@ -1495,6 +1616,17 @@ dependencies = [
"uuid", "uuid",
] ]
[[package]]
name = "group"
version = "0.13.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f0f9ef7462f7c099f518d754361858f86d8a07af53ba9af0fe635bbccb151a63"
dependencies = [
"ff",
"rand_core 0.6.4",
"subtle",
]
[[package]] [[package]]
name = "h2" name = "h2"
version = "0.3.27" version = "0.3.27"
@ -2048,11 +2180,18 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc" checksum = "eba32bfb4ffdeaca3e34431072faf01745c9b26d25504aa7a6cf5684334fc4fc"
dependencies = [ dependencies = [
"base64", "base64",
"ed25519-dalek",
"getrandom 0.2.17", "getrandom 0.2.17",
"hmac 0.12.1",
"js-sys", "js-sys",
"p256",
"p384",
"pem", "pem",
"rand 0.8.6",
"rsa",
"serde", "serde",
"serde_json", "serde_json",
"sha2 0.10.9",
"signature", "signature",
"simple_asn1", "simple_asn1",
"zeroize", "zeroize",
@ -2451,6 +2590,30 @@ version = "0.5.2"
source = "registry+https://github.com/rust-lang/crates.io-index" source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e" checksum = "1a80800c0488c3a21695ea981a54918fbb37abf04f4d0720c453632255e2ff0e"
[[package]]
name = "p256"
version = "0.13.2"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "c9863ad85fa8f4460f9c48cb909d38a0d689dba1f6f6988a5e3e0d31071bcd4b"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2 0.10.9",
]
[[package]]
name = "p384"
version = "0.13.1"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "fe42f1670a52a47d448f14b6a5c61dd78fce51856e68edaa38f7ae3a46b8d6b6"
dependencies = [
"ecdsa",
"elliptic-curve",
"primeorder",
"sha2 0.10.9",
]
[[package]] [[package]]
name = "parking" name = "parking"
version = "2.2.1" version = "2.2.1"
@ -2632,6 +2795,15 @@ dependencies = [
"syn", "syn",
] ]
[[package]]
name = "primeorder"
version = "0.13.6"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "353e1ca18966c16d9deb1c69278edbc5f194139612772bd9537af60ac231e1e6"
dependencies = [
"elliptic-curve",
]
[[package]] [[package]]
name = "proc-macro2" name = "proc-macro2"
version = "1.0.106" version = "1.0.106"
@ -2909,6 +3081,16 @@ dependencies = [
"webpki-roots 1.0.7", "webpki-roots 1.0.7",
] ]
[[package]]
name = "rfc6979"
version = "0.4.0"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "f8dd2a808d456c4a54e300a23e9f5a67e122c3024119acbfd73e3bf664491cb2"
dependencies = [
"hmac 0.12.1",
"subtle",
]
[[package]] [[package]]
name = "ring" name = "ring"
version = "0.17.14" version = "0.17.14"
@ -3080,6 +3262,20 @@ dependencies = [
"untrusted", "untrusted",
] ]
[[package]]
name = "sec1"
version = "0.7.3"
source = "registry+https://github.com/rust-lang/crates.io-index"
checksum = "d3e97a565f76233a6003f9f5c54be1d9c5bdfa3eccfb189469f11ec4901c47dc"
dependencies = [
"base16ct",
"der",
"generic-array",
"pkcs8",
"subtle",
"zeroize",
]
[[package]] [[package]]
name = "security-framework" name = "security-framework"
version = "3.7.0" version = "3.7.0"

View file

@ -23,4 +23,5 @@ Required secrets:
- `REGISTRY_USERNAME` - `REGISTRY_USERNAME`
- `REGISTRY_PASSWORD` - `REGISTRY_PASSWORD`
See `.gitea/workflows/README.md` for details. See `.forgejo/workflows/README.md` for details.
# Trigger build Fri Jun 12 03:45:06 AM IST 2026

View file

@ -186,12 +186,12 @@ async fn approve_company(
State(state): State<AppState>, State(state): State<AppState>,
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> { ) -> Result<impl IntoResponse, (StatusCode, String)> {
sqlx::query("UPDATE company_profiles SET status = 'ACTIVE', updated_at = NOW() WHERE id = $1") sqlx::query("UPDATE company_profiles SET status = 'APPROVED', updated_at = NOW() WHERE id = $1")
.bind(id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(Json(serde_json::json!({ "status": "ACTIVE" }))) Ok(Json(serde_json::json!({ "status": "APPROVED" })))
} }
async fn reject_company( async fn reject_company(

View file

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

View file

@ -83,7 +83,7 @@ impl Services {
fn resolve_upstream(&self, path: &str) -> Option<String> { fn resolve_upstream(&self, path: &str) -> Option<String> {
// Auth, users, roles, notifications, runtime-config, config, KB, support // Auth, users, roles, notifications, runtime-config, config, KB, support, reviews
if path.starts_with("/api/auth") if path.starts_with("/api/auth")
|| path.starts_with("/api/users") || path.starts_with("/api/users")
|| path.starts_with("/api/v1/users") || path.starts_with("/api/v1/users")
@ -96,6 +96,7 @@ impl Services {
|| path.starts_with("/api/kb") || path.starts_with("/api/kb")
|| path.starts_with("/api/packages") || path.starts_with("/api/packages")
|| path.starts_with("/api/support") || path.starts_with("/api/support")
|| path.starts_with("/api/reviews")
|| path.starts_with("/api/admin/roles") || path.starts_with("/api/admin/roles")
|| path.starts_with("/api/admin/users") || path.starts_with("/api/admin/users")
|| path.starts_with("/api/admin/verifications") || path.starts_with("/api/admin/verifications")

28
apps/jobs/Dockerfile Normal file
View file

@ -0,0 +1,28 @@
FROM rust:alpine AS builder
WORKDIR /usr/src/app
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static && \
rustup target add x86_64-unknown-linux-musl
COPY Cargo.toml Cargo.lock ./
COPY crates ./crates
COPY apps ./apps
ENV RUSTFLAGS='-C target-feature=+crt-static'
ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin jobs --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
RUN apk add --no-cache ca-certificates
RUN adduser -D -u 1000 appuser
WORKDIR /app
COPY --from=builder /usr/src/app/target/x86_64-unknown-linux-musl/release/jobs ./jobs
USER appuser
CMD ["./jobs"]

28
apps/leads/Dockerfile Normal file
View file

@ -0,0 +1,28 @@
FROM rust:alpine AS builder
WORKDIR /usr/src/app
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static && \
rustup target add x86_64-unknown-linux-musl
COPY Cargo.toml Cargo.lock ./
COPY crates ./crates
COPY apps ./apps
ENV RUSTFLAGS='-C target-feature=+crt-static'
ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin leads --target x86_64-unknown-linux-musl
FROM alpine:latest AS runtime
RUN apk add --no-cache ca-certificates
RUN adduser -D -u 1000 appuser
WORKDIR /app
COPY --from=builder /usr/src/app/target/x86_64-unknown-linux-musl/release/leads ./leads
USER appuser
CMD ["./leads"]

View file

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

View file

@ -1,7 +1,7 @@
use axum::{ use axum::{
extract::State, extract::State,
http::StatusCode, http::StatusCode,
routing::{get, post}, routing::{get, post, patch},
Json, Router, Json, Router,
}; };
use reqwest::Client; use reqwest::Client;
@ -41,6 +41,14 @@ pub struct CreateLead {
pub profession_key: String, pub profession_key: String,
} }
#[derive(Debug, Deserialize)]
pub struct UpdateLead {
pub title: Option<String>,
pub description: Option<String>,
pub location: Option<String>,
pub status: Option<String>,
}
async fn list_leads(State(state): State<Arc<AppState>>) -> Result<Json<Vec<Lead>>, StatusCode> { async fn list_leads(State(state): State<Arc<AppState>>) -> Result<Json<Vec<Lead>>, StatusCode> {
let leads = sqlx::query_as::<_, Lead>( let leads = sqlx::query_as::<_, Lead>(
"SELECT id, title, description, location, profession_key, status, created_at FROM leads ORDER BY created_at DESC" "SELECT id, title, description, location, profession_key, status, created_at FROM leads ORDER BY created_at DESC"
@ -94,6 +102,38 @@ async fn health() -> &'static str {
"Leads Service OK" "Leads Service OK"
} }
async fn update_lead(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<uuid::Uuid>,
Json(payload): Json<UpdateLead>,
) -> Result<Json<Lead>, StatusCode> {
let status = payload.status.as_deref().unwrap_or("OPEN");
let lead = sqlx::query_as::<_, Lead>(
r#"
UPDATE leads
SET title = COALESCE($1, title),
description = COALESCE($2, description),
location = COALESCE($3, location),
status = $4,
updated_at = NOW()
WHERE id = $5
RETURNING id, title, description, location, profession_key, status, created_at
"#,
)
.bind(&payload.title)
.bind(&payload.description)
.bind(&payload.location)
.bind(status)
.bind(id)
.fetch_optional(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(lead))
}
#[tokio::main] #[tokio::main]
async fn main() { async fn main() {
tracing_subscriber::registry() tracing_subscriber::registry()
@ -130,10 +170,13 @@ async fn main() {
let app = Router::new() let app = Router::new()
.route("/health", get(health)) .route("/health", get(health))
.nest("/api", Router::new()
.route("/leads", get(list_leads)) .route("/leads", get(list_leads))
.route("/leads", post(create_lead)) .route("/leads", post(create_lead))
.route("/leads/{id}", get(get_lead)) .route("/leads/{id}", get(get_lead))
.nest("/api/lead-requests", lead_requests::router()) .route("/leads/{id}", patch(update_lead))
.nest("/lead-requests", lead_requests::router())
)
.layer(cors) .layer(cors)
.with_state(state); .with_state(state);

View file

@ -133,7 +133,7 @@ async fn create_order(
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO payments (user_id, package_id, razorpay_order_id, amount, tracecoins_credited, status) INSERT INTO payments (user_id, package_id, razorpay_order_id, amount_inr, tracecoins_credited, status)
VALUES ($1, $2, $3, $4, $5, 'PENDING') VALUES ($1, $2, $3, $4, $5, 'PENDING')
"#, "#,
) )
@ -248,8 +248,8 @@ async fn verify_payment(
{ {
sqlx::query( sqlx::query(
r#" r#"
INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, balance_after, reference_type, reference_id, description) INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id)
VALUES ($1, 'CREDIT', $2, $2, 'PAYMENT', $3, 'Package purchase') VALUES ($1, 'CREDIT', $2, 'PAYMENT', $3)
"#, "#,
) )
.bind(wallet_id) .bind(wallet_id)
@ -262,7 +262,7 @@ async fn verify_payment(
let _ = sqlx::query( let _ = sqlx::query(
r#" r#"
INSERT INTO notifications (user_id, title, body, notification_type, reference_id) INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5) VALUES ($1, $2, $3, $4, $5)
"#, "#,
) )

View file

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

View file

@ -165,6 +165,13 @@ fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str>
vec![] vec![]
} }
fn is_dummy_account_email(email: &str) -> bool {
email.ends_with("@demo.com")
|| email == "paymentgateway@demo.com"
|| email.contains("+dummy@")
|| email.starts_with("dummy+")
}
fn role_display_name_from_code(code: &str) -> String { fn role_display_name_from_code(code: &str) -> String {
code code
.split('_') .split('_')
@ -296,6 +303,9 @@ async fn register(
} }
})?; })?;
// Check if this is a demo account (payment gateway integration)
let is_demo_account = is_dummy_account_email(&email);
// Assign signup role immediately (intent-driven). Email verification is still required for login. // Assign signup role immediately (intent-driven). Email verification is still required for login.
let role_candidates = resolve_signup_role_candidates( let role_candidates = resolve_signup_role_candidates(
payload.intent.as_deref(), payload.intent.as_deref(),
@ -304,22 +314,25 @@ async fn register(
for role_key in role_candidates { for role_key in role_candidates {
let role_id = ensure_role_exists(&state.pool, &role_key).await; let role_id = ensure_role_exists(&state.pool, &role_key).await;
if let Some(role_id) = role_id { if let Some(role_id) = role_id {
// For demo accounts, auto-approve the role immediately
let status = if is_demo_account { "APPROVED" } else { "PENDING" };
let _ = sqlx::query( let _ = sqlx::query(
r#" r#"
UPDATE user_role_assignments UPDATE user_role_assignments
SET status = 'APPROVED' SET status = $3
WHERE user_id = $1 AND role_id = $2 WHERE user_id = $1 AND role_id = $2
"#, "#,
) )
.bind(user.id) .bind(user.id)
.bind(role_id) .bind(role_id)
.bind(status)
.execute(&state.pool) .execute(&state.pool)
.await; .await;
let _ = sqlx::query( let _ = sqlx::query(
r#" r#"
INSERT INTO user_role_assignments (user_id, role_id, status) INSERT INTO user_role_assignments (user_id, role_id, status)
SELECT $1, $2, 'APPROVED' SELECT $1, $2, $3
WHERE NOT EXISTS ( WHERE NOT EXISTS (
SELECT 1 FROM user_role_assignments WHERE user_id = $1 AND role_id = $2 SELECT 1 FROM user_role_assignments WHERE user_id = $1 AND role_id = $2
) )
@ -327,12 +340,37 @@ async fn register(
) )
.bind(user.id) .bind(user.id)
.bind(role_id) .bind(role_id)
.bind(status)
.execute(&state.pool) .execute(&state.pool)
.await; .await;
break; break;
} }
} }
// For demo accounts: auto-verify email and skip OTP
if is_demo_account {
tracing::info!(email = %email, "Demo account auto-verified");
let _ = sqlx::query(
"UPDATE users SET email_verified = true, status = 'ACTIVE' WHERE id = $1"
)
.bind(user.id)
.execute(&state.pool)
.await;
// Return success with demo flag
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
return Ok((StatusCode::CREATED, Json(RegisterResponse {
user_id: user.id.to_string(),
email: user.email,
phone: None,
name: user_name,
status: "ACTIVE".to_string(),
email_verified: true,
created_at: user.created_at.to_rfc3339(),
otp: Some("DEMO".to_string()), // Return dummy OTP for demo
})));
}
// Store OTP in Redis (15-min TTL, keyed by code → user_id) // Store OTP in Redis (15-min TTL, keyed by code → user_id)
let otp = format!("{:06}", rand::random::<u32>() % 1_000_000); let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
tracing::info!(otp = %otp, email = %email, "OTP generated for registration"); tracing::info!(otp = %otp, email = %email, "OTP generated for registration");
@ -384,7 +422,10 @@ async fn login(
if user.status == "SUSPENDED" { if user.status == "SUSPENDED" {
return Err(err(StatusCode::FORBIDDEN, "Account suspended", "ACCOUNT_SUSPENDED")); return Err(err(StatusCode::FORBIDDEN, "Account suspended", "ACCOUNT_SUSPENDED"));
} }
if !user.email_verified {
// Allow demo accounts to login without email verification
let is_demo_account = is_dummy_account_email(&email);
if !user.email_verified && !is_demo_account {
return Err(err(StatusCode::UNAUTHORIZED, "Email not verified. Check your inbox.", "EMAIL_NOT_VERIFIED")); return Err(err(StatusCode::UNAUTHORIZED, "Email not verified. Check your inbox.", "EMAIL_NOT_VERIFIED"));
} }

View file

@ -3,7 +3,7 @@ use axum::{
extract::{Path, Query, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
routing::get, routing::{get, patch},
Json, Router, Json, Router,
}; };
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@ -14,6 +14,8 @@ use contracts::auth_middleware::{AuthUser, require_admin};
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(list_external_roles).post(create_external_role)) .route("/", get(list_external_roles).post(create_external_role))
.route("/by-key/{role_key}", get(get_external_role_by_key))
.route("/by-key/{role_key}", patch(update_external_role_by_key))
.route("/{id}", get(get_external_role).put(update_external_role).delete(delete_external_role)) .route("/{id}", get(get_external_role).put(update_external_role).delete(delete_external_role))
} }
@ -247,6 +249,60 @@ async fn get_external_role(
})) }))
} }
async fn get_external_role_by_key(
auth: AuthUser,
State(state): State<AppState>,
Path(role_key): Path<String>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
let row = sqlx::query_as::<_, ExternalRoleDetailRow>(
r#"
SELECT r.id, r.name, r.key as code, r.audience, r.is_active, r.created_at,
rc.updated_at as updated_at, rc.config_json as config_json
FROM roles r
LEFT JOIN role_runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
WHERE r.key = $1 AND r.audience = 'EXTERNAL'
"#,
)
.bind(&role_key)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.ok_or((StatusCode::NOT_FOUND, "External role not found".to_string()))?;
Ok(Json(ExternalRoleDetail {
id: row.id,
name: row.name,
code: row.code,
audience: row.audience,
is_active: row.is_active,
runtime: row.config_json.unwrap_or_else(|| serde_json::json!({})),
created_at: row.created_at,
updated_at: row.updated_at,
}))
}
async fn update_external_role_by_key(
auth: AuthUser,
State(state): State<AppState>,
Path(role_key): Path<String>,
Json(payload): Json<UpdateExternalRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
let row: (Uuid,) = sqlx::query_as("SELECT id FROM roles WHERE key = $1 AND audience = 'EXTERNAL'")
.bind(&role_key)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.ok_or((StatusCode::NOT_FOUND, "External role not found".to_string()))?;
update_external_role_impl(&state, row.0, payload).await
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct CreateExternalRolePayload { struct CreateExternalRolePayload {
name: String, name: String,
@ -340,6 +396,14 @@ async fn update_external_role(
if let Err(_e) = require_admin(&auth) { if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
} }
update_external_role_impl(&state, id, payload).await
}
async fn update_external_role_impl(
state: &AppState,
role_id: Uuid,
payload: UpdateExternalRolePayload,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if payload.name.is_some() || payload.is_active.is_some() { if payload.name.is_some() || payload.is_active.is_some() {
sqlx::query( sqlx::query(
r#" r#"
@ -351,7 +415,7 @@ async fn update_external_role(
) )
.bind(payload.name) .bind(payload.name)
.bind(payload.is_active) .bind(payload.is_active)
.bind(id) .bind(role_id)
.execute(&state.pool) .execute(&state.pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -364,7 +428,7 @@ async fn update_external_role(
WHERE role_id = $1 AND is_active = true WHERE role_id = $1 AND is_active = true
"#, "#,
) )
.bind(id) .bind(role_id)
.execute(&state.pool) .execute(&state.pool)
.await .await
.ok(); .ok();
@ -379,13 +443,38 @@ async fn update_external_role(
) )
"#, "#,
) )
.bind(id) .bind(role_id)
.bind(runtime) .bind(runtime)
.execute(&state.pool) .execute(&state.pool)
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
} }
get_external_role(auth, State(state), Path(id)).await // Return the updated role detail
let row = sqlx::query_as::<_, ExternalRoleDetailRow>(
r#"
SELECT r.id, r.name, r.key as code, r.audience, r.is_active, r.created_at,
rc.updated_at as updated_at, rc.config_json as config_json
FROM roles r
LEFT JOIN role_runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
WHERE r.id = $1 AND r.audience = 'EXTERNAL'
"#,
)
.bind(role_id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.ok_or((StatusCode::NOT_FOUND, "External role not found".to_string()))?;
Ok(Json(ExternalRoleDetail {
id: row.id,
name: row.name,
code: row.code,
audience: row.audience,
is_active: row.is_active,
runtime: row.config_json.unwrap_or_else(|| serde_json::json!({})),
created_at: row.created_at,
updated_at: row.updated_at,
}))
} }
async fn delete_external_role( async fn delete_external_role(

View file

@ -18,6 +18,7 @@ pub fn public_router() -> Router<AppState> {
.route("/categories", get(public_list_categories)) .route("/categories", get(public_list_categories))
.route("/articles", get(public_list_articles)) .route("/articles", get(public_list_articles))
.route("/articles/{slug}", get(public_get_article)) .route("/articles/{slug}", get(public_get_article))
.route("/articles/id/{id}", get(public_get_article_by_id))
} }
/// Admin CRUD routes /// Admin CRUD routes
@ -346,6 +347,68 @@ async fn public_get_article(
} }
} }
// ── Public: single article by ID ───────────────────────────────────────────────
async fn public_get_article_by_id(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let row = sqlx::query_as::<_, PublicArticleRow>(
r#"
SELECT
a.id, a.title, a.slug, a.summary, a.body, a.target_roles, a.tags,
a.updated_at,
c.name AS category_name, c.slug AS category_slug
FROM kb_articles a
JOIN kb_categories c ON c.id = a.category_id
WHERE a.id = $1 AND a.status = 'PUBLISHED' AND c.is_active = true
"#,
)
.bind(id)
.fetch_optional(&state.pool)
.await;
let pool = state.pool.clone();
tokio::spawn(async move {
let _ = sqlx::query("UPDATE kb_articles SET views = views + 1 WHERE id = $1")
.bind(id)
.execute(&pool)
.await;
});
match row {
Ok(Some(r)) => {
let role = derive_role(r.target_roles.as_deref().unwrap_or(&[]));
let dto = PublicArticleDto {
id: r.id,
slug: r.slug,
title: r.title,
summary: r.summary,
category_key: r.category_slug,
category: r.category_name,
role,
tags: r.tags,
updated_at: r.updated_at.to_rfc3339(),
content: r.body,
};
(StatusCode::OK, Json(dto)).into_response()
}
Ok(None) => (
StatusCode::NOT_FOUND,
Json(serde_json::json!({ "error": "Article not found" })),
)
.into_response(),
Err(e) => {
tracing::error!("Failed to fetch KB article by id {}: {}", id, e);
(
StatusCode::INTERNAL_SERVER_ERROR,
Json(serde_json::json!({ "error": "Failed to fetch article" })),
)
.into_response()
}
}
}
// ── Admin: categories ───────────────────────────────────────────────────────── // ── Admin: categories ─────────────────────────────────────────────────────────
async fn admin_list_categories( async fn admin_list_categories(

View file

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

View file

@ -7,7 +7,7 @@ use axum::{
Json, Router, Json, Router,
}; };
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::AuthUser;
use db::models::{role::RoleRepository, verification::VerificationRepository}; use db::models::{role::RoleRepository, user::UserRepository, verification::VerificationRepository};
use serde::Deserialize; use serde::Deserialize;
use uuid::Uuid; use uuid::Uuid;
@ -19,8 +19,9 @@ pub fn router() -> Router<AppState> {
.route("/submit-for-verification", post(submit_for_verification)) .route("/submit-for-verification", post(submit_for_verification))
} }
pub fn me_verification_router() -> Router<AppState> { pub fn me_router() -> Router<AppState> {
Router::new() Router::new()
.route("/", get(get_me))
.route("/verification-status", get(verification_status)) .route("/verification-status", get(verification_status))
} }
@ -68,6 +69,13 @@ fn role_to_table(role_key: &str) -> Option<&'static str> {
} }
} }
fn is_dummy_account_email(email: &str) -> bool {
email.ends_with("@demo.com")
|| email == "paymentgateway@demo.com"
|| email.contains("+dummy@")
|| email.starts_with("dummy+")
}
fn extract_documents(profile_data: &serde_json::Value) -> serde_json::Value { fn extract_documents(profile_data: &serde_json::Value) -> serde_json::Value {
let doc_keys = [ let doc_keys = [
"aadhar_doc", "aadhar_doc",
@ -304,6 +312,67 @@ async fn submit_for_verification(
) -> impl IntoResponse { ) -> impl IntoResponse {
let role_key = input.role_key.to_uppercase(); let role_key = input.role_key.to_uppercase();
// Check if user is a demo account
let is_demo = sqlx::query_scalar::<_, String>("SELECT email FROM users WHERE id = $1")
.bind(auth.user_id)
.fetch_one(&state.pool)
.await
.map(|email| is_dummy_account_email(&email))
.unwrap_or(false);
// For demo accounts: auto-approve verification
if is_demo {
tracing::info!(user_id = %auth.user_id, role_key = %role_key, "Demo account auto-approved for verification");
// Update role assignment to APPROVED
if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await {
sqlx::query(
"UPDATE user_role_assignments SET status = 'APPROVED' WHERE user_id = $1 AND role_id = $2",
)
.bind(auth.user_id)
.bind(role.id)
.execute(&state.pool)
.await
.ok();
}
// Mark profile as VERIFIED
set_profile_status(&state, auth.user_id, &role_key, "VERIFIED").await;
// Create a verification record with APPROVED status
let profile_data = input.profile_data.unwrap_or_else(|| {
serde_json::json!({
"company_name": "Payment Gateway Demo Company",
"company_description": "Demo account for reviewing packages",
"industry": "Technology",
"location": "India"
})
});
let documents = extract_documents(&profile_data);
match VerificationRepository::create_approved(
&state.pool,
auth.user_id,
&role_key,
"PROFILE_VERIFICATION",
profile_data,
documents,
)
.await
{
Ok(v) => (
StatusCode::CREATED,
Json(serde_json::json!({
"verification_id": v.id,
"status": "APPROVED",
"message": "Your profile has been auto-approved for demo access."
})),
)
.into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
} else {
// Regular verification flow for non-demo accounts
// Guard: reject if an active verification already exists // Guard: reject if an active verification already exists
let existing: Result<Option<Uuid>, sqlx::Error> = sqlx::query_scalar( let existing: Result<Option<Uuid>, sqlx::Error> = sqlx::query_scalar(
r#" r#"
@ -374,6 +443,7 @@ async fn submit_for_verification(
.into_response(), .into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
} }
}
} }
/// GET /api/me/verification-status?roleKey=PHOTOGRAPHER /// GET /api/me/verification-status?roleKey=PHOTOGRAPHER
@ -557,3 +627,22 @@ async fn fetch_saved_profile_by_urp_id(
} }
serde_json::Value::Object(Default::default()) serde_json::Value::Object(Default::default())
} }
/// GET /api/me — returns the authenticated user's basic info
pub async fn get_me(
auth: AuthUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let user = UserRepository::get_by_id(&state.pool, auth.user_id)
.await
.map_err(|_| (StatusCode::UNAUTHORIZED, "User not found".to_string()))?;
Ok(Json(serde_json::json!({
"id": user.id,
"email": user.email,
"firstName": user.first_name,
"lastName": user.last_name,
"activeRole": auth.claims.active_role,
"emailVerified": user.email_verified,
})))
}

View file

@ -1,9 +1,9 @@
use crate::AppState; use crate::AppState;
use axum::{ use axum::{
extract::{Path, State}, extract::{Path, Query, State},
http::StatusCode, http::StatusCode,
response::IntoResponse, response::IntoResponse,
routing::get, routing::{get, post},
Json, Router, Json, Router,
}; };
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::AuthUser;
@ -18,35 +18,50 @@ pub fn admin_router() -> Router<AppState> {
.route("/{id}", axum::routing::patch(admin_update_review).delete(admin_delete_review)) .route("/{id}", axum::routing::patch(admin_update_review).delete(admin_delete_review))
} }
pub fn public_router() -> Router<AppState> {
Router::new()
.route("/", get(list_reviews))
.route("/professional/{professional_id}", get(list_reviews_by_professional))
}
// ── DTOs ────────────────────────────────────────────────────────────────────── // ── DTOs ──────────────────────────────────────────────────────────────────────
#[derive(Serialize)] #[derive(Serialize)]
struct ReviewDto { struct ReviewDto {
id: Uuid, id: Uuid,
subject_type: String, professional_id: Uuid,
subject_id: Option<String>, customer_id: Uuid,
reviewer_name: Option<String>,
reviewer_id: Option<Uuid>,
rating: i16, rating: i16,
title: Option<String>,
comment: Option<String>, comment: Option<String>,
status: String, is_published: bool,
created_at: chrono::DateTime<chrono::Utc>, created_at: chrono::DateTime<chrono::Utc>,
} }
#[derive(Serialize)]
struct PublicReviewDto {
id: Uuid,
professional_id: Uuid,
rating: i16,
comment: Option<String>,
created_at: String,
}
#[derive(Deserialize)] #[derive(Deserialize)]
struct CreateReviewBody { struct CreateReviewBody {
subject_type: Option<String>, lead_request_id: Uuid,
subject_id: Option<String>,
reviewer_name: Option<String>,
rating: i16, rating: i16,
title: Option<String>,
comment: Option<String>, comment: Option<String>,
} }
#[derive(Deserialize)] #[derive(Deserialize)]
struct PatchReviewBody { struct PatchReviewBody {
status: Option<String>, is_published: Option<bool>,
}
#[derive(Deserialize)]
struct PublicListQuery {
page: Option<i64>,
limit: Option<i64>,
} }
// ── FromRow structs ────────────────────────────────────────────────────────── // ── FromRow structs ──────────────────────────────────────────────────────────
@ -54,14 +69,12 @@ struct PatchReviewBody {
#[derive(sqlx::FromRow)] #[derive(sqlx::FromRow)]
struct ReviewRow { struct ReviewRow {
id: Uuid, id: Uuid,
subject_type: String, lead_request_id: Uuid,
subject_id: Option<String>, customer_id: Uuid,
reviewer_name: Option<String>, professional_id: Uuid,
reviewer_id: Option<Uuid>,
rating: i16, rating: i16,
title: Option<String>,
comment: Option<String>, comment: Option<String>,
status: String, is_published: bool,
created_at: chrono::DateTime<chrono::Utc>, created_at: chrono::DateTime<chrono::Utc>,
} }
@ -75,14 +88,12 @@ async fn admin_list_reviews(
r#" r#"
SELECT SELECT
r.id, r.id,
r.subject_type, r.lead_request_id,
r.subject_id, r.customer_id,
r.reviewer_name, r.professional_id,
r.reviewer_user_id AS reviewer_id,
r.rating, r.rating,
r.title,
r.comment, r.comment,
r.status, r.is_published,
r.created_at r.created_at
FROM reviews r FROM reviews r
ORDER BY r.created_at DESC ORDER BY r.created_at DESC
@ -97,14 +108,11 @@ async fn admin_list_reviews(
.into_iter() .into_iter()
.map(|r| ReviewDto { .map(|r| ReviewDto {
id: r.id, id: r.id,
subject_type: r.subject_type, professional_id: r.professional_id,
subject_id: r.subject_id, customer_id: r.customer_id,
reviewer_name: r.reviewer_name,
reviewer_id: r.reviewer_id,
rating: r.rating, rating: r.rating,
title: r.title,
comment: r.comment, comment: r.comment,
status: r.status, is_published: r.is_published,
created_at: r.created_at, created_at: r.created_at,
}) })
.collect(); .collect();
@ -126,24 +134,19 @@ async fn admin_create_review(
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Rating must be 1-5" }))).into_response(); return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Rating must be 1-5" }))).into_response();
} }
let subject_type = body.subject_type.unwrap_or_else(|| "PLATFORM".to_string());
let status = "PUBLISHED".to_string();
let row = sqlx::query_as::<_, ReviewRow>( let row = sqlx::query_as::<_, ReviewRow>(
r#" r#"
INSERT INTO reviews (subject_type, subject_id, reviewer_name, rating, title, comment, status) INSERT INTO reviews (lead_request_id, customer_id, professional_id, rating, comment, is_published)
VALUES ($1, $2, $3, $4, $5, $6, $7) SELECT $1,
RETURNING id, subject_type, subject_id, reviewer_name, reviewer_user_id AS reviewer_id, (SELECT id FROM customer_profiles WHERE user_id = (SELECT user_id FROM lead_requests WHERE id = $1)),
rating, title, comment, status, created_at (SELECT user_role_profile_id FROM lead_requests WHERE id = $1),
$2, $3, true
RETURNING id, lead_request_id, customer_id, professional_id, rating, comment, is_published, created_at
"#, "#,
) )
.bind(&subject_type) .bind(body.lead_request_id)
.bind(&body.subject_id)
.bind(&body.reviewer_name)
.bind(body.rating) .bind(body.rating)
.bind(&body.title)
.bind(&body.comment) .bind(&body.comment)
.bind(&status)
.fetch_one(&state.pool) .fetch_one(&state.pool)
.await; .await;
@ -151,14 +154,11 @@ async fn admin_create_review(
Ok(r) => { Ok(r) => {
let dto = ReviewDto { let dto = ReviewDto {
id: r.id, id: r.id,
subject_type: r.subject_type, professional_id: r.professional_id,
subject_id: r.subject_id, customer_id: r.customer_id,
reviewer_name: r.reviewer_name,
reviewer_id: r.reviewer_id,
rating: r.rating, rating: r.rating,
title: r.title,
comment: r.comment, comment: r.comment,
status: r.status, is_published: r.is_published,
created_at: r.created_at, created_at: r.created_at,
}; };
(StatusCode::CREATED, Json(serde_json::json!(dto))).into_response() (StatusCode::CREATED, Json(serde_json::json!(dto))).into_response()
@ -176,13 +176,12 @@ async fn admin_update_review(
Path(id): Path<Uuid>, Path(id): Path<Uuid>,
Json(body): Json<PatchReviewBody>, Json(body): Json<PatchReviewBody>,
) -> impl IntoResponse { ) -> impl IntoResponse {
let status = body.status.as_deref().unwrap_or("PUBLISHED").to_string(); let is_published = body.is_published.unwrap_or(true);
let result = sqlx::query( let result = sqlx::query(
"UPDATE reviews SET status = $1, updated_at = NOW() WHERE id = $2", "UPDATE reviews SET is_published = $1, updated_at = NOW() WHERE id = $2",
) )
.bind(&status) .bind(is_published)
.bind(id)
.bind(id) .bind(id)
.execute(&state.pool) .execute(&state.pool)
.await; .await;
@ -220,3 +219,107 @@ async fn admin_delete_review(
} }
} }
} }
// ── Public handlers ────────────────────────────────────────────────────────────
async fn list_reviews(
State(state): State<AppState>,
Query(q): Query<PublicListQuery>,
) -> impl IntoResponse {
let page = q.page.unwrap_or(1).max(1);
let limit = q.limit.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * limit;
let rows = sqlx::query_as::<_, ReviewRow>(
r#"
SELECT id, lead_request_id, customer_id, professional_id, rating, comment, is_published, created_at
FROM reviews
WHERE is_published = true
ORDER BY created_at DESC
LIMIT $1 OFFSET $2
"#,
)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
match rows {
Ok(rows) => {
let dtos: Vec<PublicReviewDto> = rows
.into_iter()
.map(|r| PublicReviewDto {
id: r.id,
professional_id: r.professional_id,
rating: r.rating,
comment: r.comment,
created_at: r.created_at.to_rfc3339(),
})
.collect();
(StatusCode::OK, Json(serde_json::json!({ "reviews": dtos }))).into_response()
}
Err(e) => {
tracing::error!("Failed to list public reviews: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response()
}
}
}
async fn list_reviews_by_professional(
State(state): State<AppState>,
Path(professional_id): Path<Uuid>,
Query(q): Query<PublicListQuery>,
) -> impl IntoResponse {
let page = q.page.unwrap_or(1).max(1);
let limit = q.limit.unwrap_or(20).clamp(1, 100);
let offset = (page - 1) * limit;
let rows = sqlx::query_as::<_, ReviewRow>(
r#"
SELECT id, lead_request_id, customer_id, professional_id, rating, comment, is_published, created_at
FROM reviews
WHERE professional_id = $1 AND is_published = true
ORDER BY created_at DESC
LIMIT $2 OFFSET $3
"#,
)
.bind(professional_id)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
match rows {
Ok(rows) => {
let avg: (f64,) = sqlx::query_as("SELECT COALESCE(AVG(rating), 0)::float FROM reviews WHERE professional_id = $1 AND is_published = true")
.bind(professional_id)
.fetch_one(&state.pool)
.await
.unwrap_or((0.0,));
let count: (i64,) = sqlx::query_scalar("SELECT COUNT(*) FROM reviews WHERE professional_id = $1 AND is_published = true")
.bind(professional_id)
.fetch_one(&state.pool)
.await
.unwrap_or((0,));
let dtos: Vec<PublicReviewDto> = rows
.into_iter()
.map(|r| PublicReviewDto {
id: r.id,
professional_id: r.professional_id,
rating: r.rating,
comment: r.comment,
created_at: r.created_at.to_rfc3339(),
})
.collect();
(StatusCode::OK, Json(serde_json::json!({
"reviews": dtos,
"averageRating": avg.0,
"totalCount": count.0
}))).into_response()
}
Err(e) => {
tracing::error!("Failed to list reviews for professional {professional_id}: {e}");
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to load reviews" }))).into_response()
}
}
}

View file

@ -479,7 +479,7 @@ struct AdminTicketRow {
created_at: chrono::DateTime<chrono::Utc>, created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>, updated_at: chrono::DateTime<chrono::Utc>,
user_name: Option<String>, user_name: Option<String>,
user_email: String, user_email: Option<String>,
} }
async fn admin_list_cases( async fn admin_list_cases(
@ -503,7 +503,11 @@ async fn admin_list_cases(
CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email
FROM support_tickets t FROM support_tickets t
LEFT JOIN users u ON u.id = t.user_id LEFT JOIN users u ON u.id = t.user_id
WHERE t.id = $1 WHERE ($1 = '' OR t.status = $1)
AND ($2 = '' OR t.priority = $2)
AND ($3 = '' OR t.category = $3)
ORDER BY t.updated_at DESC
LIMIT $4 OFFSET $5
"#, "#,
) )
.bind(&status_filter) .bind(&status_filter)
@ -526,7 +530,7 @@ async fn admin_list_cases(
.map(|r| { .map(|r| {
// Use user info if available, fall back to requester fields // Use user info if available, fall back to requester fields
let requester_name = r.requester_name.or(r.user_name); let requester_name = r.requester_name.or(r.user_name);
let requester_email = r.requester_email.or(Some(r.user_email)); let requester_email = r.requester_email.or(r.user_email);
serde_json::json!({ serde_json::json!({
"id": r.id, "id": r.id,
"title": r.subject, "title": r.subject,
@ -642,11 +646,7 @@ async fn admin_get_case(
CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email CONCAT(u.first_name, ' ', u.last_name) AS user_name, u.email AS user_email
FROM support_tickets t FROM support_tickets t
LEFT JOIN users u ON u.id = t.user_id LEFT JOIN users u ON u.id = t.user_id
WHERE ($1 = '' OR t.status = $1) WHERE t.id = $1
AND ($2 = '' OR t.priority = $2)
AND ($3 = '' OR t.category = $3)
ORDER BY t.updated_at DESC
LIMIT $4 OFFSET $5
"#, "#,
) )
.bind(id) .bind(id)
@ -681,7 +681,7 @@ async fn admin_get_case(
.collect(); .collect();
let requester_name = t.requester_name.or(t.user_name); let requester_name = t.requester_name.or(t.user_name);
let requester_email = t.requester_email.or(Some(t.user_email)); let requester_email = t.requester_email.or(t.user_email);
(StatusCode::OK, Json(serde_json::json!({ (StatusCode::OK, Json(serde_json::json!({
"ticket": { "ticket": {

View file

@ -8,6 +8,7 @@ use axum::{
}; };
use contracts::auth_middleware::AuthUser; use contracts::auth_middleware::AuthUser;
use db::models::role::RoleRepository; use db::models::role::RoleRepository;
use db::models::user_role_profile::UserRoleProfileRepository;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
pub fn router() -> Router<AppState> { pub fn router() -> Router<AppState> {
@ -42,6 +43,7 @@ fn is_professional_role(role_key: &str) -> bool {
| "SOCIAL_MEDIA_MANAGER" | "SOCIAL_MEDIA_MANAGER"
| "FITNESS_TRAINER" | "FITNESS_TRAINER"
| "CATERING_SERVICES" | "CATERING_SERVICES"
| "UGC_CONTENT_CREATOR"
) )
} }
@ -112,7 +114,14 @@ async fn register_role(
.await .await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?; .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()))?;
// Note: Professional profile creation is now handled upon successful submission of onboarding data. // For professional/external roles, also create the user_role_profiles entry so
// downstream services (e.g. leads) can find the professional's profile.
if is_professional_role(&role_key) {
if let Err(e) = UserRoleProfileRepository::create(&state.pool, auth.user_id, &role_key).await {
tracing::warn!("Failed to create user_role_profiles entry for {}: {}", role_key, e);
// Non-fatal — the assignment is created; the profile row can be backfilled later.
}
}
Ok(( Ok((
StatusCode::OK, StatusCode::OK,

View file

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

View file

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

View file

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

View file

@ -32,6 +32,8 @@ pub struct PaginationQuery {
#[derive(Deserialize)] #[derive(Deserialize)]
pub struct LeadRequestPayload { pub struct LeadRequestPayload {
pub requirement_id: Uuid, pub requirement_id: Uuid,
#[serde(default)]
pub message: Option<String>,
} }
/// Build the shared Router that every profession service merges into its own Router. /// Build the shared Router that every profession service merges into its own Router.
@ -189,10 +191,12 @@ async fn send_lead_request(
return (StatusCode::PAYMENT_REQUIRED, "Insufficient Tracecoin balance").into_response(); return (StatusCode::PAYMENT_REQUIRED, "Insufficient Tracecoin balance").into_response();
} }
let db_payload = CreateLeadRequestPayload { let db_payload = CreateLeadRequestPayload::new(
user_role_profile_id: user_role_profile.id, req.id,
expires_at: Utc::now() + chrono::Duration::hours(24), user_role_profile.id,
}; auth.user_id,
payload.message.clone(),
);
match LeadRequestRepository::create(&state.pool, db_payload).await { match LeadRequestRepository::create(&state.pool, db_payload).await {
Ok(lead) => { Ok(lead) => {
@ -456,7 +460,7 @@ async fn cancel_request(
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(),
}; };
if lead.user_role_profile_id != user_role_profile.id { if lead.user_role_profile_id != Some(user_role_profile.id) {
return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Access denied" }))).into_response(); return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Access denied" }))).into_response();
} }

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; pub struct CompanyRepository;
impl CompanyRepository { impl CompanyRepository {
async fn is_dummy_account(pool: &PgPool, user_id: Uuid) -> Result<bool, sqlx::Error> {
let email = sqlx::query_scalar::<_, String>("SELECT email FROM users WHERE id = $1")
.bind(user_id)
.fetch_one(pool)
.await?;
Ok(
email.ends_with("@demo.com")
|| email == "paymentgateway@demo.com"
|| email.contains("+dummy@")
|| email.starts_with("dummy+"),
)
}
pub async fn get_by_user_id( pub async fn get_by_user_id(
pool: &PgPool, pool: &PgPool,
user_id: Uuid, user_id: Uuid,
@ -81,6 +95,9 @@ impl CompanyRepository {
user_id: Uuid, user_id: Uuid,
payload: UpsertCompanyProfilePayload, payload: UpsertCompanyProfilePayload,
) -> Result<CompanyProfile, sqlx::Error> { ) -> Result<CompanyProfile, sqlx::Error> {
let is_dummy_account = Self::is_dummy_account(pool, user_id).await?;
let default_status = if is_dummy_account { "APPROVED" } else { "PENDING" };
let profile = sqlx::query_as::<_, CompanyProfile>( let profile = sqlx::query_as::<_, CompanyProfile>(
r#" r#"
INSERT INTO company_profiles ( INSERT INTO company_profiles (
@ -88,7 +105,7 @@ impl CompanyRepository {
employee_count, business_type, gst_number, contact_name, employee_count, business_type, gst_number, contact_name,
contact_email, contact_phone, address_line1, city, state, postal_code, status contact_email, contact_phone, address_line1, city, state, postal_code, status
) )
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, 'PENDING') VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15, $16)
ON CONFLICT (user_id) DO UPDATE SET ON CONFLICT (user_id) DO UPDATE SET
company_name = EXCLUDED.company_name, company_name = EXCLUDED.company_name,
registration_number = EXCLUDED.registration_number, registration_number = EXCLUDED.registration_number,
@ -105,6 +122,7 @@ impl CompanyRepository {
state = EXCLUDED.state, state = EXCLUDED.state,
postal_code = EXCLUDED.postal_code, postal_code = EXCLUDED.postal_code,
status = CASE status = CASE
WHEN $17 THEN 'APPROVED'
WHEN company_profiles.status = 'APPROVED' THEN 'APPROVED' WHEN company_profiles.status = 'APPROVED' THEN 'APPROVED'
ELSE 'PENDING' ELSE 'PENDING'
END, END,
@ -133,6 +151,8 @@ impl CompanyRepository {
.bind(payload.city) .bind(payload.city)
.bind(payload.state) .bind(payload.state)
.bind(payload.postal_code) .bind(payload.postal_code)
.bind(default_status)
.bind(is_dummy_account)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;
@ -143,10 +163,13 @@ impl CompanyRepository {
pool: &PgPool, pool: &PgPool,
user_id: Uuid, user_id: Uuid,
) -> Result<CompanyProfile, sqlx::Error> { ) -> Result<CompanyProfile, sqlx::Error> {
let is_dummy_account = Self::is_dummy_account(pool, user_id).await?;
let next_status = if is_dummy_account { "APPROVED" } else { "PENDING_REVIEW" };
let profile = sqlx::query_as::<_, CompanyProfile>( let profile = sqlx::query_as::<_, CompanyProfile>(
r#" r#"
UPDATE company_profiles UPDATE company_profiles
SET status = 'PENDING_REVIEW', updated_at = NOW() SET status = $2, updated_at = NOW()
WHERE user_id = $1 WHERE user_id = $1
RETURNING RETURNING
id, user_id, company_name, registration_number, industry, id, user_id, company_name, registration_number, industry,
@ -158,6 +181,7 @@ impl CompanyRepository {
"#, "#,
) )
.bind(user_id) .bind(user_id)
.bind(next_status)
.fetch_one(pool) .fetch_one(pool)
.await?; .await?;

View file

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

View file

@ -61,6 +61,30 @@ impl VerificationRepository {
.await .await
} }
pub async fn create_approved(
pool: &PgPool,
user_id: Uuid,
role_key: &str,
case_type: &str,
payload: serde_json::Value,
documents: serde_json::Value,
) -> Result<Verification, sqlx::Error> {
sqlx::query_as::<_, Verification>(
r#"
INSERT INTO verifications (user_id, role_key, case_type, priority, status, payload, documents, reviewed_at, reviewer_notes)
VALUES ($1, $2, $3, 'MEDIUM', 'APPROVED', $4, $5, NOW(), 'Auto-approved for demo account')
RETURNING *
"#
)
.bind(user_id)
.bind(role_key)
.bind(case_type)
.bind(payload)
.bind(documents)
.fetch_one(pool)
.await
}
pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Verification>, sqlx::Error> { pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result<Option<Verification>, sqlx::Error> {
sqlx::query_as::<_, Verification>("SELECT * FROM verifications WHERE id = $1") sqlx::query_as::<_, Verification>("SELECT * FROM verifications WHERE id = $1")
.bind(id) .bind(id)

113
scripts/build-all.sh Executable file
View file

@ -0,0 +1,113 @@
#!/bin/bash
# build-all.sh - Build all nxtgauge backend services and push to registry
# Usage: ./scripts/build-all.sh [TAG]
set -e
REGISTRY="registry.nxtgauge.com"
TAG=${1:-$(git rev-parse --short HEAD)}
DOCKERFILE="Dockerfile.simple"
echo "============================================"
echo "🚀 NXTGAUGE Full Rebuild Script"
echo "============================================"
echo "Registry: ${REGISTRY}"
echo "Tag: ${TAG}"
echo "Dockerfile: ${DOCKERFILE}"
echo ""
# Services list with underscores (matching Cargo.toml bin names)
SERVICES=(
"gateway"
"users"
"companies"
"job_seekers"
"customers"
"photographers"
"makeup_artists"
"tutors"
"developers"
"video_editors"
"graphic_designers"
"social_media_managers"
"fitness_trainers"
"catering_services"
"ugc_content_creators"
"employees"
"payments"
"jobs"
"leads"
"cron"
)
# Also need service names with hyphens for image names
declare -A SERVICE_IMAGE_NAMES=(
["gateway"]="nxtgauge-rust-gateway"
["users"]="nxtgauge-rust-users"
["companies"]="nxtgauge-rust-companies"
["job_seekers"]="nxtgauge-rust-job-seekers"
["customers"]="nxtgauge-rust-customers"
["photographers"]="nxtgauge-rust-photographers"
["makeup_artists"]="nxtgauge-rust-makeup-artists"
["tutors"]="nxtgauge-rust-tutors"
["developers"]="nxtgauge-rust-developers"
["video_editors"]="nxtgauge-rust-video-editors"
["graphic_designers"]="nxtgauge-rust-graphic-designers"
["social_media_managers"]="nxtgauge-rust-social-media-managers"
["fitness_trainers"]="nxtgauge-rust-fitness-trainers"
["catering_services"]="nxtgauge-rust-catering-services"
["ugc_content_creators"]="nxtgauge-rust-ugc-content-creators"
["employees"]="nxtgauge-rust-employees"
["payments"]="nxtgauge-rust-payments"
["jobs"]="nxtgauge-rust-jobs"
["leads"]="nxtgauge-rust-leads"
["cron"]="nxtgauge-rust-cron"
)
TOTAL=${#SERVICES[@]}
CURRENT=0
build_service() {
local service=$1
local image_name=${SERVICE_IMAGE_NAMES[$service]}
local tag="${REGISTRY}/${image_name}:${TAG}"
local latest="${REGISTRY}/${image_name}:latest"
CURRENT=$((CURRENT + 1))
echo ""
echo "[$CURRENT/$TOTAL] Building ${image_name}..."
echo " Service: ${service}"
echo " Tag: ${tag}"
# Build
docker build \
--build-arg SERVICE_NAME=${service} \
-f ${DOCKERFILE} \
-t ${tag} \
-t ${latest} \
. 2>&1 | tail -5
# Push
echo " Pushing..."
docker push ${tag}
docker push ${latest}
echo "${image_name} complete"
}
# Build all services
for service in "${SERVICES[@]}"; do
build_service "${service}"
done
echo ""
echo "============================================"
echo "🎉 All services built and pushed!"
echo "============================================"
echo "Tag used: ${TAG}"
echo ""
echo "Update kustomization.yaml with:"
for service in "${SERVICES[@]}"; do
local image_name=${SERVICE_IMAGE_NAMES[$service]}
echo " ${image_name}: ${TAG}"
done