Resolve conflicts: remove Woodpecker CI, use Gitea

This commit is contained in:
Tracewebstudio Dev 2026-05-08 15:40:52 +02:00
commit 81d1df70a8
196 changed files with 16559 additions and 3014 deletions

View file

@ -0,0 +1,277 @@
#!/usr/bin/env python3
"""
Registry Image Tag Pruner - Keeps only the latest 1 SHA-tag per repository.
Usage:
python3 registry_prune.py \
--registry registry.nxtgauge.com \
--repo nxtgauge-rust-gateway \
--username "$REGISTRY_USERNAME" \
--password "$REGISTRY_PASSWORD"
Environment variables can also be used:
REGISTRY_HOST, REGISTRY_REPO, REGISTRY_USERNAME, REGISTRY_PASSWORD
SHA-like tags are identified by pattern: ^[a-f0-9]{40}$
Non-SHA tags (e.g., high-performance-latest, main-latest, latest) are NEVER deleted.
Exit code: 0 on success (or if prune fails gracefully), non-zero only on critical error.
"""
import argparse
import base64
import json
import os
import sys
import time
from urllib.request import Request, urlopen
from urllib.error import URLError, HTTPError
def parse_args():
parser = argparse.ArgumentParser(
description="Prune Docker registry tags, keeping only the latest SHA tag."
)
parser.add_argument("--registry", default=os.environ.get("REGISTRY_HOST"))
parser.add_argument("--repo", default=os.environ.get("REGISTRY_REPO"))
parser.add_argument("--username", default=os.environ.get("REGISTRY_USERNAME"))
parser.add_argument("--password", default=os.environ.get("REGISTRY_PASSWORD"))
parser.add_argument("--keep", type=int, default=1, help="Number of SHA tags to keep (default: 1)")
return parser.parse_args()
def api_request(url: str, method: str, username: str, password: str, data=None, retries: int = 3) -> dict | None:
"""Make an authenticated API request with retry logic."""
auth = base64.b64encode(f"{username}:{password}".encode()).decode()
headers = {
"Authorization": f"Basic {auth}",
"Content-Type": "application/json",
}
for attempt in range(1, retries + 1):
try:
req = Request(url, method=method, headers=headers, data=data)
with urlopen(req, timeout=30) as response:
content = response.read()
if content:
return json.loads(content)
return {}
except HTTPError as e:
if e.code == 401:
print(f" [ERROR] Authentication failed (401)")
return None
if e.code == 404:
print(f" [WARN] Resource not found: {url}")
return None
print(f" [RETRY {attempt}/{retries}] HTTP {e.code} for {url}")
except URLError as e:
print(f" [RETRY {attempt}/{retries}] URL error: {e.reason}")
except Exception as e:
print(f" [RETRY {attempt}/{retries}] Error: {e}")
if attempt < retries:
time.sleep(attempt * 2)
print(f" [ERROR] Failed after {retries} attempts for {url}")
return None
def get_tag_digest(registry: str, repo: str, tag: str, username: str, password: str) -> tuple[str, str] | None:
"""Get the digest (sha256:...) and created time for a tag."""
url = f"https://{registry}/v2/{repo}/manifests/{tag}"
auth = base64.b64encode(f"{username}:{password}".encode()).decode()
for attempt in range(1, 4):
try:
req = Request(url, method="GET", headers={
"Authorization": f"Basic {auth}",
"Accept": "application/vnd.docker.distribution.manifest.v2+json",
})
with urlopen(req, timeout=30) as response:
digest = response.headers.get("Docker-Content-Digest", "")
created = response.headers.get("Date", "")
return digest, created
except Exception as e:
print(f" [RETRY {attempt}/3] Getting digest for {tag}: {e}")
time.sleep(attempt)
return None
def delete_tag(registry: str, repo: str, digest: str, username: str, password: str) -> bool:
"""Delete a tag by its digest."""
url = f"https://{registry}/v2/{repo}/manifests/{digest}"
auth = base64.b64encode(f"{username}:{password}".encode()).decode()
for attempt in range(1, 4):
try:
req = Request(url, method="DELETE", headers={
"Authorization": f"Basic {auth}",
})
with urlopen(req, timeout=30) as response:
if response.status in (200, 202, 404):
return True
except HTTPError as e:
if e.code == 404:
return True # Already deleted
print(f" [RETRY {attempt}/3] Deleting {digest[:20]}...: {e}")
except Exception as e:
print(f" [RETRY {attempt}/3] Deleting {digest[:20]}...: {e}")
time.sleep(attempt)
return False
def is_sha_tag(tag: str) -> bool:
"""Check if tag looks like a SHA (40 hex chars)."""
import re
return bool(re.match(r"^[a-f0-9]{40}$", tag))
def prune_tags(registry: str, repo: str, username: str, password: str, keep: int = 1) -> bool:
"""
Main prune logic:
- List all tags for the repo
- Filter SHA-like tags
- Sort by created date (newest first)
- Keep newest `keep` tags
- Delete older SHA tags by digest
- Never delete non-SHA tags
"""
print(f"\n=== Pruning {registry}/{repo} ===")
print(f"Strategy: Keep {keep} newest SHA tag(s), delete older SHA tags")
print(f"Non-SHA tags (e.g., high-performance-latest, main-latest, latest) are preserved\n")
# Get catalog (list of repos)
catalog_url = f"https://{registry}/v2/_catalog"
catalog = api_request(catalog_url, "GET", username, password)
if catalog is None:
print("[ERROR] Failed to get repository catalog")
return False
if repo not in catalog.get("repositories", []):
print(f"[INFO] Repository {repo} not found in catalog")
return True
# Get tags for repo
tags_url = f"https://{registry}/v2/{repo}/tags/list"
tags_data = api_request(tags_url, "GET", username, password)
if tags_data is None:
print(f"[ERROR] Failed to get tags for {repo}")
return False
all_tags = tags_data.get("tags", [])
if not all_tags:
print("[INFO] No tags found")
return True
# Separate SHA tags from non-SHA tags
sha_tags = [t for t in all_tags if is_sha_tag(t)]
non_sha_tags = [t for t in all_tags if not is_sha_tag(t)]
print(f"Total tags: {len(all_tags)}")
print(f" SHA tags (candidates for pruning): {len(sha_tags)}")
print(f" Non-SHA tags (protected): {len(non_sha_tags)}")
if non_sha_tags:
print(f" Protected tags: {', '.join(sorted(non_sha_tags))}")
if not sha_tags:
print("\n[INFO] No SHA tags to prune")
return True
# Get digest and created time for each SHA tag
tag_info = []
for tag in sha_tags:
result = get_tag_digest(registry, repo, tag, username, password)
if result:
digest, created = result
tag_info.append({
"tag": tag,
"digest": digest,
"created": created,
"timestamp": parse_http_date(created) if created else 0,
})
time.sleep(0.1) # Be nice to the registry
if not tag_info:
print("\n[ERROR] Could not get info for any SHA tags")
return False
# Sort by timestamp (newest first)
tag_info.sort(key=lambda x: x["timestamp"], reverse=True)
print(f"\nSHA tags sorted by age (newest first):")
for i, info in enumerate(tag_info):
marker = " [KEEP]" if i < keep else " [DELETE]"
print(f" {i+1}. {info['tag']} ({info['created'] or 'unknown date'}){marker}")
# Delete older SHA tags
deleted_count = 0
kept_count = 0
for i, info in enumerate(tag_info):
if i < keep:
print(f"\n[KEEP] {info['tag']}")
kept_count += 1
continue
print(f"\n[DELETE] {info['tag']} (digest: {info['digest'][:20]}...)")
if delete_tag(registry, repo, info["digest"], username, password):
print(f" [OK] Deleted {info['tag']}")
deleted_count += 1
else:
print(f" [WARN] Failed to delete {info['tag']} (will retry next run)")
time.sleep(0.2) # Be nice to the registry
print(f"\n=== Prune Summary ===")
print(f"Tags kept: {kept_count}")
print(f"Tags deleted: {deleted_count}")
print(f"Tags protected (non-SHA): {len(non_sha_tags)}")
return True
def parse_http_date(date_str: str) -> float:
"""Parse HTTP Date header to timestamp."""
from email.utils import parsedate_to_datetime
try:
return parsedate_to_datetime(date_str).timestamp()
except Exception:
return 0
def main():
args = parse_args()
# Validate required args
registry = args.registry or os.environ.get("REGISTRY_HOST")
repo = args.repo or os.environ.get("REGISTRY_REPO")
username = args.username or os.environ.get("REGISTRY_USERNAME")
password = args.password or os.environ.get("REGISTRY_PASSWORD")
if not all([registry, repo, username, password]):
print("[ERROR] Missing required arguments. Need: --registry, --repo, --username, --password")
print("Or set environment variables: REGISTRY_HOST, REGISTRY_REPO, REGISTRY_USERNAME, REGISTRY_PASSWORD")
sys.exit(1)
print(f"Registry: {registry}")
print(f"Repository: {repo}")
print(f"Username: {username}")
try:
success = prune_tags(registry, repo, username, password, args.keep)
if success:
print("\n[OK] Prune completed successfully")
sys.exit(0)
else:
print("\n[WARN] Prune completed with some errors")
sys.exit(0) # Exit 0 per requirement - never fail workflow
except Exception as e:
print(f"\n[ERROR] Prune failed with exception: {e}")
sys.exit(0) # Exit 0 per requirement - never fail workflow
if __name__ == "__main__":
main()

View file

@ -0,0 +1,145 @@
#!/usr/bin/env python3
"""
Update GitOps kustomization.yaml with new image SHA tags.
Usage:
python3 update-gitops.py \
--repo /path/to/nxtgauge-gitops \
--service gateway \
--sha abc123def456...
This script:
1. Updates the newTag for the specified service to the SHA
2. Commits and pushes to the gitops repo
3. ArgoCD detects the change and deploys
"""
import argparse
import os
import re
import subprocess
import sys
def run(cmd: list[str], cwd: str = None) -> tuple[int, str, str]:
"""Run a command and return (returncode, stdout, stderr)."""
result = subprocess.run(cmd, cwd=cwd, capture_output=True, text=True)
return result.returncode, result.stdout, result.stderr
def update_kustomization(kustomization_path: str, service: str, sha: str) -> bool:
"""Update the newTag for a service in kustomization.yaml."""
with open(kustomization_path, "r") as f:
content = f.read()
# Pattern to find image entry for the service
# Matches: - name: registry.nxtgauge.com/nxtgauge-rust-{service}
# newTag: something
pattern = rf'(\s+-\s+name:\s+registry\.nxtgauge\.com/nxtgauge-rust-{re.escape(service)}\n\s+newTag:\s+)[^\n]+'
replacement = rf'\g<1>{sha}'
new_content, count = re.subn(pattern, replacement, content)
if count == 0:
# Try without the nxtgauge-rust- prefix (for frontend, admin, etc)
pattern = rf'(\s+-\s+name:\s+registry\.nxtgauge\.com/nxtgauge-{re.escape(service)}\n\s+newTag:\s+)[^\n]+'
new_content, count = re.subn(pattern, replacement, content)
if count == 0:
print(f"[ERROR] Could not find image entry for service: {service}")
return False
with open(kustomization_path, "w") as f:
f.write(new_content)
print(f"[OK] Updated {service} to SHA {sha}")
return True
def main():
parser = argparse.ArgumentParser(description="Update GitOps with new image SHA")
parser.add_argument("--repo", required=True, help="Path to gitops repo")
parser.add_argument("--service", required=True, help="Service name (e.g., gateway, users, frontend-solid)")
parser.add_argument("--sha", required=True, help="Git SHA to deploy")
parser.add_argument("--message", default=None, help="Commit message")
args = parser.parse_args()
service_image_map = {
"gateway": "nxtgauge-rust-gateway",
"users": "nxtgauge-rust-users",
"companies": "nxtgauge-rust-companies",
"jobs": "nxtgauge-rust-jobs",
"leads": "nxtgauge-rust-leads",
"job-seekers": "nxtgauge-rust-job-seekers",
"customers": "nxtgauge-rust-customers",
"payments": "nxtgauge-rust-payments",
"employees": "nxtgauge-rust-employees",
"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",
"cron": "nxtgauge-rust-cron",
"frontend-solid": "nxtgauge-frontend-solid",
"admin-solid": "nxtgauge-admin-solid",
"ai-assistant": "nxtgauge-ai-assistant",
}
# Determine which kustomization file to update
if service_image_map.get(args.service):
image_name = service_image_map[args.service]
else:
image_name = f"nxtgauge-{args.service}"
# Find the right kustomization file based on service
if "frontend" in args.service or "admin" in args.service:
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-frontend-solid/overlays/prod/kustomization.yaml")
if not os.path.exists(kustomization_path):
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-frontend-solid/base/kustomization.yaml")
elif "ai-assistant" in args.service:
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-ai-assistant/overlays/prod/kustomization.yaml")
if not os.path.exists(kustomization_path):
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-ai-assistant/base/kustomization.yaml")
else:
kustomization_path = os.path.join(args.repo, "apps/nxtgauge-backend-rust/overlays/prod/kustomization.yaml")
if not os.path.exists(kustomization_path):
print(f"[ERROR] Kustomization file not found: {kustomization_path}")
sys.exit(0) # Exit 0 per workflow requirement
print(f"Updating {kustomization_path} for service {args.service}")
if not update_kustomization(kustomization_path, args.service, args.sha):
sys.exit(0) # Exit 0 per workflow requirement
# Git add, commit, push
commit_msg = args.message or f"chore: deploy {args.service}@{args.sha}"
run(["git", "add", "-A"], cwd=args.repo)
code, stdout, stderr = run(["git", "diff", "--cached", "--stat"], cwd=args.repo)
if not stdout.strip():
print("[INFO] No changes to commit")
sys.exit(0)
print(f"Changes to commit:\n{stdout}")
run(["git", "commit", "-m", commit_msg], cwd=args.repo)
code, stdout, stderr = run(["git", "push"], cwd=args.repo)
if code != 0:
print(f"[ERROR] Push failed: {stderr}")
else:
print(f"[OK] Pushed update to gitops repo")
sys.exit(0) # Always exit 0 per workflow requirement
if __name__ == "__main__":
main()

273
.gitea/workflows/build.yaml Normal file
View file

