Resolve conflicts: remove Woodpecker CI, use Gitea
This commit is contained in:
commit
81d1df70a8
196 changed files with 16559 additions and 3014 deletions
277
.gitea/scripts/registry_prune.py
Normal file
277
.gitea/scripts/registry_prune.py
Normal 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()
|
||||
145
.gitea/scripts/update-gitops.py
Normal file
145
.gitea/scripts/update-gitops.py
Normal 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
273
.gitea/workflows/build.yaml
Normal 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
46
.github/workflows/sync-to-gitea.yml
vendored
Normal 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
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
65
Cargo.lock
generated
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
28
Dockerfile.migrate
Normal 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"]
|
||||
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -16,4 +16,5 @@ db = { path = "../../crates/db" }
|
|||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
cache = { path = "../../crates/cache" }
|
||||
storage = { path = "../../crates/storage" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"#,
|
||||
|
|
|
|||
|
|
@ -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) => {
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(())
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"#,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -16,4 +16,5 @@ db = { path = "../../crates/db" }
|
|||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
cache = { path = "../../crates/cache" }
|
||||
storage = { path = "../../crates/storage" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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" })))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -16,4 +16,5 @@ db = { path = "../../crates/db" }
|
|||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
cache = { path = "../../crates/cache" }
|
||||
storage = { path = "../../crates/storage" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
21
apps/jobs/Cargo.toml
Normal 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
136
apps/jobs/src/main.rs
Normal 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
22
apps/leads/Cargo.toml
Normal 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"
|
||||
746
apps/leads/src/lead_requests.rs
Normal file
746
apps/leads/src/lead_requests.rs
Normal 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
150
apps/leads/src/main.rs
Normal 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();
|
||||
}
|
||||
|
|
@ -16,4 +16,5 @@ db = { path = "../../crates/db" }
|
|||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
cache = { path = "../../crates/cache" }
|
||||
storage = { path = "../../crates/storage" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
418
apps/payments/src/packages.rs
Normal file
418
apps/payments/src/packages.rs
Normal 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()
|
||||
}
|
||||
|
|
@ -16,4 +16,5 @@ db = { path = "../../crates/db" }
|
|||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
cache = { path = "../../crates/cache" }
|
||||
storage = { path = "../../crates/storage" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -16,4 +16,5 @@ db = { path = "../../crates/db" }
|
|||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
cache = { path = "../../crates/cache" }
|
||||
storage = { path = "../../crates/storage" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -16,4 +16,5 @@ db = { path = "../../crates/db" }
|
|||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
cache = { path = "../../crates/cache" }
|
||||
storage = { path = "../../crates/storage" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
|
|
|
|||
|
|
@ -20,4 +20,5 @@ contracts = { path = "../../crates/contracts" }
|
|||
cache = { path = "../../crates/cache" }
|
||||
rand = "0.8"
|
||||
anyhow = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
"#;
|
||||
|
|
|
|||
|
|
@ -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!({
|
||||
|
|
|
|||
1473
apps/users/src/handlers/ai.rs
Normal file
1473
apps/users/src/handlers/ai.rs
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -139,6 +139,7 @@ struct ExistingCouponRow {
|
|||
}
|
||||
|
||||
#[derive(sqlx::FromRow)]
|
||||
#[allow(dead_code)]
|
||||
struct ValidateCouponRow {
|
||||
id: Uuid,
|
||||
code: String,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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()));
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>(
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
263
apps/users/src/handlers/modules.rs
Normal file
263
apps/users/src/handlers/modules.rs
Normal 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)
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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"];
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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())
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
||||
|
|
|
|||
|
|
@ -16,4 +16,5 @@ db = { path = "../../crates/db" }
|
|||
auth = { path = "../../crates/auth" }
|
||||
contracts = { path = "../../crates/contracts" }
|
||||
cache = { path = "../../crates/cache" }
|
||||
storage = { path = "../../crates/storage" }
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
1
companies.pid
Normal file
|
|
@ -0,0 +1 @@
|
|||
9692
|
||||
15
crates/auth/examples/hash_gen.rs
Normal file
15
crates/auth/examples/hash_gen.rs
Normal 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);
|
||||
}
|
||||
23
crates/auth/examples/test_verify.rs
Normal file
23
crates/auth/examples/test_verify.rs
Normal 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
80
crates/cache/src/ai.rs
vendored
Normal 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
Loading…
Add table
Reference in a new issue