@ -0,0 +1,273 @@
name: build-and-push
on:
push:
branches:
- main
- high-performance
jobs:
detect-changes:
runs-on: ubuntu-latest
outputs:
services_csv: ${{ steps.detect.outputs.services_csv }}
has_changes: ${{ steps.detect.outputs.has_changes }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Detect changed services
id: detect
run: |
set -euo pipefail
set_output() {
local key="$1"
local value="$2"
if [ -n "${GITHUB_OUTPUT:-}" ]; then
echo "$key=$value" >> "$GITHUB_OUTPUT"
fi
echo "::set-output name=$key::$value"
}
if git rev-parse --verify HEAD^ >/dev/null 2>&1; then
CHANGED_FILES=$(git diff --name-only HEAD^ HEAD)
else
CHANGED_FILES=$(git ls-files)
fi
LAST_COMMIT_MSG=$(git log -1 --pretty=%B | tr '\n' ' ')
echo "Changed files:"
echo "$CHANGED_FILES"
ALL_SERVICES='gateway,users,companies,jobs,leads,job-seekers,customers,payments,employees,photographers,makeup-artists,tutors,developers,video-editors,graphic-designers,social-media-managers,fitness-trainers,catering-services,ugc-content-creators,cron'
# Force full build for explicit trigger commits.
if echo "$LAST_COMMIT_MSG" | grep -Eiq 'trigger gitea pipeline|force build|rebuild all'; then
set_output "services_csv" "$ALL_SERVICES"
set_output "has_changes" "true"
exit 0
fi
# Build everything for workflow/docker/shared backend changes.
if echo "$CHANGED_FILES" | grep -Eq '^(\.gitea/workflows/|Dockerfile|Dockerfile\.|Cargo\.toml|Cargo\.lock|crates/|scripts/)'; then
set_output "services_csv" "$ALL_SERVICES"
set_output "has_changes" "true"
exit 0
fi
SERVICES=''
add_service() {
local svc="$1"
case ",${SERVICES}," in
*",${svc},"*) ;;
*)
if [ -z "$SERVICES" ]; then
SERVICES="$svc"
else
SERVICES="$SERVICES,$svc"
fi
;;
esac
}
while IFS= read -r f; do
case "$f" in
apps/gateway/*) add_service "gateway" ;;
apps/users/*) add_service "users" ;;
apps/companies/*) add_service "companies" ;;
apps/jobs/*) add_service "jobs" ;;
apps/leads/*) add_service "leads" ;;
apps/job_seekers/*) add_service "job-seekers" ;;
apps/customers/*) add_service "customers" ;;
apps/payments/*) add_service "payments" ;;
apps/employees/*) add_service "employees" ;;
apps/photographers/*) add_service "photographers" ;;
apps/makeup_artists/*) add_service "makeup-artists" ;;
apps/tutors/*) add_service "tutors" ;;
apps/developers/*) add_service "developers" ;;
apps/video_editors/*) add_service "video-editors" ;;
apps/graphic_designers/*) add_service "graphic-designers" ;;
apps/social_media_managers/*) add_service "social-media-managers" ;;
apps/fitness_trainers/*) add_service "fitness-trainers" ;;
apps/catering_services/*) add_service "catering-services" ;;
apps/ugc_content_creators/*) add_service "ugc-content-creators" ;;
apps/cron/*) add_service "cron" ;;
esac
done <<< "$CHANGED_FILES"
if [ -z "$SERVICES" ]; then
set_output "services_csv" ""
set_output "has_changes" "false"
else
set_output "services_csv" "$SERVICES"
set_output "has_changes" "true"
fi
build:
needs: detect-changes
if: needs.detect-changes.outputs.has_changes == 'true'
runs-on: ubuntu-latest
env:
DOCKER_HOST: unix:///var/run/docker.sock
strategy:
fail-fast: false
matrix:
service:
- gateway
- users
- companies
- jobs
- leads
- job-seekers
- customers
- payments
- employees
- photographers
- makeup-artists
- tutors
- developers
- video-editors
- graphic-designers
- social-media-managers
- fitness-trainers
- catering-services
- ugc-content-creators
- cron
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up Docker Buildx
run: |
export DOCKER_HOST=unix:///var/run/docker.sock
docker version
docker buildx create --use || true
docker buildx inspect --bootstrap
- name: Login to Registry
env:
REGISTRY_HOSTPORT: ${{ secrets.REGISTRY_HOSTPORT }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -euo pipefail
export DOCKER_HOST=unix:///var/run/docker.sock
test -n "$REGISTRY_HOSTPORT"
for attempt in 1 2 3 4 5; do
echo "Registry login attempt $attempt to $REGISTRY_HOSTPORT"
if echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOSTPORT" -u "$REGISTRY_USERNAME" --password-stdin; then
exit 0
fi
echo "Registry login failed (attempt $attempt); retrying..."
sleep $((attempt * 8))
done
echo "Registry login failed after retries"
exit 1
- name: Build and push
env:
REGISTRY_HOSTPORT: ${{ secrets.REGISTRY_HOSTPORT }}
SERVICES_CSV: ${{ needs.detect-changes.outputs.services_csv }}
run: |
set -euo pipefail
export DOCKER_HOST=unix:///var/run/docker.sock
if [ -n "$SERVICES_CSV" ] && ! echo ",$SERVICES_CSV," | grep -q ",${{ matrix.service }},"; then
echo "Skipping unchanged service: ${{ matrix.service }}"
exit 0
fi
build_with_cache() {
docker buildx build --push \
-f Dockerfile.simple \
--build-arg SERVICE_NAME=${{ matrix.service }} \
--cache-from type=registry,ref=$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:buildcache \
--cache-to type=registry,ref=$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:buildcache,mode=max \
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }}" \
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:high-performance-latest" \
.
}
build_without_cache_export() {
docker buildx build --push \
-f Dockerfile.simple \
--build-arg SERVICE_NAME=${{ matrix.service }} \
--cache-from type=registry,ref=$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:buildcache \
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }}" \
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:high-performance-latest" \
.
}
for attempt in 1 2 3; do
echo "Build attempt $attempt with cache export for ${{ matrix.service }}"
if build_with_cache; then
exit 0
fi
echo "Attempt $attempt failed; retrying after backoff"
sleep $((attempt * 10))
done
echo "Falling back to build without cache export for ${{ matrix.service }}"
if ! build_without_cache_export; then
echo "Final fallback: push tags without cache"
docker buildx build --push \
-f Dockerfile.simple \
--build-arg SERVICE_NAME=${{ matrix.service }} \
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }}" \
-t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:high-performance-latest" \
.
fi
- name: Prune old image tags (keep latest 1 SHA)
if: success()
continue-on-error: true
env:
REGISTRY_HOST: ${{ secrets.REGISTRY_HOSTPORT }}
REGISTRY_USERNAME: ${{ secrets.REGISTRY_USERNAME }}
REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }}
run: |
set -euo pipefail
python3 .gitea/scripts/registry_prune.py \
--registry "$REGISTRY_HOST" \
--repo "nxtgauge-rust-${{ matrix.service }}" \
--username "$REGISTRY_USERNAME" \
--password "$REGISTRY_PASSWORD" \
--keep 1
- name: Update GitOps and trigger deployment
if: always()
continue-on-error: true
env:
GITEOPS_REPO: ${{ secrets.GITEOPS_REPO }}
GITEOPS_SSH_KEY: ${{ secrets.GITEOPS_SSH_KEY }}
run: |
set -euo pipefail
if [ -z "$GITEOPS_REPO" ]; then
echo "GITEOPS_REPO secret not set, skipping GitOps update"
exit 0
fi
# Clone gitops repo
GITEOPS_DIR=$(mktemp -d)
git clone "$GITEOPS_REPO" "$GITEOPS_DIR"
cd "$GITEOPS_DIR"
# Set up SSH key for push
mkdir -p ~/.ssh
echo "$GITEOPS_SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan github.com >> ~/.ssh/known_hosts 2>/dev/null
# Update gitops with new SHA
python3 .gitea/scripts/update-gitops.py \
--repo "$GITEOPS_DIR" \
--service "${{ matrix.service }}" \
--sha "${{ gitea.sha }}" \
--message "chore: deploy ${{ matrix.service }}@${{ gitea.sha }}"
rm -rf "$GITEOPS_DIR"

46
.github/workflows/sync-to-gitea.yml vendored Normal file
View file

@ -0,0 +1,46 @@
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

View file

@ -1,40 +0,0 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT r.key, r.name, ur.status, ur.approved_at\n FROM user_roles ur\n INNER JOIN roles r ON r.id = ur.role_id\n WHERE ur.user_id = $1\n ORDER BY ur.created_at ASC\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "key",
"type_info": "Varchar"
},
{
"ordinal": 1,
"name": "name",
"type_info": "Varchar"
},
{
"ordinal": 2,
"name": "status",
"type_info": "Varchar"
},
{
"ordinal": 3,
"name": "approved_at",
"type_info": "Timestamptz"
}
],
"parameters": {
"Left": [
"Uuid"
]
},
"nullable": [
false,
false,
false,
true
]
},
"hash": "f479b3c6088810c02b09611eb2bc7b2b88d241b9aac76dd228fd9286c158dd77"
}

View file

@ -1,25 +0,0 @@
when:
branch: [main, high-performance]
event: push
path:
- Cargo.toml
- Cargo.lock
- crates/**
steps:
- name: build-base-image
image: woodpeckerci/plugin-docker-buildx:5.0.0
settings:
registry:
from_secret: REGISTRY_HOSTPORT
repo: nxtgauge-rust-base
context: .
dockerfile: Dockerfile.base
tags:
- latest
- ${CI_COMMIT_SHA}
username:
from_secret: GHCR_USERNAME
password:
from_secret: GHCR_TOKEN
platforms: linux/amd64

View file

@ -1,103 +0,0 @@
when:
branch: [main, high-performance]
event: push
matrix:
SERVICE:
- gateway
- users
- companies
- job_seekers
- customers
- payments
- employees
- photographers
- makeup_artists
- tutors
- developers
- video_editors
- graphic_designers
- social_media_managers
- fitness_trainers
- catering_services
- ugc_content_creators
- cron
steps:
- name: detect-changes
image: alpine/git
commands:
- apk add --no-cache bash
- |
#!/bin/bash
set -e
CHANGED_FILES=$(git diff --name-only HEAD~1 HEAD || echo "")
SERVICE_PATH=$(echo "${SERVICE}" | tr '_' '-')
SHARED_CHANGED=false
if echo "$CHANGED_FILES" | grep -q "^crates/"; then
SHARED_CHANGED=true
echo "⚠️ Shared crates changed"
fi
SERVICE_CHANGED=false
if echo "$CHANGED_FILES" | grep -q "^apps/${SERVICE_PATH}/"; then
SERVICE_CHANGED=true
echo "✅ Service ${SERVICE} changed"
fi
if [ "$SHARED_CHANGED" = "true" ] || [ "$SERVICE_CHANGED" = "true" ]; then
echo "🚀 Building ${SERVICE}"
exit 0
else
echo "⏭️ Skipping ${SERVICE}"
exit 78
fi
- name: build
image: rust:alpine
commands:
- apk add --no-cache musl-dev pkgconfig openssl-dev git
- rustup target add x86_64-unknown-linux-musl
- |
#!/bin/bash
set -e
# Build static binary directly (no Docker!)
cd /woodpecker/src/git
# Copy only needed files for this service
mkdir -p /tmp/build
cp -r Cargo.toml Cargo.lock crates/ /tmp/build/
cp -r apps/${SERVICE}/ /tmp/build/apps/
cd /tmp/build
# Build with optimizations
export RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-s"
cargo build --release \
--bin ${SERVICE} \
--target x86_64-unknown-linux-musl
# Copy binary to workspace for next step
cp target/x86_64-unknown-linux-musl/release/${SERVICE} /woodpecker/src/git/${SERVICE}-binary
echo "✅ Binary built successfully"
- name: build-docker
image: woodpeckerci/plugin-docker-buildx:5.0.0
settings:
registry:
from_secret: REGISTRY_HOSTPORT
repo: nxtgauge-rust-${SERVICE}
dockerfile: Dockerfile.binary
build_args:
- SERVICE_NAME=${SERVICE}
tags:
- ${CI_COMMIT_SHA}
- latest
username:
from_secret: DOCKERHUB_USERNAME
password:
from_secret: DOCKERHUB_TOKEN
platforms: linux/amd64

View file

@ -1,48 +0,0 @@
when:
branch: [main, high-performance]
event: push
matrix:
SERVICE:
- gateway
- users
- companies
- job-seekers
- customers
- payments
- employees
- photographers
- makeup-artists
- tutors
- developers
- video-editors
- graphic-designers
- social-media-managers
- fitness-trainers
- catering-services
- ugc-content-creators
- cron
steps:
- name: build-and-push
image: woodpeckerci/plugin-kaniko:2.1.1
settings:
registry:
from_secret: REGISTRY_HOSTPORT
repo: nxtgauge-rust-${SERVICE}
dockerfile: Dockerfile.simple
build_args:
- SERVICE_NAME=${SERVICE}
tags:
- ${CI_COMMIT_SHA}
- latest
- high-performance-latest
username:
from_secret: REGISTRY_USERNAME
password:
from_secret: REGISTRY_PASSWORD
insecure: true
insecure_pull: true
skip_tls_verify: true
platforms: linux/amd64
cache: false

View file

@ -1,18 +0,0 @@
# Woodpecker CI Secrets
The following Woodpecker secrets are required for CI/CD pipelines:
| Secret Name | Purpose |
| -------------------- | -------------------------------------------------------------- |
| `REGISTRY_HOSTPORT` | Registry host:port (e.g., `registry.nxtgauge.com`) |
| `REGISTRY_USERNAME` | Registry username for authentication |
| `REGISTRY_PASSWORD` | Registry password/token for authentication |
| `DOCKERHUB_USERNAME` | Docker Hub username (optional, for Docker Hub pushes) |
| `DOCKERHUB_TOKEN` | Docker Hub access token (optional, for Docker Hub pushes) |
| `GHCR_USERNAME` | GitHub Container Registry username (optional, for GHCR pushes) |
| `GHCR_TOKEN` | GitHub Container Registry token (optional, for GHCR pushes) |
| `GITOPS_REPO_URL` | GitOps repository URL (optional) |
## Usage
All build/push steps use these secrets via `from_secret:` references. No credentials are hardcoded in pipeline files.

65
Cargo.lock generated
View file

@ -764,6 +764,7 @@ dependencies = [
"db",
"serde",
"sqlx",
"storage",
"tokio",
"tracing",
"tracing-subscriber",
@ -847,13 +848,17 @@ version = "0.1.0"
dependencies = [
"auth",
"axum",
"bytes",
"cache",
"chrono",
"contracts",
"db",
"email",
"redis",
"serde",
"serde_json",
"sqlx",
"storage",
"tokio",
"tracing",
"tracing-subscriber",
@ -882,6 +887,7 @@ dependencies = [
"anyhow",
"async-trait",
"axum",
"bytes",
"cache",
"chrono",
"db",
@ -889,6 +895,7 @@ dependencies = [
"serde",
"serde_json",
"sqlx",
"storage",
"tracing",
"uuid",
]
@ -1060,6 +1067,18 @@ dependencies = [
"uuid",
]
[[package]]
name = "db-migrate"
version = "0.1.0"
dependencies = [
"anyhow",
"serde",
"sqlx",
"tokio",
"tracing",
"tracing-subscriber",
]
[[package]]
name = "der"
version = "0.6.1"
@ -1102,6 +1121,7 @@ dependencies = [
"db",
"serde",
"sqlx",
"storage",
"tokio",
"tracing",
"tracing-subscriber",
@ -1550,6 +1570,7 @@ dependencies = [
"db",
"serde",
"sqlx",
"storage",
"tokio",
"tracing",
"tracing-subscriber",
@ -2050,10 +2071,12 @@ dependencies = [
"auth",
"axum",
"bytes",
"cache",
"chrono",
"contracts",
"db",
"email",
"redis",
"serde",
"serde_json",
"sqlx",
@ -2064,6 +2087,23 @@ dependencies = [
"uuid",
]
[[package]]
name = "jobs"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"chrono",
"serde",
"serde_json",
"sqlx",
"tokio",
"tower-http",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "jobserver"
version = "0.1.34"
@ -2108,6 +2148,24 @@ dependencies = [
"spin",
]
[[package]]
name = "leads"
version = "0.1.0"
dependencies = [
"anyhow",
"axum",
"chrono",
"reqwest",
"serde",
"serde_json",
"sqlx",
"tokio",
"tower-http",
"tracing",
"tracing-subscriber",
"uuid",
]
[[package]]
name = "leb128fmt"
version = "0.1.0"
@ -2231,6 +2289,7 @@ dependencies = [
"db",
"serde",
"sqlx",
"storage",
"tokio",
"tracing",
"tracing-subscriber",
@ -2580,6 +2639,7 @@ dependencies = [
"db",
"serde",
"sqlx",
"storage",
"tokio",
"tracing",
"tracing-subscriber",
@ -3365,6 +3425,7 @@ dependencies = [
"db",
"serde",
"sqlx",
"storage",
"tokio",
"tracing",
"tracing-subscriber",
@ -3935,6 +3996,7 @@ dependencies = [
"tower",
"tower-layer",
"tower-service",
"tracing",
]
[[package]]
@ -4029,6 +4091,7 @@ dependencies = [
"db",
"serde",
"sqlx",
"storage",
"tokio",
"tracing",
"tracing-subscriber",
@ -4135,6 +4198,7 @@ dependencies = [
"db",
"email",
"rand 0.8.5",
"reqwest",
"serde",
"serde_json",
"sqlx",
@ -4192,6 +4256,7 @@ dependencies = [
"db",
"serde",
"sqlx",
"storage",
"tokio",
"tracing",
"tracing-subscriber",

View file

@ -24,7 +24,10 @@ members = [
"crates/email",
"apps/cron",
"apps/employees",
"apps/payments"
"apps/payments",
"apps/jobs",
"apps/leads",
"crates/db-migrate"
]
[workspace.package]
@ -51,3 +54,5 @@ redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] }
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }
async-trait = "0.1"
bytes = "1"
tower-http = "0.6"
reqwest = { version = "0.12", features = ["json", "rustls-tls"] }

28
Dockerfile.migrate Normal file
View file

@ -0,0 +1,28 @@
FROM registry.nxtgauge.com/rust:alpine AS builder
WORKDIR /app
RUN apk add --no-cache curl ca-certificates bash build-base musl-dev pkgconfig openssl-dev openssl-libs-static
RUN update-ca-certificates
RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
ENV PATH="/root/.cargo/bin:${PATH}"
RUN rustup target add x86_64-unknown-linux-musl
COPY Cargo.toml Cargo.lock ./
COPY crates/db-migrate ./crates/db-migrate
COPY crates/db ./crates/db
COPY crates/cache ./crates/cache
COPY crates/email ./crates/email
WORKDIR /app/crates/db-migrate
ENV OPENSSL_STATIC=1
ENV OPENSSL_DIR=/usr
RUN cargo build --release --bin db-migrate --target x86_64-unknown-linux-musl
FROM alpine:3.19
RUN apk add --no-cache ca-certificates libpq
COPY --from=builder /app/crates/db-migrate/target/x86_64-unknown-linux-musl/release/db-migrate /usr/local/bin/
COPY crates/db/migrations /migrations
ENTRYPOINT ["db-migrate"]

View file

@ -3,12 +3,15 @@
ARG SERVICE_NAME
FROM rust:alpine AS builder
FROM registry.nxtgauge.com/rust:alpine AS builder
ARG SERVICE_NAME
# Install deps
RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static && \
rustup target add x86_64-unknown-linux-musl
# Install build deps + rust toolchain (Alpine-packaged Rust lacks proc-macro support)
RUN apk add --no-cache curl ca-certificates bash build-base musl-dev pkgconfig openssl-dev openssl-libs-static
RUN update-ca-certificates
RUN curl -sSf https://sh.rustup.rs | sh -s -- -y --profile minimal --default-toolchain stable
ENV PATH="/root/.cargo/bin:${PATH}"
RUN rustup target add x86_64-unknown-linux-musl
WORKDIR /app

View file

@ -16,3 +16,11 @@ Rust migration target for `nxtgauge-nov-2025-backend`, preserving the same micro
- Replace service implementations one by one.
See `docs/MIGRATION_MASTER_PLAN.md` for full staged plan.
## CI (Woodpecker)
Required secrets:
- `REGISTRY_USERNAME`
- `REGISTRY_PASSWORD`
See `.woodpecker/README.md` for details.

View file

@ -16,4 +16,5 @@ db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }
storage = { path = "../../crates/storage" }

View file

@ -1,5 +1,5 @@
use contracts::ProfessionState;
use db::models::catering_service::CateringServiceProfile;
use db::models::user_role_profile::UserRoleProfile;
use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use serde::Serialize;
use uuid::Uuid;
@ -7,8 +7,9 @@ use uuid::Uuid;
#[derive(Serialize)]
pub struct AdminCateringServiceList {
pub id: Uuid,
pub user_role_profile_id: Uuid,
pub user_id: Uuid,
pub business_name: Option<String>,
pub display_name: Option<String>,
pub bio: Option<String>,
pub location: Option<String>,
pub status: String,
@ -16,12 +17,13 @@ pub struct AdminCateringServiceList {
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<CateringServiceProfile> for AdminCateringServiceList {
fn from(p: CateringServiceProfile) -> Self {
impl From<UserRoleProfile> for AdminCateringServiceList {
fn from(p: UserRoleProfile) -> Self {
Self {
id: p.id,
user_role_profile_id: p.id,
user_id: p.user_id,
business_name: p.business_name,
display_name: p.display_name,
bio: p.bio,
location: p.location,
status: p.status,
@ -40,10 +42,15 @@ pub fn router() -> Router<ProfessionState> {
async fn list_catering_services(
State(state): State<ProfessionState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let services = sqlx::query_as::<_, CateringServiceProfile>(
let services = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, business_name, bio, location, custom_data, status, created_at, updated_at
FROM catering_service_profiles
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE role_key = 'catering_service'
ORDER BY created_at DESC
LIMIT 100
"#,
@ -60,11 +67,15 @@ async fn get_catering_service(
State(state): State<ProfessionState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let service = sqlx::query_as::<_, CateringServiceProfile>(
let service = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, business_name, bio, location, custom_data, status, created_at, updated_at
FROM catering_service_profiles
WHERE id = $1
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE id = $1 AND role_key = 'catering_service'
"#,
)
.bind(id)

View file

@ -21,7 +21,7 @@ async fn update_profile(
auth: AuthUser,
Json(payload): Json<UpsertCateringServiceProfilePayload>,
) -> impl IntoResponse {
match CateringServiceRepository::upsert(&state.pool, auth.user_id, payload).await {
match CateringServiceRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}

View file

@ -3,6 +3,7 @@ mod admin;
use axum::{routing::get, Router};
use std::net::SocketAddr;
use std::sync::Arc;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::ProfessionState;
@ -30,7 +31,8 @@ async fn main() {
tracing::info!("Catering Services service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
let storage = Arc::new(storage::StorageClient::from_env().await);
let state = ProfessionState { pool, redis, storage };
let app = Router::new()
.nest("/api/catering-services", handlers::router())

View file

@ -4,7 +4,7 @@ version = "0.1.0"
edition = "2021"
[dependencies]
axum = { workspace = true }
axum = { workspace = true, features = ["multipart"] }
tokio = { workspace = true }
serde = { workspace = true }
sqlx = { workspace = true }
@ -17,4 +17,8 @@ auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
serde_json = { workspace = true }
email = { path = "../../crates/email" }
storage = { path = "../../crates/storage" }
bytes = { workspace = true }
cache = { path = "../../crates/cache" }
redis = { workspace = true }

View file

@ -106,8 +106,7 @@ pub struct AdminApplicationRow {
pub applicant_name: String,
pub applicant_email: String,
pub status: String,
pub cover_letter: Option<String>,
pub resume_url: Option<String>,
pub cover_note: Option<String>,
pub applied_at: DateTime<Utc>,
pub created_at: DateTime<Utc>,
}
@ -120,12 +119,11 @@ impl From<Application> for AdminApplicationRow {
job_title: String::new(),
company_id: Uuid::nil(),
company_name: String::new(),
applicant_id: a.job_seeker_id,
applicant_id: a.applicant_user_id,
applicant_name: String::new(),
applicant_email: String::new(),
status: a.status,
cover_letter: a.cover_letter,
resume_url: a.resume_url,
cover_note: a.cover_note,
applied_at: a.applied_at,
created_at: a.updated_at,
}
@ -252,9 +250,9 @@ async fn list_applications(
) -> Result<impl IntoResponse, (StatusCode, String)> {
let applications = sqlx::query_as::<_, Application>(
r#"
SELECT id, job_id, job_seeker_id, cover_letter, resume_url, status,
applied_at, updated_at, contact_viewed
FROM applications
SELECT id, job_id, applicant_user_id, cover_note, status,
applied_at, updated_at
FROM job_applications
ORDER BY applied_at DESC
LIMIT 100
"#,

View file

@ -1,11 +1,14 @@
pub mod admin;
use axum::{
extract::{Path, Query, State},
extract::{Multipart, Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, patch, post},
Json, Router,
};
use bytes::BufMut;
use cache::jobs as cache_jobs;
use redis::AsyncCommands;
use serde::Deserialize;
use uuid::Uuid;
use db::models::company::{CompanyRepository, UpsertCompanyProfilePayload};
@ -19,6 +22,7 @@ use crate::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/profile/me", get(get_profile).patch(update_profile))
.route("/profile/documents", post(upload_documents))
.route("/profile/submit", post(submit_for_verification))
.route("/jobs", get(list_jobs).post(create_job))
.route("/jobs/{id}", get(get_job).patch(update_job))
@ -58,8 +62,23 @@ async fn get_profile(
State(state): State<AppState>,
auth: AuthUser,
) -> impl IntoResponse {
let cache_key = format!("profile:company:{}", auth.user_id);
let mut redis = state.redis.clone();
// Try cache first
if let Ok(cached) = redis.get::<_, String>(&cache_key).await {
tracing::debug!("Cache hit for company profile: {}", auth.user_id);
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&cached) {
return (StatusCode::OK, Json(parsed)).into_response();
}
}
match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
Ok(Some(profile)) => {
// Cache for 5 minutes
let _: Result<(), _> = redis.set_ex(&cache_key, &serde_json::to_string(&profile).unwrap_or_default(), 300).await;
(StatusCode::OK, Json(profile)).into_response()
}
Ok(None) => (StatusCode::NOT_FOUND, "Company profile not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
@ -71,7 +90,13 @@ async fn update_profile(
Json(payload): Json<UpsertCompanyProfilePayload>,
) -> impl IntoResponse {
match CompanyRepository::upsert(&state.pool, auth.user_id, payload).await {
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
Ok(profile) => {
// Invalidate profile cache
let cache_key = format!("profile:company:{}", auth.user_id);
let mut redis = state.redis.clone();
let _ = redis.del::<_, ()>(&cache_key).await;
(StatusCode::OK, Json(profile)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
@ -99,10 +124,16 @@ async fn submit_for_verification(
}
match CompanyRepository::submit_for_verification(&state.pool, auth.user_id).await {
Ok(profile) => (StatusCode::OK, Json(serde_json::json!({
"status": profile.status,
"message": "Profile submitted for verification"
}))).into_response(),
Ok(profile) => {
// Invalidate company profile cache
let cache_key = format!("profile:company:{}", auth.user_id);
let mut redis = state.redis.clone();
let _ = redis.del::<_, ()>(&cache_key).await;
(StatusCode::OK, Json(serde_json::json!({
"status": profile.status,
"message": "Profile submitted for verification"
}))).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
@ -119,11 +150,30 @@ async fn list_jobs(
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
let status_filter = q.status.as_deref().unwrap_or("");
// Build cache key
let cache_key = format!("jobs:company:{}:{}:{}:{}", company.id, page, limit, status_filter);
let mut redis = state.redis.clone();
// Try cache first
if let Ok(cached) = redis.get::<_, String>(&cache_key).await {
tracing::debug!("Cache hit for company jobs: {}", cache_key);
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&cached) {
return (StatusCode::OK, Json(parsed)).into_response();
}
}
match JobRepository::list_by_company_id(&state.pool, company.id, q.status, page, limit).await {
Ok(jobs) => (StatusCode::OK, Json(serde_json::json!({
"data": jobs,
"pagination": { "page": page, "limit": limit }
}))).into_response(),
Ok(jobs) => {
let response = serde_json::json!({
"data": jobs,
"pagination": { "page": page, "limit": limit }
});
// Cache for 5 minutes
let _: Result<(), _> = redis.set_ex(&cache_key, &serde_json::to_string(&response).unwrap_or_default(), 300).await;
(StatusCode::OK, Json(response)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
@ -190,7 +240,17 @@ async fn create_job(
};
match JobRepository::create(&state.pool, db_payload).await {
Ok(job) => (StatusCode::CREATED, Json(job)).into_response(),
Ok(job) => {
// Invalidate company's job list cache
let mut redis = state.redis.clone();
let pattern = format!("jobs:company:{}:*", company.id);
if let Ok(keys) = redis.keys::<_, Vec<String>>(pattern).await {
if !keys.is_empty() {
let _ = redis.del::<_, ()>(keys).await;
}
}
(StatusCode::CREATED, Json(job)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
@ -229,7 +289,17 @@ async fn update_job(
};
match JobRepository::update(&state.pool, job.id, payload).await {
Ok(updated) => (StatusCode::OK, Json(updated)).into_response(),
Ok(updated) => {
// Invalidate company job list cache
let mut redis = state.redis.clone();
let pattern = format!("jobs:company:{}:*", company.id);
if let Ok(keys) = redis.keys::<_, Vec<String>>(pattern).await {
if !keys.is_empty() {
let _ = redis.del::<_, ()>(keys).await;
}
}
(StatusCode::OK, Json(updated)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
@ -258,7 +328,7 @@ async fn submit_job(
Ok(updated) => {
// Fire email to company user (ignore failures)
if let Ok(user) = UserRepository::get_by_id(&state.pool, auth.user_id).await {
let _ = state.mail.send_job_submitted_email(&user.email, user.full_name.as_deref().unwrap_or("User"), &updated.title).await;
let _ = state.mail.send_job_submitted_email(&user.email, &format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()), &updated.title).await;
}
// Create verification case so the request appears in Verification Management first.
@ -282,6 +352,14 @@ async fn submit_job(
serde_json::json!([]),
)
.await;
// Invalidate company job list cache
let mut redis = state.redis.clone();
let pattern = format!("jobs:company:{}:*", company.id);
if let Ok(keys) = redis.keys::<_, Vec<String>>(pattern).await {
if !keys.is_empty() {
let _ = redis.del::<_, ()>(keys).await;
}
}
(StatusCode::OK, Json(updated)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
@ -305,7 +383,17 @@ async fn close_job(
};
match JobRepository::update_status(&state.pool, job.id, "CLOSED").await {
Ok(updated) => (StatusCode::OK, Json(updated)).into_response(),
Ok(updated) => {
// Invalidate company job list cache
let mut redis = state.redis.clone();
let pattern = format!("jobs:company:{}:*", company.id);
if let Ok(keys) = redis.keys::<_, Vec<String>>(pattern).await {
if !keys.is_empty() {
let _ = redis.del::<_, ()>(keys).await;
}
}
(StatusCode::OK, Json(updated)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
@ -366,14 +454,28 @@ async fn update_application_status(
match ApplicationRepository::update_status(&state.pool, app.id, &payload.status).await {
Ok(updated) => {
// Notify applicant of status change (ignore failures)
let applicant_info = sqlx::query_as::<_, (String, String)>(
"SELECT u.full_name, u.email FROM users u INNER JOIN job_seekers js ON js.user_id = u.id WHERE js.id = $1",
let applicant_info = sqlx::query_as::<_, (String, String, Uuid)>(
"SELECT CONCAT(u.first_name, ' ', u.last_name) AS name, u.email, u.id FROM users u WHERE u.id = $1",
)
.bind(app.job_seeker_id)
.bind(app.applicant_user_id)
.fetch_optional(&state.pool)
.await;
if let Ok(Some((name, email))) = applicant_info {
if let Ok(Some((name, email, applicant_uuid))) = applicant_info {
let _ = state.mail.send_application_status_email(&email, &name, &job.title, &payload.status).await;
// Send in-app notification to job seeker
sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(applicant_uuid)
.bind(format!("Application Status: {}", payload.status))
.bind(format!("Your application for '{}' has been {}.", job.title, payload.status.to_lowercase()))
.bind("APPLICATION")
.bind(app.id)
.execute(&state.pool)
.await
.ok();
}
(StatusCode::OK, Json(updated)).into_response()
}
@ -381,6 +483,96 @@ async fn update_application_status(
}
}
async fn upload_documents(
State(state): State<AppState>,
auth: AuthUser,
mut multipart: Multipart,
) -> impl IntoResponse {
let company = match CompanyRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(c)) => c,
Ok(None) => return (StatusCode::NOT_FOUND, "Company profile not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let mut uploaded_urls: Vec<String> = Vec::new();
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
if name != "documents" && name != "files" && name != "file" {
continue;
}
let content_type = field.content_type()
.unwrap_or("application/octet-stream")
.to_string();
let ext = if let Some(fname) = field.file_name() {
fname.rsplit('.').next().unwrap_or("bin").to_lowercase()
} else {
match content_type.as_str() {
"application/pdf" => "pdf".to_string(),
"image/jpeg" => "jpg".to_string(),
"image/png" => "png".to_string(),
_ => "bin".to_string(),
}
};
let data = match field.bytes().await {
Ok(b) => b,
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("Failed to read file: {}", e) }))).into_response(),
};
if data.is_empty() {
continue;
}
if data.len() > 10 * 1024 * 1024 {
return (StatusCode::PAYLOAD_TOO_LARGE, Json(serde_json::json!({ "error": "File too large. Maximum 10 MB per file." }))).into_response();
}
let data_len = data.len();
let url = match state.storage
.upload("company_documents", &ext, data, &content_type)
.await
{
Ok(u) => u,
Err(e) => {
tracing::error!("B2 upload failed for company {}: {}", company.id, e);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "File upload failed" }))).into_response();
}
};
// Persist document record
if let Err(e) = sqlx::query(
r#"
INSERT INTO company_documents (company_id, document_name, document_url, file_size, mime_type)
VALUES ($1, $2, $3, $4, $5)
"#,
)
.bind(company.id)
.bind(format!("document_{}", Uuid::new_v4()))
.bind(&url)
.bind(data_len as i64)
.bind(&content_type)
.execute(&state.pool)
.await
{
tracing::error!("Failed to save document record for company {}: {}", company.id, e);
}
uploaded_urls.push(url);
}
if uploaded_urls.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No valid document files provided. Send multipart fields named 'documents'." }))).into_response();
}
(StatusCode::OK, Json(serde_json::json!({
"documents": uploaded_urls,
"count": uploaded_urls.len()
}))).into_response()
}
async fn view_contact(
State(state): State<AppState>,
Path(id): Path<Uuid>,
@ -405,73 +597,81 @@ async fn view_contact(
return (StatusCode::FORBIDDEN, "Access denied").into_response();
}
// If contact was already viewed for this application, return info without deducting again
if !app.contact_viewed {
let total_remaining = company.free_contact_views + company.purchased_contact_views;
if total_remaining <= 0 {
return (
StatusCode::PAYMENT_REQUIRED,
Json(serde_json::json!({
"error": "Contact view quota exhausted. Please purchase a package.",
"code": "QUOTA_EXHAUSTED"
})),
)
.into_response();
}
let free_views = company.free_contact_views;
let purchased_views = company.purchased_contact_views;
// Deduct from free views first, then purchased
let sql = if company.free_contact_views > 0 {
"UPDATE companies SET free_contact_views = free_contact_views - 1 WHERE id = $1"
} else {
"UPDATE companies SET purchased_contact_views = purchased_contact_views - 1 WHERE id = $1"
};
if let Err(e) = sqlx::query(sql).bind(company.id).execute(&state.pool).await {
tracing::error!("Failed to deduct contact view quota: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "Failed to deduct quota").into_response();
}
if let Err(e) = ApplicationRepository::mark_contact_viewed(&state.pool, app.id).await {
tracing::error!("Failed to mark contact viewed: {}", e);
}
if free_views <= 0 && purchased_views <= 0 {
return (StatusCode::PAYMENT_REQUIRED, Json(serde_json::json!({
"error": "Contact view quota exhausted",
"code": "QUOTA_EXHAUSTED",
"requires_purchase": true,
"message": "You have used all your free contact views. Please purchase a contact view package to continue."
}))).into_response();
}
let used_free = free_views > 0;
if used_free {
sqlx::query(
"UPDATE company_profiles SET free_contact_views = free_contact_views - 1, updated_at = NOW() WHERE id = $1"
)
.bind(company.id)
.execute(&state.pool)
.await
.ok();
} else {
sqlx::query(
"UPDATE company_profiles SET purchased_contact_views = purchased_contact_views - 1, updated_at = NOW() WHERE id = $1"
)
.bind(company.id)
.execute(&state.pool)
.await
.ok();
}
// Fetch job seeker contact info via job_seeker_id → job_seekers.user_id → users
let contact = sqlx::query_as::<_, (Option<String>, String, Option<String>)>(
r#"
SELECT u.full_name, u.email, u.phone
SELECT CONCAT(u.first_name, ' ', u.last_name) AS name, u.email, u.phone
FROM users u
INNER JOIN job_seekers js ON js.user_id = u.id
WHERE js.id = $1
WHERE u.id = $1
"#,
)
.bind(app.job_seeker_id)
.bind(app.applicant_user_id)
.fetch_optional(&state.pool)
.await;
match contact {
Ok(Some((full_name, email, phone))) => {
// Fetch updated quota to return to client
let updated_company = CompanyRepository::get_by_user_id(&state.pool, auth.user_id)
.await
.ok()
.flatten();
let (free_remaining, purchased_remaining) = updated_company
.map(|c| (c.free_contact_views, c.purchased_contact_views))
.unwrap_or((0, 0));
Ok(Some((name, email, phone))) => {
let new_free = if used_free { free_views - 1 } else { free_views };
let new_purchased = if used_free { purchased_views } else { purchased_views - 1 };
let _ = sqlx::query(
r#"
INSERT INTO notifications (user_id, title, body, notification_type, reference_id)
VALUES ($1, $2, $3, $4, $5)
"#
)
.bind(app.applicant_user_id)
.bind("Your contact was viewed")
.bind(format!("{} viewed your application for {}", company.company_name, job.title))
.bind("APPLICATION")
.bind(id)
.execute(&state.pool)
.await
.ok();
(StatusCode::OK, Json(serde_json::json!({
"application_id": id,
"full_name": full_name,
"name": name,
"email": email,
"phone": phone,
"quota": {
"free_remaining": free_remaining,
"purchased_remaining": purchased_remaining,
"total_remaining": free_remaining + purchased_remaining
"used_free_view": used_free,
"free_remaining": new_free,
"purchased_remaining": new_purchased,
"total_remaining": new_free + new_purchased
}
})))
.into_response()
}))).into_response()
}
Ok(None) => (StatusCode::NOT_FOUND, "Applicant not found").into_response(),
Err(e) => {

View file

@ -1,6 +1,7 @@
mod handlers;
use axum::{routing::get, Router};
use cache::RedisPool;
use std::net::SocketAddr;
use std::sync::Arc;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@ -9,7 +10,9 @@ use sqlx::PgPool;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub storage: Arc<storage::StorageClient>,
pub mail: Arc<email::Mailer>,
pub redis: RedisPool,
}
#[tokio::main]
@ -30,8 +33,14 @@ async fn main() {
tracing::info!("Companies service — connected to database");
let storage = Arc::new(storage::StorageClient::from_env().await);
let mailer = Arc::new(email::Mailer::new());
let state = AppState { pool, mail: mailer };
let redis_url = std::env::var("REDIS_URL").expect("REDIS_URL must be set");
let redis = cache::connect(&redis_url).await.expect("Failed to connect to Redis");
tracing::info!("Companies service — connected to Redis");
let state = AppState { pool, storage, mail: mailer, redis };
let app = Router::new()
.nest("/api/companies", handlers::router())

View file

@ -33,16 +33,16 @@ async fn main() -> Result<(), Box<dyn std::error::Error>> {
}
});
// Spawn Hourly Requirement expiry task
// Spawn Hourly Lead expiry task
let p_req_sys = pool.clone();
let m_req_sys = Arc::clone(&mailer);
tokio::spawn(async move {
let mut interval = time::interval(Duration::from_secs(60 * 60));
loop {
interval.tick().await;
tracing::info!("Running Requirement Expiry Task...");
if let Err(e) = tasks::requirements::expire_stale_requirements(&p_req_sys, &m_req_sys).await {
tracing::error!("Requirement Expiry Task Failed: {}", e);
tracing::info!("Running Lead Expiry Task...");
if let Err(e) = tasks::requirements::expire_stale_leads(&p_req_sys, &m_req_sys).await {
tracing::error!("Lead Expiry Task Failed: {}", e);
}
}
});

View file

@ -16,7 +16,7 @@ pub async fn expire_stale_jobs(
job_id: Uuid,
title: String,
email: String,
full_name: String,
name: String,
}
let records = sqlx::query_as::<_, JobRecord>(
@ -28,7 +28,7 @@ pub async fn expire_stale_jobs(
WHERE jobs.company_id = c.id
AND jobs.status = 'LIVE'
AND jobs.expires_at < $1
RETURNING jobs.id as job_id, jobs.title, u.email, u.full_name
RETURNING jobs.id as job_id, jobs.title, u.email, CONCAT(u.first_name, ' ', u.last_name) AS name
"#
)
.bind(now)
@ -42,7 +42,7 @@ pub async fn expire_stale_jobs(
tracing::info!("Expired {} stale jobs.", records.len());
for rec in records {
let _ = mailer.send_job_expired_email(&rec.email, &rec.full_name, &rec.title).await;
let _ = mailer.send_job_expired_email(&rec.email, &rec.name, &rec.title).await;
tracing::info!("Sent expiry email to {} for job {}", rec.email, rec.job_id);
}

View file

@ -15,22 +15,21 @@ pub async fn expire_stale_lead_requests(
tracecoins_reserved: i32,
user_id: Uuid,
email: String,
full_name: String,
name: String,
}
// Find stale requests that are still PENDING
let records = sqlx::query_as::<_, Record>(
r#"
SELECT
lr.id AS lead_request_id,
lr.professional_id,
lr.user_role_profile_id,
lr.tracecoins_reserved,
pp.user_id,
urp.user_id,
u.email,
u.full_name
CONCAT(u.first_name, ' ', u.last_name) AS name
FROM lead_requests lr
INNER JOIN professional_profiles pp ON pp.id = lr.professional_id
INNER JOIN users u ON u.id = pp.user_id
INNER JOIN user_role_profiles urp ON urp.id = lr.user_role_profile_id
INNER JOIN users u ON u.id = urp.user_id
WHERE lr.status = 'PENDING'
AND lr.requested_at < $1
"#
@ -46,10 +45,8 @@ pub async fn expire_stale_lead_requests(
tracing::info!("Found {} stale lead requests to expire.", records.len());
for rec in records {
// Run expiry flow inside a transaction to ensure we don't duplicate refunds
let mut tx = pool.begin().await?;
// 1. Mark as expired
let updated = sqlx::query(
"UPDATE lead_requests SET status = 'EXPIRED', resolved_at = $1 WHERE id = $2 AND status = 'PENDING'"
)
@ -59,43 +56,37 @@ pub async fn expire_stale_lead_requests(
.await?;
if updated.rows_affected() == 0 {
// Already updated concurrently
tx.rollback().await?;
continue;
}
// 2. Refund Tracecoins if they were reserved
if rec.tracecoins_reserved > 0 {
// Re-use logic: Release reserved Tracecoins
// 2.a Add to balance
sqlx::query(
"UPDATE professional_wallets SET balance = balance + $1 WHERE user_id = $2"
"UPDATE tracecoin_wallets SET current_balance = current_balance + $1, updated_at = NOW() WHERE user_id = $2"
)
.bind(rec.tracecoins_reserved)
.bind(rec.user_id)
.execute(&mut *tx)
.await?;
// 2.b Insert ledger entry
sqlx::query(
r#"
INSERT INTO tracecoin_ledger (user_id, amount, transaction_type, reference_id, description, created_at)
VALUES ($1, $2, 'RELEASE', $3, 'Lead Request Expired', $4)
INSERT INTO tracecoin_ledger (wallet_id, amount, transaction_type, reference_type, reference_id, created_at)
SELECT w.id, $1, 'RELEASE', 'Lead Request Expired', $2, $3
FROM tracecoin_wallets w WHERE w.user_id = $4
"#
)
.bind(rec.user_id)
.bind(rec.tracecoins_reserved)
.bind(rec.lead_request_id)
.bind(Utc::now())
.bind(rec.user_id)
.execute(&mut *tx)
.await?;
}
tx.commit().await?;
// 3. Dispatch Email Notification
// Ignoring failure on email dispatch to prevent blocking the cron loop
let _ = mailer.send_lead_expired_email(&rec.email, &rec.full_name, rec.tracecoins_reserved).await;
let _ = mailer.send_lead_expired_email(&rec.email, &rec.name, rec.tracecoins_reserved).await;
tracing::info!("Expired lead request {} and refunded {} tracecoins to {}", rec.lead_request_id, rec.tracecoins_reserved, rec.email);
}

View file

@ -2,34 +2,31 @@ use sqlx::PgPool;
use email::Mailer;
use chrono::Utc;
pub async fn expire_stale_requirements(
pub async fn expire_stale_leads(
pool: &PgPool,
mailer: &Mailer,
) -> Result<(), Box<dyn std::error::Error + Send + Sync>> {
let now = Utc::now();
// Find stale requirements that are still OPEN
// Update them directly returning the affected customer info
use uuid::Uuid;
#[derive(sqlx::FromRow)]
struct ReqRecord {
requirement_id: Uuid,
struct LeadRecord {
lead_id: Uuid,
title: String,
email: String,
full_name: String,
name: String,
}
let records = sqlx::query_as::<_, ReqRecord>(
let records = sqlx::query_as::<_, LeadRecord>(
r#"
UPDATE requirements
UPDATE leads
SET status = 'EXPIRED'
FROM customers c
JOIN users u ON u.id = c.user_id
WHERE requirements.customer_id = c.id
AND requirements.status = 'OPEN'
AND requirements.expires_at < $1
RETURNING requirements.id as requirement_id, requirements.title, u.email, u.full_name
FROM users u
WHERE leads.created_by_user_id = u.id
AND leads.status = 'OPEN'
AND leads.expires_at < $1
RETURNING leads.id as lead_id, leads.title, u.email, CONCAT(u.first_name, ' ', u.last_name) AS name
"#
)
.bind(now)
@ -40,11 +37,11 @@ pub async fn expire_stale_requirements(
return Ok(());
}
tracing::info!("Expired {} stale requirements.", records.len());
tracing::info!("Expired {} stale leads.", records.len());
for rec in records {
let _ = mailer.send_requirement_expired_email(&rec.email, &rec.full_name, &rec.title).await;
tracing::info!("Sent expiry email to {} for requirement {}", rec.email, rec.requirement_id);
let _ = mailer.send_requirement_expired_email(&rec.email, &rec.name, &rec.title).await;
tracing::info!("Sent expiry email to {} for lead {}", rec.email, rec.lead_id);
}
Ok(())

View file

@ -11,7 +11,7 @@ pub struct AdminLeadRow {
pub description: Option<String>,
pub profession_key: String,
pub location: String,
pub budget: Option<i32>,
pub budget_inr: Option<i32>,
pub status: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
@ -25,7 +25,7 @@ impl From<Requirement> for AdminLeadRow {
description: Some(r.description),
profession_key: r.profession_key,
location: r.location,
budget: r.budget,
budget_inr: r.budget_inr,
status: r.status,
created_at: r.created_at,
updated_at: r.updated_at,
@ -42,10 +42,10 @@ async fn list_leads(
) -> Result<impl IntoResponse, (StatusCode, String)> {
let requirements = sqlx::query_as::<_, Requirement>(
r#"
SELECT id, customer_id, profession_key, title, description, location, budget,
preferred_date, extra_data_json, status, rejection_reason, request_count, accepted_count,
SELECT id, created_by_user_id, profession_key, title, description, location, budget_inr,
required_date, extra_data_json, status, rejection_reason, request_count, accepted_count,
expires_at, approved_at, approved_by, created_at, updated_at
FROM requirements
FROM leads
ORDER BY created_at DESC
LIMIT 100
"#,

View file

@ -8,11 +8,11 @@ use axum::{
use serde::Deserialize;
use uuid::Uuid;
use db::models::customer::{CustomerRepository, UpsertCustomerProfilePayload};
use db::models::professional::ProfessionalRepository;
use db::models::requirement::{RequirementRepository, CreateRequirementPayload as DbCreateRequirementPayload, UpdateRequirementPayload as DbUpdateRequirementPayload};
use db::models::lead_request::LeadRequestRepository;
use db::models::user::UserRepository;
use db::models::verification::VerificationRepository;
use db::models::tracecoin_wallet::TracecoinWalletRepository;
use contracts::auth_middleware::AuthUser;
use crate::AppState;
@ -23,9 +23,9 @@ pub fn router() -> Router<AppState> {
.route("/requirements", get(list_requirements).post(create_requirement))
.route("/requirements/{id}", get(get_requirement).patch(update_requirement))
.route("/requirements/{id}/submit", post(submit_requirement))
.route("/requirements/{id}/requests", get(list_requests))
.route("/requirements/{id}/requests/{lead_id}/approve", post(approve_request))
.route("/requirements/{id}/requests/{lead_id}/reject", post(reject_request))
.route("/requests", get(list_requests))
.route("/requests/{lead_id}/approve", post(approve_request))
.route("/requests/{lead_id}/reject", post(reject_request))
}
#[derive(Deserialize)]
@ -109,14 +109,9 @@ async fn list_requirements(
auth: AuthUser,
Query(q): Query<PaginationQuery>,
) -> impl IntoResponse {
let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(),
};
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
match RequirementRepository::list_by_customer_id(&state.pool, customer.id, page, limit).await {
match RequirementRepository::list_by_user_id(&state.pool, auth.user_id, page, limit).await {
Ok(reqs) => (StatusCode::OK, Json(serde_json::json!({
"data": reqs,
"pagination": { "page": page, "limit": limit }
@ -127,40 +122,23 @@ async fn list_requirements(
async fn create_requirement(
State(state): State<AppState>,
auth: AuthUser,
_auth: AuthUser,
Json(payload): Json<CreateRequirementRequest>,
) -> impl IntoResponse {
let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(),
};
if customer.status != "APPROVED" {
return (StatusCode::FORBIDDEN, "Customer profile approval is required before posting requirements").into_response();
}
if customer.active_requirement_count >= 2 {
return (StatusCode::TOO_MANY_REQUESTS, "Max 2 active requirements allowed").into_response();
}
let p_date = payload.preferred_date.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok());
let db_payload = DbCreateRequirementPayload {
customer_id: customer.id,
profession_key: payload.profession_key,
title: payload.title,
description: payload.description,
location: payload.location,
budget: payload.budget,
preferred_date: p_date,
budget_inr: payload.budget,
required_date: p_date,
extra_data_json: payload.extra_data_json,
};
match RequirementRepository::create(&state.pool, db_payload).await {
Ok(req) => {
let _ = CustomerRepository::update_active_requirement_count(&state.pool, customer.id, 1).await;
(StatusCode::CREATED, Json(req)).into_response()
},
Ok(req) => (StatusCode::CREATED, Json(req)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
@ -180,17 +158,11 @@ async fn get_requirement(
async fn update_requirement(
State(state): State<AppState>,
Path(id): Path<Uuid>,
auth: AuthUser,
_auth: AuthUser,
Json(payload): Json<DbUpdateRequirementPayload>,
) -> impl IntoResponse {
let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(),
};
let req = match RequirementRepository::get_by_id(&state.pool, id).await {
Ok(Some(r)) if r.customer_id == customer.id => r,
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
Ok(Some(r)) => r,
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
};
@ -205,18 +177,8 @@ async fn submit_requirement(
Path(id): Path<Uuid>,
auth: AuthUser,
) -> impl IntoResponse {
let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(),
};
if customer.status != "APPROVED" {
return (StatusCode::FORBIDDEN, "Customer profile approval is required before submitting requirements").into_response();
}
let req = match RequirementRepository::get_by_id(&state.pool, id).await {
Ok(Some(r)) if r.customer_id == customer.id => r,
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
Ok(Some(r)) => r,
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
};
@ -228,7 +190,7 @@ async fn submit_requirement(
Ok(updated) => {
// Fire email to customer (ignore failures)
if let Ok(user) = UserRepository::get_by_id(&state.pool, auth.user_id).await {
let _ = state.mail.send_requirement_submitted_email(&user.email, user.full_name.as_deref().unwrap_or("User"), &updated.title).await;
let _ = state.mail.send_requirement_submitted_email(&user.email, &format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()), &updated.title).await;
}
// Create verification case so this request enters Verification Management first.
@ -238,9 +200,9 @@ async fn submit_requirement(
"title": updated.title,
"profession_key": updated.profession_key,
"location": updated.location,
"budget": updated.budget,
"budget_inr": updated.budget_inr,
"status": updated.status,
"customer_id": updated.customer_id,
"created_by_user_id": updated.created_by_user_id,
});
let _ = VerificationRepository::create(
&state.pool,
@ -261,45 +223,22 @@ async fn submit_requirement(
async fn list_requests(
State(state): State<AppState>,
Path(id): Path<Uuid>,
auth: AuthUser,
_auth: AuthUser,
Query(q): Query<PaginationQuery>,
) -> impl IntoResponse {
let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(),
};
let req = match RequirementRepository::get_by_id(&state.pool, id).await {
Ok(Some(r)) if r.customer_id == customer.id => r,
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
};
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
let offset = (page - 1) * limit;
#[derive(serde::Serialize, sqlx::FromRow)]
struct RichLeadReqForCustomer {
#[serde(flatten)]
#[sqlx(flatten)]
lead: db::models::lead_request::LeadRequest,
professional_name: Option<String>,
professional_avatar_url: Option<String>,
}
let rows_result = sqlx::query_as::<_, RichLeadReqForCustomer>(
let rows_result = sqlx::query_as::<_, db::models::lead_request::LeadRequest>(
r#"
SELECT lr.*, u.full_name as professional_name, u.avatar_url as professional_avatar_url
FROM lead_requests lr
LEFT JOIN professional_profiles pp ON pp.id = lr.professional_id
LEFT JOIN users u ON u.id = pp.user_id
WHERE lr.requirement_id = $1
ORDER BY lr.requested_at DESC
SELECT * FROM lead_requests
WHERE user_role_profile_id = $1
ORDER BY requested_at DESC
LIMIT $2 OFFSET $3
"#
)
.bind(req.id)
.bind(id)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
@ -316,22 +255,11 @@ async fn list_requests(
async fn approve_request(
State(state): State<AppState>,
Path((req_id, lead_id)): Path<(Uuid, Uuid)>,
auth: AuthUser,
Path(lead_id): Path<Uuid>,
_auth: AuthUser,
) -> impl IntoResponse {
let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(),
};
let req = match RequirementRepository::get_by_id(&state.pool, req_id).await {
Ok(Some(r)) if r.customer_id == customer.id => r,
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
};
let lead = match LeadRequestRepository::get_by_id(&state.pool, lead_id).await {
Ok(Some(l)) if l.requirement_id == req.id => l,
Ok(Some(l)) => l,
_ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(),
};
@ -341,15 +269,9 @@ async fn approve_request(
match LeadRequestRepository::update_status(&state.pool, lead.id, "ACCEPTED").await {
Ok(updated) => {
let prof_user_id = match ProfessionalRepository::get_user_id_by_professional_id(&state.pool, lead.professional_id).await {
Ok(Some(user_id)) => user_id,
Ok(None) => return (StatusCode::NOT_FOUND, "Professional not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
match ProfessionalRepository::try_debit_reserved_tracecoins(
match TracecoinWalletRepository::try_debit_reserved_tracecoins(
&state.pool,
prof_user_id,
lead.user_role_profile_id,
lead.tracecoins_reserved,
lead.id,
).await {
@ -358,33 +280,8 @@ async fn approve_request(
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
let req_after = match RequirementRepository::increment_accepted_count_and_get(&state.pool, req.id).await {
Ok(r) => r,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
if req_after.accepted_count >= 10 && req_after.status != "CLOSED" {
let _ = RequirementRepository::update_status(&state.pool, req.id, "CLOSED").await;
}
// Send contact-exchange emails to both parties (ignore failures)
let customer_user = UserRepository::get_by_id(&state.pool, auth.user_id).await.ok();
let professional_user = UserRepository::get_by_id(&state.pool, prof_user_id).await.ok();
if let (Some(cust), Some(prof)) = (customer_user, professional_user) {
let cust_phone = cust.phone.as_deref().unwrap_or("N/A");
let prof_phone = prof.phone.as_deref().unwrap_or("N/A");
let _ = state.mail.send_lead_accepted_professional_email(
&prof.email, prof.full_name.as_deref().unwrap_or("Professional"), cust.full_name.as_deref().unwrap_or("Customer"), &cust.email, cust_phone,
).await;
let _ = state.mail.send_lead_accepted_customer_email(
&cust.email, cust.full_name.as_deref().unwrap_or("Customer"), prof.full_name.as_deref().unwrap_or("Professional"), &prof.email, prof_phone,
).await;
}
(StatusCode::OK, Json(serde_json::json!({
"lead_request": updated,
"requirement_status": if req_after.accepted_count >= 10 { "CLOSED" } else { req_after.status.as_str() },
"accepted_count": req_after.accepted_count,
}))).into_response()
},
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
@ -393,23 +290,12 @@ async fn approve_request(
async fn reject_request(
State(state): State<AppState>,
Path((req_id, lead_id)): Path<(Uuid, Uuid)>,
auth: AuthUser,
Path(lead_id): Path<Uuid>,
_auth: AuthUser,
Json(_payload): Json<RejectRequestPayload>,
) -> impl IntoResponse {
let customer = match CustomerRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(c)) => c,
_ => return (StatusCode::NOT_FOUND, "Customer not found").into_response(),
};
let req = match RequirementRepository::get_by_id(&state.pool, req_id).await {
Ok(Some(r)) if r.customer_id == customer.id => r,
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
_ => return (StatusCode::NOT_FOUND, "Requirement not found").into_response(),
};
let lead = match LeadRequestRepository::get_by_id(&state.pool, lead_id).await {
Ok(Some(l)) if l.requirement_id == req.id => l,
Ok(Some(l)) => l,
_ => return (StatusCode::NOT_FOUND, "Lead request not found").into_response(),
};
@ -419,15 +305,9 @@ async fn reject_request(
match LeadRequestRepository::update_status(&state.pool, lead.id, "REJECTED").await {
Ok(updated) => {
let prof_user_id = match ProfessionalRepository::get_user_id_by_professional_id(&state.pool, lead.professional_id).await {
Ok(Some(user_id)) => user_id,
Ok(None) => return (StatusCode::NOT_FOUND, "Professional not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
match ProfessionalRepository::try_release_reserved_tracecoins(
match TracecoinWalletRepository::try_release_reserved_tracecoins(
&state.pool,
prof_user_id,
lead.user_role_profile_id,
lead.tracecoins_reserved,
lead.id,
"LEAD_REJECTED",
@ -437,13 +317,6 @@ async fn reject_request(
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
// Notify professional their request was rejected (ignore failures)
if let Ok(prof_user) = UserRepository::get_by_id(&state.pool, prof_user_id).await {
let _ = state.mail.send_lead_rejected_email(
&prof_user.email, prof_user.full_name.as_deref().unwrap_or("Professional"), &req.title,
).await;
}
(StatusCode::OK, Json(updated)).into_response()
},
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),

View file

@ -16,4 +16,5 @@ db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }
storage = { path = "../../crates/storage" }

View file

@ -1,5 +1,5 @@
use contracts::ProfessionState;
use db::models::developer::DeveloperProfile;
use db::models::user_role_profile::UserRoleProfile;
use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use serde::Serialize;
use uuid::Uuid;
@ -7,6 +7,7 @@ use uuid::Uuid;
#[derive(Serialize)]
pub struct AdminDeveloperList {
pub id: Uuid,
pub user_role_profile_id: Uuid,
pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>,
@ -16,10 +17,11 @@ pub struct AdminDeveloperList {
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<DeveloperProfile> for AdminDeveloperList {
fn from(p: DeveloperProfile) -> Self {
impl From<UserRoleProfile> for AdminDeveloperList {
fn from(p: UserRoleProfile) -> Self {
Self {
id: p.id,
user_role_profile_id: p.id,
user_id: p.user_id,
display_name: p.display_name,
bio: p.bio,
@ -40,10 +42,15 @@ pub fn router() -> Router<ProfessionState> {
async fn list_developers(
State(state): State<ProfessionState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let developers = sqlx::query_as::<_, DeveloperProfile>(
let developers = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at
FROM developer_profiles
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE role_key = 'developer'
ORDER BY created_at DESC
LIMIT 100
"#,
@ -60,11 +67,15 @@ async fn get_developer(
State(state): State<ProfessionState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let developer = sqlx::query_as::<_, DeveloperProfile>(
let developer = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at
FROM developer_profiles
WHERE id = $1
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE id = $1 AND role_key = 'developer'
"#,
)
.bind(id)

View file

@ -21,7 +21,7 @@ async fn update_profile(
auth: AuthUser,
Json(payload): Json<UpsertDeveloperProfilePayload>,
) -> impl IntoResponse {
match DeveloperRepository::upsert(&state.pool, auth.user_id, payload).await {
match DeveloperRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}

View file

@ -3,6 +3,7 @@ mod admin;
use axum::{routing::get, Router};
use std::net::SocketAddr;
use std::sync::Arc;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::ProfessionState;
@ -30,7 +31,8 @@ async fn main() {
tracing::info!("Developers service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
let storage = Arc::new(storage::StorageClient::from_env().await);
let state = ProfessionState { pool, redis, storage };
let app = Router::new()
.nest("/api/developers", handlers::router())

View file

@ -3,18 +3,21 @@ use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::get,
routing::{get, post, patch},
Json, Router,
};
use contracts::auth_middleware::{AuthUser, require_admin};
use serde::{Deserialize, Serialize};
use uuid::Uuid;
use db::models::employee::{EmployeeRepository, CreateEmployeePayload};
use auth::crypto::hash_password;
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list_employees).post(create_employee))
.route("/provision", post(provision_employee))
.route("/{id}", get(get_employee).patch(update_employee).delete(delete_employee))
.route("/{id}/change-password", patch(change_password))
}
#[derive(Deserialize)]
@ -82,6 +85,49 @@ async fn create_employee(
Ok((StatusCode::CREATED, Json(employee)))
}
#[derive(Deserialize)]
pub struct ProvisionEmployeePayload {
pub email: String,
pub first_name: String,
pub last_name: String,
pub phone: Option<String>,
pub role_code: String,
pub department_id: Option<Uuid>,
pub designation_id: Option<Uuid>,
pub employee_code: Option<String>,
pub password: String,
}
async fn provision_employee(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<ProvisionEmployeePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string()));
}
let password_hash = hash_password(&payload.password)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Password hash error: {}", e)))?;
let create_payload = CreateEmployeePayload {
first_name: payload.first_name,
last_name: payload.last_name,
email: payload.email,
phone: payload.phone,
password_hash,
department_id: payload.department_id,
designation_id: payload.designation_id,
role_code: payload.role_code,
};
let employee = EmployeeRepository::create_with_code(&state.pool, create_payload, payload.employee_code)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {}", e)))?;
Ok((StatusCode::CREATED, Json(employee)))
}
#[derive(Deserialize)]
pub struct UpdateEmployeePayload {
pub first_name: Option<String>,
@ -133,3 +179,28 @@ async fn delete_employee(
Ok(StatusCode::NO_CONTENT)
}
#[derive(Deserialize)]
pub struct ChangePasswordPayload {
pub password: String,
}
async fn change_password(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<ChangePasswordPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Insufficient permissions".to_string()));
}
let password_hash = hash_password(&payload.password)
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Password hash error: {}", e)))?;
EmployeeRepository::change_password(&state.pool, id, &password_hash)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {}", e)))?;
Ok(Json(serde_json::json!({ "message": "Password updated successfully" })))
}

View file

@ -1,5 +1,5 @@
use contracts::ProfessionState;
use db::models::fitness_trainer::FitnessTrainerProfile;
use db::models::user_role_profile::UserRoleProfile;
use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use serde::Serialize;
use uuid::Uuid;
@ -7,6 +7,7 @@ use uuid::Uuid;
#[derive(Serialize)]
pub struct AdminFitnessTrainerList {
pub id: Uuid,
pub user_role_profile_id: Uuid,
pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>,
@ -16,10 +17,11 @@ pub struct AdminFitnessTrainerList {
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<FitnessTrainerProfile> for AdminFitnessTrainerList {
fn from(p: FitnessTrainerProfile) -> Self {
impl From<UserRoleProfile> for AdminFitnessTrainerList {
fn from(p: UserRoleProfile) -> Self {
Self {
id: p.id,
user_role_profile_id: p.id,
user_id: p.user_id,
display_name: p.display_name,
bio: p.bio,
@ -40,10 +42,15 @@ pub fn router() -> Router<ProfessionState> {
async fn list_fitness_trainers(
State(state): State<ProfessionState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let trainers = sqlx::query_as::<_, FitnessTrainerProfile>(
let trainers = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at
FROM fitness_trainer_profiles
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE role_key = 'fitness_trainer'
ORDER BY created_at DESC
LIMIT 100
"#,
@ -60,11 +67,15 @@ async fn get_fitness_trainer(
State(state): State<ProfessionState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let trainer = sqlx::query_as::<_, FitnessTrainerProfile>(
let trainer = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at
FROM fitness_trainer_profiles
WHERE id = $1
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE id = $1 AND role_key = 'fitness_trainer'
"#,
)
.bind(id)

View file

@ -21,7 +21,7 @@ async fn update_profile(
auth: AuthUser,
Json(payload): Json<UpsertFitnessTrainerProfilePayload>,
) -> impl IntoResponse {
match FitnessTrainerRepository::upsert(&state.pool, auth.user_id, payload).await {
match FitnessTrainerRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}

View file

@ -1,3 +1,4 @@
// Gateway service - routes requests to upstream services
use axum::{
body::Body,
extract::{Request, State},
@ -14,6 +15,8 @@ use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
struct Services {
users_url: String,
companies_url: String,
jobs_url: String,
leads_url: String,
job_seekers_url: String,
customers_url: String,
// ── 9 separate profession services ────────────────────────────────────
@ -41,6 +44,10 @@ impl Services {
.unwrap_or_else(|_| "http://localhost:9101".to_string()),
companies_url: std::env::var("COMPANIES_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:9102".to_string()),
jobs_url: std::env::var("JOBS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:9103".to_string()),
leads_url: std::env::var("LEADS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:9118".to_string()),
job_seekers_url: std::env::var("JOB_SEEKERS_SERVICE_URL")
.unwrap_or_else(|_| "http://localhost:9104".to_string()),
customers_url: std::env::var("CUSTOMERS_SERVICE_URL")
@ -78,6 +85,7 @@ impl Services {
// Auth, users, roles, notifications, runtime-config, config, KB, support
if path.starts_with("/api/auth")
|| path.starts_with("/api/users")
|| path.starts_with("/api/v1/users")
|| path.starts_with("/api/me")
|| path.starts_with("/api/profile")
|| path.starts_with("/api/onboarding")
@ -115,21 +123,31 @@ impl Services {
{
Some(self.employees_url.clone())
}
// Companies + Jobs + Applications + Packages
// Companies + Applications + Packages
else if path.starts_with("/api/companies")
|| path.starts_with("/api/jobs")
|| path.starts_with("/api/applications")
|| path.starts_with("/api/pricing")
|| path.starts_with("/api/admin/companies")
|| path.starts_with("/api/admin/jobs")
|| path.starts_with("/api/admin/applications")
{
Some(self.companies_url.clone())
}
// Job Seekers
// Job Seekers — must come BEFORE /api/jobs to avoid prefix collision
else if path.starts_with("/api/jobseeker") {
Some(self.job_seekers_url.clone())
}
// Jobs (separate service)
else if path.starts_with("/api/jobs")
|| path.starts_with("/api/admin/jobs")
{
Some(self.jobs_url.clone())
}
// Leads (separate service)
else if path.starts_with("/api/leads")
|| path.starts_with("/api/admin/leads")
{
Some(self.leads_url.clone())
}
// Customers + Leads
else if path.starts_with("/api/customers")
|| path.starts_with("/api/admin/customers")
@ -181,10 +199,18 @@ impl Services {
else if path.starts_with("/api/credits") {
Some(self.payments_url.clone())
}
// ── AI Chat (routes to users service, which calls Ollama directly) ───
else if path.starts_with("/api/ai") {
Some(self.users_url.clone())
}
// Admin runtime config management defaults to users service
else if path.starts_with("/api/admin/runtime-configs") {
Some(self.users_url.clone())
}
// User-facing runtime config (role + permissions bundle)
else if path.starts_with("/api/runtime-config") {
Some(self.users_url.clone())
}
// Catch-all for any other admin endpoints → users service
else if path.starts_with("/api/admin/") {
Some(self.users_url.clone())
@ -245,7 +271,7 @@ async fn main() {
.expect("PORT must be a valid u16");
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Gateway listening on {}", addr);
tracing::info!("Gateway listening on {} (routing v2)", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();

View file

@ -16,4 +16,5 @@ db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }
storage = { path = "../../crates/storage" }

View file

@ -1,5 +1,5 @@
use contracts::ProfessionState;
use db::models::graphic_designer::GraphicDesignerProfile;
use db::models::user_role_profile::UserRoleProfile;
use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use serde::Serialize;
use uuid::Uuid;
@ -7,6 +7,7 @@ use uuid::Uuid;
#[derive(Serialize)]
pub struct AdminGraphicDesignerList {
pub id: Uuid,
pub user_role_profile_id: Uuid,
pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>,
@ -16,10 +17,11 @@ pub struct AdminGraphicDesignerList {
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<GraphicDesignerProfile> for AdminGraphicDesignerList {
fn from(p: GraphicDesignerProfile) -> Self {
impl From<UserRoleProfile> for AdminGraphicDesignerList {
fn from(p: UserRoleProfile) -> Self {
Self {
id: p.id,
user_role_profile_id: p.id,
user_id: p.user_id,
display_name: p.display_name,
bio: p.bio,
@ -40,10 +42,15 @@ pub fn router() -> Router<ProfessionState> {
async fn list_graphic_designers(
State(state): State<ProfessionState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let designers = sqlx::query_as::<_, GraphicDesignerProfile>(
let designers = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at
FROM graphic_designer_profiles
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE role_key = 'graphic_designer'
ORDER BY created_at DESC
LIMIT 100
"#,
@ -60,11 +67,15 @@ async fn get_graphic_designer(
State(state): State<ProfessionState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let designer = sqlx::query_as::<_, GraphicDesignerProfile>(
let designer = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at
FROM graphic_designer_profiles
WHERE id = $1
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE id = $1 AND role_key = 'graphic_designer'
"#,
)
.bind(id)

View file

@ -21,7 +21,7 @@ async fn update_profile(
auth: AuthUser,
Json(payload): Json<UpsertGraphicDesignerProfilePayload>,
) -> impl IntoResponse {
match GraphicDesignerRepository::upsert(&state.pool, auth.user_id, payload).await {
match GraphicDesignerRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}

View file

@ -3,6 +3,7 @@ mod admin;
use axum::{routing::get, Router};
use std::net::SocketAddr;
use std::sync::Arc;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::ProfessionState;
@ -30,7 +31,8 @@ async fn main() {
tracing::info!("Graphic Designers service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
let storage = Arc::new(storage::StorageClient::from_env().await);
let state = ProfessionState { pool, redis, storage };
let app = Router::new()
.nest("/api/graphic-designers", handlers::router())

View file

@ -19,4 +19,6 @@ contracts = { path = "../../crates/contracts" }
storage = { path = "../../crates/storage" }
email = { path = "../../crates/email" }
serde_json = { workspace = true }
redis = { workspace = true }
cache = { path = "../../crates/cache" }

View file

@ -3,13 +3,15 @@ use axum::{
extract::{Multipart, Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
routing::{delete, get, post},
Json, Router,
};
use bytes::BufMut;
use cache::jobs as cache_jobs;
use redis::AsyncCommands;
use serde::Deserialize;
use uuid::Uuid;
use db::models::job_seeker::{JobSeekerRepository, UpsertJobSeekerProfilePayload};
use db::models::job_seeker::{JobSeekerRepository, UpsertJobSeekerProfilePayload, CreateJobSeekerDocumentPayload};
use db::models::job::JobRepository;
use db::models::application::{ApplicationRepository, CreateApplicationPayload};
use contracts::auth_middleware::AuthUser;
@ -18,6 +20,9 @@ pub fn router() -> Router<AppState> {
Router::new()
.route("/profile/me", get(get_profile).patch(update_profile))
.route("/profile/resume", post(upload_resume))
.route("/profile/documents", post(upload_document))
.route("/profile/documents", get(list_documents))
.route("/profile/documents/{id}", delete(delete_document))
.route("/profile/submit", post(submit_for_verification))
.route("/jobs", get(browse_jobs))
.route("/jobs/{id}", get(get_job))
@ -34,11 +39,15 @@ pub struct JobBrowseQuery {
pub location: Option<String>,
pub job_type: Option<String>,
pub search: Option<String>,
pub skills: Option<String>,
pub sort_by: Option<String>,
pub order: Option<String>,
}
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct ApplyRequest {
pub cover_letter: Option<String>,
pub cover_note: Option<String>,
pub resume_url: Option<String>,
}
@ -54,8 +63,23 @@ async fn get_profile(
State(state): State<AppState>,
auth: AuthUser,
) -> impl IntoResponse {
let cache_key = format!("profile:job_seeker:{}", auth.user_id);
let mut redis = state.redis.clone();
// Try cache first
if let Ok(cached) = redis.get::<_, String>(&cache_key).await {
tracing::debug!("Cache hit for job seeker profile: {}", auth.user_id);
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&cached) {
return (StatusCode::OK, Json(parsed)).into_response();
}
}
match JobSeekerRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(profile)) => (StatusCode::OK, Json(profile)).into_response(),
Ok(Some(profile)) => {
// Cache for 5 minutes
let _: Result<(), _> = redis.set_ex(&cache_key, &serde_json::to_string(&profile).unwrap_or_default(), 300).await;
(StatusCode::OK, Json(profile)).into_response()
}
Ok(None) => (StatusCode::NOT_FOUND, "Job seeker profile not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
@ -67,7 +91,13 @@ async fn update_profile(
Json(payload): Json<UpsertJobSeekerProfilePayload>,
) -> impl IntoResponse {
match JobSeekerRepository::upsert(&state.pool, auth.user_id, payload).await {
Ok(profile) => (StatusCode::OK, Json(profile)).into_response(),
Ok(profile) => {
// Invalidate profile cache
let cache_key = format!("profile:job_seeker:{}", auth.user_id);
let mut redis = state.redis.clone();
let _ = redis.del::<_, ()>(&cache_key).await;
(StatusCode::OK, Json(profile)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
@ -167,35 +197,166 @@ async fn browse_jobs(
State(state): State<AppState>,
Query(q): Query<JobBrowseQuery>,
) -> impl IntoResponse {
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
let page = q.page.unwrap_or(1).max(1);
let limit = q.limit.unwrap_or(20).min(100).max(1);
let offset = (page - 1) * limit;
let jobs = sqlx::query_as::<_, db::models::job::Job>(
// Parse sort_by and order, with defaults
let sort_by = q.sort_by.as_deref().unwrap_or("created_at");
let order = q.order.as_deref().unwrap_or("desc");
let order_dir = if order.eq_ignore_ascii_case("asc") { "ASC" } else { "DESC" };
// Build cache key based on all query params
let cache_key = format!(
"jobs:list:{}:{}:{}:{}:{}:{}:{}:{}",
page,
limit,
sort_by,
order_dir,
q.search.as_deref().unwrap_or(""),
q.location.as_deref().unwrap_or(""),
q.job_type.as_deref().unwrap_or(""),
q.skills.as_deref().unwrap_or(""),
);
// Try cache first
let mut redis = state.redis.clone();
if let Ok(cached) = redis.get::<_, String>(&cache_key).await {
tracing::debug!("Cache hit for jobs list: {}", cache_key);
if let Ok(parsed) = serde_json::from_str::<serde_json::Value>(&cached) {
return (StatusCode::OK, Json(parsed)).into_response();
}
}
// Validate sort_by column to prevent SQL injection
let sort_column = match sort_by {
"created_at" => "j.created_at",
"salary" => "j.salary_max",
"title" => "j.title",
_ => "j.created_at",
};
#[derive(serde::Serialize, sqlx::FromRow)]
struct JobWithCompany {
id: uuid::Uuid,
company_id: uuid::Uuid,
title: String,
category: Option<String>,
description: String,
location: String,
job_type: String,
salary_min: Option<i32>,
salary_max: Option<i32>,
experience_years: Option<i32>,
skills: Option<Vec<String>>,
status: String,
rejection_reason: Option<String>,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
approved_at: Option<chrono::DateTime<chrono::Utc>>,
approved_by: Option<uuid::Uuid>,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
company_name: String,
}
#[derive(serde::Serialize, sqlx::FromRow)]
struct TotalCount {
count: i64,
}
// Build the dynamic WHERE clause
let search_pattern = q.search.as_ref().map(|s| format!("%{}%", s));
// Skills filter: comma-separated -> convert to array overlap check
// Assuming jobs.skills is text[] in PostgreSQL
let skills_param: Option<Vec<String>> = q.skills.as_ref().map(|s| {
s.split(',').map(|sk| sk.trim().to_lowercase()).collect()
});
// Get total count first
let count_query = format!(
r#"
SELECT * FROM jobs
WHERE status = 'LIVE'
AND ($1::VARCHAR IS NULL OR location ILIKE '%' || $1 || '%')
AND ($2::VARCHAR IS NULL OR job_type = $2)
AND ($3::VARCHAR IS NULL OR title ILIKE '%' || $3 || '%')
ORDER BY created_at DESC
LIMIT $4 OFFSET $5
SELECT COUNT(*) as count
FROM jobs j
LEFT JOIN company_profiles c ON c.id = j.company_id
WHERE j.status = 'LIVE'
AND ($1::VARCHAR IS NULL OR j.location ILIKE '%' || $1 || '%')
AND ($2::VARCHAR IS NULL OR j.job_type = $2)
AND ($3::VARCHAR IS NULL OR j.title ILIKE '%' || $3 || '%' OR j.location ILIKE '%' || $3 || '%' OR c.company_name ILIKE '%' || $3 || '%')
AND ($5::text[] IS NULL OR j.skills && $5::text[])
"#,
)
.bind(q.location)
.bind(q.job_type)
.bind(q.search)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
);
let total_result = sqlx::query_as::<_, TotalCount>(&count_query)
.bind(&q.location)
.bind(&q.job_type)
.bind(&search_pattern)
.bind(&q.skills) // placeholder for skills array (unused when None)
.bind(&skills_param)
.fetch_one(&state.pool)
.await;
let total = match total_result {
Ok(t) => t.count,
Err(e) => {
tracing::error!("Count query failed: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response();
}
};
let total_pages = (total as f64 / limit as f64).ceil() as i64;
// Main query with pagination
let jobs_query = format!(
r#"
SELECT j.id, j.company_id, j.title, j.category, j.description, j.location,
j.job_type, j.salary_min, j.salary_max, j.experience_years, j.skills,
j.status, j.rejection_reason, j.expires_at, j.approved_at, j.approved_by,
j.created_at, j.updated_at,
COALESCE(c.company_name, 'Company') AS company_name
FROM jobs j
LEFT JOIN company_profiles c ON c.id = j.company_id
WHERE j.status = 'LIVE'
AND ($1::VARCHAR IS NULL OR j.location ILIKE '%' || $1 || '%')
AND ($2::VARCHAR IS NULL OR j.job_type = $2)
AND ($3::VARCHAR IS NULL OR j.title ILIKE '%' || $3 || '%' OR j.location ILIKE '%' || $3 || '%' OR c.company_name ILIKE '%' || $3 || '%')
AND ($5::text[] IS NULL OR j.skills && $5::text[])
ORDER BY {} {}
LIMIT $6 OFFSET $7
"#,
sort_column, order_dir
);
let jobs = sqlx::query_as::<_, JobWithCompany>(&jobs_query)
.bind(&q.location)
.bind(&q.job_type)
.bind(&search_pattern)
.bind(&q.skills) // placeholder
.bind(&skills_param)
.bind(limit)
.bind(offset)
.fetch_all(&state.pool)
.await;
match jobs {
Ok(j) => (StatusCode::OK, Json(serde_json::json!({
"data": j,
"pagination": { "page": page, "limit": limit }
}))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
Ok(j) => {
let response = serde_json::json!({
"data": j,
"pagination": {
"page": page,
"limit": limit,
"total": total,
"total_pages": total_pages
}
});
// Cache result for 5 minutes
let _: Result<(), _> = redis.set_ex(&cache_key, &serde_json::to_string(&response).unwrap_or_default(), 300).await;
(StatusCode::OK, Json(response)).into_response()
}
Err(e) => {
tracing::error!("Browse jobs query failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response()
}
}
}
@ -203,8 +364,47 @@ async fn get_job(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match JobRepository::get_by_id(&state.pool, id).await {
Ok(Some(job)) if job.status == "LIVE" => (StatusCode::OK, Json(job)).into_response(),
#[derive(serde::Serialize, sqlx::FromRow)]
struct JobWithCompany {
id: uuid::Uuid,
company_id: uuid::Uuid,
title: String,
category: Option<String>,
description: String,
location: String,
job_type: String,
salary_min: Option<i32>,
salary_max: Option<i32>,
experience_years: Option<i32>,
skills: Option<Vec<String>>,
status: String,
rejection_reason: Option<String>,
expires_at: Option<chrono::DateTime<chrono::Utc>>,
approved_at: Option<chrono::DateTime<chrono::Utc>>,
approved_by: Option<uuid::Uuid>,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
company_name: String,
}
let job = sqlx::query_as::<_, JobWithCompany>(
r#"
SELECT j.id, j.company_id, j.title, j.category, j.description, j.location,
j.job_type, j.salary_min, j.salary_max, j.experience_years, j.skills,
j.status, j.rejection_reason, j.expires_at, j.approved_at, j.approved_by,
j.created_at, j.updated_at,
COALESCE(c.company_name, 'Company') AS company_name
FROM jobs j
LEFT JOIN company_profiles c ON c.id = j.company_id
WHERE j.id = $1
"#,
)
.bind(id)
.fetch_optional(&state.pool)
.await;
match job {
Ok(Some(j)) if j.status == "LIVE" => (StatusCode::OK, Json(j)).into_response(),
Ok(Some(_)) => (StatusCode::FORBIDDEN, "Job is not live").into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Job not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
@ -234,9 +434,8 @@ async fn apply_to_job(
let db_payload = CreateApplicationPayload {
job_id: job.id,
job_seeker_id: seeker.id,
cover_letter: payload.cover_letter,
resume_url: payload.resume_url.or(seeker.resume_url),
applicant_user_id: auth.user_id,
cover_note: payload.cover_note,
};
match ApplicationRepository::create(&state.pool, db_payload).await {
@ -245,21 +444,35 @@ async fn apply_to_job(
// Send email notification to company
// Get company user details via raw query
let company_user = sqlx::query_as::<_, (String, Option<String>)>(
"SELECT u.email, u.full_name FROM users u INNER JOIN companies c ON c.user_id = u.id WHERE c.id = $1"
let company_user = sqlx::query_as::<_, (String, Option<String>, uuid::Uuid)>(
"SELECT u.email, CONCAT(u.first_name, ' ', u.last_name) AS name, u.id FROM users u INNER JOIN companies c ON c.user_id = u.id WHERE c.id = $1"
)
.bind(job.company_id)
.fetch_optional(&state.pool)
.await;
if let Ok(Some((email, full_name))) = company_user {
let seeker_name = seeker.full_name.as_deref().unwrap_or("A candidate");
if let Ok(Some((email, name, company_user_id))) = company_user {
let seeker_name = format!("{} {}", seeker.first_name.unwrap_or_default(), seeker.last_name.unwrap_or_default());
let _ = state.mail.send_new_application_email(
&email,
full_name.as_deref().unwrap_or("Company"),
name.as_deref().unwrap_or("Company"),
&job.title,
seeker_name
&seeker_name
).await;
// Send in-app notification to company
sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(company_user_id)
.bind("New Application Received")
.bind(format!("{} applied for your job '{}'. View their application now.", seeker_name, job.title))
.bind("APPLICATION")
.bind(app.id)
.execute(&state.pool)
.await
.ok();
}
(StatusCode::CREATED, Json(app)).into_response()
@ -279,7 +492,7 @@ async fn list_my_applications(
auth: AuthUser,
Query(q): Query<PaginationQuery>,
) -> impl IntoResponse {
let seeker = match JobSeekerRepository::get_by_user_id(&state.pool, auth.user_id).await {
let _seeker = match JobSeekerRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(s)) => s,
_ => return (StatusCode::NOT_FOUND, "Job seeker profile not found").into_response(),
};
@ -287,7 +500,7 @@ async fn list_my_applications(
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
match ApplicationRepository::list_by_job_seeker_id(&state.pool, seeker.id, page, limit).await {
match ApplicationRepository::list_by_user_id(&state.pool, auth.user_id, page, limit).await {
Ok(apps) => (StatusCode::OK, Json(serde_json::json!({
"data": apps,
"pagination": { "page": page, "limit": limit }
@ -301,13 +514,13 @@ async fn get_my_application(
auth: AuthUser,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let seeker = match JobSeekerRepository::get_by_user_id(&state.pool, auth.user_id).await {
let _seeker = match JobSeekerRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(s)) => s,
_ => return (StatusCode::NOT_FOUND, "Job seeker profile not found").into_response(),
};
match ApplicationRepository::get_by_id(&state.pool, id).await {
Ok(Some(app)) if app.job_seeker_id == seeker.id => (StatusCode::OK, Json(app)).into_response(),
Ok(Some(app)) if app.applicant_user_id == auth.user_id => (StatusCode::OK, Json(app)).into_response(),
Ok(Some(_)) => (StatusCode::FORBIDDEN, "Access denied").into_response(),
Ok(None) => (StatusCode::NOT_FOUND, "Application not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
@ -325,7 +538,7 @@ async fn withdraw_application(
};
let app = match ApplicationRepository::get_by_id(&state.pool, id).await {
Ok(Some(a)) if a.job_seeker_id == seeker.id => a,
Ok(Some(a)) if a.applicant_user_id == auth.user_id => a,
Ok(Some(_)) => return (StatusCode::FORBIDDEN, "Access denied").into_response(),
_ => return (StatusCode::NOT_FOUND, "Application not found").into_response(),
};
@ -369,3 +582,167 @@ async fn submit_for_verification(
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn upload_document(
State(state): State<AppState>,
auth: AuthUser,
mut multipart: Multipart,
) -> impl IntoResponse {
let seeker = match JobSeekerRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(s)) => s,
Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Job seeker profile not found" }))).into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(),
};
let mut file_bytes = bytes::BytesMut::new();
let mut content_type = "application/octet-stream".to_string();
let mut ext = "bin".to_string();
let mut found = false;
// Extract document_type from multipart fields (non-file fields)
let mut document_type = "other".to_string();
let mut file_name = "document".to_string();
let mut file_size: i64 = 0;
while let Ok(Some(field)) = multipart.next_field().await {
let name = field.name().unwrap_or("").to_string();
if name == "document_type" {
if let Ok(text) = field.text().await {
document_type = text;
}
} else if name == "file_name" {
if let Ok(text) = field.text().await {
file_name = text;
}
} else if name == "file" || name == "document" || (!found && !name.is_empty() && field.file_name().is_some()) {
if let Some(ct) = field.content_type() {
content_type = ct.to_string();
ext = match ct {
"application/pdf" => "pdf",
"image/jpeg" => "jpg",
"image/png" => "png",
"image/webp" => "webp",
"application/msword" => "doc",
"application/vnd.openxmlformats-officedocument.wordprocessingml.document" => "docx",
_ => "bin",
}.to_string();
}
if let Some(fname) = field.file_name() {
file_name = fname.to_string();
if ext == "bin" {
if let Some(e) = fname.rsplit('.').next() {
ext = e.to_lowercase();
}
}
}
let data = match field.bytes().await {
Ok(b) => b,
Err(e) => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": format!("Failed to read file: {}", e) }))).into_response(),
};
if data.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Empty file" }))).into_response();
}
if data.len() > 10 * 1024 * 1024 {
return (StatusCode::PAYLOAD_TOO_LARGE, Json(serde_json::json!({ "error": "File too large. Maximum 10 MB." }))).into_response();
}
file_size = data.len() as i64;
file_bytes.put(data);
found = true;
}
}
if !found || file_bytes.is_empty() {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No document file provided. Send a multipart field named 'file' or 'document'." }))).into_response();
}
// Upload to Backblaze B2 under "documents" prefix
let file_url = match state.storage
.upload("documents", &ext, file_bytes.freeze(), &content_type)
.await
{
Ok(url) => url,
Err(e) => {
tracing::error!("B2 upload failed: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "File upload failed" }))).into_response();
}
};
let payload = CreateJobSeekerDocumentPayload {
document_type,
file_name: file_name.clone(),
file_size,
mime_type: content_type,
};
match JobSeekerRepository::create_document(&state.pool, seeker.id, payload, file_url.clone()).await {
Ok(doc) => (StatusCode::CREATED, Json(serde_json::json!({
"id": doc.id,
"document_type": doc.document_type,
"file_name": doc.file_name,
"file_url": doc.file_url,
"file_size": doc.file_size,
"mime_type": doc.mime_type,
"created_at": doc.created_at,
}))).into_response(),
Err(e) => {
tracing::error!("Failed to save document record: {}", e);
// Best-effort cleanup
state.storage.delete_by_url(&file_url).await;
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to save document record" }))).into_response()
}
}
}
async fn list_documents(
State(state): State<AppState>,
auth: AuthUser,
) -> impl IntoResponse {
let seeker = match JobSeekerRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(s)) => s,
Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Job seeker profile not found" }))).into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(),
};
match JobSeekerRepository::list_documents(&state.pool, seeker.id).await {
Ok(docs) => (StatusCode::OK, Json(serde_json::json!({ "data": docs }))).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn delete_document(
State(state): State<AppState>,
auth: AuthUser,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
let seeker = match JobSeekerRepository::get_by_user_id(&state.pool, auth.user_id).await {
Ok(Some(s)) => s,
Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Job seeker profile not found" }))).into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(),
};
// Fetch doc to get file_url for cleanup
match JobSeekerRepository::list_documents(&state.pool, seeker.id).await {
Ok(docs) => {
let doc = docs.iter().find(|d| d.id == id);
if doc.is_none() {
return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Document not found" }))).into_response();
}
let file_url = doc.unwrap().file_url.clone();
match JobSeekerRepository::delete_document(&state.pool, seeker.id, id).await {
Ok(_) => {
state.storage.delete_by_url(&file_url).await;
(StatusCode::OK, Json(serde_json::json!({ "message": "Document deleted" }))).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}

View file

@ -1,6 +1,7 @@
mod handlers;
use axum::{routing::get, Router};
use cache::RedisPool;
use std::net::SocketAddr;
use std::sync::Arc;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
@ -10,6 +11,7 @@ pub struct AppState {
pub pool: sqlx::PgPool,
pub storage: Arc<storage::StorageClient>,
pub mail: Arc<email::Mailer>,
pub redis: RedisPool,
}
#[tokio::main]
@ -33,7 +35,11 @@ async fn main() {
let storage = Arc::new(storage::StorageClient::from_env().await);
let mailer = Arc::new(email::Mailer::new());
let state = AppState { pool, storage, mail: mailer };
let redis_url = std::env::var("REDIS_URL").expect("REDIS_URL must be set");
let redis = cache::connect(&redis_url).await.expect("Failed to connect to Redis");
tracing::info!("Job Seekers service — connected to Redis");
let state = AppState { pool, storage, mail: mailer, redis };
let app = Router::new()
.nest("/api/jobseeker", handlers::router())

21
apps/jobs/Cargo.toml Normal file
View file

@ -0,0 +1,21 @@
[package]
name = "jobs"
version = "0.1.0"
edition = "2021"
[dependencies]
sqlx = { workspace = true }
axum = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tracing = { workspace = true }
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
anyhow = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tower-http = { version = "0.6", features = ["cors", "trace"] }
[[bin]]
name = "jobs"
path = "src/main.rs"

136
apps/jobs/src/main.rs Normal file
View file

@ -0,0 +1,136 @@
use axum::{
extract::State,
http::StatusCode,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::net::SocketAddr;
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Job {
pub id: uuid::Uuid,
pub title: String,
pub description: String,
pub location: String,
pub job_type: String,
pub status: String,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateJob {
pub title: String,
pub description: String,
pub location: String,
pub job_type: String,
}
async fn list_jobs(State(state): State<Arc<AppState>>) -> Result<Json<Vec<Job>>, StatusCode> {
let jobs = sqlx::query_as::<_, Job>(
"SELECT id, title, description, location, job_type, status, created_at FROM jobs ORDER BY created_at DESC"
)
.fetch_all(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(jobs))
}
async fn create_job(
State(state): State<Arc<AppState>>,
Json(payload): Json<CreateJob>,
) -> Result<Json<Job>, StatusCode> {
let job = sqlx::query_as::<_, Job>(
r#"
INSERT INTO jobs (title, description, location, job_type)
VALUES ($1, $2, $3, $4)
RETURNING id, title, description, location, job_type, status, created_at
"#,
)
.bind(&payload.title)
.bind(&payload.description)
.bind(&payload.location)
.bind(&payload.job_type)
.fetch_one(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(job))
}
async fn get_job(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<uuid::Uuid>,
) -> Result<Json<Job>, StatusCode> {
let job = sqlx::query_as::<_, Job>(
"SELECT id, title, description, location, job_type, status, created_at FROM jobs WHERE id = $1"
)
.bind(id)
.fetch_optional(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(job))
}
async fn health() -> &'static str {
"Jobs Service OK"
}
#[tokio::main]
async fn main() {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(10)
.connect(&database_url)
.await
.expect("Failed to connect to database");
tracing::info!("Connected to database");
let state = Arc::new(AppState { pool });
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", get(health))
.route("/jobs", get(list_jobs))
.route("/jobs", post(create_job))
.route("/jobs/{id}", get(get_job))
.layer(cors)
.with_state(state);
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "9103".to_string())
.parse()
.expect("PORT must be a valid u16");
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Jobs service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

22
apps/leads/Cargo.toml Normal file
View file

@ -0,0 +1,22 @@
[package]
name = "leads"
version = "0.1.0"
edition = "2021"
[dependencies]
sqlx = { workspace = true }
axum = { workspace = true }
serde = { workspace = true }
serde_json = { workspace = true }
tokio = { workspace = true, features = ["full"] }
tracing = { workspace = true }
tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] }
anyhow = { workspace = true }
uuid = { workspace = true }
chrono = { workspace = true }
tower-http = { version = "0.6", features = ["cors", "trace"] }
reqwest = { workspace = true }
[[bin]]
name = "leads"
path = "src/main.rs"

View file

@ -0,0 +1,746 @@
use crate::AppState;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{get, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use std::sync::Arc;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct PaginationQuery {
pub page: Option<i64>,
pub limit: Option<i64>,
pub status: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SendLeadRequestPayload {
pub lead_id: Uuid,
pub message: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct SendLeadRequestAiPayload {
pub lead_id: Uuid,
pub user_id: Uuid,
pub profession_key: String,
}
#[derive(Debug, FromRow)]
pub struct LeadRequestRow {
pub id: Uuid,
pub lead_id: Uuid,
pub user_role_profile_id: Uuid,
pub customer_user_id: Uuid,
pub status: String,
pub tracecoins_reserved: i32,
pub message: Option<String>,
pub expires_at: chrono::DateTime<chrono::Utc>,
pub accepted_at: Option<chrono::DateTime<chrono::Utc>>,
pub rejected_at: Option<chrono::DateTime<chrono::Utc>>,
pub rejected_reason: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize)]
pub struct LeadRequestResponse {
pub id: Uuid,
pub lead_id: Uuid,
pub user_role_profile_id: Uuid,
pub customer_user_id: Uuid,
pub professional_name: Option<String>,
pub professional_role: Option<String>,
pub customer_name: Option<String>,
pub lead_title: Option<String>,
pub status: String,
pub tracecoins_reserved: i32,
pub message: Option<String>,
pub expires_at: chrono::DateTime<chrono::Utc>,
pub accepted_at: Option<chrono::DateTime<chrono::Utc>>,
pub rejected_at: Option<chrono::DateTime<chrono::Utc>>,
pub rejected_reason: Option<String>,
pub created_at: chrono::DateTime<chrono::Utc>,
}
pub fn router() -> Router<Arc<AppState>> {
Router::new()
.route("/", get(list_lead_requests))
.route("/send", post(send_lead_request))
.route("/send-ai", post(send_lead_request_ai))
.route("/{id}/accept", post(accept_lead_request))
.route("/{id}/reject", post(reject_lead_request))
.route("/my-requests", get(my_requests))
.route("/my-pending", get(my_pending_requests))
.route("/customer/{lead_id}", get(get_customer_lead_requests))
}
fn lead_request_to_response(row: LeadRequestRow) -> LeadRequestResponse {
LeadRequestResponse {
id: row.id,
lead_id: row.lead_id,
user_role_profile_id: row.user_role_profile_id,
customer_user_id: row.customer_user_id,
professional_name: None,
professional_role: None,
customer_name: None,
lead_title: None,
status: row.status,
tracecoins_reserved: row.tracecoins_reserved,
message: row.message,
expires_at: row.expires_at,
accepted_at: row.accepted_at,
rejected_at: row.rejected_at,
rejected_reason: row.rejected_reason,
created_at: row.created_at,
}
}
async fn list_lead_requests(
State(state): State<Arc<AppState>>,
Query(q): Query<PaginationQuery>,
) -> impl IntoResponse {
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
let offset = (page - 1) * limit;
let status_filter = q.status
.as_ref()
.map(|s| format!("AND lr.status = '{}'", s))
.unwrap_or_default();
let requests = match sqlx::query_as::<_, LeadRequestRow>(&format!(
r#"
SELECT lr.* FROM lead_requests lr
WHERE 1=1 {}
ORDER BY lr.created_at DESC
LIMIT {} OFFSET {}
"#,
status_filter, limit, offset
))
.fetch_all(&state.pool)
.await
{
Ok(r) => r,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let requests: Vec<LeadRequestResponse> = requests.into_iter().map(lead_request_to_response).collect();
(StatusCode::OK, Json(serde_json::json!({
"data": requests,
"pagination": { "page": page, "limit": limit }
}))).into_response()
}
async fn send_lead_request(
State(state): State<Arc<AppState>>,
axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
Json(payload): Json<SendLeadRequestPayload>,
) -> impl IntoResponse {
let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default();
let user_role_profile_id = match sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM user_role_profiles WHERE user_id = $1 LIMIT 1"
)
.bind(user_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(id)) => id,
Ok(None) => return (StatusCode::NOT_FOUND, "Professional profile not found. Please complete your profile first.").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let lead = match sqlx::query_as::<_, (Uuid, String, Uuid, String, i32)>(
"SELECT id, title, customer_user_id, status, COALESCE(current_acceptances, 0) FROM leads WHERE id = $1"
)
.bind(payload.lead_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(l)) => l,
Ok(None) => return (StatusCode::NOT_FOUND, "Lead not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
if lead.3 != "OPEN" {
return (StatusCode::BAD_REQUEST, "Lead is not open for requests").into_response();
}
if lead.4 >= 10 {
return (StatusCode::BAD_REQUEST, "Lead has reached maximum acceptances").into_response();
}
let duplicate = match sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM lead_requests WHERE lead_id = $1 AND user_role_profile_id = $2 AND status IN ('PENDING', 'ACCEPTED')"
)
.bind(payload.lead_id)
.bind(user_role_profile_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(_)) => true,
Ok(None) => false,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
if duplicate {
return (StatusCode::CONFLICT, "You have already sent a request for this lead").into_response();
}
let request_count: (i64,) = match sqlx::query_as(
"SELECT COUNT(*) FROM lead_requests WHERE lead_id = $1 AND status IN ('PENDING', 'ACCEPTED')"
)
.bind(payload.lead_id)
.fetch_one(&state.pool)
.await
{
Ok(c) => c,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
if request_count.0 >= 20 {
return (StatusCode::CONFLICT, "Lead has reached maximum requests").into_response();
}
let wallet = match sqlx::query_as::<_, (Uuid, i64)>(
"SELECT id, balance FROM tracecoin_wallets WHERE user_id = $1"
)
.bind(user_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(w)) => w,
Ok(None) => return (StatusCode::BAD_REQUEST, "Wallet not found. Please contact support.").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let tracecoins_cost = 25;
if wallet.1 < tracecoins_cost as i64 {
return (StatusCode::PAYMENT_REQUIRED, format!("Insufficient balance. You need at least {} Tracecoins.", tracecoins_cost)).into_response();
}
let expires_at = chrono::Utc::now() + chrono::Duration::hours(24);
let result = sqlx::query_as::<_, LeadRequestRow>(
r#"
INSERT INTO lead_requests (lead_id, user_role_profile_id, customer_user_id, status, tracecoins_reserved, message, expires_at)
VALUES ($1, $2, $3, 'PENDING', $4, $5, $6)
RETURNING *
"#
)
.bind(payload.lead_id)
.bind(user_role_profile_id)
.bind(lead.2)
.bind(tracecoins_cost)
.bind(&payload.message)
.bind(expires_at)
.fetch_one(&state.pool)
.await;
match result {
Ok(req) => {
let _ = sqlx::query(
r#"
UPDATE tracecoin_wallets SET
balance = balance - $1,
reserved = COALESCE(reserved, 0) + $1,
updated_at = NOW()
WHERE user_id = $2
"#
)
.bind(tracecoins_cost as i64)
.bind(user_id)
.execute(&state.pool)
.await;
let _ = sqlx::query(
r#"
INSERT INTO notifications (user_id, title, body, notification_type, reference_id)
VALUES ($1, $2, $3, $4, $5)
"#
)
.bind(lead.2)
.bind("New Lead Request")
.bind("You have a new lead request. Please review and respond within 24 hours.")
.bind("LEAD_REQUEST")
.bind(req.id)
.execute(&state.pool)
.await;
let response = lead_request_to_response(req);
(StatusCode::CREATED, Json(response)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn send_lead_request_ai(
State(state): State<Arc<AppState>>,
Json(payload): Json<SendLeadRequestAiPayload>,
) -> impl IntoResponse {
let user_id = payload.user_id;
let lead = match sqlx::query_as::<_, (Uuid, String, String, String, String, Option<i32>, Option<i32>)>(
"SELECT id, title, description, location, profession_key, budget_min, budget_max FROM leads WHERE id = $1"
)
.bind(payload.lead_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(l)) => l,
Ok(None) => return (StatusCode::NOT_FOUND, "Lead not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
if lead.4 != payload.profession_key {
return (StatusCode::BAD_REQUEST, "Lead profession does not match your profile").into_response();
}
let user_role_profile_id: Uuid = match sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM user_role_profiles WHERE user_id = $1 LIMIT 1"
)
.bind(user_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(id)) => id,
Ok(None) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let existing = match sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM lead_requests WHERE lead_id = $1 AND user_role_profile_id = $2 AND status IN ('PENDING', 'ACCEPTED')"
)
.bind(payload.lead_id)
.bind(user_role_profile_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(_)) => true,
Ok(None) => false,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
if existing {
return (StatusCode::CONFLICT, "You have already sent a request for this lead").into_response();
}
let wallet = match sqlx::query_as::<_, (Uuid, i64)>(
"SELECT id, balance FROM tracecoin_wallets WHERE user_id = $1"
)
.bind(user_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(w)) => w,
Ok(None) => return (StatusCode::BAD_REQUEST, "Wallet not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let tracecoins_cost = 30;
if wallet.1 < tracecoins_cost as i64 {
return (StatusCode::PAYMENT_REQUIRED, format!("Insufficient balance. You need {} Tracecoins.", tracecoins_cost)).into_response();
}
let budget = match (lead.5, lead.6) {
(Some(min), Some(max)) => format!("Budget: ₹{}-₹{}", min, max),
(Some(min), None) => format!("Budget: ₹{} onwards", min),
_ => "Budget: Not specified".to_string(),
};
let prompt = format!(
"You are a professional {} responding to a potential client's lead/request.\n\n\
IMPORTANT: Do NOT include phone number, email, or any contact information in your response. \
Clients pay to view contact details through the platform.\n\n\
LEAD DETAILS:\n\
Title: {}\n\
Description: {}\n\
Location: {}\n\
{}\n\n\
Write a professional, friendly message (max 150 words) expressing your interest and qualifications. \
Mention relevant experience and ask any clarifying questions. Be concise and compelling.",
payload.profession_key.replace("_", " "),
lead.1,
lead.2,
lead.3,
budget
);
let ai_message = match generate_ai_message(&state.http_client, &state.ollama_base_url, &state.ollama_model, &prompt).await {
Ok(msg) => msg,
Err(e) => {
tracing::error!("AI message generation failed: {}", e);
return (StatusCode::INTERNAL_SERVER_ERROR, "AI generation failed").into_response();
}
};
let expires_at = chrono::Utc::now() + chrono::Duration::hours(24);
let result = sqlx::query_as::<_, LeadRequestRow>(
r#"
INSERT INTO lead_requests (lead_id, user_role_profile_id, customer_user_id, status, tracecoins_reserved, message, expires_at)
VALUES ($1, $2, $3, 'PENDING', $4, $5, $6)
RETURNING *
"#
)
.bind(payload.lead_id)
.bind(user_role_profile_id)
.bind(user_id)
.bind(tracecoins_cost)
.bind(&ai_message)
.bind(expires_at)
.fetch_one(&state.pool)
.await;
match result {
Ok(req) => {
let _ = sqlx::query(
r#"
UPDATE tracecoin_wallets SET
balance = balance - $1,
reserved = COALESCE(reserved, 0) + $1,
updated_at = NOW()
WHERE user_id = $2
"#
)
.bind(tracecoins_cost as i64)
.bind(user_id)
.execute(&state.pool)
.await;
let _ = sqlx::query(
r#"
INSERT INTO notifications (user_id, title, body, notification_type, reference_id)
VALUES ($1, $2, $3, $4, $5)
"#
)
.bind(user_id)
.bind("AI Auto-Respond Sent")
.bind("Your AI-assisted response has been sent to the customer.")
.bind("LEAD_REQUEST")
.bind(req.id)
.execute(&state.pool)
.await;
let response = lead_request_to_response(req);
(StatusCode::CREATED, Json(response)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn generate_ai_message(
client: &reqwest::Client,
base_url: &str,
model: &str,
prompt: &str,
) -> Result<String, String> {
#[derive(Serialize)]
struct GenerateRequest<'a> {
model: &'a str,
prompt: String,
stream: bool,
}
#[derive(Deserialize)]
struct GenerateResponse {
response: String,
}
let url = format!("{}/api/generate", base_url.trim_end_matches('/'));
let req = GenerateRequest {
model,
prompt: prompt.to_string(),
stream: false,
};
let response = client
.post(&url)
.json(&req)
.send()
.await
.map_err(|e| format!("ollama request failed: {}", e))?;
if !response.status().is_success() {
return Err(format!("ollama returned status: {}", response.status()));
}
let result: GenerateResponse = response
.json()
.await
.map_err(|e| format!("failed to parse ollama response: {}", e))?;
Ok(result.response.trim().to_string())
}
async fn accept_lead_request(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
) -> impl IntoResponse {
let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default();
let request = match sqlx::query_as::<_, LeadRequestRow>(
"SELECT * FROM lead_requests WHERE id = $1 AND status = 'PENDING'"
)
.bind(id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(r)) => r,
Ok(None) => return (StatusCode::NOT_FOUND, "Lead request not found or already processed").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
if request.customer_user_id != user_id {
return (StatusCode::FORBIDDEN, "You are not authorized to accept this request").into_response();
}
if request.expires_at < chrono::Utc::now() {
return (StatusCode::BAD_REQUEST, "This request has expired").into_response();
}
let lead_acceptances: (i32,) = match sqlx::query_as(
"SELECT COALESCE(current_acceptances, 0) FROM leads WHERE id = $1"
)
.bind(request.lead_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(l)) => l,
Ok(None) => return (StatusCode::NOT_FOUND, "Lead not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
if lead_acceptances.0 >= 10 {
return (StatusCode::BAD_REQUEST, "Lead has reached maximum acceptances").into_response();
}
let _ = sqlx::query(
"UPDATE lead_requests SET status = 'ACCEPTED', accepted_at = NOW(), updated_at = NOW() WHERE id = $1"
)
.bind(id)
.execute(&state.pool)
.await;
let _ = sqlx::query(
"UPDATE leads SET current_acceptances = current_acceptances + 1, updated_at = NOW() WHERE id = $1"
)
.bind(request.lead_id)
.execute(&state.pool)
.await;
let _ = sqlx::query(
r#"
UPDATE tracecoin_wallets SET
reserved = reserved - $1,
updated_at = NOW()
WHERE user_id = $2
"#
)
.bind(request.tracecoins_reserved as i64)
.bind(user_id)
.execute(&state.pool)
.await;
if lead_acceptances.0 + 1 >= 10 {
let _ = sqlx::query("UPDATE leads SET status = 'CLOSED', updated_at = NOW() WHERE id = $1")
.bind(request.lead_id)
.execute(&state.pool)
.await;
}
let _ = sqlx::query(
r#"
INSERT INTO notifications (user_id, title, body, notification_type, reference_id)
VALUES ($1, $2, $3, $4, $5)
"#
)
.bind(user_id)
.bind("Lead Request Accepted")
.bind("Your lead request has been accepted! Contact details have been shared.")
.bind("LEAD_REQUEST")
.bind(id)
.execute(&state.pool)
.await;
(StatusCode::OK, Json(serde_json::json!({
"message": "Lead request accepted successfully",
"contact_details_shared": true
}))).into_response()
}
async fn reject_lead_request(
State(state): State<Arc<AppState>>,
Path(id): Path<Uuid>,
axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
) -> impl IntoResponse {
let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default();
let request = match sqlx::query_as::<_, LeadRequestRow>(
"SELECT * FROM lead_requests WHERE id = $1 AND status = 'PENDING'"
)
.bind(id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(r)) => r,
Ok(None) => return (StatusCode::NOT_FOUND, "Lead request not found or already processed").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
if request.customer_user_id != user_id {
return (StatusCode::FORBIDDEN, "You are not authorized to reject this request").into_response();
}
let _ = sqlx::query(
"UPDATE lead_requests SET status = 'REJECTED', rejected_at = NOW(), rejected_reason = 'Rejected by customer', updated_at = NOW() WHERE id = $1"
)
.bind(id)
.execute(&state.pool)
.await;
let _ = sqlx::query(
r#"
UPDATE tracecoin_wallets SET
balance = balance + $1,
reserved = reserved - $1,
updated_at = NOW()
WHERE user_id = $2
"#
)
.bind(request.tracecoins_reserved as i64)
.bind(user_id)
.execute(&state.pool)
.await;
let _ = sqlx::query(
r#"
INSERT INTO notifications (user_id, title, body, notification_type, reference_id)
VALUES ($1, $2, $3, $4, $5)
"#
)
.bind(user_id)
.bind("Lead Request Rejected")
.bind("Your lead request was not accepted. Tracecoins have been refunded.")
.bind("LEAD_REQUEST")
.bind(id)
.execute(&state.pool)
.await;
(StatusCode::OK, Json(serde_json::json!({
"message": "Lead request rejected. Tracecoins refunded.",
"refunded": request.tracecoins_reserved
}))).into_response()
}
async fn my_requests(
State(state): State<Arc<AppState>>,
Query(q): Query<PaginationQuery>,
axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
) -> impl IntoResponse {
let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default();
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
let offset = (page - 1) * limit;
let status_filter = q.status
.as_ref()
.map(|s| format!("AND lr.status = '{}'", s))
.unwrap_or_default();
let requests = match sqlx::query_as::<_, LeadRequestRow>(&format!(
r#"
SELECT lr.* FROM lead_requests lr
JOIN user_role_profiles urp ON urp.id = lr.user_role_profile_id
WHERE urp.user_id = $1 {}
ORDER BY lr.created_at DESC
LIMIT {} OFFSET {}
"#,
status_filter, limit, offset
))
.bind(user_id)
.fetch_all(&state.pool)
.await
{
Ok(r) => r,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let requests: Vec<LeadRequestResponse> = requests.into_iter().map(lead_request_to_response).collect();
(StatusCode::OK, Json(serde_json::json!({
"data": requests,
"pagination": { "page": page, "limit": limit }
}))).into_response()
}
async fn my_pending_requests(
State(state): State<Arc<AppState>>,
axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
) -> impl IntoResponse {
let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default();
let requests = match sqlx::query_as::<_, LeadRequestRow>(
r#"
SELECT lr.* FROM lead_requests lr
WHERE lr.customer_user_id = $1 AND lr.status = 'PENDING'
ORDER BY lr.expires_at ASC
"#
)
.bind(user_id)
.fetch_all(&state.pool)
.await
{
Ok(r) => r,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let requests: Vec<LeadRequestResponse> = requests.into_iter().map(lead_request_to_response).collect();
(StatusCode::OK, Json(serde_json::json!({
"data": requests
}))).into_response()
}
async fn get_customer_lead_requests(
State(state): State<Arc<AppState>>,
Path(lead_id): Path<Uuid>,
Query(q): Query<PaginationQuery>,
axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo<std::net::SocketAddr>,
) -> impl IntoResponse {
let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default();
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20);
let offset = (page - 1) * limit;
let requests = match sqlx::query_as::<_, LeadRequestRow>(&format!(
r#"
SELECT lr.* FROM lead_requests lr
WHERE lr.lead_id = $1 AND lr.customer_user_id = $2
ORDER BY lr.created_at DESC
LIMIT {} OFFSET {}
"#,
limit, offset
))
.bind(lead_id)
.bind(user_id)
.fetch_all(&state.pool)
.await
{
Ok(r) => r,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let requests: Vec<LeadRequestResponse> = requests.into_iter().map(lead_request_to_response).collect();
(StatusCode::OK, Json(serde_json::json!({
"data": requests,
"pagination": { "page": page, "limit": limit }
}))).into_response()
}

150
apps/leads/src/main.rs Normal file
View file

@ -0,0 +1,150 @@
use axum::{
extract::State,
http::StatusCode,
routing::{get, post},
Json, Router,
};
use reqwest::Client;
use serde::{Deserialize, Serialize};
use sqlx::PgPool;
use std::net::SocketAddr;
use std::sync::Arc;
use tower_http::cors::{Any, CorsLayer};
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
pub mod lead_requests;
#[derive(Clone)]
pub struct AppState {
pub pool: PgPool,
pub http_client: reqwest::Client,
pub ollama_base_url: String,
pub ollama_model: String,
}
#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)]
pub struct Lead {
pub id: uuid::Uuid,
pub title: String,
pub description: String,
pub location: String,
pub profession_key: String,
pub status: String,
pub created_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Deserialize)]
pub struct CreateLead {
pub title: String,
pub description: String,
pub location: String,
pub profession_key: String,
}
async fn list_leads(State(state): State<Arc<AppState>>) -> Result<Json<Vec<Lead>>, StatusCode> {
let leads = sqlx::query_as::<_, Lead>(
"SELECT id, title, description, location, profession_key, status, created_at FROM leads ORDER BY created_at DESC"
)
.fetch_all(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(leads))
}
async fn create_lead(
State(state): State<Arc<AppState>>,
Json(payload): Json<CreateLead>,
) -> Result<Json<Lead>, StatusCode> {
let lead = sqlx::query_as::<_, Lead>(
r#"
INSERT INTO leads (title, description, location, profession_key)
VALUES ($1, $2, $3, $4)
RETURNING id, title, description, location, profession_key, status, created_at
"#
)
.bind(&payload.title)
.bind(&payload.description)
.bind(&payload.location)
.bind(&payload.profession_key)
.fetch_one(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?;
Ok(Json(lead))
}
async fn get_lead(
State(state): State<Arc<AppState>>,
axum::extract::Path(id): axum::extract::Path<uuid::Uuid>,
) -> Result<Json<Lead>, StatusCode> {
let lead = sqlx::query_as::<_, Lead>(
"SELECT id, title, description, location, profession_key, status, created_at FROM leads WHERE id = $1"
)
.bind(id)
.fetch_optional(&state.pool)
.await
.map_err(|_| StatusCode::INTERNAL_SERVER_ERROR)?
.ok_or(StatusCode::NOT_FOUND)?;
Ok(Json(lead))
}
async fn health() -> &'static str {
"Leads Service OK"
}
#[tokio::main]
async fn main() {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "info".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
let database_url = std::env::var("DATABASE_URL")
.expect("DATABASE_URL must be set");
let pool = sqlx::postgres::PgPoolOptions::new()
.max_connections(10)
.connect(&database_url)
.await
.expect("Failed to connect to database");
tracing::info!("Connected to database");
let state = Arc::new(AppState {
pool,
http_client: Client::new(),
ollama_base_url: std::env::var("OLLAMA_BASE_URL")
.unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()),
ollama_model: std::env::var("OLLAMA_CHAT_MODEL")
.unwrap_or_else(|_| "gemma3:270m".to_string()),
});
let cors = CorsLayer::new()
.allow_origin(Any)
.allow_methods(Any)
.allow_headers(Any);
let app = Router::new()
.route("/health", get(health))
.route("/leads", get(list_leads))
.route("/leads", post(create_lead))
.route("/leads/{id}", get(get_lead))
.nest("/api/lead-requests", lead_requests::router())
.layer(cors)
.with_state(state);
let port: u16 = std::env::var("PORT")
.unwrap_or_else(|_| "9118".to_string())
.parse()
.expect("PORT must be a valid u16");
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Leads service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();
}

View file

@ -16,4 +16,5 @@ db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }
storage = { path = "../../crates/storage" }

View file

@ -1,5 +1,5 @@
use contracts::ProfessionState;
use db::models::makeup_artist::MakeupArtistProfile;
use db::models::user_role_profile::UserRoleProfile;
use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use serde::Serialize;
use uuid::Uuid;
@ -7,6 +7,7 @@ use uuid::Uuid;
#[derive(Serialize)]
pub struct AdminMakeupArtistList {
pub id: Uuid,
pub user_role_profile_id: Uuid,
pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>,
@ -16,10 +17,11 @@ pub struct AdminMakeupArtistList {
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<MakeupArtistProfile> for AdminMakeupArtistList {
fn from(p: MakeupArtistProfile) -> Self {
impl From<UserRoleProfile> for AdminMakeupArtistList {
fn from(p: UserRoleProfile) -> Self {
Self {
id: p.id,
user_role_profile_id: p.id,
user_id: p.user_id,
display_name: p.display_name,
bio: p.bio,
@ -40,10 +42,15 @@ pub fn router() -> Router<ProfessionState> {
async fn list_makeup_artists(
State(state): State<ProfessionState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let artists = sqlx::query_as::<_, MakeupArtistProfile>(
let artists = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at
FROM makeup_artist_profiles
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE role_key = 'makeup_artist'
ORDER BY created_at DESC
LIMIT 100
"#,
@ -60,11 +67,15 @@ async fn get_makeup_artist(
State(state): State<ProfessionState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let artist = sqlx::query_as::<_, MakeupArtistProfile>(
let artist = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at
FROM makeup_artist_profiles
WHERE id = $1
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE id = $1 AND role_key = 'makeup_artist'
"#,
)
.bind(id)

View file

@ -21,7 +21,7 @@ async fn update_profile(
auth: AuthUser,
Json(payload): Json<UpsertMakeupArtistProfilePayload>,
) -> impl IntoResponse {
match MakeupArtistRepository::upsert(&state.pool, auth.user_id, payload).await {
match MakeupArtistRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}

View file

@ -3,6 +3,7 @@ mod admin;
use axum::{routing::get, Router};
use std::net::SocketAddr;
use std::sync::Arc;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::ProfessionState;
@ -30,7 +31,8 @@ async fn main() {
tracing::info!("Makeup Artists service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
let storage = Arc::new(storage::StorageClient::from_env().await);
let state = ProfessionState { pool, redis, storage };
let app = Router::new()
.nest("/api/makeup-artists", handlers::router())

View file

@ -12,8 +12,10 @@ use uuid::Uuid;
use sqlx::postgres::PgPool;
use sqlx::FromRow;
pub mod packages;
#[derive(Clone)]
struct AppState {
pub struct AppState {
beeceptor_url: String,
client: reqwest::Client,
pool: PgPool,
@ -64,10 +66,12 @@ struct PricingPackageRow {
}
#[derive(Debug, FromRow)]
#[allow(dead_code)]
struct PaymentRow {
id: Uuid,
user_id: Uuid,
tracecoins_credited: i32,
package_id: Option<Uuid>,
tracecoins_credited: Option<i32>,
}
async fn create_order(
@ -77,11 +81,9 @@ async fn create_order(
) -> Result<Json<CreateOrderResponse>, (StatusCode, String)> {
tracing::info!("Creating payment order: amount={}", payload.amount);
// Validate package_id
let package_id_str = payload.package_id.as_ref().ok_or((StatusCode::BAD_REQUEST, "package_id is required".to_string()))?;
let package_id = Uuid::parse_str(package_id_str).map_err(|_| (StatusCode::BAD_REQUEST, "Invalid package id".to_string()))?;
// Fetch package to get tracecoins amount
let package = sqlx::query_as::<_, PricingPackageRow>(
"SELECT tracecoins_amount FROM pricing_packages WHERE id = $1 AND is_active = true",
)
@ -93,7 +95,6 @@ async fn create_order(
let package = package.ok_or((StatusCode::BAD_REQUEST, "Invalid or inactive package".to_string()))?;
let tracecoins_credited = package.tracecoins_amount;
// Call Beeceptor to create order
let resp = state
.client
.post(&state.beeceptor_url)
@ -130,9 +131,11 @@ async fn create_order(
.unwrap_or("mock_order_123")
.to_string();
// Insert payment record
sqlx::query(
"INSERT INTO payments (user_id, package_id, razorpay_order_id, amount_inr, tracecoins_credited, status) VALUES ($1, $2, $3, $4, $5, 'PENDING')",
r#"
INSERT INTO payments (user_id, package_id, razorpay_order_id, amount, tracecoins_credited, status)
VALUES ($1, $2, $3, $4, $5, 'PENDING')
"#,
)
.bind(auth.user_id)
.bind(package_id)
@ -158,7 +161,6 @@ async fn verify_payment(
) -> Result<Json<VerifyPaymentResponse>, (StatusCode, String)> {
tracing::info!("Verifying payment: order_id={}", payload.order_id);
// Verify with Beeceptor
let verify_url = format!("{}/verify", state.beeceptor_url.trim_end_matches('/'));
let resp = state
.client
@ -185,9 +187,12 @@ async fn verify_payment(
));
}
// Find pending payment by razorpay_order_id
let payment = sqlx::query_as::<_, PaymentRow>(
"SELECT id, user_id, tracecoins_credited FROM payments WHERE razorpay_order_id = $1 AND status = 'PENDING'",
r#"
SELECT id, user_id, package_id, tracecoins_credited
FROM payments
WHERE razorpay_order_id = $1 AND status = 'PENDING'
"#,
)
.bind(&payload.order_id)
.fetch_optional(&state.pool)
@ -199,14 +204,20 @@ async fn verify_payment(
None => return Err((StatusCode::NOT_FOUND, "Payment not found or already processed".to_string())),
};
// Ensure the authenticated user matches the payment user
if payment.user_id != auth.user_id {
return Err((StatusCode::FORBIDDEN, "Payment does not belong to user".to_string()));
}
// Update payment status to SUCCESS
let tracecoins = payment.tracecoins_credited.unwrap_or(0);
sqlx::query(
"UPDATE payments SET status = 'SUCCESS', verified_at = NOW(), razorpay_payment_id = $1 WHERE id = $2",
r#"
UPDATE payments SET
status = 'SUCCESS',
razorpay_payment_id = $1,
verified_at = NOW()
WHERE id = $2
"#,
)
.bind(&payload.payment_id)
.bind(payment.id)
@ -214,49 +225,50 @@ async fn verify_payment(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Credit wallet (increase balance)
sqlx::query(
"INSERT INTO tracecoin_wallets (user_id, balance, reserved) VALUES ($1, $2, 0) ON CONFLICT (user_id) DO UPDATE SET balance = tracecoin_wallets.balance + excluded.balance",
r#"
INSERT INTO tracecoin_wallets (user_id, balance, reserved)
VALUES ($1, $2, 0)
ON CONFLICT (user_id) DO UPDATE SET
balance = tracecoin_wallets.balance + excluded.balance
"#,
)
.bind(payment.user_id)
.bind(payment.tracecoins_credited)
.bind(tracecoins as i64)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Get wallet id for ledger
match sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM tracecoin_wallets WHERE user_id = $1",
if let Ok(Some(wallet_id)) = sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM tracecoin_wallets WHERE user_id = $1"
)
.bind(payment.user_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(wallet_id)) => {
sqlx::query(
"INSERT INTO tracecoin_ledger (wallet_id, type, amount, reason, reference_id) VALUES ($1, 'CREDIT', $2, $3, $4)",
)
.bind(wallet_id)
.bind(payment.tracecoins_credited as i64)
.bind("PURCHASE")
.bind(payment.id)
.execute(&state.pool)
.await
.ok();
}
_ => {}
}
sqlx::query(
r#"
INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, balance_after, reference_type, reference_id, description)
VALUES ($1, 'CREDIT', $2, $2, 'PAYMENT', $3, 'Package purchase')
"#,
)
.bind(wallet_id)
.bind(tracecoins as i64)
.bind(payment.id)
.execute(&state.pool)
.await
.ok();
}
// Send notification to user about successful purchase
let _ = sqlx::query(
r#"
INSERT INTO notifications (user_id, title, body, type, reference_id)
INSERT INTO notifications (user_id, title, body, notification_type, reference_id)
VALUES ($1, $2, $3, $4, $5)
"#,
)
.bind(payment.user_id)
.bind("Tracecoins Purchased Successfully")
.bind(format!("Your {} Tracecoin package has been credited to your wallet.", payment.tracecoins_credited))
.bind(format!("Your {} Tracecoin package has been credited to your wallet.", tracecoins))
.bind("PAYMENT")
.bind(payment.id)
.execute(&state.pool)
@ -348,6 +360,7 @@ async fn main() {
.route("/api/payments/create-order", post(create_order))
.route("/api/payments/verify", post(verify_payment))
.route("/api/payments/{id}/status", get(get_payment_status))
.nest("/api/packages", packages::router())
.with_state(state);
let port: u16 = std::env::var("PORT")
@ -356,7 +369,7 @@ async fn main() {
.expect("PORT must be a valid u16");
let addr = SocketAddr::from(([0, 0, 0, 0], port));
tracing::info!("Payments service (mock via Beeceptor) listening on {}", addr);
tracing::info!("Payments service listening on {}", addr);
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
axum::serve(listener, app).await.unwrap();

View file

@ -0,0 +1,418 @@
use crate::AppState;
use axum::{
extract::{Path, Query, State},
http::StatusCode,
response::IntoResponse,
routing::{delete, get, patch, post},
Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::FromRow;
use uuid::Uuid;
#[derive(Debug, Deserialize)]
pub struct PackageTypeQuery {
pub package_type: Option<String>,
pub applicable_role: Option<String>,
pub active_only: Option<bool>,
}
#[derive(Debug, Deserialize)]
pub struct PaginationQuery {
pub page: Option<i64>,
pub limit: Option<i64>,
pub search: Option<String>,
}
#[derive(Debug, Deserialize)]
pub struct CreatePackageRequest {
pub name: String,
pub description: Option<String>,
pub package_type: String,
pub applicable_roles: Vec<String>,
pub tracecoins_amount: i32,
pub price: i32,
pub duration_days: Option<i32>,
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
pub is_promotional: Option<bool>,
pub is_active: Option<bool>,
pub features: Option<serde_json::Value>,
}
#[derive(Debug, Deserialize)]
pub struct UpdatePackageRequest {
pub name: Option<String>,
pub description: Option<String>,
pub tracecoins_amount: Option<i32>,
pub price: Option<i32>,
pub duration_days: Option<i32>,
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
pub is_promotional: Option<bool>,
pub is_active: Option<bool>,
pub features: Option<serde_json::Value>,
}
#[derive(Debug, FromRow)]
pub struct PricingPackageRow {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub package_type: String,
pub applicable_roles: Vec<String>,
pub tracecoins_amount: i32,
pub price: i32,
pub duration_days: Option<i32>,
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
pub is_promotional: bool,
pub is_active: bool,
pub features: Option<serde_json::Value>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
}
#[derive(Debug, Serialize)]
pub struct PricingPackageResponse {
pub id: Uuid,
pub name: String,
pub description: Option<String>,
pub package_type: String,
pub applicable_roles: Vec<String>,
pub tracecoins_amount: i32,
pub price: i32,
pub duration_days: Option<i32>,
pub valid_from: Option<chrono::DateTime<chrono::Utc>>,
pub valid_until: Option<chrono::DateTime<chrono::Utc>>,
pub is_promotional: bool,
pub is_active: bool,
pub features: Option<serde_json::Value>,
pub created_at: chrono::DateTime<chrono::Utc>,
pub updated_at: chrono::DateTime<chrono::Utc>,
pub is_available: bool,
pub is_expired: bool,
}
impl From<PricingPackageRow> for PricingPackageResponse {
fn from(row: PricingPackageRow) -> Self {
let now = chrono::Utc::now();
let is_expired = row.valid_until.map(|v| v < now).unwrap_or(false);
let is_not_started = row.valid_from.map(|v| v > now).unwrap_or(false);
let is_available = row.is_active && !is_expired && !is_not_started;
PricingPackageResponse {
id: row.id,
name: row.name,
description: row.description,
package_type: row.package_type,
applicable_roles: row.applicable_roles,
tracecoins_amount: row.tracecoins_amount,
price: row.price,
duration_days: row.duration_days,
valid_from: row.valid_from,
valid_until: row.valid_until,
is_promotional: row.is_promotional,
is_active: row.is_active,
features: row.features,
created_at: row.created_at,
updated_at: row.updated_at,
is_available,
is_expired,
}
}
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list_packages))
.route("/", post(create_package))
.route("/{id}", get(get_package))
.route("/{id}", patch(update_package))
.route("/{id}", delete(delete_package))
.route("/by-type", get(get_packages_by_type))
.route("/for-role", get(get_packages_for_role))
}
async fn list_packages(
State(state): State<AppState>,
Query(q): Query<PaginationQuery>,
) -> impl IntoResponse {
let page = q.page.unwrap_or(1);
let limit = q.limit.unwrap_or(20).min(100);
let offset = (page - 1) * limit;
let search_filter = q.search
.as_ref()
.map(|s| format!("AND (name ILIKE '%{}%' OR description ILIKE '%{}%')", s.replace('\'', "''"), s.replace('\'', "''")))
.unwrap_or_default();
let packages = sqlx::query_as::<_, PricingPackageRow>(
&format!(
r#"
SELECT id, name, description, package_type, applicable_roles,
tracecoins_amount, price, duration_days, valid_from, valid_until,
is_promotional, is_active, features, created_at, updated_at
FROM pricing_packages
WHERE 1=1 {}
ORDER BY created_at DESC
LIMIT {} OFFSET {}
"#,
search_filter, limit, offset
)
)
.fetch_all(&state.pool)
.await;
let packages = match packages {
Ok(p) => p,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let total: (i64,) = match sqlx::query_as(
&format!(
"SELECT COUNT(*) FROM pricing_packages WHERE 1=1 {}",
search_filter
)
)
.fetch_one(&state.pool)
.await
{
Ok(t) => t,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let packages: Vec<PricingPackageResponse> = packages.into_iter().map(|p| p.into()).collect();
(StatusCode::OK, Json(serde_json::json!({
"data": packages,
"pagination": {
"page": page,
"limit": limit,
"total": total.0,
"pages": (total.0 as f64 / limit as f64).ceil() as i64
}
}))).into_response()
}
async fn get_package(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match sqlx::query_as::<_, PricingPackageRow>(
r#"
SELECT id, name, description, package_type, applicable_roles,
tracecoins_amount, price, duration_days, valid_from, valid_until,
is_promotional, is_active, features, created_at, updated_at
FROM pricing_packages WHERE id = $1
"#
)
.bind(id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(pkg)) => {
let response: PricingPackageResponse = pkg.into();
(StatusCode::OK, Json(response)).into_response()
}
Ok(None) => (StatusCode::NOT_FOUND, "Package not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn create_package(
State(state): State<AppState>,
Json(payload): Json<CreatePackageRequest>,
) -> impl IntoResponse {
let result = sqlx::query_as::<_, PricingPackageRow>(
r#"
INSERT INTO pricing_packages (name, description, package_type, applicable_roles,
tracecoins_amount, price, duration_days, valid_from, valid_until,
is_promotional, is_active, features)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12)
RETURNING id, name, description, package_type, applicable_roles,
tracecoins_amount, price, duration_days, valid_from, valid_until,
is_promotional, is_active, features, created_at, updated_at
"#
)
.bind(&payload.name)
.bind(&payload.description)
.bind(&payload.package_type)
.bind(&payload.applicable_roles)
.bind(payload.tracecoins_amount)
.bind(payload.price)
.bind(payload.duration_days)
.bind(payload.valid_from)
.bind(payload.valid_until)
.bind(payload.is_promotional.unwrap_or(false))
.bind(payload.is_active.unwrap_or(true))
.bind(payload.features)
.fetch_one(&state.pool)
.await;
match result {
Ok(pkg) => {
let response: PricingPackageResponse = pkg.into();
(StatusCode::CREATED, Json(response)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn update_package(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdatePackageRequest>,
) -> impl IntoResponse {
let existing = sqlx::query_as::<_, PricingPackageRow>(
"SELECT * FROM pricing_packages WHERE id = $1"
)
.bind(id)
.fetch_optional(&state.pool)
.await;
let _existing = match existing {
Ok(Some(e)) => e,
Ok(None) => return (StatusCode::NOT_FOUND, "Package not found").into_response(),
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let updated = sqlx::query_as::<_, PricingPackageRow>(
r#"
UPDATE pricing_packages SET
name = COALESCE($2, name),
description = COALESCE($3, description),
tracecoins_amount = COALESCE($4, tracecoins_amount),
price = COALESCE($5, price),
duration_days = COALESCE($6, duration_days),
valid_from = COALESCE($7, valid_from),
valid_until = COALESCE($8, valid_until),
is_promotional = COALESCE($9, is_promotional),
is_active = COALESCE($10, is_active),
features = COALESCE($11, features),
updated_at = NOW()
WHERE id = $1
RETURNING id, name, description, package_type, applicable_roles,
tracecoins_amount, price, duration_days, valid_from, valid_until,
is_promotional, is_active, features, created_at, updated_at
"#
)
.bind(id)
.bind(&payload.name)
.bind(&payload.description)
.bind(payload.tracecoins_amount)
.bind(payload.price)
.bind(payload.duration_days)
.bind(payload.valid_from)
.bind(payload.valid_until)
.bind(payload.is_promotional)
.bind(payload.is_active)
.bind(payload.features)
.fetch_one(&state.pool)
.await;
match updated {
Ok(pkg) => {
let response: PricingPackageResponse = pkg.into();
(StatusCode::OK, Json(response)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn delete_package(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> impl IntoResponse {
match sqlx::query("DELETE FROM pricing_packages WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await
{
Ok(r) if r.rows_affected() > 0 => {
(StatusCode::OK, Json(serde_json::json!({"message": "Package deleted"}))).into_response()
}
Ok(_) => (StatusCode::NOT_FOUND, "Package not found").into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}
}
async fn get_packages_by_type(
State(state): State<AppState>,
Query(q): Query<PackageTypeQuery>,
) -> impl IntoResponse {
let package_type = q.package_type.as_deref().unwrap_or("TRACECOIN_BUNDLE");
let now = chrono::Utc::now();
let packages = sqlx::query_as::<_, PricingPackageRow>(
r#"
SELECT id, name, description, package_type, applicable_roles,
tracecoins_amount, price, duration_days, valid_from, valid_until,
is_promotional, is_active, features, created_at, updated_at
FROM pricing_packages
WHERE package_type = $1
AND is_active = true
AND (valid_from IS NULL OR valid_from <= $2)
AND (valid_until IS NULL OR valid_until > $2)
ORDER BY is_promotional DESC, price ASC
"#
)
.bind(package_type)
.bind(now)
.fetch_all(&state.pool)
.await;
let packages = match packages {
Ok(p) => p,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let packages: Vec<PricingPackageResponse> = packages.into_iter().map(|p| p.into()).collect();
(StatusCode::OK, Json(serde_json::json!({
"data": packages,
"package_type": package_type
}))).into_response()
}
async fn get_packages_for_role(
State(state): State<AppState>,
Query(q): Query<PackageTypeQuery>,
) -> impl IntoResponse {
let applicable_role = q.applicable_role.as_deref().unwrap_or("");
let active_only = q.active_only.unwrap_or(true);
let now = chrono::Utc::now();
let packages = sqlx::query_as::<_, PricingPackageRow>(
&format!(
r#"
SELECT id, name, description, package_type, applicable_roles,
tracecoins_amount, price, duration_days, valid_from, valid_until,
is_promotional, is_active, features, created_at, updated_at
FROM pricing_packages
WHERE ($1 = '' OR $1 = ANY(applicable_roles))
AND (is_active = true OR {} = false)
AND (valid_from IS NULL OR valid_from <= $2)
AND (valid_until IS NULL OR valid_until > $2)
ORDER BY is_promotional DESC, price ASC
"#,
if active_only { "true" } else { "false" }
)
)
.bind(applicable_role)
.bind(now)
.fetch_all(&state.pool)
.await;
let packages = match packages {
Ok(p) => p,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
let packages: Vec<PricingPackageResponse> = packages.into_iter().map(|p| p.into()).collect();
(StatusCode::OK, Json(serde_json::json!({
"data": packages,
"applicable_role": applicable_role
}))).into_response()
}

View file

@ -16,4 +16,5 @@ db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }
storage = { path = "../../crates/storage" }

View file

@ -1,5 +1,5 @@
use contracts::ProfessionState;
use db::models::photographer::PhotographerProfile;
use db::models::user_role_profile::UserRoleProfile;
use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use serde::Serialize;
use uuid::Uuid;
@ -7,6 +7,7 @@ use uuid::Uuid;
#[derive(Serialize)]
pub struct AdminPhotographerList {
pub id: Uuid,
pub user_role_profile_id: Uuid,
pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>,
@ -16,10 +17,11 @@ pub struct AdminPhotographerList {
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<PhotographerProfile> for AdminPhotographerList {
fn from(p: PhotographerProfile) -> Self {
impl From<UserRoleProfile> for AdminPhotographerList {
fn from(p: UserRoleProfile) -> Self {
Self {
id: p.id,
user_role_profile_id: p.id,
user_id: p.user_id,
display_name: p.display_name,
bio: p.bio,
@ -40,10 +42,15 @@ pub fn router() -> Router<ProfessionState> {
async fn list_photographers(
State(state): State<ProfessionState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let photographers = sqlx::query_as::<_, PhotographerProfile>(
let photographers = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at
FROM photographer_profiles
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE role_key = 'photographer'
ORDER BY created_at DESC
LIMIT 100
"#,
@ -60,11 +67,15 @@ async fn get_photographer(
State(state): State<ProfessionState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let photographer = sqlx::query_as::<_, PhotographerProfile>(
let photographer = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at
FROM photographer_profiles
WHERE id = $1
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE id = $1 AND role_key = 'photographer'
"#,
)
.bind(id)

View file

@ -21,7 +21,7 @@ async fn update_profile(
auth: AuthUser,
Json(payload): Json<UpsertPhotographerProfilePayload>,
) -> impl IntoResponse {
match PhotographerRepository::upsert(&state.pool, auth.user_id, payload).await {
match PhotographerRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}

View file

@ -3,6 +3,7 @@ mod admin;
use axum::{routing::get, Router};
use std::net::SocketAddr;
use std::sync::Arc;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::ProfessionState;
@ -30,7 +31,8 @@ async fn main() {
tracing::info!("Photographers service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
let storage = Arc::new(storage::StorageClient::from_env().await);
let state = ProfessionState { pool, redis, storage };
let app = Router::new()
.nest("/api/photographers", handlers::router())

View file

@ -16,4 +16,5 @@ db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }
storage = { path = "../../crates/storage" }

View file

@ -1,5 +1,5 @@
use contracts::ProfessionState;
use db::models::social_media_manager::SocialMediaManagerProfile;
use db::models::user_role_profile::UserRoleProfile;
use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use serde::Serialize;
use uuid::Uuid;
@ -7,6 +7,7 @@ use uuid::Uuid;
#[derive(Serialize)]
pub struct AdminSocialMediaManagerList {
pub id: Uuid,
pub user_role_profile_id: Uuid,
pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>,
@ -16,10 +17,11 @@ pub struct AdminSocialMediaManagerList {
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<SocialMediaManagerProfile> for AdminSocialMediaManagerList {
fn from(p: SocialMediaManagerProfile) -> Self {
impl From<UserRoleProfile> for AdminSocialMediaManagerList {
fn from(p: UserRoleProfile) -> Self {
Self {
id: p.id,
user_role_profile_id: p.id,
user_id: p.user_id,
display_name: p.display_name,
bio: p.bio,
@ -40,10 +42,15 @@ pub fn router() -> Router<ProfessionState> {
async fn list_social_media_managers(
State(state): State<ProfessionState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let managers = sqlx::query_as::<_, SocialMediaManagerProfile>(
let managers = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at
FROM social_media_manager_profiles
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE role_key = 'social_media_manager'
ORDER BY created_at DESC
LIMIT 100
"#,
@ -60,11 +67,15 @@ async fn get_social_media_manager(
State(state): State<ProfessionState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let manager = sqlx::query_as::<_, SocialMediaManagerProfile>(
let manager = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at
FROM social_media_manager_profiles
WHERE id = $1
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE id = $1 AND role_key = 'social_media_manager'
"#,
)
.bind(id)

View file

@ -21,7 +21,7 @@ async fn update_profile(
auth: AuthUser,
Json(payload): Json<UpsertSocialMediaManagerProfilePayload>,
) -> impl IntoResponse {
match SocialMediaManagerRepository::upsert(&state.pool, auth.user_id, payload).await {
match SocialMediaManagerRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}

View file

@ -3,6 +3,7 @@ mod admin;
use axum::{routing::get, Router};
use std::net::SocketAddr;
use std::sync::Arc;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::ProfessionState;
@ -30,7 +31,8 @@ async fn main() {
tracing::info!("Social Media Managers service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
let storage = Arc::new(storage::StorageClient::from_env().await);
let state = ProfessionState { pool, redis, storage };
let app = Router::new()
.nest("/api/social-media-managers", handlers::router())

View file

@ -16,4 +16,5 @@ db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }
storage = { path = "../../crates/storage" }

View file

@ -1,5 +1,5 @@
use contracts::ProfessionState;
use db::models::tutor::TutorProfile;
use db::models::user_role_profile::UserRoleProfile;
use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use serde::Serialize;
use uuid::Uuid;
@ -7,6 +7,7 @@ use uuid::Uuid;
#[derive(Serialize)]
pub struct AdminTutorList {
pub id: Uuid,
pub user_role_profile_id: Uuid,
pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>,
@ -16,10 +17,11 @@ pub struct AdminTutorList {
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<TutorProfile> for AdminTutorList {
fn from(p: TutorProfile) -> Self {
impl From<UserRoleProfile> for AdminTutorList {
fn from(p: UserRoleProfile) -> Self {
Self {
id: p.id,
user_role_profile_id: p.id,
user_id: p.user_id,
display_name: p.display_name,
bio: p.bio,
@ -43,10 +45,15 @@ pub fn router() -> Router<ProfessionState> {
async fn list_tutors(
State(state): State<ProfessionState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let tutors = sqlx::query_as::<_, TutorProfile>(
let tutors = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at
FROM tutor_profiles
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE role_key = 'tutor'
ORDER BY created_at DESC
LIMIT 100
"#,
@ -63,11 +70,15 @@ async fn get_tutor(
State(state): State<ProfessionState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let tutor = sqlx::query_as::<_, TutorProfile>(
let tutor = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at
FROM tutor_profiles
WHERE id = $1
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE id = $1 AND role_key = 'tutor'
"#,
)
.bind(id)

View file

@ -21,7 +21,7 @@ async fn update_profile(
auth: AuthUser,
Json(payload): Json<UpsertTutorProfilePayload>,
) -> impl IntoResponse {
match TutorRepository::upsert(&state.pool, auth.user_id, payload).await {
match TutorRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}

View file

@ -3,6 +3,7 @@ mod admin;
use axum::{routing::get, Router};
use std::net::SocketAddr;
use std::sync::Arc;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::ProfessionState;
@ -30,7 +31,8 @@ async fn main() {
tracing::info!("Tutors service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
let storage = Arc::new(storage::StorageClient::from_env().await);
let state = ProfessionState { pool, redis, storage };
let app = Router::new()
.nest("/api/tutors", handlers::router())

View file

@ -21,7 +21,7 @@ async fn update_profile(
auth: AuthUser,
Json(payload): Json<UpsertUgcContentCreatorProfilePayload>,
) -> impl IntoResponse {
match UgcContentCreatorRepository::upsert(&state.pool, auth.user_id, payload).await {
match UgcContentCreatorRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}

View file

@ -2,6 +2,7 @@ mod handlers;
use axum::{routing::get, Router};
use std::net::SocketAddr;
use std::sync::Arc;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::ProfessionState;
@ -29,7 +30,8 @@ async fn main() {
tracing::info!("UGC Content Creators service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
let storage = Arc::new(storage::StorageClient::from_env().await);
let state = ProfessionState { pool, redis, storage };
let app = Router::new()
.nest("/api/ugc-content-creators", handlers::router())

View file

@ -20,4 +20,5 @@ contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }
rand = "0.8"
anyhow = { workspace = true }
reqwest = { workspace = true }

View file

@ -31,7 +31,8 @@ pub struct ListQuery {
pub struct AdminUserRow {
pub id: Uuid,
pub email: String,
pub full_name: Option<String>,
pub first_name: Option<String>,
pub last_name: Option<String>,
pub status: String,
pub created_at: chrono::DateTime<chrono::Utc>,
pub roles: Vec<String>,
@ -48,13 +49,13 @@ async fn list_users(
let sql = if role_filter.is_empty() {
// Generic list: users + their approved roles
r#"
SELECT
u.id, u.email, u.full_name, u.status, u.created_at,
SELECT
u.id, u.email, u.first_name, u.last_name, u.status, u.created_at,
COALESCE(array_agg(r.key) FILTER (WHERE r.key IS NOT NULL), '{}') as roles
FROM users u
LEFT JOIN user_roles ur ON ur.user_id = u.id AND ur.status = 'APPROVED'
LEFT JOIN user_role_assignments ur ON ur.user_id = u.id AND ur.status = 'APPROVED'
LEFT JOIN roles r ON r.id = ur.role_id
WHERE ($1 = '' OR LOWER(u.full_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
GROUP BY u.id
ORDER BY u.created_at DESC
LIMIT 100
@ -67,24 +68,24 @@ async fn list_users(
"TUTOR" => "tutor_profiles",
"DEVELOPER" => "developer_profiles",
"VIDEO_EDITOR" => "video_editor_profiles",
"GRAPHIC_DESIGNER" => "graphic_designer_profiles",
"GRAPHIC_DESIGNER" => "graphic_designer_profiles",
"SOCIAL_MEDIA_MANAGER" => "social_media_manager_profiles",
"FITNESS_TRAINER" => "fitness_trainer_profiles",
"CATERING_SERVICES" => "catering_service_profiles",
"CUSTOMER" => "customer_profiles",
"COMPANY" => "company_profiles",
"JOB_SEEKER" => "job_seeker_profiles",
_ => "user_roles", // fallback
_ => "user_role_assignments", // fallback
};
format!(
r#"
SELECT
u.id, u.email, u.full_name, p.status, u.created_at,
u.id, u.email, u.first_name, u.last_name, p.status, u.created_at,
ARRAY['{}']::text[] as roles
FROM users u
JOIN {} p ON p.user_id = u.id
WHERE ($1 = '' OR LOWER(u.full_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
ORDER BY u.created_at DESC
LIMIT 100
"#,
@ -109,13 +110,13 @@ async fn list_customers(
let search = q.q.unwrap_or_default().to_lowercase();
let sql = r#"
SELECT
u.id, u.email, u.full_name, u.status, u.created_at,
SELECT
u.id, u.email, u.first_name, u.last_name, u.status, u.created_at,
ARRAY['CUSTOMER']::text[] as roles
FROM users u
JOIN user_roles ur ON ur.user_id = u.id AND ur.status = 'APPROVED'
JOIN user_role_assignments ur ON ur.user_id = u.id AND ur.status = 'APPROVED'
JOIN roles r ON r.id = ur.role_id AND r.key = 'CUSTOMER'
WHERE ($1 = '' OR LOWER(u.full_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
ORDER BY u.created_at DESC
LIMIT 50
"#;
@ -137,13 +138,13 @@ async fn list_candidates(
let search = q.q.unwrap_or_default().to_lowercase();
let sql = r#"
SELECT
u.id, u.email, u.full_name, u.status, u.created_at,
SELECT
u.id, u.email, u.first_name, u.last_name, u.status, u.created_at,
ARRAY['JOB_SEEKER']::text[] as roles
FROM users u
JOIN user_roles ur ON ur.user_id = u.id AND ur.status = 'APPROVED'
JOIN user_role_assignments ur ON ur.user_id = u.id AND ur.status = 'APPROVED'
JOIN roles r ON r.id = ur.role_id AND r.key = 'JOB_SEEKER'
WHERE ($1 = '' OR LOWER(u.full_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
WHERE ($1 = '' OR LOWER(u.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%')
ORDER BY u.created_at DESC
LIMIT 50
"#;

View file

@ -12,8 +12,8 @@ use crate::AppState;
pub fn router() -> Router<AppState> {
Router::new()
.route("/templates", get(list_templates))
.route("/templates/:name/preview", get(preview_template))
.route("/templates/:name/test", post(send_test_email))
.route("/templates/{name}/preview", get(preview_template))
.route("/templates/{name}/test", post(send_test_email))
.route("/email-config", get(get_email_config).post(update_email_config))
.route("/email-test", post(test_email_connection))
}
@ -388,7 +388,7 @@ async fn send_test_email(
state.mail.send_verification_email(&req.to_email, first_name, "123456").await
}
"password-reset" => {
state.mail.send_password_reset_email(&req.to_email, first_name, "sample-token").await
state.mail.send_password_reset_email(&req.to_email, first_name, "123456").await
}
"profile-verified" => {
state.mail.send_profile_verified_email(&req.to_email, first_name, "Photographer").await
@ -559,7 +559,13 @@ async fn test_email_connection(
Json(req): Json<EmailTestRequest>,
) -> impl IntoResponse {
// Send a test email using current or provided config
let result = state.mail.send_test_email(&req.to_email).await;
let result = if let Some(test_config) = req.config {
// For now, just use the existing mailer - test config would require recreating mailer
state.mail.send_test_email(&req.to_email).await
} else {
// Use existing mailer
state.mail.send_test_email(&req.to_email).await
};
match result {
Ok(_) => (StatusCode::OK, Json(serde_json::json!({

File diff suppressed because it is too large Load diff

View file

@ -94,9 +94,9 @@ async fn get_submission(
Json(serde_json::json!({
"user": {
"id": user.id,
"name": user.full_name,
"name": format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()),
"email": user.email,
"phone": user.phone,
"phone": null,
"status": user.status,
"email_verified": user.email_verified,
"created_at": user.created_at,
@ -205,22 +205,36 @@ async fn activate_profile_after_final_approval(
_ => return Ok(()),
};
let user_role_profile_id = match sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2",
)
.bind(user_id)
.bind(&role_key)
.fetch_optional(&state.pool)
.await?
{
Some(id) => id,
None => return Ok(()),
};
let query = format!(
"UPDATE {} SET status = 'APPROVED', updated_at = NOW() WHERE user_id = $1",
"UPDATE {} SET status = 'ACTIVE', updated_at = NOW() WHERE id = $1",
table
);
sqlx::query(&query).bind(user_id).execute(&state.pool).await?;
sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?;
// Update user's role to match the approved role_key and set status to ACTIVE
sqlx::query(
"UPDATE users SET status = 'ACTIVE', updated_at = NOW() WHERE id = $1 AND status = 'PENDING'",
"UPDATE users SET role = $1, status = 'ACTIVE', updated_at = NOW() WHERE id = $2",
)
.bind(&role_key)
.bind(user_id)
.execute(&state.pool)
.await?;
if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await {
sqlx::query(
"INSERT INTO user_roles (user_id, role_id, status, approved_at) VALUES ($1, $2, 'APPROVED', NOW()) ON CONFLICT (user_id, role_id) DO UPDATE SET status = 'APPROVED', approved_at = NOW()",
"INSERT INTO user_role_assignments (user_id, role_id, status, approved_at) VALUES ($1, $2, 'APPROVED', NOW()) ON CONFLICT (user_id, role_id) DO UPDATE SET status = 'APPROVED', approved_at = NOW()",
)
.bind(user_id)
.bind(role.id)
@ -231,16 +245,27 @@ async fn activate_profile_after_final_approval(
if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await {
let display = role_key_to_display(&role_key);
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state
.mail
.send_approval_approved_email(
&user.email,
user.full_name.as_deref().unwrap_or_default(),
&display,
)
.send_approval_approved_email(&user.email, &user_name, &display)
.await;
}
// Send in-app notification for final approval
sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(user_id)
.bind("Congratulations! Your Profile is Now Active")
.bind(format!("Your {} profile has been fully approved and is now active on Nxtgauge.", role_key_to_display(&role_key)))
.bind("PROFILE")
.bind(user_id)
.execute(&state.pool)
.await
.ok();
Ok(())
}
@ -267,25 +292,52 @@ async fn reject_profile_after_final_approval(
_ => return Ok(()),
};
let user_role_profile_id = match sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2",
)
.bind(user_id)
.bind(&role_key)
.fetch_optional(&state.pool)
.await?
{
Some(id) => id,
None => return Ok(()),
};
let query = format!(
"UPDATE {} SET status = 'REJECTED', updated_at = NOW() WHERE user_id = $1",
"UPDATE {} SET status = 'REJECTED', updated_at = NOW() WHERE id = $1",
table
);
sqlx::query(&query).bind(user_id).execute(&state.pool).await?;
sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?;
if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await {
let display = role_key_to_display(&role_key);
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state
.mail
.send_approval_rejected_email(
&user.email,
user.full_name.as_deref().unwrap_or_default(),
&user_name,
&display,
reason.unwrap_or("Rejected by final approval"),
)
.await;
}
// Send in-app notification for final rejection
sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(user_id)
.bind("Profile Verification Update")
.bind(format!("Your {} profile was not approved. Reason: {}", role_key_to_display(&role_key), reason.unwrap_or("Rejected by final approval")))
.bind("PROFILE")
.bind(user_id)
.execute(&state.pool)
.await
.ok();
Ok(())
}
@ -415,15 +467,29 @@ async fn approve_job(
)
.await;
let company_info = sqlx::query_as::<_, (String, String)>(
"SELECT u.full_name, u.email FROM companies c JOIN users u ON u.id = c.user_id WHERE c.id = $1",
let company_info = sqlx::query_as::<_, (String, String, Uuid)>(
"SELECT CONCAT(u.first_name, ' ', u.last_name) AS u_full_name, u.email, u.id FROM company_profiles c JOIN users u ON u.id = c.user_id WHERE c.id = $1",
)
.bind(existing.company_id)
.fetch_optional(&state.pool)
.await;
if let Ok(Some((name, email))) = company_info {
if let Ok(Some((name, email, user_uuid))) = company_info {
let _ = state.mail.send_job_approved_email(&email, &name, &existing.title).await;
// Send in-app notification to company
sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(user_uuid)
.bind("Your Job is Now Live!")
.bind(format!("Your job posting '{}' has been approved and is now visible to job seekers.", existing.title))
.bind("JOB")
.bind(id)
.execute(&state.pool)
.await
.ok();
}
finalize_verification_case_for_entity(&state.pool, id, "JOB_APPROVAL", "COMPLETED").await;
(StatusCode::OK, Json(job)).into_response()
@ -465,16 +531,30 @@ async fn reject_job(
)
.await;
let company_info = sqlx::query_as::<_, (String, String)>(
"SELECT u.full_name, u.email FROM companies c JOIN users u ON u.id = c.user_id WHERE c.id = $1",
let company_info = sqlx::query_as::<_, (String, String, Uuid)>(
"SELECT CONCAT(u.first_name, ' ', u.last_name) AS u_full_name, u.email, u.id FROM company_profiles c JOIN users u ON u.id = c.user_id WHERE c.id = $1",
)
.bind(existing.company_id)
.fetch_optional(&state.pool)
.await;
if let Ok(Some((name, email))) = company_info {
if let Ok(Some((name, email, user_uuid))) = company_info {
let r = payload.reason.as_deref().unwrap_or("Rejected by admin");
let _ = state.mail.send_job_rejected_email(&email, &name, &existing.title, r).await;
// Send in-app notification to company
sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(user_uuid)
.bind("Your Job Posting Was Not Approved")
.bind(format!("Your job posting '{}' was not approved. Reason: {}", existing.title, r))
.bind("JOB")
.bind(id)
.execute(&state.pool)
.await
.ok();
}
finalize_verification_case_for_entity(&state.pool, id, "JOB_APPROVAL", "FINAL_REJECTED").await;
(StatusCode::OK, Json(job)).into_response()
@ -514,6 +594,29 @@ async fn approve_requirement(
None,
)
.await;
// Send in-app notification to customer
sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(req.created_by_user_id)
.bind("Your Requirement is Now Live!")
.bind(format!("Your requirement '{}' has been approved and is now visible to professionals.", req.title))
.bind("REQUIREMENT")
.bind(req.id)
.execute(&state.pool)
.await
.ok();
// Send email notification to customer
if let Some(user_id) = req.created_by_user_id {
if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await {
let name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_requirement_approved_email(&user.email, &name, &req.title).await;
}
}
finalize_verification_case_for_entity(&state.pool, id, "REQUIREMENT_APPROVAL", "COMPLETED").await;
(StatusCode::OK, Json(req)).into_response()
}
@ -543,6 +646,24 @@ async fn reject_requirement(
Some(serde_json::json!({ "reason": payload.reason })),
)
.await;
// Send in-app notification to customer
let reason_str = payload.reason.as_deref().unwrap_or("Rejected by admin");
if let Some(user_id) = req.created_by_user_id {
sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(user_id)
.bind("Your Requirement Was Not Approved")
.bind(format!("Your requirement '{}' was not approved. Reason: {}", req.title, reason_str))
.bind("REQUIREMENT")
.bind(req.id)
.execute(&state.pool)
.await
.ok();
}
finalize_verification_case_for_entity(&state.pool, id, "REQUIREMENT_APPROVAL", "FINAL_REJECTED").await;
(StatusCode::OK, Json(req)).into_response()
}

View file

@ -25,6 +25,7 @@ pub fn router() -> Router<AppState> {
.route("/session", get(session))
.route("/switch-role", post(switch_role))
.route("/verify-email", post(verify_email))
.route("/verify-otp", post(verify_email))
.route("/resend-otp", post(resend_otp))
.route("/forgot-password", post(forgot_password))
.route("/reset-password", post(reset_password))
@ -34,13 +35,22 @@ pub fn router() -> Router<AppState> {
// ── DTOs ──────────────────────────────────────────────────────────────────────
#[derive(Deserialize)]
#[allow(dead_code)]
pub struct RegisterPayload {
pub full_name: String,
#[serde(default)]
pub first_name: Option<String>,
#[serde(default)]
pub last_name: Option<String>,
#[serde(default)]
pub name: Option<String>,
pub email: String,
pub phone: Option<String>,
pub password: String,
pub intent: Option<String>,
#[serde(alias = "role_key", alias = "roleKey")]
pub profession: Option<String>,
#[serde(default)]
pub test_mode: Option<bool>,
}
#[derive(Deserialize)]
@ -71,7 +81,7 @@ pub struct ForgotPasswordPayload {
#[derive(Deserialize)]
pub struct ResetPasswordPayload {
pub token: String,
pub code: String,
pub new_password: String,
}
@ -91,17 +101,18 @@ pub struct RegisterResponse {
pub user_id: String,
pub email: String,
pub phone: Option<String>,
pub full_name: String,
pub name: String,
pub status: String,
pub email_verified: bool,
pub created_at: String,
pub otp: Option<String>,
}
#[derive(Serialize)]
pub struct SessionUser {
pub id: String,
pub email: String,
pub full_name: String,
pub name: String,
pub email_verified: bool,
pub roles: Vec<String>,
pub active_role: Option<String>,
@ -128,9 +139,13 @@ fn normalize_role_key(raw: &str) -> String {
}
fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str>) -> Vec<String> {
let normalized_intent = normalize_role_key(intent.unwrap_or("JOB_SEEKER"));
let normalized_intent = intent.map(normalize_role_key).unwrap_or_default();
let normalized_profession = profession.map(normalize_role_key).filter(|v| !v.is_empty());
if normalized_intent.is_empty() {
return vec![];
}
if normalized_intent.contains("COMPANY") {
return vec!["COMPANY".to_string()];
}
@ -147,7 +162,58 @@ fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str>
return vec!["PHOTOGRAPHER".to_string(), "JOB_SEEKER".to_string()];
}
vec!["JOB_SEEKER".to_string()]
vec![]
}
fn role_display_name_from_code(code: &str) -> String {
code
.split('_')
.filter(|part| !part.is_empty())
.map(|part| {
let lower = part.to_lowercase();
let mut chars = lower.chars();
match chars.next() {
Some(first) => format!("{}{}", first.to_uppercase(), chars.collect::<String>()),
None => String::new(),
}
})
.collect::<Vec<String>>()
.join(" ")
}
async fn ensure_role_exists(pool: &sqlx::PgPool, role_code: &str) -> Option<Uuid> {
let normalized = normalize_role_key(role_code);
if normalized.is_empty() {
return None;
}
if let Ok(found) = sqlx::query_scalar::<_, Uuid>("SELECT id FROM roles WHERE key = $1")
.bind(&normalized)
.fetch_optional(pool)
.await
{
if found.is_some() {
return found;
}
}
let display_name = role_display_name_from_code(&normalized);
let role_id = sqlx::query_scalar::<_, Uuid>(
r#"
INSERT INTO roles (key, name, audience, is_active)
VALUES ($1, $2, 'EXTERNAL', true)
ON CONFLICT (key)
DO UPDATE SET is_active = true
RETURNING id
"#,
)
.bind(&normalized)
.bind(display_name)
.fetch_one(pool)
.await
.ok()?;
Some(role_id)
}
// ── Handlers ──────────────────────────────────────────────────────────────────
@ -168,11 +234,22 @@ async fn check_email(
);
}
let exists = UserRepository::get_by_email(&state.pool, &email).await.is_ok();
let user = UserRepository::get_by_email(&state.pool, &email).await.ok();
let exists = user.is_some();
let roles = if let Some(ref found_user) = user {
UserRepository::get_user_role_keys(&state.pool, found_user.id)
.await
.unwrap_or_default()
} else {
Vec::new()
};
let active_role = roles.first().cloned();
(
StatusCode::OK,
Json(serde_json::json!({
"exists": exists
"exists": exists,
"active_role": active_role,
"roles": roles,
})),
)
}
@ -183,6 +260,7 @@ async fn register(
Json(payload): Json<RegisterPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let email = payload.email.to_lowercase();
let test_mode = payload.test_mode.unwrap_or(false);
let mut redis = state.redis.clone();
// Rate limit: max 10 registrations per hour per email
@ -197,10 +275,13 @@ async fn register(
let password_hash = hash_password(&payload.password)
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?;
let first_name = payload.first_name.unwrap_or_default().trim().to_string();
let last_name = payload.last_name.unwrap_or_default().trim().to_string();
let user = UserRepository::create(&state.pool, CreateUserPayload {
full_name: payload.full_name,
email: email.clone(),
phone: payload.phone.filter(|p| !p.trim().is_empty()),
first_name: Some(first_name),
last_name: Some(last_name),
email: email.clone(),
password_hash,
})
.await
@ -221,20 +302,27 @@ async fn register(
payload.profession.as_deref(),
);
for role_key in role_candidates {
let role = sqlx::query_scalar::<_, Uuid>("SELECT id FROM roles WHERE key = $1")
.bind(&role_key)
.fetch_optional(&state.pool)
.await
.ok()
.flatten();
if let Some(role_id) = role {
let role_id = ensure_role_exists(&state.pool, &role_key).await;
if let Some(role_id) = role_id {
let _ = sqlx::query(
r#"
INSERT INTO user_roles (user_id, role_id, status, approved_at)
VALUES ($1, $2, 'APPROVED', NOW())
ON CONFLICT (user_id, role_id)
DO UPDATE SET status = 'APPROVED', approved_at = NOW()
UPDATE user_role_assignments
SET status = 'APPROVED'
WHERE user_id = $1 AND role_id = $2
"#,
)
.bind(user.id)
.bind(role_id)
.execute(&state.pool)
.await;
let _ = sqlx::query(
r#"
INSERT INTO user_role_assignments (user_id, role_id, status)
SELECT $1, $2, 'APPROVED'
WHERE NOT EXISTS (
SELECT 1 FROM user_role_assignments WHERE user_id = $1 AND role_id = $2
)
"#,
)
.bind(user.id)
@ -247,21 +335,32 @@ async fn register(
// Store OTP in Redis (15-min TTL, keyed by code → user_id)
let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
tracing::info!(otp = %otp, email = %email, "OTP generated for registration");
cache::otp::set(&mut redis, &otp, &user.id.to_string())
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok();
let _ = state.mail.send_verification_email(&user.email, &user.full_name.clone().unwrap_or_default(), &otp).await;
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
if let Err(e) = state.mail.send_verification_email(&user.email, &user_name, &otp).await {
tracing::error!(
error = %e,
email = %user.email,
endpoint = "/api/auth/register",
"Failed to send verification email - OTP still stored in Redis"
);
// OTP is already in Redis — do not fail registration if email sending fails
}
Ok((StatusCode::CREATED, Json(RegisterResponse {
user_id: user.id.to_string(),
email: user.email,
phone: user.phone,
full_name: user.full_name.unwrap_or_default(),
phone: None,
name: user_name,
status: user.status,
email_verified: user.email_verified,
created_at: user.created_at.to_rfc3339(),
otp: if test_mode { Some(otp) } else { None },
})))
}
@ -320,6 +419,7 @@ async fn login(
);
let active_role = user_roles.first().cloned();
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
Ok((StatusCode::OK, [(SET_COOKIE, cookie)], Json(serde_json::json!({
"access_token": tokens.access_token,
"token_type": "Bearer",
@ -327,7 +427,7 @@ async fn login(
"user": {
"id": user.id.to_string(),
"email": user.email,
"full_name": user.full_name.unwrap_or_default(),
"name": user_name,
"email_verified": user.email_verified,
"active_role": active_role,
"roles": user_roles,
@ -436,10 +536,11 @@ async fn session(
.await
.unwrap_or_default();
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
Ok(Json(SessionUser {
id: user.id.to_string(),
email: user.email,
full_name: user.full_name.unwrap_or_default(),
name: user_name,
email_verified: user.email_verified,
active_role: user_roles.first().cloned(),
roles: user_roles,
@ -469,7 +570,15 @@ async fn verify_email(
// Get user details for welcome email
if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await {
let _ = state.mail.send_welcome_email(&user.email, &user.full_name.unwrap_or_default()).await;
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
if let Err(e) = state.mail.send_welcome_email(&user.email, &user_name).await {
tracing::error!(
error = %e,
email = %user.email,
endpoint = "/api/auth/verify-email",
"Failed to send welcome email"
);
}
}
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Email verified successfully" }))))
@ -500,12 +609,26 @@ async fn resend_otp(
}
let otp = format!("{:06}", rand::random::<u32>() % 1_000_000);
tracing::info!(otp = %otp, email = %user.email, "OTP generated for resend");
cache::otp::set(&mut redis, &otp, &user.id.to_string())
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok();
let _ = state.mail.send_verification_email(&user.email, &user.full_name.unwrap_or_default(), &otp).await;
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
if let Err(e) = state.mail.send_verification_email(&user.email, &user_name, &otp).await {
tracing::error!(
error = %e,
email = %user.email,
endpoint = "/api/auth/resend-otp",
"Failed to resend verification email"
);
return Err(err(
StatusCode::INTERNAL_SERVER_ERROR,
"Failed to resend verification email",
"SMTP_ERROR",
));
}
Ok(silent_ok)
}
@ -515,22 +638,23 @@ async fn forgot_password(
State(state): State<AppState>,
Json(payload): Json<ForgotPasswordPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let silent_ok = (StatusCode::OK, Json(serde_json::json!({ "message": "Reset link sent if email exists" })));
let silent_ok = (StatusCode::OK, Json(serde_json::json!({ "message": "Reset code sent if email exists" })));
let user = match UserRepository::get_by_email(&state.pool, &payload.email.to_lowercase()).await {
Ok(u) => u,
Err(_) => return Ok(silent_ok),
};
let token = uuid::Uuid::new_v4().to_string();
let code = format!("{:06}", rand::random::<u32>() % 1_000_000);
tracing::info!(otp = %code, email = %user.email, "OTP generated for password reset");
let mut redis = state.redis.clone();
// Store reset token in Redis (1-hour TTL, consumed single-use on reset)
cache::token::store_reset(&mut redis, &token, &user.id.to_string())
cache::token::store_reset(&mut redis, &code, &user.id.to_string())
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?;
let _ = state.mail.send_password_reset_email(&user.email, &user.full_name.unwrap_or_default(), &token).await;
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_password_reset_email(&user.email, &user_name, &code).await;
Ok(silent_ok)
}
@ -542,15 +666,15 @@ async fn reset_password(
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
let mut redis = state.redis.clone();
// Consume reset token from Redis (single-use GETDEL)
let user_id_str = cache::token::consume_reset(&mut redis, &payload.token)
// Consume reset code from Redis (single-use GETDEL)
let user_id_str = cache::token::consume_reset(&mut redis, &payload.code)
.await
.map_err(|_| err(StatusCode::INTERNAL_SERVER_ERROR, "Cache error", "CACHE_ERROR"))?
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Invalid or expired reset token", "INVALID_TOKEN"))?;
.ok_or_else(|| err(StatusCode::UNAUTHORIZED, "Invalid or expired reset code", "INVALID_CODE"))?;
let user_id = user_id_str
.parse::<uuid::Uuid>()
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid reset token", "INVALID_TOKEN"))?;
.map_err(|_| err(StatusCode::UNAUTHORIZED, "Invalid reset code", "INVALID_CODE"))?;
if payload.new_password.len() < 8 {
return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Password must be at least 8 characters", "VALIDATION_ERROR"));
@ -563,8 +687,9 @@ async fn reset_password(
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await {
let _ = state.mail.send_password_changed_email(&user.email, user.full_name.as_deref().unwrap_or_default()).await;
if let Ok(user) = UserRepository::get_by_id(&state.pool, user_id).await {
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_password_changed_email(&user.email, &user_name).await;
}
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password reset successfully" }))))
@ -597,7 +722,8 @@ async fn change_password(
.await
.map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "DB_ERROR"))?;
let _ = state.mail.send_password_changed_email(&user.email, user.full_name.as_deref().unwrap_or_default()).await;
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_password_changed_email(&user.email, &user_name).await;
Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password changed successfully" }))))
}
@ -632,3 +758,34 @@ async fn switch_role(
"expires_in": 900
}))))
}
// ── V1 API Router (for backward compatibility) ─────────────────────────
pub fn v1_router() -> Router<AppState> {
Router::new()
.route("/sign-up", post(v1_sign_up))
.route("/verify-otp", post(v1_verify_otp))
.route("/resend-otp", post(resend_otp))
}
#[derive(Deserialize)]
struct V1VerifyOtpPayload {
#[serde(alias = "code")]
otp: String,
}
/// POST /api/v1/users/sign-up
async fn v1_sign_up(
State(state): State<AppState>,
Json(payload): Json<RegisterPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
register(State(state), Json(payload)).await
}
/// POST /api/v1/users/verify-otp
async fn v1_verify_otp(
State(state): State<AppState>,
Json(payload): Json<V1VerifyOtpPayload>,
) -> Result<impl IntoResponse, (StatusCode, Json<ErrorResponse>)> {
verify_email(State(state), Json(VerifyEmailPayload { otp: payload.otp })).await
}

View file

@ -84,7 +84,7 @@ async fn list_runtime_configs(
sqlx::query_as::<_, RcRow>(
r#"
SELECT id, role_id, config_json, version, is_active, updated_at
FROM runtime_configs
FROM role_runtime_configs
WHERE role_id = $1
ORDER BY version DESC
"#,
@ -107,7 +107,7 @@ async fn list_runtime_configs(
sqlx::query_as::<_, RcRow>(
r#"
SELECT rc.id, rc.role_id, rc.config_json, rc.version, rc.is_active, rc.updated_at
FROM runtime_configs rc
FROM role_runtime_configs rc
JOIN roles r ON rc.role_id = r.id
WHERE r.audience = 'INTERNAL'
ORDER BY rc.updated_at DESC
@ -149,7 +149,7 @@ async fn get_runtime_config_by_id(
updated_at: chrono::DateTime<chrono::Utc>,
}
let r = sqlx::query_as::<_, RcDetailRow>(
"SELECT id, role_id, config_json, version, is_active, updated_at FROM runtime_configs WHERE id = $1",
"SELECT id, role_id, config_json, version, is_active, updated_at FROM role_runtime_configs WHERE id = $1",
)
.bind(id)
.fetch_optional(&state.pool)
@ -193,20 +193,20 @@ async fn activate_runtime_config(
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
// Fetch role_id for the target config
let role_id: Uuid = sqlx::query_scalar::<_, Uuid>("SELECT role_id FROM runtime_configs WHERE id = $1")
let role_id: Uuid = sqlx::query_scalar::<_, Uuid>("SELECT role_id FROM role_runtime_configs WHERE id = $1")
.bind(id)
.fetch_optional(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?
.ok_or((StatusCode::NOT_FOUND, "Runtime config not found".to_string()))?;
// Disable existing active
sqlx::query("UPDATE runtime_configs SET is_active = false WHERE role_id = $1 AND is_active = true")
sqlx::query("UPDATE role_runtime_configs SET is_active = false WHERE role_id = $1 AND is_active = true")
.bind(role_id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Activate target
sqlx::query("UPDATE runtime_configs SET is_active = true WHERE id = $1")
sqlx::query("UPDATE role_runtime_configs SET is_active = true WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await
@ -222,7 +222,7 @@ async fn delete_runtime_config(
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
let result = sqlx::query("DELETE FROM runtime_configs WHERE id = $1")
let result = sqlx::query("DELETE FROM role_runtime_configs WHERE id = $1")
.bind(id)
.execute(&state.pool)
.await
@ -232,13 +232,24 @@ async fn delete_runtime_config(
}
Ok((StatusCode::NO_CONTENT, "".to_string()))
}
#[derive(Deserialize)]
struct RuntimeConfigQuery {
role: Option<String>,
}
async fn get_my_runtime_config(
auth: contracts::auth_middleware::AuthUser,
State(state): State<AppState>,
Query(q): Query<RuntimeConfigQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let role_key = auth.claims.active_role.clone().to_uppercase();
// Allow frontend to override role via ?role= query param (falls back to JWT claim)
let role_key = q.role
.map(|r| r.to_uppercase())
.filter(|r| !r.is_empty())
.unwrap_or_else(|| auth.claims.active_role.clone().to_uppercase());
#[derive(sqlx::FromRow)]
#[allow(dead_code)]
struct RoleRow {
id: Uuid,
key: String,
@ -284,7 +295,7 @@ async fn get_my_runtime_config(
"user".to_string(),
serde_json::json!({
"id": user.id.to_string(),
"full_name": user.full_name.unwrap_or_default(),
"name": format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()),
"email": user.email,
"roles": roles,
"active_role": role_key,
@ -296,7 +307,7 @@ async fn get_my_runtime_config(
if role.audience == "INTERNAL" {
let permission_keys: Vec<String> = sqlx::query_scalar::<_, String>(
"SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key",
"SELECT permission_key FROM role_admin_permissions WHERE role_id = $1 ORDER BY permission_key",
)
.bind(role.id)
.fetch_all(&state.pool)

View file

@ -139,6 +139,7 @@ struct ExistingCouponRow {
}
#[derive(sqlx::FromRow)]
#[allow(dead_code)]
struct ValidateCouponRow {
id: Uuid,
code: String,

View file

@ -28,7 +28,7 @@ async fn get_metrics(State(state): State<crate::AppState>) -> Json<DashboardMetr
.unwrap_or(0);
let open_leads: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM requirements WHERE status = 'PENDING_APPROVAL' OR status = 'APPROVED'",
"SELECT COUNT(*) FROM leads WHERE status = 'PENDING_APPROVAL' OR status = 'APPROVED'",
)
.fetch_one(&state.pool)
.await
@ -37,13 +37,7 @@ async fn get_metrics(State(state): State<crate::AppState>) -> Json<DashboardMetr
let pending_approvals: i64 = sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*) FROM (
SELECT id FROM company_profiles WHERE status = 'PENDING_APPROVAL'
UNION ALL
SELECT id FROM customer_profiles WHERE status = 'PENDING_APPROVAL'
UNION ALL
SELECT id FROM job_seeker_profiles WHERE status = 'PENDING_APPROVAL'
UNION ALL
SELECT id FROM professionals WHERE status = 'PENDING_APPROVAL'
SELECT id FROM user_role_profiles WHERE status = 'PENDING_APPROVAL'
) sub
"#,
)
@ -131,10 +125,9 @@ async fn get_metrics(State(state): State<crate::AppState>) -> Json<DashboardMetr
let recent_leads = sqlx::query_as::<_, LeadRow>(
r#"
SELECT r.id, r.title, r.status, r.created_at,
u.full_name AS requester_name
FROM requirements r
LEFT JOIN customer_profiles cp ON cp.id = r.customer_id
LEFT JOIN users u ON u.id = cp.user_id
CONCAT(u.first_name, ' ', u.last_name) AS requester_name
FROM leads r
LEFT JOIN users u ON u.id = r.created_by_user_id
WHERE r.status IN ('PENDING_APPROVAL', 'APPROVED')
ORDER BY r.created_at DESC
LIMIT 5

View file

@ -20,9 +20,9 @@ pub fn router() -> Router<AppState> {
#[derive(Deserialize)]
struct ListQuery {
q: Option<String>,
status: Option<String>, // ACTIVE | INACTIVE
vertical: Option<String>, // jobs | marketplace
category: Option<String>, // provider | employer | consumer | specialist
status: Option<String>,
vertical: Option<String>,
category: Option<String>,
page: Option<i64>,
per_page: Option<i64>,
}
@ -32,6 +32,7 @@ struct ExternalRoleRow {
id: Uuid,
name: String,
code: String,
persona_type: Option<String>,
vertical: Option<String>,
category: Option<String>,
onboarding_schema_id: Option<String>,
@ -61,6 +62,7 @@ struct ExternalRoleListRow {
id: Uuid,
name: String,
code: String,
persona_type: Option<String>,
is_active: bool,
created_date: chrono::DateTime<chrono::Utc>,
updated_at: Option<chrono::DateTime<chrono::Utc>>,
@ -71,7 +73,7 @@ async fn list_external_roles(
auth: AuthUser,
State(state): State<AppState>,
Query(q): Query<ListQuery>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
@ -83,20 +85,19 @@ async fn list_external_roles(
let vertical = q.vertical.unwrap_or_default().to_lowercase();
let category = q.category.unwrap_or_default().to_lowercase();
// Join roles with active runtime_config for that role (optional) and count assigned user_roles
let rows = sqlx::query_as::<_, ExternalRoleListRow>(
r#"
SELECT
r.id,
r.name,
r.key as code,
r.persona_type,
r.is_active,
r.created_at as created_date,
rc.updated_at as "updated_at",
rc.config_json as "config_json"
FROM roles r
LEFT JOIN runtime_configs rc
ON rc.role_id = r.id AND rc.is_active = true
LEFT JOIN role_runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
WHERE r.audience = 'EXTERNAL'
AND ($1 = '' OR LOWER(r.name) LIKE '%' || $1 || '%' OR LOWER(r.key) LIKE '%' || $1 || '%')
AND ($2 = '' OR (CASE WHEN $2 = 'ACTIVE' THEN r.is_active ELSE NOT r.is_active END))
@ -112,7 +113,6 @@ async fn list_external_roles(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Compute total with same filters
let total: i64 = sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*)
@ -149,16 +149,14 @@ async fn list_external_roles(
assigned_user_types = arr.iter().filter_map(|v| v.as_str().map(|s| s.to_string())).collect();
}
}
// Additional filters by vertical/category after extracting from config
if !vertical.is_empty() && vertical_v.as_deref() != Some(vertical.as_str()) {
continue;
}
if !category.is_empty() && category_v.as_deref() != Some(category.as_str()) {
continue;
}
// Count assigned users from user_roles (approved)
let assigned_users: i64 = sqlx::query_scalar::<_, i64>(
"SELECT COUNT(*) FROM user_roles WHERE role_id = $1 AND status = 'APPROVED'",
"SELECT COUNT(*) FROM user_role_assignments WHERE role_id = $1 AND status = 'APPROVED'",
)
.bind(row.id)
.fetch_one(&state.pool)
@ -169,6 +167,7 @@ async fn list_external_roles(
id: row.id,
name: row.name,
code: row.code,
persona_type: row.persona_type.or(vertical_v.clone()),
vertical: vertical_v,
category: category_v,
onboarding_schema_id,
@ -217,15 +216,16 @@ async fn get_external_role(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, 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
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 runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true
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'
"#,
)
@ -252,7 +252,8 @@ struct CreateExternalRolePayload {
name: String,
code: String,
is_active: Option<bool>,
runtime: JsonValue, // carries vertical/category/modules/permissions/assigned_user_types/requires/feature_limits/onboarding_schema_id
persona_type: Option<String>,
runtime: Option<JsonValue>,
}
#[derive(sqlx::FromRow)]
@ -274,36 +275,36 @@ async fn create_external_role(
auth: AuthUser,
State(state): State<AppState>,
Json(payload): Json<CreateExternalRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
let is_active = payload.is_active.unwrap_or(true);
// Insert role
let role = sqlx::query_as::<_, InsertedRole>(
r#"
INSERT INTO roles (key, name, audience, is_active)
VALUES ($1, $2, 'EXTERNAL', $3)
INSERT INTO roles (key, name, audience, is_active, persona_type)
VALUES ($1, $2, 'EXTERNAL', $3, $4)
RETURNING id, key, name, audience, is_active, created_at
"#,
)
.bind(payload.code.to_uppercase())
.bind(&payload.name)
.bind(is_active)
.bind(&payload.persona_type)
.fetch_one(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Create runtime config version 1
let runtime = payload.runtime.unwrap_or_else(|| serde_json::json!({}));
let rc = sqlx::query_as::<_, InsertedRc>(
r#"
INSERT INTO runtime_configs (role_id, config_json, version, is_active)
INSERT INTO role_runtime_configs (role_id, config_json, version, is_active)
VALUES ($1, $2, 1, true)
RETURNING updated_at
"#,
)
.bind(role.id)
.bind(&payload.runtime)
.bind(&runtime)
.fetch_one(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
@ -316,7 +317,7 @@ async fn create_external_role(
code: role.key,
audience: role.audience,
is_active: role.is_active,
runtime: payload.runtime,
runtime,
created_at: role.created_at,
updated_at: Some(rc.updated_at),
}),
@ -335,11 +336,10 @@ async fn update_external_role(
State(state): State<AppState>,
Path(id): Path<Uuid>,
Json(payload): Json<UpdateExternalRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}
// Update role basic fields
if payload.name.is_some() || payload.is_active.is_some() {
sqlx::query(
r#"
@ -356,11 +356,10 @@ async fn update_external_role(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
}
// Create a new runtime config version if provided
if let Some(runtime) = payload.runtime {
sqlx::query(
r#"
UPDATE runtime_configs
UPDATE role_runtime_configs
SET is_active = false
WHERE role_id = $1 AND is_active = true
"#,
@ -371,11 +370,11 @@ async fn update_external_role(
.ok();
sqlx::query(
r#"
INSERT INTO runtime_configs (role_id, config_json, version, is_active)
INSERT INTO role_runtime_configs (role_id, config_json, version, is_active)
VALUES (
$1,
$2,
COALESCE((SELECT MAX(version) FROM runtime_configs WHERE role_id = $1), 0) + 1,
COALESCE((SELECT MAX(version) FROM role_runtime_configs WHERE role_id = $1), 0) + 1,
true
)
"#,
@ -393,7 +392,7 @@ async fn delete_external_role(
auth: AuthUser,
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
) -> Result<impl IntoResponse, (StatusCode, String)> {
if let Err(_e) = require_admin(&auth) {
return Err((StatusCode::FORBIDDEN, "Forbidden".to_string()));
}

View file

@ -132,7 +132,7 @@ struct AdminArticleRow {
category_id: Uuid,
target_roles: Option<Vec<String>>,
tags: Vec<String>,
is_published: bool,
status: String,
views: i32,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
@ -149,7 +149,7 @@ struct InsertedArticleRow {
category_id: Uuid,
target_roles: Option<Vec<String>>,
tags: Vec<String>,
is_published: bool,
status: String,
views: i32,
created_at: chrono::DateTime<chrono::Utc>,
updated_at: chrono::DateTime<chrono::Utc>,
@ -227,7 +227,7 @@ async fn public_list_articles(
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.is_published = true
WHERE a.status = 'PUBLISHED'
AND c.is_active = true
AND ($1 = '' OR c.slug = $1)
AND ($2 = '' OR $2 = 'ALL'
@ -294,7 +294,7 @@ async fn public_get_article(
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.slug = $1 AND a.is_published = true AND c.is_active = true
WHERE a.slug = $1 AND a.status = 'PUBLISHED' AND c.is_active = true
"#,
)
.bind(&slug)
@ -523,6 +523,7 @@ async fn admin_delete_category(
Path(id): Path<Uuid>,
) -> impl IntoResponse {
#[derive(sqlx::FromRow)]
#[allow(dead_code)]
struct IdRow { id: Uuid }
let result = sqlx::query_as::<_, IdRow>(
@ -569,26 +570,26 @@ async fn admin_list_articles(
Query(params): Query<AdminArticleQuery>,
) -> impl IntoResponse {
let q = params.q.as_deref().unwrap_or("").to_lowercase();
let published_filter: Option<bool> = params.status.as_deref().map(|s| s == "PUBLISHED");
let status_filter: Option<String> = params.status.as_deref().map(|s| s.to_string());
let rows = sqlx::query_as::<_, AdminArticleRow>(
r#"
SELECT
a.id, a.title, a.slug, a.summary, a.body, a.target_roles, a.tags,
a.is_published, a.views, a.category_id, a.created_at, a.updated_at,
a.status, a.views, a.category_id, a.created_at, a.updated_at,
c.name AS category_name
FROM kb_articles a
JOIN kb_categories c ON c.id = a.category_id
WHERE ($1 = '' OR LOWER(a.title) LIKE '%' || $1 || '%')
AND ($2::uuid IS NULL OR a.category_id = $2)
AND ($3::bool IS NULL OR a.is_published = $3)
AND ($3::text IS NULL OR a.status = $3)
ORDER BY a.updated_at DESC
LIMIT 200
"#,
)
.bind(&q)
.bind(params.category_id)
.bind(published_filter)
.bind(status_filter)
.fetch_all(&state.pool)
.await;
@ -604,7 +605,7 @@ async fn admin_list_articles(
category_id: Some(r.category_id),
category: Some(r.category_name),
content: r.body,
status: if r.is_published { "PUBLISHED".into() } else { "DRAFT".into() },
status: r.status,
target_roles: r.target_roles.unwrap_or_default(),
tags: r.tags,
views: r.views,
@ -646,16 +647,16 @@ async fn admin_create_article(
.slug
.filter(|s| !s.is_empty())
.unwrap_or_else(|| slugify(&body.title));
let is_published = body.status.as_deref() == Some("PUBLISHED");
let status = body.status.as_deref().unwrap_or("DRAFT").to_string();
let roles: Vec<String> = body.target_roles.unwrap_or_default();
let tags: Vec<String> = body.tags.unwrap_or_default();
let result = sqlx::query_as::<_, InsertedArticleRow>(
r#"
INSERT INTO kb_articles
(title, slug, summary, body, category_id, is_published, target_roles, tags, created_by)
(title, slug, summary, body, category_id, status, target_roles, tags, created_by)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)
RETURNING id, title, slug, summary, body, category_id, is_published,
RETURNING id, title, slug, summary, body, category_id, status,
target_roles, tags, views, created_at, updated_at
"#,
)
@ -664,7 +665,7 @@ async fn admin_create_article(
.bind(&body.summary)
.bind(&body.content)
.bind(body.category_id)
.bind(is_published)
.bind(&status)
.bind(&roles)
.bind(&tags)
.bind(auth.user_id)
@ -682,7 +683,7 @@ async fn admin_create_article(
category_id: Some(r.category_id),
category: None,
content: r.body,
status: if r.is_published { "PUBLISHED".into() } else { "DRAFT".into() },
status: r.status,
target_roles: r.target_roles.unwrap_or_default(),
tags: r.tags,
views: r.views,
@ -721,7 +722,7 @@ async fn admin_get_article(
r#"
SELECT
a.id, a.title, a.slug, a.summary, a.body, a.category_id,
a.target_roles, a.tags, a.is_published, a.views,
a.target_roles, a.tags, a.status, a.views,
a.created_at, a.updated_at,
c.name AS category_name
FROM kb_articles a
@ -744,7 +745,7 @@ async fn admin_get_article(
category_id: Some(r.category_id),
category: Some(r.category_name),
content: r.body,
status: if r.is_published { "PUBLISHED".into() } else { "DRAFT".into() },
status: r.status,
target_roles: r.target_roles.unwrap_or_default(),
tags: r.tags,
views: r.views,
@ -787,7 +788,7 @@ async fn admin_update_article(
Path(id): Path<Uuid>,
Json(body): Json<UpdateArticleBody>,
) -> impl IntoResponse {
let is_published: Option<bool> = body.status.as_deref().map(|s| s == "PUBLISHED");
let status: Option<String> = body.status.as_deref().map(|s| s.to_string());
let result = sqlx::query_as::<_, InsertedArticleRow>(
r#"
UPDATE kb_articles SET
@ -796,13 +797,13 @@ async fn admin_update_article(
summary = COALESCE($4, summary),
body = COALESCE($5, body),
category_id = COALESCE($6, category_id),
is_published = COALESCE($7, is_published),
status = COALESCE($7, status),
target_roles = COALESCE($8, target_roles),
tags = COALESCE($9, tags),
updated_at = NOW()
WHERE id = $1
RETURNING id, title, slug, summary, body, category_id,
target_roles, tags, is_published, views, created_at, updated_at
target_roles, tags, status, views, created_at, updated_at
"#,
)
.bind(id)
@ -811,7 +812,7 @@ async fn admin_update_article(
.bind(&body.summary)
.bind(&body.content)
.bind(body.category_id)
.bind(is_published)
.bind(&status)
.bind(body.target_roles.as_deref())
.bind(body.tags.as_deref())
.fetch_optional(&state.pool)
@ -828,7 +829,7 @@ async fn admin_update_article(
category_id: Some(r.category_id),
category: None,
content: r.body,
status: if r.is_published { "PUBLISHED".into() } else { "DRAFT".into() },
status: r.status,
target_roles: r.target_roles.unwrap_or_default(),
tags: r.tags,
views: r.views,
@ -859,6 +860,7 @@ async fn admin_delete_article(
Path(id): Path<Uuid>,
) -> impl IntoResponse {
#[derive(sqlx::FromRow)]
#[allow(dead_code)]
struct IdRow { id: Uuid }
let result = sqlx::query_as::<_, IdRow>(

View file

@ -3,10 +3,12 @@ pub mod admin_email;
pub mod activity_logs;
pub mod approvals;
pub mod auth;
pub mod ai;
pub mod config;
pub mod coupons;
pub mod dashboard;
pub mod kb;
pub mod modules;
pub mod notifications;
pub mod onboarding;
pub mod permissions;

View file

@ -0,0 +1,263 @@
use crate::AppState;
use axum::{
extract::{Path, State},
http::StatusCode,
response::IntoResponse,
routing::get,
Json, Router,
};
use serde::{Deserialize, Serialize};
use sqlx::types::Uuid;
use contracts::auth_middleware::AuthUser;
pub fn persona_types_router() -> Router<AppState> {
Router::new()
.route("/api/admin/persona-types", get(list_persona_types))
}
pub fn modules_router() -> Router<AppState> {
Router::new()
.route("/api/admin/modules", get(list_modules))
}
pub fn role_modules_router() -> Router<AppState> {
Router::new()
.route("/api/admin/roles/{id}/modules", get(get_role_modules).post(add_role_module))
.route("/api/admin/roles/{id}/modules/{module_id}", axum::routing::delete(remove_role_module))
.route("/api/admin/roles/{id}/permissions", get(get_role_permissions).put(update_role_permission))
}
#[derive(Serialize, sqlx::FromRow)]
struct PersonaTypeRow {
id: Uuid,
code: String,
name: String,
description: Option<String>,
is_active: bool,
}
async fn list_persona_types(
_auth: AuthUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let rows = sqlx::query_as::<_, PersonaTypeRow>(
"SELECT id, code, name, description, is_active FROM persona_types WHERE is_active = true ORDER BY name",
)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(Json(rows))
}
#[derive(Serialize, sqlx::FromRow)]
struct ModuleRow {
id: Uuid,
module_key: String,
module_name: String,
category: String,
description: Option<String>,
backend_domain: Option<String>,
default_route: Option<String>,
default_sidebar_label: Option<String>,
icon_key: Option<String>,
is_core: bool,
is_active: bool,
}
async fn list_modules(
_auth: AuthUser,
State(state): State<AppState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let rows = sqlx::query_as::<_, ModuleRow>(
r#"
SELECT id, module_key, module_name, category, description,
backend_domain, default_route, default_sidebar_label,
icon_key, is_core, is_active
FROM modules
WHERE is_active = true
ORDER BY category, module_name
"#,
)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(Json(rows))
}
#[derive(Serialize, sqlx::FromRow)]
struct RoleModuleAccessRow {
id: Uuid,
module_id: Uuid,
module_key: String,
module_name: String,
is_enabled: bool,
is_sidebar_visible: bool,
sidebar_label_override: Option<String>,
route_override: Option<String>,
}
async fn get_role_modules(
_auth: AuthUser,
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let rows = sqlx::query_as::<_, RoleModuleAccessRow>(
r#"
SELECT rma.id, rma.module_id, m.module_key, m.module_name,
rma.is_enabled, rma.is_sidebar_visible,
rma.sidebar_label_override, rma.route_override
FROM role_module_access rma
JOIN modules m ON m.id = rma.module_id
WHERE rma.role_id = $1
ORDER BY m.category, m.module_name
"#,
)
.bind(role_id)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(Json(rows))
}
#[derive(Deserialize)]
struct AddModulePayload {
module_id: Uuid,
is_enabled: Option<bool>,
is_sidebar_visible: Option<bool>,
sidebar_label_override: Option<String>,
route_override: Option<String>,
}
async fn add_role_module(
_auth: AuthUser,
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
Json(payload): Json<AddModulePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let is_enabled = payload.is_enabled.unwrap_or(true);
let is_sidebar_visible = payload.is_sidebar_visible.unwrap_or(true);
sqlx::query(
r#"
INSERT INTO role_module_access (role_id, module_id, is_enabled, is_sidebar_visible, sidebar_label_override, route_override)
VALUES ($1, $2, $3, $4, $5, $6)
ON CONFLICT (role_id, module_id) DO UPDATE SET
is_enabled = EXCLUDED.is_enabled,
is_sidebar_visible = EXCLUDED.is_sidebar_visible,
sidebar_label_override = EXCLUDED.sidebar_label_override,
route_override = EXCLUDED.route_override
"#,
)
.bind(role_id)
.bind(payload.module_id)
.bind(is_enabled)
.bind(is_sidebar_visible)
.bind(&payload.sidebar_label_override)
.bind(&payload.route_override)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(StatusCode::CREATED)
}
async fn remove_role_module(
_auth: AuthUser,
State(state): State<AppState>,
Path((role_id, module_id)): Path<(Uuid, Uuid)>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let result = sqlx::query(
"DELETE FROM role_module_access WHERE role_id = $1 AND module_id = $2",
)
.bind(role_id)
.bind(module_id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
if result.rows_affected() == 0 {
return Err((StatusCode::NOT_FOUND, "Module access not found".to_string()));
}
Ok(StatusCode::NO_CONTENT)
}
#[derive(Serialize, sqlx::FromRow)]
struct RolePermissionRow {
id: Uuid,
module_id: Uuid,
module_key: String,
module_name: String,
category: String,
can_view: bool,
can_list: bool,
can_create: bool,
can_update: bool,
can_delete: bool,
}
async fn get_role_permissions(
_auth: AuthUser,
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let rows = sqlx::query_as::<_, RolePermissionRow>(
r#"
SELECT rmp.id, rmp.module_id, m.module_key, m.module_name, m.category,
rmp.can_view, rmp.can_list, rmp.can_create, rmp.can_update, rmp.can_delete
FROM role_module_permissions rmp
JOIN modules m ON m.id = rmp.module_id
WHERE rmp.role_id = $1
ORDER BY m.category, m.module_name
"#,
)
.bind(role_id)
.fetch_all(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(Json(rows))
}
#[derive(Deserialize)]
struct UpdatePermissionPayload {
module_key: String,
permission: String,
enabled: bool,
}
async fn update_role_permission(
_auth: AuthUser,
State(state): State<AppState>,
Path(role_id): Path<Uuid>,
Json(payload): Json<UpdatePermissionPayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let permission_col = match payload.permission.as_str() {
"view" => "can_view",
"list" => "can_list",
"create" => "can_create",
"update" => "can_update",
"delete" => "can_delete",
_ => return Err((StatusCode::BAD_REQUEST, "Invalid permission type".to_string())),
};
sqlx::query(&format!(
r#"
UPDATE role_module_permissions
SET {} = $1
WHERE role_id = $2 AND module_id = (SELECT id FROM modules WHERE module_key = $3)
"#,
permission_col
))
.bind(payload.enabled)
.bind(role_id)
.bind(&payload.module_key)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
Ok(StatusCode::OK)
}

View file

@ -162,21 +162,29 @@ async fn submit(
};
if let Some(tbl) = table_name {
let user_role_profile_id = get_or_create_user_role_profile_id(
&state.pool,
auth.user_id,
&role_key,
role.id,
)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("Failed to create user role profile: {}", e)))?;
let query = format!(
r#"
INSERT INTO {} (user_id, "profileData", verification_status, submitted_at, updated_at)
VALUES ($1, $2, 'PENDING', NOW(), NOW())
ON CONFLICT (user_id) DO UPDATE SET
"profileData" = EXCLUDED."profileData",
verification_status = 'PENDING',
submitted_at = NOW(),
INSERT INTO {} (id, custom_data, status, updated_at)
VALUES ($1, $2, 'PENDING', NOW())
ON CONFLICT (id) DO UPDATE SET
custom_data = EXCLUDED.custom_data,
status = 'PENDING',
updated_at = NOW()
"#,
tbl
);
sqlx::query(&query)
.bind(auth.user_id)
.bind(user_role_profile_id)
.bind(&progress)
.execute(&state.pool)
.await
@ -185,11 +193,11 @@ async fn submit(
// Simple companies upsert (using basic fields if possible)
sqlx::query(
r#"
INSERT INTO companies ("userId", status, "updatedAt")
INSERT INTO company_profiles (user_id, status, updated_at)
VALUES ($1, 'PENDING', NOW())
ON CONFLICT ("userId") DO UPDATE SET
ON CONFLICT (user_id) DO UPDATE SET
status = 'PENDING',
"updatedAt" = NOW()
updated_at = NOW()
"#,
)
.bind(auth.user_id)
@ -201,8 +209,8 @@ async fn submit(
// 3. Mark the user_role as PENDING (awaiting admin review of onboarding)
sqlx::query(
r#"
UPDATE user_roles
SET status = 'PENDING', updated_at = NOW()
UPDATE user_role_assignments
SET status = 'PENDING'
WHERE user_id = $1 AND role_id = $2
"#,
)
@ -254,3 +262,34 @@ async fn profile_status(
}),
))
}
async fn get_or_create_user_role_profile_id(
pool: &sqlx::PgPool,
user_id: uuid::Uuid,
role_key: &str,
_role_id: uuid::Uuid,
) -> Result<uuid::Uuid, sqlx::Error> {
if let Some(id) = sqlx::query_scalar::<_, uuid::Uuid>(
r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2"#,
)
.bind(user_id)
.bind(role_key)
.fetch_optional(pool)
.await?
{
return Ok(id);
}
sqlx::query_scalar::<_, uuid::Uuid>(
r#"
INSERT INTO user_role_profiles (user_id, role_key, status)
VALUES ($1, $2, 'DRAFT')
ON CONFLICT (user_id, role_key) DO UPDATE SET updated_at = NOW()
RETURNING id
"#,
)
.bind(user_id)
.bind(role_key)
.fetch_one(pool)
.await
}

View file

@ -37,6 +37,7 @@ const MODULES: &[&str] = &[
"Social Media Management",
"Video Editor Management",
"Catering Services Management",
"UGC Content Creator Management",
"Jobs Management",
"Leads Management",
"Applications Management",
@ -49,11 +50,15 @@ const MODULES: &[&str] = &[
"Tax Management",
"Order Management",
"Invoice Management",
"Payment Gateway Management",
"Ledger Management",
"Knowledge Base Management",
"Support Management",
"Report Management",
"SMTP Management",
"Email Management",
"Notifications",
"Dashboard",
];
const ACTIONS: &[&str] = &["View", "Create", "Update", "Delete"];

View file

@ -113,12 +113,20 @@ struct ExistingPackageRow {
#[derive(Deserialize)]
struct PackageQuery {
role: Option<String>,
#[serde(rename = "roleKey", alias = "role_key")]
role_key: Option<String>,
}
async fn public_list_packages(
State(state): State<AppState>,
Query(params): Query<PackageQuery>,
) -> impl IntoResponse {
let requested_role = params
.role
.or(params.role_key)
.map(|r| r.trim().to_uppercase())
.filter(|r| !r.is_empty() && r != "PROFESSIONAL");
let rows = sqlx::query_as::<_, PackageRow>(
r#"
SELECT id, name, role_key, package_type, tracecoins_amount, price_inr, description, is_active
@ -128,7 +136,7 @@ async fn public_list_packages(
ORDER BY role_key, price_inr
"#,
)
.bind(params.role)
.bind(requested_role)
.fetch_all(&state.pool)
.await;

View file

@ -115,7 +115,7 @@ async fn get_profile(
if role_key == "COMPANY" {
let row = sqlx::query(
r#"SELECT name, status, "updatedAt" FROM companies WHERE "userId" = $1"#,
r#"SELECT company_name, status, updated_at FROM company_profiles WHERE user_id = $1"#,
)
.bind(auth.user_id)
.fetch_optional(&state.pool)
@ -124,7 +124,7 @@ async fn get_profile(
return match row {
Ok(Some(r)) => {
use sqlx::Row;
let name: Option<String> = r.try_get("name").ok();
let name: Option<String> = r.try_get("company_name").ok();
let status: String = r.try_get("status").unwrap_or_default();
(
StatusCode::OK,
@ -161,22 +161,38 @@ async fn get_profile(
};
let query = format!(
r#"SELECT "profileData", verification_status FROM {} WHERE user_id = $1"#,
r#"SELECT custom_data, status FROM {} WHERE id = $1"#,
table
);
let user_role_profile_id = match get_user_role_profile_id(&state.pool, auth.user_id, &role_key).await {
Ok(Some(id)) => id,
Ok(None) => {
return (
StatusCode::OK,
Json(serde_json::json!({
"role_key": role_key,
"profile_data": null,
"verification_status": "NOT_STARTED",
})),
)
.into_response();
}
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
match sqlx::query(&query)
.bind(auth.user_id)
.bind(user_role_profile_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(row)) => {
use sqlx::Row;
let profile_data: serde_json::Value = row
.try_get("profileData")
.try_get("custom_data")
.unwrap_or(serde_json::Value::Null);
let verification_status: String =
row.try_get("verification_status").unwrap_or_default();
row.try_get("status").unwrap_or_default();
(
StatusCode::OK,
Json(serde_json::json!({
@ -218,11 +234,11 @@ async fn save_profile(
return match sqlx::query(
r#"
INSERT INTO companies ("userId", name, status, "updatedAt")
INSERT INTO company_profiles (user_id, company_name, status, updated_at)
VALUES ($1, $2, 'DRAFT', NOW())
ON CONFLICT ("userId") DO UPDATE SET
name = EXCLUDED.name,
"updatedAt" = NOW()
ON CONFLICT (user_id) DO UPDATE SET
company_name = EXCLUDED.company_name,
updated_at = NOW()
"#,
)
.bind(auth.user_id)
@ -252,16 +268,21 @@ async fn save_profile(
let query = format!(
r#"
INSERT INTO {table} (user_id, "profileData", verification_status, updated_at)
INSERT INTO {table} (id, custom_data, status, updated_at)
VALUES ($1, $2, 'DRAFT', NOW())
ON CONFLICT (user_id) DO UPDATE SET
"profileData" = EXCLUDED."profileData",
ON CONFLICT (id) DO UPDATE SET
custom_data = EXCLUDED.custom_data,
updated_at = NOW()
"#
);
let user_role_profile_id = match get_or_create_user_role_profile_id(&state.pool, auth.user_id, &role_key).await {
Ok(id) => id,
Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
};
match sqlx::query(&query)
.bind(auth.user_id)
.bind(user_role_profile_id)
.bind(&input.profile_data)
.execute(&state.pool)
.await
@ -321,7 +342,7 @@ async fn submit_for_verification(
// Mark user_role as PENDING
if let Ok(role) = RoleRepository::get_by_key(&state.pool, &role_key).await {
sqlx::query(
"UPDATE user_roles SET status = 'PENDING' WHERE user_id = $1 AND role_id = $2",
"UPDATE user_role_assignments SET status = 'PENDING' WHERE user_id = $1 AND role_id = $2",
)
.bind(auth.user_id)
.bind(role.id)
@ -420,32 +441,22 @@ async fn fetch_saved_profile(
role_key: &str,
) -> serde_json::Value {
if role_key == "COMPANY" {
return match sqlx::query(r#"SELECT name FROM companies WHERE "userId" = $1"#)
return match sqlx::query(r#"SELECT company_name FROM company_profiles WHERE user_id = $1"#)
.bind(user_id)
.fetch_optional(&state.pool)
.await
{
Ok(Some(r)) => {
use sqlx::Row;
let name: Option<String> = r.try_get("name").ok();
let name: Option<String> = r.try_get("company_name").ok();
serde_json::json!({ "company_name": name })
}
_ => serde_json::Value::Object(Default::default()),
};
}
if let Some(table) = role_to_table(role_key) {
let q = format!(r#"SELECT "profileData" FROM {} WHERE user_id = $1"#, table);
if let Ok(Some(row)) = sqlx::query(&q)
.bind(user_id)
.fetch_optional(&state.pool)
.await
{
use sqlx::Row;
return row
.try_get::<serde_json::Value, _>("profileData")
.unwrap_or(serde_json::Value::Object(Default::default()));
}
if let Some(urp_id) = get_user_role_profile_id(&state.pool, user_id, role_key).await.ok().flatten() {
return fetch_saved_profile_by_urp_id(state, urp_id, role_key).await;
}
serde_json::Value::Object(Default::default())
@ -454,7 +465,7 @@ async fn fetch_saved_profile(
async fn set_profile_status(state: &AppState, user_id: Uuid, role_key: &str, status: &str) {
if role_key == "COMPANY" {
sqlx::query(
r#"UPDATE companies SET status = $1, "updatedAt" = NOW() WHERE "userId" = $2"#,
r#"UPDATE company_profiles SET status = $1, updated_at = NOW() WHERE user_id = $2"#,
)
.bind(status)
.bind(user_id)
@ -464,16 +475,85 @@ async fn set_profile_status(state: &AppState, user_id: Uuid, role_key: &str, sta
return;
}
let user_role_profile_id = match get_user_role_profile_id(&state.pool, user_id, role_key).await {
Ok(Some(id)) => id,
Ok(None) => return,
Err(_) => return,
};
if let Some(table) = role_to_table(role_key) {
let q = format!(
"UPDATE {} SET verification_status = $1, submitted_at = NOW(), updated_at = NOW() WHERE user_id = $2",
"UPDATE {} SET status = $1, updated_at = NOW() WHERE id = $2",
table
);
sqlx::query(&q)
.bind(status)
.bind(user_id)
.bind(user_role_profile_id)
.execute(&state.pool)
.await
.ok();
}
}
async fn get_user_role_profile_id(
pool: &sqlx::PgPool,
user_id: Uuid,
role_key: &str,
) -> Result<Option<Uuid>, sqlx::Error> {
sqlx::query_scalar::<_, Uuid>(
r#"
SELECT id FROM user_role_profiles
WHERE user_id = $1 AND role_key = $2
"#,
)
.bind(user_id)
.bind(role_key)
.fetch_optional(pool)
.await
}
async fn get_or_create_user_role_profile_id(
pool: &sqlx::PgPool,
user_id: Uuid,
role_key: &str,
) -> Result<Uuid, sqlx::Error> {
if let Some(id) = get_user_role_profile_id(pool, user_id, role_key).await? {
return Ok(id);
}
let _role = RoleRepository::get_by_key(pool, role_key).await?;
sqlx::query_scalar::<_, Uuid>(
r#"
INSERT INTO user_role_profiles (user_id, role_key, status)
VALUES ($1, $2, 'DRAFT')
ON CONFLICT (user_id, role_key) DO UPDATE SET updated_at = NOW()
RETURNING id
"#,
)
.bind(user_id)
.bind(role_key)
.fetch_one(pool)
.await
}
async fn fetch_saved_profile_by_urp_id(
state: &AppState,
user_role_profile_id: Uuid,
role_key: &str,
) -> serde_json::Value {
if let Some(table) = role_to_table(role_key) {
let q = format!(r#"SELECT custom_data FROM {} WHERE id = $1"#, table);
if let Ok(Some(row)) = sqlx::query(&q)
.bind(user_role_profile_id)
.fetch_optional(&state.pool)
.await
{
use sqlx::Row;
return row
.try_get::<serde_json::Value, _>("custom_data")
.unwrap_or(serde_json::Value::Object(Default::default()));
}
}
serde_json::Value::Object(Default::default())
}

View file

@ -31,7 +31,6 @@ struct ReviewDto {
title: Option<String>,
comment: Option<String>,
status: String,
is_published: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
@ -48,7 +47,6 @@ struct CreateReviewBody {
#[derive(Deserialize)]
struct PatchReviewBody {
status: Option<String>,
is_published: Option<bool>,
}
// ── FromRow structs ──────────────────────────────────────────────────────────
@ -64,7 +62,6 @@ struct ReviewRow {
title: Option<String>,
comment: Option<String>,
status: String,
is_published: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
@ -81,12 +78,11 @@ async fn admin_list_reviews(
r.subject_type,
r.subject_id,
r.reviewer_name,
r.customer_id AS reviewer_id,
r.reviewer_user_id AS reviewer_id,
r.rating,
r.title,
r.comment,
r.status,
r.is_published,
r.created_at
FROM reviews r
ORDER BY r.created_at DESC
@ -109,7 +105,6 @@ async fn admin_list_reviews(
title: r.title,
comment: r.comment,
status: r.status,
is_published: r.is_published,
created_at: r.created_at,
})
.collect();
@ -136,10 +131,10 @@ async fn admin_create_review(
let row = sqlx::query_as::<_, ReviewRow>(
r#"
INSERT INTO reviews (subject_type, subject_id, reviewer_name, rating, title, comment, status, is_published)
VALUES ($1, $2, $3, $4, $5, $6, $7, true)
RETURNING id, subject_type, subject_id, reviewer_name, customer_id AS reviewer_id,
rating, title, comment, status, is_published, created_at
INSERT INTO reviews (subject_type, subject_id, reviewer_name, rating, title, comment, status)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, subject_type, subject_id, reviewer_name, reviewer_user_id AS reviewer_id,
rating, title, comment, status, created_at
"#,
)
.bind(&subject_type)
@ -164,7 +159,6 @@ async fn admin_create_review(
title: r.title,
comment: r.comment,
status: r.status,
is_published: r.is_published,
created_at: r.created_at,
};
(StatusCode::CREATED, Json(serde_json::json!(dto))).into_response()
@ -182,24 +176,13 @@ async fn admin_update_review(
Path(id): Path<Uuid>,
Json(body): Json<PatchReviewBody>,
) -> impl IntoResponse {
// Derive is_published from status string, or use explicit field
let (status, published) = match (body.status.as_deref(), body.is_published) {
(Some("PUBLISHED"), _) => ("PUBLISHED".to_string(), true),
(Some("HIDDEN"), _) => ("HIDDEN".to_string(), false),
(Some(s), _) => (s.to_string(), false),
(None, Some(p)) => {
if p { ("PUBLISHED".to_string(), true) } else { ("HIDDEN".to_string(), false) }
}
(None, None) => {
return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Provide status or is_published" }))).into_response();
}
};
let status = body.status.as_deref().unwrap_or("PUBLISHED").to_string();
let result = sqlx::query(
"UPDATE reviews SET status = $1, is_published = $2, updated_at = NOW() WHERE id = $3",
"UPDATE reviews SET status = $1, updated_at = NOW() WHERE id = $2",
)
.bind(&status)
.bind(published)
.bind(id)
.bind(id)
.execute(&state.pool)
.await;

View file

@ -15,18 +15,13 @@ pub fn router() -> Router<AppState> {
.route("/{id}", get(get_role).patch(update_role).delete(delete_role))
}
// ── Query params ─────────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct ListQuery {
audience: Option<String>,
q: Option<String>,
page: Option<i64>,
per_page: Option<i64>,
}
// ── Response types ───────────────────────────────────────────────────────────
#[derive(Serialize)]
struct RoleRow {
id: Uuid,
@ -68,13 +63,10 @@ struct RoleDetail {
created_at: chrono::DateTime<chrono::Utc>,
}
// ── Request types ────────────────────────────────────────────────────────────
#[derive(Deserialize)]
struct CreateRolePayload {
key: String,
name: String,
audience: String,
description: Option<String>,
department_id: Option<Uuid>,
is_active: Option<bool>,
@ -94,8 +86,6 @@ struct UpdateRolePayload {
permission_keys: Option<Vec<String>>,
}
// ── FromRow structs ──────────────────────────────────────────────────────────
#[derive(sqlx::FromRow)]
struct RoleListRow {
id: Uuid,
@ -134,11 +124,7 @@ struct InsertedRoleRow {
key: String,
name: String,
audience: String,
description: Option<String>,
department_id: Option<Uuid>,
is_active: bool,
can_approve_requests: bool,
can_manage_system_settings: bool,
created_at: chrono::DateTime<chrono::Utc>,
}
@ -152,8 +138,6 @@ struct CurrentRoleRow {
can_manage_system_settings: bool,
}
// ── Handlers ─────────────────────────────────────────────────────────────────
async fn list_roles(
State(state): State<AppState>,
Query(params): Query<ListQuery>,
@ -162,7 +146,6 @@ async fn list_roles(
let per_page = params.per_page.unwrap_or(20).min(100);
let offset = (page - 1) * per_page;
let search = params.q.as_deref().unwrap_or("").to_lowercase();
let audience = params.audience.as_deref().unwrap_or("").to_string();
let rows = sqlx::query_as::<_, RoleListRow>(
r#"
@ -171,27 +154,27 @@ async fn list_roles(
r.key,
r.name,
r.audience,
r.description,
r.department_id,
ir.description,
ir.department_id,
d.name AS department_name,
r.is_active,
r.can_approve_requests,
r.can_manage_system_settings,
COALESCE(ir.can_approve_requests, false) AS can_approve_requests,
COALESCE(ir.can_manage_system_settings, false) AS can_manage_system_settings,
r.created_at,
COUNT(DISTINCT e.id) AS users_assigned,
COUNT(DISTINCT rp.id) AS permissions_count
FROM roles r
LEFT JOIN departments d ON d.id = r.department_id
JOIN internal_role_details ir ON ir.role_id = r.id
LEFT JOIN departments d ON d.id = ir.department_id
LEFT JOIN employees e ON e.role_code = r.key
LEFT JOIN role_permissions rp ON rp.role_id = r.id
WHERE ($1 = '' OR r.audience = $1)
AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.key) LIKE '%' || $2 || '%')
GROUP BY r.id, d.name
LEFT JOIN role_admin_permissions rp ON rp.role_id = r.id
WHERE r.audience = 'INTERNAL'
AND ($1 = '' OR LOWER(r.name) LIKE '%' || $1 || '%' OR LOWER(r.key) LIKE '%' || $1 || '%')
GROUP BY r.id, ir.description, ir.department_id, ir.can_approve_requests, ir.can_manage_system_settings, d.name
ORDER BY r.created_at DESC
LIMIT $3 OFFSET $4
LIMIT $2 OFFSET $3
"#,
)
.bind(&audience)
.bind(&search)
.bind(per_page)
.bind(offset)
@ -202,11 +185,11 @@ async fn list_roles(
let total: i64 = sqlx::query_scalar::<_, i64>(
r#"
SELECT COUNT(*) FROM roles r
WHERE ($1 = '' OR r.audience = $1)
AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.key) LIKE '%' || $2 || '%')
JOIN internal_role_details ir ON ir.role_id = r.id
WHERE r.audience = 'INTERNAL'
AND ($1 = '' OR LOWER(r.name) LIKE '%' || $1 || '%' OR LOWER(r.key) LIKE '%' || $1 || '%')
"#,
)
.bind(&audience)
.bind(&search)
.fetch_one(&state.pool)
.await
@ -241,13 +224,17 @@ async fn get_role(
let row = sqlx::query_as::<_, RoleDetailRow>(
r#"
SELECT
r.id, r.key, r.name, r.audience, r.description,
r.department_id, d.name AS department_name,
r.is_active, r.can_approve_requests, r.can_manage_system_settings,
r.id, r.key, r.name, r.audience,
ir.description,
ir.department_id, d.name AS department_name,
r.is_active,
COALESCE(ir.can_approve_requests, false) AS can_approve_requests,
COALESCE(ir.can_manage_system_settings, false) AS can_manage_system_settings,
r.created_at
FROM roles r
LEFT JOIN departments d ON d.id = r.department_id
WHERE r.id = $1
JOIN internal_role_details ir ON ir.role_id = r.id
LEFT JOIN departments d ON d.id = ir.department_id
WHERE r.id = $1 AND r.audience = 'INTERNAL'
"#,
)
.bind(id)
@ -257,7 +244,7 @@ async fn get_role(
.ok_or((StatusCode::NOT_FOUND, "Role not found".to_string()))?;
let permission_keys: Vec<String> = sqlx::query_scalar::<_, String>(
"SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key",
"SELECT permission_key FROM role_admin_permissions WHERE role_id = $1 ORDER BY permission_key",
)
.bind(id)
.fetch_all(&state.pool)
@ -290,28 +277,37 @@ async fn create_role(
let role = sqlx::query_as::<_, InsertedRoleRow>(
r#"
INSERT INTO roles (key, name, audience, description, department_id, is_active, can_approve_requests, can_manage_system_settings)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)
RETURNING id, key, name, audience, description, department_id, is_active, can_approve_requests, can_manage_system_settings, created_at
INSERT INTO roles (key, name, audience, is_active)
VALUES ($1, $2, 'INTERNAL', $3)
RETURNING id, key, name, audience, is_active, created_at
"#,
)
.bind(&payload.key)
.bind(&payload.name)
.bind(&payload.audience)
.bind(&payload.description)
.bind(payload.department_id)
.bind(is_active)
.bind(can_approve)
.bind(can_manage)
.fetch_one(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Insert permission keys
sqlx::query(
r#"
INSERT INTO internal_role_details (role_id, description, department_id, can_approve_requests, can_manage_system_settings)
VALUES ($1, $2, $3, $4, $5)
"#,
)
.bind(role.id)
.bind(&payload.description)
.bind(payload.department_id)
.bind(can_approve)
.bind(can_manage)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
if let Some(keys) = &payload.permission_keys {
for key in keys {
sqlx::query(
"INSERT INTO role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING",
"INSERT INTO role_admin_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(role.id)
.bind(key)
@ -322,7 +318,7 @@ async fn create_role(
}
let permission_keys: Vec<String> = sqlx::query_scalar::<_, String>(
"SELECT permission_key FROM role_permissions WHERE role_id = $1 ORDER BY permission_key",
"SELECT permission_key FROM role_admin_permissions WHERE role_id = $1 ORDER BY permission_key",
)
.bind(role.id)
.fetch_all(&state.pool)
@ -336,12 +332,12 @@ async fn create_role(
key: role.key,
name: role.name,
audience: role.audience,
description: role.description,
department_id: role.department_id,
description: payload.description,
department_id: payload.department_id,
department_name: None,
is_active: role.is_active,
can_approve_requests: role.can_approve_requests,
can_manage_system_settings: role.can_manage_system_settings,
can_approve_requests: can_approve,
can_manage_system_settings: can_manage,
permission_keys,
created_at: role.created_at,
}),
@ -353,9 +349,15 @@ async fn update_role(
Path(id): Path<Uuid>,
Json(payload): Json<UpdateRolePayload>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
// Fetch current values first
let current = sqlx::query_as::<_, CurrentRoleRow>(
"SELECT name, description, department_id, is_active, can_approve_requests, can_manage_system_settings FROM roles WHERE id = $1",
r#"
SELECT r.name, ir.description, ir.department_id, r.is_active,
COALESCE(ir.can_approve_requests, false) AS can_approve_requests,
COALESCE(ir.can_manage_system_settings, false) AS can_manage_system_settings
FROM roles r
JOIN internal_role_details ir ON ir.role_id = r.id
WHERE r.id = $1 AND r.audience = 'INTERNAL'
"#,
)
.bind(id)
.fetch_optional(&state.pool)
@ -364,28 +366,35 @@ async fn update_role(
.ok_or((StatusCode::NOT_FOUND, "Role not found".to_string()))?;
let name = payload.name.unwrap_or(current.name);
let is_active = payload.is_active.unwrap_or(current.is_active);
sqlx::query(
"UPDATE roles SET name = $1, is_active = $2 WHERE id = $3",
)
.bind(&name)
.bind(is_active)
.bind(id)
.execute(&state.pool)
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
let description = payload.description.or(current.description);
let department_id = payload.department_id.or(current.department_id);
let is_active = payload.is_active.unwrap_or(current.is_active);
let can_approve = payload.can_approve_requests.unwrap_or(current.can_approve_requests);
let can_manage = payload.can_manage_system_settings.unwrap_or(current.can_manage_system_settings);
sqlx::query(
r#"
UPDATE roles SET
name = $1,
description = $2,
department_id = $3,
is_active = $4,
can_approve_requests = $5,
can_manage_system_settings = $6
WHERE id = $7
UPDATE internal_role_details SET
description = $1,
department_id = $2,
can_approve_requests = $3,
can_manage_system_settings = $4
WHERE role_id = $5
"#,
)
.bind(name)
.bind(description)
.bind(&description)
.bind(department_id)
.bind(is_active)
.bind(can_approve)
.bind(can_manage)
.bind(id)
@ -393,9 +402,8 @@ async fn update_role(
.await
.map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?;
// Replace permissions if provided
if let Some(keys) = &payload.permission_keys {
sqlx::query("DELETE FROM role_permissions WHERE role_id = $1")
sqlx::query("DELETE FROM role_admin_permissions WHERE role_id = $1")
.bind(id)
.execute(&state.pool)
.await
@ -403,7 +411,7 @@ async fn update_role(
for key in keys {
sqlx::query(
"INSERT INTO role_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING",
"INSERT INTO role_admin_permissions (role_id, permission_key) VALUES ($1, $2) ON CONFLICT DO NOTHING",
)
.bind(id)
.bind(key)
@ -413,7 +421,6 @@ async fn update_role(
}
}
// Return updated role
get_role(State(state), Path(id)).await
}
@ -421,7 +428,7 @@ async fn delete_role(
State(state): State<AppState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let result = sqlx::query("DELETE FROM roles WHERE id = $1")
let result = sqlx::query("DELETE FROM roles WHERE id = $1 AND audience = 'INTERNAL'")
.bind(id)
.execute(&state.pool)
.await

View file

@ -225,7 +225,7 @@ async fn create_delete_account_request(
.mail
.send_account_deleted_email(
&user.email,
user.full_name.as_deref().unwrap_or_default(),
&format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()),
)
.await;
let _ = sqlx::query(

View file

@ -18,6 +18,7 @@ pub fn user_router() -> Router<AppState> {
.route("/", post(user_create_ticket).get(user_list_tickets))
.route("/{id}", get(user_get_ticket))
.route("/{id}/messages", post(user_add_message))
.route("/ai/create", post(ai_create_ticket))
}
/// Admin support routes
@ -92,6 +93,61 @@ struct MessageRow {
created_at: chrono::DateTime<chrono::Utc>,
}
// ── AI Service: create ticket (no user auth required) ────────────────────────
#[derive(Deserialize)]
struct AiCreateTicketBody {
subject: String,
description: Option<String>,
category: Option<String>,
priority: Option<String>,
#[serde(rename = "userId")]
user_id: Option<Uuid>,
}
async fn ai_create_ticket(
State(state): State<AppState>,
axum::extract::Json(body): axum::extract::Json<AiCreateTicketBody>,
) -> impl IntoResponse {
let user_id = body.user_id.unwrap_or_else(|| Uuid::nil());
let category = body.category.clone().unwrap_or_else(|| "ai_assisted".to_string());
let priority = body.priority.clone().unwrap_or_else(|| "medium".to_string());
let result = sqlx::query_as::<_, TicketRow>(
r#"
INSERT INTO support_tickets (user_id, subject, description, category, priority, status)
VALUES ($1, $2, $3, $4, $5, 'new')
RETURNING id, subject, description, category, priority, status,
requester_name, requester_email, assigned_to, created_at, updated_at
"#,
)
.bind(user_id)
.bind(&body.subject)
.bind(&body.description)
.bind(&category)
.bind(&priority)
.fetch_one(&state.pool)
.await;
match result {
Ok(r) => (
StatusCode::CREATED,
Json(serde_json::json!({
"id": r.id,
"subject": r.subject,
"description": r.description,
"category": r.category,
"priority": r.priority,
"status": r.status,
})),
).into_response(),
Err(e) => {
tracing::error!("AI ticket creation failed: {}", e);
(StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to create ticket" }))).into_response()
}
}
}
// ── User: create ticket ───────────────────────────────────────────────────────
#[derive(Deserialize)]
@ -137,7 +193,7 @@ async fn user_create_ticket(
};
let _ = state.mail.send_support_ticket_created_email(
&user.email,
user.full_name.as_deref().unwrap_or_default(),
&format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()),
&r.id.to_string(),
&body.subject,
&category,
@ -444,14 +500,10 @@ async fn admin_list_cases(
t.id, t.subject, t.description, t.category, t.priority, t.status,
t.requester_name, t.requester_email, t.assigned_to,
t.created_at, t.updated_at,
u.full_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
LEFT JOIN users u ON u.id = t.user_id
WHERE ($1 = '' OR t.status = $1)
AND ($2 = '' OR t.priority = $2)
AND ($3 = '' OR t.category = $3)
ORDER BY t.updated_at DESC
LIMIT $4 OFFSET $5
WHERE t.id = $1
"#,
)
.bind(&status_filter)
@ -531,17 +583,18 @@ async fn admin_create_case(
INSERT INTO support_tickets
(subject, description, category, priority, status,
requester_name, requester_email)
VALUES ($1, $2, $3, $4, 'new', $5, $6)
VALUES ($1, $2, $3, $4, $5, $6, $7)
RETURNING id, subject, description, category, priority, status,
requester_name, requester_email, assigned_to, created_at, updated_at
"#,
)
.bind(&body.title)
.bind(&body.description)
.bind(&category)
.bind(&priority)
.bind(&body.requester_name)
.bind(&body.requester_email)
.bind(&body.description)
.bind(&category)
.bind(&priority)
.bind("new")
.bind(&body.requester_name)
.bind(&body.requester_email)
.fetch_one(&state.pool)
.await;
@ -586,10 +639,14 @@ async fn admin_get_case(
t.id, t.subject, t.description, t.category, t.priority, t.status,
t.requester_name, t.requester_email, t.assigned_to,
t.created_at, t.updated_at,
u.full_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
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(id)
@ -832,7 +889,7 @@ async fn admin_add_message(
if let Some(user_email) = ticket.requester_email {
// Try to get user name from user table
let user_name = if let Ok(user) = db::models::user::UserRepository::get_by_email(&state.pool, &user_email).await {
user.full_name.unwrap_or_default()
format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default())
} else {
ticket.requester_name.unwrap_or_default()
};

View file

@ -9,7 +9,6 @@ use axum::{
use contracts::auth_middleware::AuthUser;
use db::models::role::RoleRepository;
use serde::{Deserialize, Serialize};
use uuid::Uuid;
pub fn router() -> Router<AppState> {
Router::new()
@ -61,7 +60,7 @@ async fn list_my_roles(
let rows = sqlx::query_as::<_, UserRoleRow>(
r#"
SELECT r.key, r.name, ur.status, ur.approved_at
FROM user_roles ur
FROM user_role_assignments ur
INNER JOIN roles r ON r.id = ur.role_id
WHERE ur.user_id = $1
ORDER BY ur.created_at ASC
@ -101,7 +100,7 @@ async fn register_role(
sqlx::query(
r#"
INSERT INTO user_roles (user_id, role_id, status, approved_at)
INSERT INTO user_role_assignments (user_id, role_id, status, approved_at)
VALUES ($1, $2, 'APPROVED', NOW())
ON CONFLICT (user_id, role_id)
DO UPDATE SET status = 'APPROVED', approved_at = NOW()

View file

@ -11,6 +11,56 @@ use db::models::verification::{VerificationRepository};
use serde::Deserialize;
use uuid::Uuid;
/// Creates an entry in approval_requests after verification is approved.
/// This is the bridge between Verification Management and Approval Management.
async fn create_approval_request_from_verification(
pool: &sqlx::PgPool,
verification: &db::models::verification::Verification,
) -> Result<(), sqlx::Error> {
// Determine entity_type and entity_id from the verification payload
let payload = &verification.payload;
let entity_type = match verification.case_type.as_str() {
"JOB_APPROVAL" => "JOB",
"REQUIREMENT_APPROVAL" => "REQUIREMENT",
"PORTFOLIO_APPROVAL" => "PORTFOLIO",
_ => "PROFILE",
};
// Extract entity_id from payload (could be entity_id, job_id, requirement_id, etc.)
let entity_id = payload
.get("entity_id")
.or_else(|| payload.get("job_id"))
.or_else(|| payload.get("requirement_id"))
.and_then(|v| v.as_str())
.and_then(|s| Uuid::parse_str(s).ok())
.unwrap_or(verification.user_id); // Fall back to user_id if no entity_id found
let approval_type = match verification.case_type.as_str() {
"JOB_APPROVAL" => "JOB",
"REQUIREMENT_APPROVAL" => "REQUIREMENT",
"PORTFOLIO_APPROVAL" => "PORTFOLIO",
"COMPANY_APPROVAL" => "BUSINESS",
_ => "PROFILE",
};
sqlx::query(
r#"
INSERT INTO approval_requests (entity_type, entity_id, approval_type, status, submitted_by_user_id)
VALUES ($1, $2, $3, 'PENDING', $4)
ON CONFLICT (entity_type, entity_id) DO UPDATE
SET status = 'PENDING', updated_at = NOW()
"#,
)
.bind(entity_type)
.bind(entity_id)
.bind(approval_type)
.bind(verification.user_id)
.execute(pool)
.await?;
Ok(())
}
pub fn router() -> Router<AppState> {
Router::new()
.route("/", get(list_verifications))
@ -123,22 +173,44 @@ async fn trigger_rejection(
_ => return Ok(()),
};
let user_role_profile_id = match sqlx::query_scalar::<_, Uuid>(
"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2",
)
.bind(user_id)
.bind(&role_key)
.fetch_optional(&state.pool)
.await?
{
Some(id) => id,
None => return Ok(()),
};
let query = format!(
"UPDATE {} SET status = 'REJECTED', updated_at = NOW() WHERE user_id = $1",
"UPDATE {} SET status = 'REJECTED', updated_at = NOW() WHERE id = $1",
table
);
sqlx::query(&query).bind(user_id).execute(&state.pool).await?;
sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?;
// Send Email
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, user_id).await {
let display = role_key_to_display(&role_key);
let _ = state.mail.send_approval_rejected_email(
&user.email,
user.full_name.as_deref().unwrap_or_default(),
&display,
reason_str
).await;
}
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, user_id).await {
let display = role_key_to_display(&role_key);
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_approval_rejected_email(&user.email, &user_name, &display, reason_str).await;
}
// Send in-app notification
sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(user_id)
.bind("Profile Verification Update")
.bind(format!("Your {} profile was not approved. Reason: {}", role_key_to_display(&role_key), reason_str))
.bind("VERIFICATION")
.bind(user_id)
.execute(&state.pool)
.await
.ok();
}
Ok(())
@ -165,15 +237,35 @@ async fn approve_verification(
.await
{
Ok(v) => {
// Send approval email
// Create an entry in approval_requests so it appears in Approval Management
// for the second-level review (final approval/rejection)
if let Err(e) = create_approval_request_from_verification(&state.pool, &v).await {
eprintln!("Failed to create approval request: {}", e);
}
// Send notification that verification passed first stage
// (Approval Management will handle final approval email)
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, v.user_id).await {
let display = role_key_to_display(&v.role_key);
let _ = state.mail.send_approval_approved_email(
&user.email,
user.full_name.as_deref().unwrap_or_default(),
&display
).await;
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
// Use a "verification passed" notification instead of final approval
let _ = state.mail.send_approval_approved_email(&user.email, &user_name, &display).await;
}
// Send in-app notification - profile verified, pending final approval
sqlx::query(
r#"INSERT INTO notifications (user_id, title, body, type, reference_id)
VALUES ($1, $2, $3, $4, $5)"#,
)
.bind(v.user_id)
.bind("Profile Verified — Pending Final Approval")
.bind(format!("Your {} profile has been verified and is now pending final approval. You'll be notified once approved.", role_key_to_display(&v.role_key)))
.bind("VERIFICATION")
.bind(v.id)
.execute(&state.pool)
.await
.ok();
(StatusCode::OK, Json(v)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
@ -282,12 +374,8 @@ async fn request_documents(
// Send email notification
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, v.user_id).await {
let display = role_key_to_display(&v.role_key);
let _ = state.mail.send_documents_requested_email(
&user.email,
user.full_name.as_deref().unwrap_or_default(),
&display,
&payload.message
).await;
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_documents_requested_email(&user.email, &user_name, &display, &payload.message).await;
}
(StatusCode::OK, Json(v)).into_response()
@ -332,6 +420,13 @@ async fn request_revision(
.await
.ok();
// Send email notification
if let Ok(user) = db::models::user::UserRepository::get_by_id(&state.pool, v.user_id).await {
let display = role_key_to_display(&v.role_key);
let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default());
let _ = state.mail.send_revision_requested_email(&user.email, &user_name, &display, &payload.message).await;
}
(StatusCode::OK, Json(v)).into_response()
}
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),

View file

@ -54,10 +54,15 @@ async fn main() {
let app = Router::new()
// ── Auth ─────────────────────────────────────────────────────────
.nest("/api/auth", handlers::auth::router())
// ── V1 API (backward compatibility) ───────────────────────────────
.nest("/api/v1/users", handlers::auth::v1_router())
// ── Roles & User Self-Service ─────────────────────────────────────
.nest("/api/admin/roles", handlers::roles::router())
.nest("/api/admin/permissions", handlers::permissions::router())
.nest("/api/admin/external-roles", handlers::external_roles::router())
.merge(handlers::modules::persona_types_router())
.merge(handlers::modules::modules_router())
.merge(handlers::modules::role_modules_router())
.nest("/api/admin/users", handlers::admin::router())
.nest("/api/me/roles", handlers::user_roles::router())
// ── Notifications ─────────────────────────────────────────────────
@ -104,6 +109,8 @@ async fn main() {
.nest("/api/admin/reports", handlers::pricing::reports_router())
// ── Email Management (admin) ──────────────────────────────────────
.nest("/api/admin/email", handlers::admin_email::router())
// ── AI Assistant ──────────────────────────────────────────────────
.nest("/api/ai", handlers::ai::ai_router())
.route("/health", get(|| async { "Users OK" }))
.with_state(state);

View file

@ -16,4 +16,5 @@ db = { path = "../../crates/db" }
auth = { path = "../../crates/auth" }
contracts = { path = "../../crates/contracts" }
cache = { path = "../../crates/cache" }
storage = { path = "../../crates/storage" }

View file

@ -1,5 +1,5 @@
use contracts::ProfessionState;
use db::models::video_editor::VideoEditorProfile;
use db::models::user_role_profile::UserRoleProfile;
use axum::{extract::{Path, State}, http::StatusCode, response::IntoResponse, routing::get, Json, Router};
use serde::Serialize;
use uuid::Uuid;
@ -7,6 +7,7 @@ use uuid::Uuid;
#[derive(Serialize)]
pub struct AdminVideoEditorList {
pub id: Uuid,
pub user_role_profile_id: Uuid,
pub user_id: Uuid,
pub display_name: Option<String>,
pub bio: Option<String>,
@ -16,10 +17,11 @@ pub struct AdminVideoEditorList {
pub updated_at: chrono::DateTime<chrono::Utc>,
}
impl From<VideoEditorProfile> for AdminVideoEditorList {
fn from(p: VideoEditorProfile) -> Self {
impl From<UserRoleProfile> for AdminVideoEditorList {
fn from(p: UserRoleProfile) -> Self {
Self {
id: p.id,
user_role_profile_id: p.id,
user_id: p.user_id,
display_name: p.display_name,
bio: p.bio,
@ -40,10 +42,15 @@ pub fn router() -> Router<ProfessionState> {
async fn list_video_editors(
State(state): State<ProfessionState>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let editors = sqlx::query_as::<_, VideoEditorProfile>(
let editors = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at
FROM video_editor_profiles
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE role_key = 'video_editor'
ORDER BY created_at DESC
LIMIT 100
"#,
@ -60,11 +67,15 @@ async fn get_video_editor(
State(state): State<ProfessionState>,
Path(id): Path<Uuid>,
) -> Result<impl IntoResponse, (StatusCode, String)> {
let editor = sqlx::query_as::<_, VideoEditorProfile>(
let editor = sqlx::query_as::<_, UserRoleProfile>(
r#"
SELECT id, user_id, display_name, bio, location, custom_data, status, created_at, updated_at
FROM video_editor_profiles
WHERE id = $1
SELECT id, user_id, role_key, display_name, bio, location,
avatar_url, phone, email, status,
verification_status, approval_status, rejection_reason,
approved_at, verified_at, is_profile_public,
created_at, updated_at
FROM user_role_profiles
WHERE id = $1 AND role_key = 'video_editor'
"#,
)
.bind(id)

View file

@ -21,7 +21,7 @@ async fn update_profile(
auth: AuthUser,
Json(payload): Json<UpsertVideoEditorProfilePayload>,
) -> impl IntoResponse {
match VideoEditorRepository::upsert(&state.pool, auth.user_id, payload).await {
match VideoEditorRepository::upsert_by_user_id(&state.pool, auth.user_id, payload).await {
Ok(p) => (StatusCode::OK, Json(p)).into_response(),
Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(),
}

View file

@ -3,6 +3,7 @@ mod admin;
use axum::{routing::get, Router};
use std::net::SocketAddr;
use std::sync::Arc;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
use contracts::ProfessionState;
@ -30,7 +31,8 @@ async fn main() {
tracing::info!("Video Editors service — connected to DB and Redis");
let state = ProfessionState { pool, redis };
let storage = Arc::new(storage::StorageClient::from_env().await);
let state = ProfessionState { pool, redis, storage };
let app = Router::new()
.nest("/api/video-editors", handlers::router())

1
companies.pid Normal file
View file

@ -0,0 +1 @@
9692

View file

@ -0,0 +1,15 @@
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, SaltString},
Argon2,
};
fn main() {
let password = std::env::args().nth(1).unwrap_or_default();
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hashed = argon2
.hash_password(password.as_bytes(), &salt)
.unwrap()
.to_string();
println!("{}", hashed);
}

View file

@ -0,0 +1,23 @@
use argon2::{
password_hash::{rand_core::OsRng, PasswordHash, PasswordHasher, PasswordVerifier, SaltString},
Argon2,
};
fn main() {
// Generate hash for Admin@nxtgauge1
let password = "Admin@nxtgauge1";
let salt = SaltString::generate(&mut OsRng);
let argon2 = Argon2::default();
let hashed = argon2.hash_password(password.as_bytes(), &salt).unwrap().to_string();
println!("Generated hash: {}", hashed);
// Verify it
let parsed_hash = PasswordHash::new(&hashed).unwrap();
let result = argon2.verify_password(password.as_bytes(), &parsed_hash);
println!("Verify result: {:?}", result.is_ok());
// Also test with a known hash format from the example
let known_hash = "$argon2id$v=19$m=19456,t=2,p=1$lNkVG5s+qYFEtzYMqgTfoQ$xlCVvu8mUrVhBudqW1MDbjwcY+Sp6Wbe4vBXZBeaKPI";
let parsed_known = PasswordHash::new(known_hash);
println!("Parse known hash result: {:?}", parsed_known.is_ok());
}

80
crates/cache/src/ai.rs vendored Normal file
View file

@ -0,0 +1,80 @@
//! Redis caching for AI generation rate limiting and response caching.
//!
//! Key patterns:
//! - `ai:rate:{user_id}` - sliding window counter for rate limiting
//! - `ai:resp:{hash}` - cached AI response (by prompt hash)
use redis::AsyncCommands;
use crate::RedisPool;
const AI_RATE_WINDOW_SECS: i64 = 86_400; // 24 hours
const AI_CACHE_TTL_SECS: i64 = 3_600; // 1 hour
/// Check + increment AI generation rate limit counter.
/// Uses a simple counter with TTL reset on first write.
///
/// Returns `Ok(true)` if allowed, `Ok(false)` if rate limited.
pub async fn check_ai_rate_limit(
redis: &mut RedisPool,
user_id: &str,
max_generations: i64,
) -> Result<bool, redis::RedisError> {
let key = format!("ai:rate:{}", user_id);
let count: i64 = redis.incr(&key, 1i64).await?;
if count == 1 {
redis.expire::<_, ()>(&key, AI_RATE_WINDOW_SECS).await?;
}
Ok(count <= max_generations)
}
/// Get current AI generation count for a user.
pub async fn get_ai_usage(
redis: &mut RedisPool,
user_id: &str,
) -> Result<i64, redis::RedisError> {
let key = format!("ai:rate:{}", user_id);
let count: Option<i64> = redis.get(&key).await?;
Ok(count.unwrap_or(0))
}
/// Store AI-generated response in cache.
pub async fn cache_ai_response(
redis: &mut RedisPool,
prompt_hash: &str,
response: &str,
) -> Result<(), redis::RedisError> {
let key = format!("ai:resp:{}", prompt_hash);
let ttl: u64 = AI_CACHE_TTL_SECS.try_into().unwrap();
let _: () = redis.set_ex(&key, response, ttl).await?;
Ok(())
}
/// Get cached AI response if available.
pub async fn get_cached_ai_response(
redis: &mut RedisPool,
prompt_hash: &str,
) -> Result<Option<String>, redis::RedisError> {
let key = format!("ai:resp:{}", prompt_hash);
let result: Option<String> = redis.get(&key).await?;
Ok(result)
}
/// Invalidate cached AI response.
pub async fn invalidate_ai_cache(
redis: &mut RedisPool,
prompt_hash: &str,
) -> Result<(), redis::RedisError> {
let key = format!("ai:resp:{}", prompt_hash);
let _: () = redis.del(&key).await?;
Ok(())
}
/// Reset daily AI usage counter (called at start of new day or when daily limit changes).
pub async fn reset_daily_usage(
redis: &mut RedisPool,
user_id: &str,
) -> Result<(), redis::RedisError> {
let key = format!("ai:rate:{}", user_id);
let _: () = redis.del(&key).await?;
Ok(())
}

Some files were not shown because too many files have changed in this diff Show more