From 8a25eab1b458a17264acfebc3f39a47776a3e8b7 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Sun, 12 Apr 2026 12:17:08 +0200 Subject: [PATCH 001/182] fix(ci): use REGISTRY_HOSTPORT secret for private registry --- .woodpecker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 6225a2f..b75e0fd 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -27,7 +27,7 @@ steps: - name: build-and-push image: woodpeckerci/plugin-kaniko:2.1.1 settings: - registry: registry.nxtgauge.internal:5000 + registry: ${REGISTRY_HOSTPORT} repo: nxtgauge-rust-${SERVICE} dockerfile: Dockerfile.simple build_args: From 588ddbef7e891b2445531b56cd9993ac808407bd Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Sun, 12 Apr 2026 12:40:51 +0200 Subject: [PATCH 002/182] chore(ci): trigger pipeline with updated registry secrets From ca9f9acf79c9cd8d611ed929c1e5f71ffcd73a52 Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Sun, 12 Apr 2026 13:39:29 +0200 Subject: [PATCH 003/182] fix(ci): redirect docker-buildx to private registry - Use REGISTRY_HOSTPORT for registry (not docker.io or ghcr.io) - Use REGISTRY_USERNAME/REGISTRY_PASSWORD for auth - Fix repo path to not include full URL or username prefix --- .woodpecker-base.yml | 9 +++++---- .woodpecker-dockerhub.yml | 9 +++++---- 2 files changed, 10 insertions(+), 8 deletions(-) diff --git a/.woodpecker-base.yml b/.woodpecker-base.yml index 278bdd2..796439e 100644 --- a/.woodpecker-base.yml +++ b/.woodpecker-base.yml @@ -10,15 +10,16 @@ steps: - name: build-base-image image: woodpeckerci/plugin-docker-buildx:5.0.0 settings: - registry: ghcr.io - repo: ghcr.io/traceworks2023/nxtgauge-rust-base + registry: + from_secret: REGISTRY_HOSTPORT + repo: nxtgauge-rust-base context: . dockerfile: Dockerfile.base tags: - latest - ${CI_COMMIT_SHA} username: - from_secret: GHCR_USERNAME + from_secret: REGISTRY_USERNAME password: - from_secret: GHCR_TOKEN + from_secret: REGISTRY_PASSWORD platforms: linux/amd64 diff --git a/.woodpecker-dockerhub.yml b/.woodpecker-dockerhub.yml index 93f47e0..07f6b52 100644 --- a/.woodpecker-dockerhub.yml +++ b/.woodpecker-dockerhub.yml @@ -87,8 +87,9 @@ steps: - name: build-docker image: woodpeckerci/plugin-docker-buildx:5.0.0 settings: - registry: docker.io - repo: your-dockerhub-username/nxtgauge-rust-${SERVICE} + registry: + from_secret: REGISTRY_HOSTPORT + repo: nxtgauge-rust-${SERVICE} dockerfile: Dockerfile.binary build_args: - SERVICE_NAME=${SERVICE} @@ -96,7 +97,7 @@ steps: - ${CI_COMMIT_SHA} - latest username: - from_secret: DOCKERHUB_USERNAME + from_secret: REGISTRY_USERNAME password: - from_secret: DOCKERHUB_TOKEN + from_secret: REGISTRY_PASSWORD platforms: linux/amd64 From 7ee2b21e74c52621f635fb26f25f8b4f75102c2a Mon Sep 17 00:00:00 2001 From: Ashwin Kumar Date: Sun, 12 Apr 2026 13:44:23 +0200 Subject: [PATCH 004/182] fix(ci): use from_secret for REGISTRY_HOSTPORT in kaniko --- .woodpecker.yml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index b75e0fd..1acba5b 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -27,7 +27,8 @@ steps: - name: build-and-push image: woodpeckerci/plugin-kaniko:2.1.1 settings: - registry: ${REGISTRY_HOSTPORT} + registry: + from_secret: REGISTRY_HOSTPORT repo: nxtgauge-rust-${SERVICE} dockerfile: Dockerfile.simple build_args: From 8d920c4b34a72a62472daeed707f721f1370f661 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 12 Apr 2026 16:58:07 +0200 Subject: [PATCH 005/182] fix(users): update Axum routes to 0.7 syntax for email templates --- apps/users/src/handlers/admin_email.rs | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/users/src/handlers/admin_email.rs b/apps/users/src/handlers/admin_email.rs index bd63c70..5eed7d9 100644 --- a/apps/users/src/handlers/admin_email.rs +++ b/apps/users/src/handlers/admin_email.rs @@ -12,8 +12,8 @@ use crate::AppState; pub fn router() -> Router { 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("/smtp-config", get(get_smtp_config).post(update_smtp_config)) .route("/smtp-test", post(test_smtp_connection)) } From 019613aa82b06f6b101857550b3f7370ada78de7 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 12 Apr 2026 18:08:56 +0200 Subject: [PATCH 006/182] chore(ci): trigger rebuild after registry and backend fixes From dade35b328554181797bf4e64d2f20d936b8c7e6 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 12 Apr 2026 21:57:28 +0200 Subject: [PATCH 007/182] feat: add db-migrate tool for running SQL migrations - Create db-migrate binary that runs all .up.sql migration files - Add Dockerfile.migrate for building the migration image - Add migration job to Woodpecker CI pipeline - Image will be pushed to registry.nxtgauge.com:5000/nxtgauge-db-migrate --- .woodpecker.yml | 28 ++++++++++++++ Cargo.toml | 3 +- Dockerfile.migrate | 22 +++++++++++ crates/db-migrate/Cargo.toml | 12 ++++++ crates/db-migrate/src/main.rs | 72 +++++++++++++++++++++++++++++++++++ 5 files changed, 136 insertions(+), 1 deletion(-) create mode 100644 Dockerfile.migrate create mode 100644 crates/db-migrate/Cargo.toml create mode 100644 crates/db-migrate/src/main.rs diff --git a/.woodpecker.yml b/.woodpecker.yml index 1acba5b..16e9e3b 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -46,3 +46,31 @@ steps: skip_tls_verify: true platforms: linux/amd64 cache: false + +--- +when: + branch: [main, high-performance] + event: push + +steps: + - name: build-and-push-migrate + image: woodpeckerci/plugin-kaniko:2.1.1 + settings: + registry: + from_secret: REGISTRY_HOSTPORT + repo: nxtgauge-db-migrate + dockerfile: Dockerfile.migrate + context: . + 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 diff --git a/Cargo.toml b/Cargo.toml index 1ccceb7..8c954d6 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -24,7 +24,8 @@ members = [ "crates/email", "apps/cron", "apps/employees", - "apps/payments" + "apps/payments", + "crates/db-migrate" ] [workspace.package] diff --git a/Dockerfile.migrate b/Dockerfile.migrate new file mode 100644 index 0000000..c5ef565 --- /dev/null +++ b/Dockerfile.migrate @@ -0,0 +1,22 @@ +FROM rust:1.75-alpine AS builder + +WORKDIR /app + +RUN apk add --no-cache musl-dev pkgconfig openssl-dev + +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 +RUN cargo build --release --bin db-migrate + +FROM alpine:3.19 +RUN apk add --no-cache ca-certificates libpq + +COPY --from=builder /app/crates/db-migrate/target/release/db-migrate /usr/local/bin/ +COPY crates/db/migrations /migrations + +ENTRYPOINT ["db-migrate"] diff --git a/crates/db-migrate/Cargo.toml b/crates/db-migrate/Cargo.toml new file mode 100644 index 0000000..033c9ac --- /dev/null +++ b/crates/db-migrate/Cargo.toml @@ -0,0 +1,12 @@ +[package] +name = "db-migrate" +version = "0.1.0" +edition = "2021" + +[dependencies] +sqlx = { workspace = true } +tracing = { workspace = true } +tracing-subscriber = { version = "0.3", features = ["env-filter", "fmt"] } +anyhow = { workspace = true } +tokio = { workspace = true, features = ["full"] } +serde = { workspace = true } diff --git a/crates/db-migrate/src/main.rs b/crates/db-migrate/src/main.rs new file mode 100644 index 0000000..343171b --- /dev/null +++ b/crates/db-migrate/src/main.rs @@ -0,0 +1,72 @@ +use std::path::Path; +use anyhow::{Context, Result}; + +#[tokio::main] +async fn main() -> Result<()> { + 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") + .context("DATABASE_URL must be set")?; + + tracing::info!("Connecting to database..."); + let pool = sqlx::postgres::PgPoolOptions::new() + .max_connections(1) + .connect(&database_url) + .await + .context("Failed to connect to database")?; + tracing::info!("Connected to database"); + + let migrations_dir = std::env::var("MIGRATIONS_DIR") + .unwrap_or_else(|_| "/migrations".to_string()); + + run_migrations(&pool, &migrations_dir).await?; + + tracing::info!("All migrations completed successfully!"); + Ok(()) +} + +async fn run_migrations(pool: &sqlx::PgPool, migrations_dir: &str) -> Result<()> { + let migrations_path = Path::new(migrations_dir); + + if !migrations_path.exists() { + tracing::warn!("Migrations directory does not exist: {}", migrations_dir); + return Ok(()); + } + + let mut entries: Vec<_> = std::fs::read_dir(migrations_path)? + .filter_map(|e| e.ok()) + .filter(|e| { + let name = e.file_name(); + let name_str = name.to_string_lossy(); + name_str.ends_with(".up.sql") + }) + .collect(); + + entries.sort_by_key(|e| e.file_name()); + + tracing::info!("Found {} migration files", entries.len()); + + for entry in entries { + let file_name = entry.file_name(); + let file_path = entry.path(); + + tracing::info!("Applying migration: {}", file_name.to_string_lossy()); + + let sql = std::fs::read_to_string(&file_path) + .with_context(|| format!("Failed to read migration: {}", file_name.to_string_lossy()))?; + + sqlx::raw_sql(&sql) + .execute(pool) + .await + .with_context(|| format!("Failed to execute migration: {}", file_name.to_string_lossy()))?; + + tracing::info!("Applied migration: {}", file_name.to_string_lossy()); + } + + Ok(()) +} From 81c00eca96467351bf6a4d03fd8c94b2be3ddc86 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 12 Apr 2026 21:58:57 +0200 Subject: [PATCH 008/182] feat(db-migrate): add DROP_EXISTING_TABLES support When DROP_EXISTING_TABLES=true, drop all existing tables before running migrations. --- crates/db-migrate/src/main.rs | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/crates/db-migrate/src/main.rs b/crates/db-migrate/src/main.rs index 343171b..a250a20 100644 --- a/crates/db-migrate/src/main.rs +++ b/crates/db-migrate/src/main.rs @@ -21,6 +21,14 @@ async fn main() -> Result<()> { .context("Failed to connect to database")?; tracing::info!("Connected to database"); + let drop_existing = std::env::var("DROP_EXISTING_TABLES") + .unwrap_or_default() + .to_lowercase(); + + if drop_existing == "true" || drop_existing == "1" || drop_existing == "yes" { + drop_all_tables(&pool).await?; + } + let migrations_dir = std::env::var("MIGRATIONS_DIR") .unwrap_or_else(|_| "/migrations".to_string()); @@ -30,6 +38,30 @@ async fn main() -> Result<()> { Ok(()) } +async fn drop_all_tables(pool: &sqlx::PgPool) -> Result<()> { + tracing::warn!("DROP_EXISTING_TABLES is enabled - dropping all tables!"); + + let rows: Vec<(String,)> = sqlx::query_as("SELECT tablename FROM pg_tables WHERE schemaname = 'public'") + .fetch_all(pool) + .await?; + + if rows.is_empty() { + tracing::info!("No tables to drop"); + return Ok(()); + } + + tracing::info!("Found {} tables to drop", rows.len()); + + for (table_name,) in rows { + let sql = format!("DROP TABLE IF EXISTS \"{}\" CASCADE", table_name); + tracing::info!("Dropping table: {}", table_name); + sqlx::raw_sql(&sql).execute(pool).await?; + } + + tracing::info!("All tables dropped successfully"); + Ok(()) +} + async fn run_migrations(pool: &sqlx::PgPool, migrations_dir: &str) -> Result<()> { let migrations_path = Path::new(migrations_dir); From 1e6abd9397431423d5b040b4f6f344a90b099939 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 12 Apr 2026 22:03:06 +0200 Subject: [PATCH 009/182] feat: add separate jobs and leads services - Create jobs service (port 9103) for job postings management - Create leads service (port 9118) for lead/requirement management - Update gateway to route /api/jobs to jobs service - Update gateway to route /api/leads to leads service - Add jobs and leads to Woodpecker CI matrix --- .woodpecker.yml | 2 + Cargo.toml | 3 + apps/gateway/src/main.rs | 22 ++++++- apps/jobs/Cargo.toml | 21 ++++++ apps/jobs/src/main.rs | 136 +++++++++++++++++++++++++++++++++++++++ apps/leads/Cargo.toml | 21 ++++++ apps/leads/src/main.rs | 136 +++++++++++++++++++++++++++++++++++++++ docker-compose.yml | 26 ++++++++ 8 files changed, 364 insertions(+), 3 deletions(-) create mode 100644 apps/jobs/Cargo.toml create mode 100644 apps/jobs/src/main.rs create mode 100644 apps/leads/Cargo.toml create mode 100644 apps/leads/src/main.rs diff --git a/.woodpecker.yml b/.woodpecker.yml index 16e9e3b..53493c4 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -7,6 +7,8 @@ matrix: - gateway - users - companies + - jobs + - leads - job-seekers - customers - payments diff --git a/Cargo.toml b/Cargo.toml index 8c954d6..dcf1d42 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -25,6 +25,8 @@ members = [ "apps/cron", "apps/employees", "apps/payments", + "apps/jobs", + "apps/leads", "crates/db-migrate" ] @@ -51,3 +53,4 @@ lettre = { version = "0.11", default-features = false, features = ["tokio1-rustl redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } async-trait = "0.1" bytes = "1" +tower-http = "0.6" diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index f305c0a..3433561 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -14,6 +14,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 +43,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") @@ -115,17 +121,27 @@ 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()) } + // 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()) + } // Job Seekers else if path.starts_with("/api/jobseeker") { Some(self.job_seekers_url.clone()) diff --git a/apps/jobs/Cargo.toml b/apps/jobs/Cargo.toml new file mode 100644 index 0000000..72c437c --- /dev/null +++ b/apps/jobs/Cargo.toml @@ -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" diff --git a/apps/jobs/src/main.rs b/apps/jobs/src/main.rs new file mode 100644 index 0000000..3cd4ff7 --- /dev/null +++ b/apps/jobs/src/main.rs @@ -0,0 +1,136 @@ +use axum::{ + extract::State, + http::StatusCode, + routing::{get, post, put, delete}, + 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)] +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, +} + +#[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>) -> Result>, 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>, + Json(payload): Json, +) -> Result, 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>, + axum::extract::Path(id): axum::extract::Path, +) -> Result, 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(); +} diff --git a/apps/leads/Cargo.toml b/apps/leads/Cargo.toml new file mode 100644 index 0000000..144c352 --- /dev/null +++ b/apps/leads/Cargo.toml @@ -0,0 +1,21 @@ +[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"] } + +[[bin]] +name = "leads" +path = "src/main.rs" diff --git a/apps/leads/src/main.rs b/apps/leads/src/main.rs new file mode 100644 index 0000000..9aac078 --- /dev/null +++ b/apps/leads/src/main.rs @@ -0,0 +1,136 @@ +use axum::{ + extract::State, + http::StatusCode, + routing::{get, post, put, delete}, + 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)] +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, +} + +#[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>) -> Result>, StatusCode> { + let leads = sqlx::query_as::<_, Lead>( + "SELECT id, title, description, location, profession_key, status, created_at FROM requirements 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>, + Json(payload): Json, +) -> Result, StatusCode> { + let lead = sqlx::query_as::<_, Lead>( + r#" + INSERT INTO requirements (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>, + axum::extract::Path(id): axum::extract::Path, +) -> Result, StatusCode> { + let lead = sqlx::query_as::<_, Lead>( + "SELECT id, title, description, location, profession_key, status, created_at FROM requirements 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 }); + + 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)) + .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(); +} diff --git a/docker-compose.yml b/docker-compose.yml index 906ff56..fb280e9 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -48,6 +48,8 @@ services: ADMIN_URL: http://localhost:9202 USERS_SERVICE_URL: http://users:9101 COMPANIES_SERVICE_URL: http://companies:9102 + JOBS_SERVICE_URL: http://jobs:9103 + LEADS_SERVICE_URL: http://leads:9118 JOB_SEEKERS_SERVICE_URL: http://job-seekers:9104 CUSTOMERS_SERVICE_URL: http://customers:9105 EMPLOYEES_SERVICE_URL: http://employees:9106 @@ -71,6 +73,10 @@ services: condition: service_started companies: condition: service_started + jobs: + condition: service_started + leads: + condition: service_started job-seekers: condition: service_started customers: @@ -130,6 +136,26 @@ services: redis: condition: service_healthy + jobs: + platform: linux/amd64 + image: ghcr.io/traceworks2023/nxtgauge-rust-jobs:high-performance-latest + environment: + PORT: "9103" + DATABASE_URL: postgresql://nxtgauge:nxtgauge_dev@postgres:5432/nxtgauge_db + depends_on: + postgres: + condition: service_healthy + + leads: + platform: linux/amd64 + image: ghcr.io/traceworks2023/nxtgauge-rust-leads:high-performance-latest + environment: + PORT: "9118" + DATABASE_URL: postgresql://nxtgauge:nxtgauge_dev@postgres:5432/nxtgauge_db + depends_on: + postgres: + condition: service_healthy + job-seekers: platform: linux/amd64 image: ghcr.io/traceworks2023/nxtgauge-rust-job-seekers:high-performance-latest From 79fbba8107cc7e503c3dc4507717535b954aea85 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 12 Apr 2026 22:04:04 +0200 Subject: [PATCH 010/182] chore(ci): trigger rebuild for jobs and leads services From 03376b95677717548e706e88e8b15a5cd4d56750 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 12 Apr 2026 23:21:11 +0200 Subject: [PATCH 011/182] feat: Add database redesign documentation and Phase 1-2 migrations - Add schema_audit.md documenting current schema issues - Add target_schema.md with complete target schema design - Add old_to_new_mapping.md with table mapping - Add migration_plan.md with phased migration strategy - Add Phase 1 migrations (core infrastructure): - user_sessions table - users missing columns - departments updates - designations updates - employees updates - Add Phase 2 migrations (profile domain - CRITICAL): - create user_role_profiles root table - backfill user_role_profiles from existing profiles - add user_role_profile_id to extension tables - remove forbidden external portfolio links - Add user_role_profile Rust model - Update photographer model to use user_role_profile_id --- ...260415000001_create_user_sessions.down.sql | 2 + ...20260415000001_create_user_sessions.up.sql | 16 + ...5000002_add_users_missing_columns.down.sql | 4 + ...415000002_add_users_missing_columns.up.sql | 8 + ...20260415000003_update_departments.down.sql | 11 + .../20260415000003_update_departments.up.sql | 15 + ...0260415000004_update_designations.down.sql | 12 + .../20260415000004_update_designations.up.sql | 16 + .../20260415000005_update_employees.down.sql | 8 + .../20260415000005_update_employees.up.sql | 12 + ...5010001_create_user_role_profiles.down.sql | 3 + ...415010001_create_user_role_profiles.up.sql | 31 + ...10002_backfill_user_role_profiles.down.sql | 2 + ...5010002_backfill_user_role_profiles.up.sql | 262 ++++ ...15010003_add_user_role_profile_id.down.sql | 23 + ...0415010003_add_user_role_profile_id.up.sql | 85 ++ ...60415010004_remove_external_links.down.sql | 3 + ...0260415010004_remove_external_links.up.sql | 31 + crates/db/src/models/mod.rs | 1 + crates/db/src/models/photographer.rs | 75 +- crates/db/src/models/user_role_profile.rs | 237 ++++ docs/migration_plan.md | 767 ++++++++++++ docs/old_to_new_mapping.md | 508 ++++++++ docs/schema_audit.md | 216 ++++ docs/target_schema.md | 1052 +++++++++++++++++ 25 files changed, 3367 insertions(+), 33 deletions(-) create mode 100644 crates/db/migrations/20260415000001_create_user_sessions.down.sql create mode 100644 crates/db/migrations/20260415000001_create_user_sessions.up.sql create mode 100644 crates/db/migrations/20260415000002_add_users_missing_columns.down.sql create mode 100644 crates/db/migrations/20260415000002_add_users_missing_columns.up.sql create mode 100644 crates/db/migrations/20260415000003_update_departments.down.sql create mode 100644 crates/db/migrations/20260415000003_update_departments.up.sql create mode 100644 crates/db/migrations/20260415000004_update_designations.down.sql create mode 100644 crates/db/migrations/20260415000004_update_designations.up.sql create mode 100644 crates/db/migrations/20260415000005_update_employees.down.sql create mode 100644 crates/db/migrations/20260415000005_update_employees.up.sql create mode 100644 crates/db/migrations/20260415010001_create_user_role_profiles.down.sql create mode 100644 crates/db/migrations/20260415010001_create_user_role_profiles.up.sql create mode 100644 crates/db/migrations/20260415010002_backfill_user_role_profiles.down.sql create mode 100644 crates/db/migrations/20260415010002_backfill_user_role_profiles.up.sql create mode 100644 crates/db/migrations/20260415010003_add_user_role_profile_id.down.sql create mode 100644 crates/db/migrations/20260415010003_add_user_role_profile_id.up.sql create mode 100644 crates/db/migrations/20260415010004_remove_external_links.down.sql create mode 100644 crates/db/migrations/20260415010004_remove_external_links.up.sql create mode 100644 crates/db/src/models/user_role_profile.rs create mode 100644 docs/migration_plan.md create mode 100644 docs/old_to_new_mapping.md create mode 100644 docs/schema_audit.md create mode 100644 docs/target_schema.md diff --git a/crates/db/migrations/20260415000001_create_user_sessions.down.sql b/crates/db/migrations/20260415000001_create_user_sessions.down.sql new file mode 100644 index 0000000..141c136 --- /dev/null +++ b/crates/db/migrations/20260415000001_create_user_sessions.down.sql @@ -0,0 +1,2 @@ +-- Rollback: Drop user_sessions table +DROP TABLE IF EXISTS user_sessions; diff --git a/crates/db/migrations/20260415000001_create_user_sessions.up.sql b/crates/db/migrations/20260415000001_create_user_sessions.up.sql new file mode 100644 index 0000000..150980b --- /dev/null +++ b/crates/db/migrations/20260415000001_create_user_sessions.up.sql @@ -0,0 +1,16 @@ +-- Phase 1.1: Create user_sessions table +-- Migration: 20260415000001 + +CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_token TEXT UNIQUE NOT NULL, + ip_address TEXT, + user_agent TEXT, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(session_token); +CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at); diff --git a/crates/db/migrations/20260415000002_add_users_missing_columns.down.sql b/crates/db/migrations/20260415000002_add_users_missing_columns.down.sql new file mode 100644 index 0000000..a4db613 --- /dev/null +++ b/crates/db/migrations/20260415000002_add_users_missing_columns.down.sql @@ -0,0 +1,4 @@ +-- Rollback: Remove added columns from users +ALTER TABLE users DROP COLUMN IF EXISTS account_type; +ALTER TABLE users DROP COLUMN IF EXISTS last_login_at; +ALTER TABLE users DROP COLUMN IF EXISTS updated_at; diff --git a/crates/db/migrations/20260415000002_add_users_missing_columns.up.sql b/crates/db/migrations/20260415000002_add_users_missing_columns.up.sql new file mode 100644 index 0000000..180fd88 --- /dev/null +++ b/crates/db/migrations/20260415000002_add_users_missing_columns.up.sql @@ -0,0 +1,8 @@ +-- Phase 1.2: Add missing columns to users table +-- Migration: 20260415000002 + +ALTER TABLE users ADD COLUMN IF NOT EXISTS account_type TEXT DEFAULT 'INDIVIDUAL'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ; +ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +UPDATE users SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; diff --git a/crates/db/migrations/20260415000003_update_departments.down.sql b/crates/db/migrations/20260415000003_update_departments.down.sql new file mode 100644 index 0000000..723c472 --- /dev/null +++ b/crates/db/migrations/20260415000003_update_departments.down.sql @@ -0,0 +1,11 @@ +-- Rollback: Remove added columns from departments +ALTER TABLE departments DROP COLUMN IF EXISTS code; +ALTER TABLE departments DROP COLUMN IF EXISTS description; +ALTER TABLE departments DROP COLUMN IF EXISTS department_head; +ALTER TABLE departments DROP COLUMN IF EXISTS department_email; +ALTER TABLE departments DROP COLUMN IF EXISTS visibility; +ALTER TABLE departments DROP COLUMN IF EXISTS transfers_enabled; +ALTER TABLE departments DROP COLUMN IF EXISTS updated_at; + +DROP INDEX IF EXISTS idx_departments_code; +DROP INDEX IF EXISTS idx_departments_is_active; diff --git a/crates/db/migrations/20260415000003_update_departments.up.sql b/crates/db/migrations/20260415000003_update_departments.up.sql new file mode 100644 index 0000000..542c50f --- /dev/null +++ b/crates/db/migrations/20260415000003_update_departments.up.sql @@ -0,0 +1,15 @@ +-- Phase 1.3: Update departments table with new fields +-- Migration: 20260415000003 + +ALTER TABLE departments ADD COLUMN IF NOT EXISTS code VARCHAR(64); +ALTER TABLE departments ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE departments ADD COLUMN IF NOT EXISTS department_head VARCHAR(255); +ALTER TABLE departments ADD COLUMN IF NOT EXISTS department_email VARCHAR(255); +ALTER TABLE departments ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) DEFAULT 'INTERNAL'; +ALTER TABLE departments ADD COLUMN IF NOT EXISTS transfers_enabled BOOLEAN DEFAULT false; +ALTER TABLE departments ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +UPDATE departments SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_departments_code ON departments(LOWER(code)) WHERE code IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_departments_is_active ON departments(is_active); diff --git a/crates/db/migrations/20260415000004_update_designations.down.sql b/crates/db/migrations/20260415000004_update_designations.down.sql new file mode 100644 index 0000000..a3412ad --- /dev/null +++ b/crates/db/migrations/20260415000004_update_designations.down.sql @@ -0,0 +1,12 @@ +-- Rollback: Remove added columns from designations +ALTER TABLE designations DROP COLUMN IF EXISTS code; +ALTER TABLE designations DROP COLUMN IF EXISTS department_id; +ALTER TABLE designations DROP COLUMN IF EXISTS description; +ALTER TABLE designations DROP COLUMN IF EXISTS level; +ALTER TABLE designations DROP COLUMN IF EXISTS can_manage_team; +ALTER TABLE designations DROP COLUMN IF EXISTS can_approve; +ALTER TABLE designations DROP COLUMN IF EXISTS is_active; +ALTER TABLE designations DROP COLUMN IF EXISTS updated_at; + +DROP INDEX IF EXISTS idx_designations_code; +DROP INDEX IF EXISTS idx_designations_is_active; diff --git a/crates/db/migrations/20260415000004_update_designations.up.sql b/crates/db/migrations/20260415000004_update_designations.up.sql new file mode 100644 index 0000000..6f2d561 --- /dev/null +++ b/crates/db/migrations/20260415000004_update_designations.up.sql @@ -0,0 +1,16 @@ +-- Phase 1.4: Update designations table with new fields +-- Migration: 20260415000004 + +ALTER TABLE designations ADD COLUMN IF NOT EXISTS code VARCHAR(64); +ALTER TABLE designations ADD COLUMN IF NOT EXISTS department_id UUID REFERENCES departments(id) ON DELETE SET NULL; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS level VARCHAR(100); +ALTER TABLE designations ADD COLUMN IF NOT EXISTS can_manage_team BOOLEAN DEFAULT false; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS can_approve BOOLEAN DEFAULT false; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT true; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +UPDATE designations SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +CREATE UNIQUE INDEX IF NOT EXISTS idx_designations_code ON designations(LOWER(code)) WHERE code IS NOT NULL; +CREATE INDEX IF NOT EXISTS idx_designations_is_active ON designations(is_active); diff --git a/crates/db/migrations/20260415000005_update_employees.down.sql b/crates/db/migrations/20260415000005_update_employees.down.sql new file mode 100644 index 0000000..c34e788 --- /dev/null +++ b/crates/db/migrations/20260415000005_update_employees.down.sql @@ -0,0 +1,8 @@ +-- Rollback: Remove added columns from employees +ALTER TABLE employees DROP COLUMN IF EXISTS joining_date; +ALTER TABLE employees DROP COLUMN IF EXISTS employment_status; +ALTER TABLE employees DROP COLUMN IF EXISTS manager_employee_id; +ALTER TABLE employees DROP COLUMN IF EXISTS updated_at; + +DROP INDEX IF EXISTS idx_employees_manager; +DROP INDEX IF EXISTS idx_employees_status; diff --git a/crates/db/migrations/20260415000005_update_employees.up.sql b/crates/db/migrations/20260415000005_update_employees.up.sql new file mode 100644 index 0000000..9b6723e --- /dev/null +++ b/crates/db/migrations/20260415000005_update_employees.up.sql @@ -0,0 +1,12 @@ +-- Phase 1.5: Update employees table with new fields +-- Migration: 20260415000005 + +ALTER TABLE employees ADD COLUMN IF NOT EXISTS joining_date DATE; +ALTER TABLE employees ADD COLUMN IF NOT EXISTS employment_status VARCHAR(50) DEFAULT 'ACTIVE'; +ALTER TABLE employees ADD COLUMN IF NOT EXISTS manager_employee_id UUID REFERENCES employees(id) ON DELETE SET NULL; +ALTER TABLE employees ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +UPDATE employees SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +CREATE INDEX IF NOT EXISTS idx_employees_manager ON employees(manager_employee_id); +CREATE INDEX IF NOT EXISTS idx_employees_status ON employees(employment_status); diff --git a/crates/db/migrations/20260415010001_create_user_role_profiles.down.sql b/crates/db/migrations/20260415010001_create_user_role_profiles.down.sql new file mode 100644 index 0000000..532bb47 --- /dev/null +++ b/crates/db/migrations/20260415010001_create_user_role_profiles.down.sql @@ -0,0 +1,3 @@ +-- Rollback: Drop user_role_profiles table +-- WARNING: This will fail if data exists and FK constraints are in place +DROP TABLE IF EXISTS user_role_profiles CASCADE; diff --git a/crates/db/migrations/20260415010001_create_user_role_profiles.up.sql b/crates/db/migrations/20260415010001_create_user_role_profiles.up.sql new file mode 100644 index 0000000..58ef62d --- /dev/null +++ b/crates/db/migrations/20260415010001_create_user_role_profiles.up.sql @@ -0,0 +1,31 @@ +-- Phase 2.1: Create user_role_profiles root table (CRITICAL) +-- Migration: 20260415010001 +-- This is the ROOT table for all user role profiles + +CREATE TABLE IF NOT EXISTS user_role_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_key VARCHAR(50) NOT NULL, + display_name TEXT, + bio TEXT, + location TEXT, + avatar_url TEXT, + phone TEXT, + email TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + verification_status VARCHAR(50) DEFAULT 'PENDING', + approval_status VARCHAR(50) DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + verified_at TIMESTAMPTZ, + is_profile_public BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, role_key) +); + +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_user_id ON user_role_profiles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_role_key ON user_role_profiles(role_key); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_status ON user_role_profiles(status); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_verification ON user_role_profiles(verification_status); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_approval ON user_role_profiles(approval_status); diff --git a/crates/db/migrations/20260415010002_backfill_user_role_profiles.down.sql b/crates/db/migrations/20260415010002_backfill_user_role_profiles.down.sql new file mode 100644 index 0000000..bb0ee89 --- /dev/null +++ b/crates/db/migrations/20260415010002_backfill_user_role_profiles.down.sql @@ -0,0 +1,2 @@ +-- Rollback: Clear backfilled data (run before dropping user_role_profiles) +DELETE FROM user_role_profiles WHERE created_at > '2024-04-15'; diff --git a/crates/db/migrations/20260415010002_backfill_user_role_profiles.up.sql b/crates/db/migrations/20260415010002_backfill_user_role_profiles.up.sql new file mode 100644 index 0000000..46397d5 --- /dev/null +++ b/crates/db/migrations/20260415010002_backfill_user_role_profiles.up.sql @@ -0,0 +1,262 @@ +-- Phase 2.2: Backfill user_role_profiles from existing profile tables +-- Migration: 20260415010002 + +-- Backfill from photographer_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, verification_status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT + gen_random_uuid(), + p.user_id, + 'photographer', + COALESCE(p.display_name, ''), + p.bio, + p.location, + COALESCE(p.status, 'ACTIVE'), + CASE WHEN p.status = 'VERIFIED' THEN 'VERIFIED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + CASE WHEN p.status = 'APPROVED' THEN 'APPROVED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + p.rejection_reason, + p.approved_at, + p.created_at, + COALESCE(p.updated_at, NOW()) +FROM photographer_profiles p +WHERE NOT EXISTS ( + SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'photographer' +); + +-- Backfill from tutor_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, verification_status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT + gen_random_uuid(), + p.user_id, + 'tutor', + COALESCE(p.display_name, ''), + p.bio, + p.location, + COALESCE(p.status, 'ACTIVE'), + CASE WHEN p.status = 'VERIFIED' THEN 'VERIFIED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + CASE WHEN p.status = 'APPROVED' THEN 'APPROVED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + p.rejection_reason, + p.approved_at, + p.created_at, + COALESCE(p.updated_at, NOW()) +FROM tutor_profiles p +WHERE NOT EXISTS ( + SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'tutor' +); + +-- Backfill from makeup_artist_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, verification_status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT + gen_random_uuid(), + p.user_id, + 'makeup_artist', + COALESCE(p.display_name, ''), + p.bio, + p.location, + COALESCE(p.status, 'ACTIVE'), + CASE WHEN p.status = 'VERIFIED' THEN 'VERIFIED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + CASE WHEN p.status = 'APPROVED' THEN 'APPROVED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + p.rejection_reason, + p.approved_at, + p.created_at, + COALESCE(p.updated_at, NOW()) +FROM makeup_artist_profiles p +WHERE NOT EXISTS ( + SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'makeup_artist' +); + +-- Backfill from developer_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, verification_status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT + gen_random_uuid(), + p.user_id, + 'developer', + COALESCE(p.display_name, ''), + p.bio, + p.location, + COALESCE(p.status, 'ACTIVE'), + CASE WHEN p.status = 'VERIFIED' THEN 'VERIFIED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + CASE WHEN p.status = 'APPROVED' THEN 'APPROVED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + p.rejection_reason, + p.approved_at, + p.created_at, + COALESCE(p.updated_at, NOW()) +FROM developer_profiles p +WHERE NOT EXISTS ( + SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'developer' +); + +-- Backfill from video_editor_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, verification_status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT + gen_random_uuid(), + p.user_id, + 'video_editor', + COALESCE(p.display_name, ''), + p.bio, + p.location, + COALESCE(p.status, 'ACTIVE'), + CASE WHEN p.status = 'VERIFIED' THEN 'VERIFIED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + CASE WHEN p.status = 'APPROVED' THEN 'APPROVED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + p.rejection_reason, + p.approved_at, + p.created_at, + COALESCE(p.updated_at, NOW()) +FROM video_editor_profiles p +WHERE NOT EXISTS ( + SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'video_editor' +); + +-- Backfill from graphic_designer_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, verification_status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT + gen_random_uuid(), + p.user_id, + 'graphic_designer', + COALESCE(p.display_name, ''), + p.bio, + p.location, + COALESCE(p.status, 'ACTIVE'), + CASE WHEN p.status = 'VERIFIED' THEN 'VERIFIED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + CASE WHEN p.status = 'APPROVED' THEN 'APPROVED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + p.rejection_reason, + p.approved_at, + p.created_at, + COALESCE(p.updated_at, NOW()) +FROM graphic_designer_profiles p +WHERE NOT EXISTS ( + SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'graphic_designer' +); + +-- Backfill from social_media_manager_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, verification_status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT + gen_random_uuid(), + p.user_id, + 'social_media_manager', + COALESCE(p.display_name, ''), + p.bio, + p.location, + COALESCE(p.status, 'ACTIVE'), + CASE WHEN p.status = 'VERIFIED' THEN 'VERIFIED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + CASE WHEN p.status = 'APPROVED' THEN 'APPROVED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + p.rejection_reason, + p.approved_at, + p.created_at, + COALESCE(p.updated_at, NOW()) +FROM social_media_manager_profiles p +WHERE NOT EXISTS ( + SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'social_media_manager' +); + +-- Backfill from fitness_trainer_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, verification_status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT + gen_random_uuid(), + p.user_id, + 'fitness_trainer', + COALESCE(p.display_name, ''), + p.bio, + p.location, + COALESCE(p.status, 'ACTIVE'), + CASE WHEN p.status = 'VERIFIED' THEN 'VERIFIED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + CASE WHEN p.status = 'APPROVED' THEN 'APPROVED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + p.rejection_reason, + p.approved_at, + p.created_at, + COALESCE(p.updated_at, NOW()) +FROM fitness_trainer_profiles p +WHERE NOT EXISTS ( + SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'fitness_trainer' +); + +-- Backfill from catering_service_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, verification_status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT + gen_random_uuid(), + p.user_id, + 'catering_service', + COALESCE(p.business_name, ''), + p.bio, + p.location, + COALESCE(p.status, 'ACTIVE'), + CASE WHEN p.status = 'VERIFIED' THEN 'VERIFIED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + CASE WHEN p.status = 'APPROVED' THEN 'APPROVED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + p.rejection_reason, + p.approved_at, + p.created_at, + COALESCE(p.updated_at, NOW()) +FROM catering_service_profiles p +WHERE NOT EXISTS ( + SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'catering_service' +); + +-- Backfill from ugc_content_creator_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, verification_status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT + gen_random_uuid(), + p.user_id, + 'ugc_content_creator', + COALESCE(p.display_name, ''), + p.bio, + p.location, + COALESCE(p.status, 'ACTIVE'), + CASE WHEN p.status = 'VERIFIED' THEN 'VERIFIED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + CASE WHEN p.status = 'APPROVED' THEN 'APPROVED' WHEN p.status = 'REJECTED' THEN 'REJECTED' ELSE 'PENDING' END, + p.rejection_reason, + p.approved_at, + p.created_at, + COALESCE(p.updated_at, NOW()) +FROM ugc_content_creator_profiles p +WHERE NOT EXISTS ( + SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'ugc_content_creator' +); + +-- Backfill from company_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, created_at, updated_at) +SELECT + gen_random_uuid(), + cp.user_id, + 'company', + cp.company_name, + cp.bio, + NULL, + COALESCE(cp.status, 'ACTIVE'), + cp.created_at, + COALESCE(cp.updated_at, NOW()) +FROM company_profiles cp +WHERE NOT EXISTS ( + SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = cp.user_id AND urp.role_key = 'company' +); + +-- Backfill from customer_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, location, status, created_at, updated_at) +SELECT + gen_random_uuid(), + cp.user_id, + 'customer', + COALESCE(cp.full_name, ''), + cp.city, + COALESCE(cp.status, 'ACTIVE'), + cp.created_at, + COALESCE(cp.updated_at, NOW()) +FROM customer_profiles cp +WHERE NOT EXISTS ( + SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = cp.user_id AND urp.role_key = 'customer' +); + +-- Backfill from job_seeker_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, created_at, updated_at) +SELECT + gen_random_uuid(), + jsp.user_id, + 'candidate', + COALESCE(jsp.full_name, ''), + jsp.bio, + jsp.location, + COALESCE(jsp.status, 'ACTIVE'), + jsp.created_at, + COALESCE(jsp.updated_at, NOW()) +FROM job_seeker_profiles jsp +WHERE NOT EXISTS ( + SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = jsp.user_id AND urp.role_key = 'candidate' +); diff --git a/crates/db/migrations/20260415010003_add_user_role_profile_id.down.sql b/crates/db/migrations/20260415010003_add_user_role_profile_id.down.sql new file mode 100644 index 0000000..c5d1c76 --- /dev/null +++ b/crates/db/migrations/20260415010003_add_user_role_profile_id.down.sql @@ -0,0 +1,23 @@ +-- Rollback: Remove user_role_profile_id columns +-- WARNING: This will fail if FK constraints exist +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS user_role_profile_id; + +DROP INDEX IF EXISTS idx_photographer_profiles_user_role; +DROP INDEX IF EXISTS idx_tutor_profiles_user_role; +DROP INDEX IF EXISTS idx_makeup_artist_profiles_user_role; +DROP INDEX IF EXISTS idx_developer_profiles_user_role; +DROP INDEX IF EXISTS idx_video_editor_profiles_user_role; +DROP INDEX IF EXISTS idx_graphic_designer_profiles_user_role; +DROP INDEX IF EXISTS idx_social_media_manager_profiles_user_role; +DROP INDEX IF EXISTS idx_fitness_trainer_profiles_user_role; +DROP INDEX IF EXISTS idx_catering_service_profiles_user_role; +DROP INDEX IF EXISTS idx_ugc_content_creator_profiles_user_role; diff --git a/crates/db/migrations/20260415010003_add_user_role_profile_id.up.sql b/crates/db/migrations/20260415010003_add_user_role_profile_id.up.sql new file mode 100644 index 0000000..fec1959 --- /dev/null +++ b/crates/db/migrations/20260415010003_add_user_role_profile_id.up.sql @@ -0,0 +1,85 @@ +-- Phase 2.3: Add user_role_profile_id to extension tables +-- Migration: 20260415010003 +-- This links existing extension tables to the new user_role_profiles root + +-- photographer_profiles +ALTER TABLE photographer_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE photographer_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'photographer'; +ALTER TABLE photographer_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- tutor_profiles +ALTER TABLE tutor_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE tutor_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'tutor'; +ALTER TABLE tutor_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- makeup_artist_profiles +ALTER TABLE makeup_artist_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE makeup_artist_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'makeup_artist'; +ALTER TABLE makeup_artist_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- developer_profiles +ALTER TABLE developer_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE developer_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'developer'; +ALTER TABLE developer_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- video_editor_profiles +ALTER TABLE video_editor_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE video_editor_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'video_editor'; +ALTER TABLE video_editor_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- graphic_designer_profiles +ALTER TABLE graphic_designer_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE graphic_designer_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'graphic_designer'; +ALTER TABLE graphic_designer_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- social_media_manager_profiles +ALTER TABLE social_media_manager_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE social_media_manager_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'social_media_manager'; +ALTER TABLE social_media_manager_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- fitness_trainer_profiles +ALTER TABLE fitness_trainer_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE fitness_trainer_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'fitness_trainer'; +ALTER TABLE fitness_trainer_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- catering_service_profiles +ALTER TABLE catering_service_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE catering_service_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'catering_service'; +ALTER TABLE catering_service_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- ugc_content_creator_profiles +ALTER TABLE ugc_content_creator_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE ugc_content_creator_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'ugc_content_creator'; +ALTER TABLE ugc_content_creator_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_photographer_profiles_user_role ON photographer_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_tutor_profiles_user_role ON tutor_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_makeup_artist_profiles_user_role ON makeup_artist_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_developer_profiles_user_role ON developer_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_video_editor_profiles_user_role ON video_editor_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_graphic_designer_profiles_user_role ON graphic_designer_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_social_media_manager_profiles_user_role ON social_media_manager_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_fitness_trainer_profiles_user_role ON fitness_trainer_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_catering_service_profiles_user_role ON catering_service_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_ugc_content_creator_profiles_user_role ON ugc_content_creator_profiles(user_role_profile_id); diff --git a/crates/db/migrations/20260415010004_remove_external_links.down.sql b/crates/db/migrations/20260415010004_remove_external_links.down.sql new file mode 100644 index 0000000..c6d9593 --- /dev/null +++ b/crates/db/migrations/20260415010004_remove_external_links.down.sql @@ -0,0 +1,3 @@ +-- Rollback: Cannot easily restore removed columns +-- This migration is NOT easily reversible +-- Only run after full backup and testing diff --git a/crates/db/migrations/20260415010004_remove_external_links.up.sql b/crates/db/migrations/20260415010004_remove_external_links.up.sql new file mode 100644 index 0000000..334c090 --- /dev/null +++ b/crates/db/migrations/20260415010004_remove_external_links.up.sql @@ -0,0 +1,31 @@ +-- Phase 2.4: Remove forbidden external portfolio links +-- Migration: 20260415010004 +-- Per source of truth: NO external portfolio links allowed + +-- Remove github_url, portfolio_url from developer_profiles +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS github_url; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS portfolio_url; + +-- Remove reel_url from video_editor_profiles +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS reel_url; + +-- Remove portfolio_url from graphic_designer_profiles +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS portfolio_url; + +-- Remove portfolio_url from photographer_profiles +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS portfolio_url; + +-- Remove custom_data from all extension tables (preserve as JSONB if needed) +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS custom_data; + +-- Rename inconsistent columns +ALTER TABLE tutor_profiles RENAME COLUMN subjects_taught TO subjects; diff --git a/crates/db/src/models/mod.rs b/crates/db/src/models/mod.rs index 4d10aef..5e40ef3 100644 --- a/crates/db/src/models/mod.rs +++ b/crates/db/src/models/mod.rs @@ -25,4 +25,5 @@ pub mod employee; pub mod department; pub mod designation; pub mod verification; +pub mod user_role_profile; diff --git a/crates/db/src/models/photographer.rs b/crates/db/src/models/photographer.rs index 7a25bcb..229ad7c 100644 --- a/crates/db/src/models/photographer.rs +++ b/crates/db/src/models/photographer.rs @@ -6,58 +6,67 @@ use uuid::Uuid; #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct PhotographerProfile { pub id: Uuid, - pub user_id: Uuid, - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, - pub status: String, + pub user_role_profile_id: Uuid, + pub specialties: Vec, + pub camera_brands: Vec, + pub studio_available: bool, + pub outdoor_shoots: bool, + pub travel_radius_km: Option, + pub starting_price_inr: Option, pub created_at: DateTime, pub updated_at: DateTime, } #[derive(Debug, Serialize, Deserialize)] pub struct UpsertPhotographerProfilePayload { - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, + pub specialties: Vec, + pub camera_brands: Vec, + pub studio_available: bool, + pub outdoor_shoots: bool, + pub travel_radius_km: Option, + pub starting_price_inr: Option, } pub struct PhotographerRepository; impl PhotographerRepository { - pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, PhotographerProfile>( - r#"SELECT id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at - FROM photographer_profiles WHERE user_id = $1"#, + r#"SELECT id, user_role_profile_id, specialties, camera_brands, + studio_available, outdoor_shoots, travel_radius_km, + starting_price_inr, + created_at, updated_at + FROM photographer_profiles WHERE user_role_profile_id = $1"#, ) - .bind(user_id) + .bind(user_role_profile_id) .fetch_optional(pool) .await } - pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertPhotographerProfilePayload) -> Result { + pub async fn upsert(pool: &PgPool, user_role_profile_id: Uuid, p: UpsertPhotographerProfilePayload) -> Result { sqlx::query_as::<_, PhotographerProfile>( - r#"INSERT INTO photographer_profiles (user_id, display_name, bio, location, custom_data) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id) DO UPDATE SET - display_name = COALESCE(EXCLUDED.display_name, photographer_profiles.display_name), - bio = EXCLUDED.bio, - location = EXCLUDED.location, - custom_data = EXCLUDED.custom_data, - updated_at = NOW() - RETURNING id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at"#, + r#"INSERT INTO photographer_profiles (user_role_profile_id, specialties, camera_brands, + studio_available, outdoor_shoots, travel_radius_km, starting_price_inr) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + specialties = COALESCE(EXCLUDED.specialties, photographer_profiles.specialties), + camera_brands = COALESCE(EXCLUDED.camera_brands, photographer_profiles.camera_brands), + studio_available = EXCLUDED.studio_available, + outdoor_shoots = EXCLUDED.outdoor_shoots, + travel_radius_km = EXCLUDED.travel_radius_km, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, specialties, camera_brands, + studio_available, outdoor_shoots, travel_radius_km, + starting_price_inr, created_at, updated_at"#, ) - .bind(user_id) - .bind(p.display_name) - .bind(p.bio) - .bind(p.location) - .bind(p.custom_data) + .bind(user_role_profile_id) + .bind(&p.specialties) + .bind(&p.camera_brands) + .bind(p.studio_available) + .bind(p.outdoor_shoots) + .bind(p.travel_radius_km) + .bind(p.starting_price_inr) .fetch_one(pool) .await } diff --git a/crates/db/src/models/user_role_profile.rs b/crates/db/src/models/user_role_profile.rs new file mode 100644 index 0000000..0735d1f --- /dev/null +++ b/crates/db/src/models/user_role_profile.rs @@ -0,0 +1,237 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] +pub struct UserRoleProfile { + pub id: Uuid, + pub user_id: Uuid, + pub role_key: String, + pub display_name: Option, + pub bio: Option, + pub location: Option, + pub avatar_url: Option, + pub phone: Option, + pub email: Option, + pub status: String, + pub verification_status: String, + pub approval_status: String, + pub rejection_reason: Option, + pub approved_at: Option>, + pub verified_at: Option>, + pub is_profile_public: bool, + pub created_at: DateTime, + pub updated_at: DateTime, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct UserRoleProfileWithExtension { + #[serde(flatten)] + pub profile: UserRoleProfile, + #[serde(flatten)] + pub extension: T, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub enum RoleKey { + Photographer, + Tutor, + MakeupArtist, + Developer, + VideoEditor, + GraphicDesigner, + SocialMediaManager, + FitnessTrainer, + CateringService, + UgcContentCreator, + Company, + Customer, + Candidate, +} + +impl RoleKey { + pub fn as_str(&self) -> &'static str { + match self { + RoleKey::Photographer => "photographer", + RoleKey::Tutor => "tutor", + RoleKey::MakeupArtist => "makeup_artist", + RoleKey::Developer => "developer", + RoleKey::VideoEditor => "video_editor", + RoleKey::GraphicDesigner => "graphic_designer", + RoleKey::SocialMediaManager => "social_media_manager", + RoleKey::FitnessTrainer => "fitness_trainer", + RoleKey::CateringService => "catering_service", + RoleKey::UgcContentCreator => "ugc_content_creator", + RoleKey::Company => "company", + RoleKey::Customer => "customer", + RoleKey::Candidate => "candidate", + } + } +} + +pub struct UserRoleProfileRepository; + +impl UserRoleProfileRepository { + pub async fn get_by_user_and_role( + pool: &PgPool, + user_id: Uuid, + role_key: &str, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, UserRoleProfile>( + r#"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 user_id = $1 AND role_key = $2"#, + ) + .bind(user_id) + .bind(role_key) + .fetch_optional(pool) + .await + } + + pub async fn get_by_id( + pool: &PgPool, + id: Uuid, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, UserRoleProfile>( + r#"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"#, + ) + .bind(id) + .fetch_optional(pool) + .await + } + + pub async fn get_all_by_user( + pool: &PgPool, + user_id: Uuid, + ) -> Result, sqlx::Error> { + sqlx::query_as::<_, UserRoleProfile>( + r#"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 user_id = $1"#, + ) + .bind(user_id) + .fetch_all(pool) + .await + } + + pub async fn create( + pool: &PgPool, + user_id: Uuid, + role_key: &str, + ) -> Result { + sqlx::query_as::<_, UserRoleProfile>( + r#"INSERT INTO user_role_profiles (user_id, role_key) + VALUES ($1, $2) + ON CONFLICT (user_id, role_key) DO UPDATE SET user_id = EXCLUDED.user_id + RETURNING 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"#, + ) + .bind(user_id) + .bind(role_key) + .fetch_one(pool) + .await + } + + pub async fn update( + pool: &PgPool, + id: Uuid, + display_name: Option, + bio: Option, + location: Option, + avatar_url: Option, + phone: Option, + email: Option, + is_profile_public: bool, + ) -> Result { + sqlx::query_as::<_, UserRoleProfile>( + r#"UPDATE user_role_profiles SET + display_name = COALESCE($2, display_name), + bio = COALESCE($3, bio), + location = COALESCE($4, location), + avatar_url = COALESCE($5, avatar_url), + phone = COALESCE($6, phone), + email = COALESCE($7, email), + is_profile_public = $8, + updated_at = NOW() + WHERE id = $1 + RETURNING 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"#, + ) + .bind(id) + .bind(display_name) + .bind(bio) + .bind(location) + .bind(avatar_url) + .bind(phone) + .bind(email) + .bind(is_profile_public) + .fetch_one(pool) + .await + } + + pub async fn approve( + pool: &PgPool, + id: Uuid, + approved_by: Uuid, + ) -> Result { + sqlx::query_as::<_, UserRoleProfile>( + r#"UPDATE user_role_profiles SET + approval_status = 'APPROVED', + approved_at = NOW(), + updated_at = NOW() + WHERE id = $1 + RETURNING 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"#, + ) + .bind(id) + .fetch_one(pool) + .await + } + + pub async fn reject( + pool: &PgPool, + id: Uuid, + rejection_reason: &str, + ) -> Result { + sqlx::query_as::<_, UserRoleProfile>( + r#"UPDATE user_role_profiles SET + approval_status = 'REJECTED', + rejection_reason = $2, + updated_at = NOW() + WHERE id = $1 + RETURNING 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"#, + ) + .bind(id) + .bind(rejection_reason) + .fetch_one(pool) + .await + } +} diff --git a/docs/migration_plan.md b/docs/migration_plan.md new file mode 100644 index 0000000..5f507ea --- /dev/null +++ b/docs/migration_plan.md @@ -0,0 +1,767 @@ +# Migration Plan — Nxtgauge Database Redesign + +This document outlines the step-by-step migration strategy to transform the current schema to the target schema. + +--- + +## Migration Principles + +1. **Additive First** — Always create new tables before modifying existing ones +2. **No Data Loss** — Preserve all existing data during migration +3. **Reversible** — Each step can be rolled back if needed +4. **Service-by-Service** — Migrate one domain at a time +5. **Backward Compatible** — Keep old tables until services are updated + +--- + +## Migration Phases + +### Phase 1: Core Infrastructure (Week 1) + +### Phase 2: Profile Domain (Week 2) + +### Phase 3: Portfolio Domain (Week 2-3) + +### Phase 4: Verification & Approval (Week 3) + +### Phase 5: Marketplace (Week 3-4) + +### Phase 6: Finance (Week 4) + +### Phase 7: Audit & Cleanup (Week 5) + +--- + +## Phase 1: Core Infrastructure + +### Step 1.1: Create New Tables + +**Files:** `20260415000001_create_user_sessions.up.sql` + +```sql +CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_token TEXT UNIQUE NOT NULL, + ip_address TEXT, + user_agent TEXT, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(session_token); +``` + +### Step 1.2: Add Missing Columns to users + +**Files:** `20260415000002_add_users_missing_columns.up.sql` + +```sql +ALTER TABLE users ADD COLUMN IF NOT EXISTS account_type TEXT DEFAULT 'INDIVIDUAL'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ; +ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +UPDATE users SET updated_at = COALESCE(updated_at, created_at, NOW()); +``` + +### Step 1.3: Update departments + +**Files:** `20260415000003_update_departments.up.sql` + +```sql +ALTER TABLE departments ADD COLUMN IF NOT EXISTS code VARCHAR(64); +ALTER TABLE departments ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE departments ADD COLUMN IF NOT EXISTS department_head VARCHAR(255); +ALTER TABLE departments ADD COLUMN IF NOT EXISTS department_email VARCHAR(255); +ALTER TABLE departments ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) DEFAULT 'INTERNAL'; +ALTER TABLE departments ADD COLUMN IF NOT EXISTS transfers_enabled BOOLEAN DEFAULT false; +ALTER TABLE departments ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_departments_code ON departments(LOWER(code)) WHERE code IS NOT NULL; +``` + +### Step 1.4: Update designations + +**Files:** `20260415000004_update_designations.up.sql` + +```sql +ALTER TABLE designations ADD COLUMN IF NOT EXISTS code VARCHAR(64); +ALTER TABLE designations ADD COLUMN IF NOT EXISTS department_id UUID REFERENCES departments(id); +ALTER TABLE designations ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS level VARCHAR(100); +ALTER TABLE designations ADD COLUMN IF NOT EXISTS can_manage_team BOOLEAN DEFAULT false; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS can_approve BOOLEAN DEFAULT false; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT true; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +CREATE UNIQUE INDEX IF NOT EXISTS idx_designations_code ON designations(LOWER(code)) WHERE code IS NOT NULL; +``` + +### Step 1.5: Update employees + +**Files:** `20260415000005_update_employees.up.sql` + +```sql +ALTER TABLE employees ADD COLUMN IF NOT EXISTS joining_date DATE; +ALTER TABLE employees ADD COLUMN IF NOT EXISTS employment_status VARCHAR(50) DEFAULT 'ACTIVE'; +ALTER TABLE employees ADD COLUMN IF NOT EXISTS manager_employee_id UUID REFERENCES employees(id); +ALTER TABLE employees ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +``` + +### Step 1.6: Update roles + +**Files:** `20260415000006_update_roles.up.sql` + +```sql +ALTER TABLE roles ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS can_approve_requests BOOLEAN DEFAULT false; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS can_manage_system_settings BOOLEAN DEFAULT false; +``` + +--- + +## Phase 2: Profile Domain (CRITICAL) + +### Step 2.1: Create user_role_profiles Root Table + +**Files:** `20260415010001_create_user_role_profiles.up.sql` + +```sql +CREATE TABLE IF NOT EXISTS user_role_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_key VARCHAR(50) NOT NULL, + display_name TEXT, + bio TEXT, + location TEXT, + avatar_url TEXT, + phone TEXT, + email TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + verification_status VARCHAR(50) DEFAULT 'PENDING', + approval_status VARCHAR(50) DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + verified_at TIMESTAMPTZ, + is_profile_public BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, role_key) +); + +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_user_id ON user_role_profiles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_role_key ON user_role_profiles(role_key); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_status ON user_role_profiles(status); +``` + +### Step 2.2: Backfill user_role_profiles from Existing Data + +**Files:** `20260415010002_backfill_user_role_profiles.up.sql` + +```sql +-- Backfill from photographer_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, approved_at, created_at, updated_at) +SELECT + gen_random_uuid(), + p.user_id, + 'photographer', + COALESCE(p.display_name, ''), + p.bio, + p.location, + COALESCE(p.status, 'ACTIVE'), + p.approved_at, + p.created_at, + COALESCE(p.updated_at, NOW()) +FROM photographer_profiles p +ON CONFLICT (user_id, 'photographer') DO NOTHING; + +-- Repeat for all other profession tables... + +-- Backfill from company_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, created_at, updated_at) +SELECT + gen_random_uuid(), + cp.user_id, + 'company', + cp.company_name, + cp.bio, + NULL, + COALESCE(cp.status, 'ACTIVE'), + cp.created_at, + COALESCE(cp.updated_at, NOW()) +FROM company_profiles cp +ON CONFLICT (user_id, 'company') DO NOTHING; + +-- Backfill from customer_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, location, status, created_at, updated_at) +SELECT + gen_random_uuid(), + cp.user_id, + 'customer', + COALESCE(cp.full_name, ''), + cp.city, + COALESCE(cp.status, 'ACTIVE'), + cp.created_at, + COALESCE(cp.updated_at, NOW()) +FROM customer_profiles cp +ON CONFLICT (user_id, 'customer') DO NOTHING; +``` + +### Step 2.3: Add user_role_profile_id to Extension Tables + +**Files:** `20260415010003_add_user_role_profile_id.up.sql` + +```sql +-- Add temporary column for mapping +ALTER TABLE photographer_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; + +-- Update with backfilled data +UPDATE photographer_profiles p +SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'photographer'; + +-- Add FK constraint +ALTER TABLE photographer_profiles + ADD CONSTRAINT fk_photographer_profiles_user_role_profile + FOREIGN KEY (user_role_profile_id) REFERENCES user_role_profiles(id); + +-- Repeat for all extension tables... +``` + +### Step 2.4: Update Extension Tables Schema + +**Files:** `20260415010004_update_extension_tables.up.sql` + +```sql +-- Remove forbidden external links +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS github_url; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS portfolio_url; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS reel_url; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS portfolio_url; +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS portfolio_url; + +-- Remove custom_data (preserve if needed) +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS custom_data; +-- ... repeat for all tables + +-- Rename columns for consistency +ALTER TABLE tutor_profiles RENAME COLUMN subjects_taught TO subjects; +``` + +--- + +## Phase 3: Portfolio Domain + +### Step 3.1: Update portfolio_items + +**Files:** `20260415020001_update_portfolio_items.up.sql` + +```sql +-- Add user_role_profile_id +ALTER TABLE portfolio_items ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +ALTER TABLE portfolio_items ADD COLUMN IF NOT EXISTS display_order INTEGER DEFAULT 0; + +-- Backfill from professionals +UPDATE portfolio_items pi +SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE pi.professional_id = urp.user_id; + +-- Remove old columns +ALTER TABLE portfolio_items DROP COLUMN IF EXISTS professional_id; +ALTER TABLE portfolio_items DROP COLUMN IF EXISTS user_id; +ALTER TABLE portfolio_items DROP COLUMN IF EXISTS profession_key; +``` + +### Step 3.2: Update services + +**Files:** `20260415020002_update_services.up.sql` + +```sql +ALTER TABLE services ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; + +UPDATE services s +SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE s.professional_id = urp.user_id; + +ALTER TABLE services DROP COLUMN IF EXISTS professional_id; +ALTER TABLE services DROP COLUMN IF EXISTS user_id; +ALTER TABLE services DROP COLUMN IF EXISTS profession_key; +``` + +--- + +## Phase 4: Verification & Approval + +### Step 4.1: Create Verification Tables + +**Files:** `20260415030001_create_verification_tables.up.sql` + +```sql +CREATE TABLE IF NOT EXISTS verification_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID NOT NULL REFERENCES user_role_profiles(id), + verification_type VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + reviewed_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS verification_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_request_id UUID NOT NULL REFERENCES verification_requests(id) ON DELETE CASCADE, + document_type VARCHAR(100) NOT NULL, + file_url TEXT NOT NULL, + file_name TEXT, + mime_type TEXT, + status VARCHAR(50) DEFAULT 'PENDING', + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + reviewed_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS verification_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_request_id UUID NOT NULL REFERENCES verification_requests(id), + action VARCHAR(50) NOT NULL, + old_status VARCHAR(50), + new_status VARCHAR(50), + acted_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +### Step 4.2: Create Approval Tables + +**Files:** `20260415030002_create_approval_tables.up.sql` + +```sql +CREATE TABLE IF NOT EXISTS approval_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type VARCHAR(50) NOT NULL, + entity_id UUID NOT NULL, + approval_type VARCHAR(50), + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + submitted_by_user_id UUID REFERENCES users(id), + reviewed_by_user_id UUID REFERENCES users(id), + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS approval_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + approval_request_id UUID NOT NULL REFERENCES approval_requests(id), + action VARCHAR(50) NOT NULL, + old_status VARCHAR(50), + new_status VARCHAR(50), + acted_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +--- + +## Phase 5: Marketplace + +### Step 5.1: Rename and Update jobs-related Tables + +**Files:** `20260415040001_update_jobs_tables.up.sql` + +```sql +-- Rename applications to job_applications +ALTER TABLE applications RENAME TO job_applications; + +-- Add new columns +ALTER TABLE job_applications ADD COLUMN IF NOT EXISTS applicant_user_id UUID; + +-- Backfill from job_seeker_profiles +UPDATE job_applications ja +SET applicant_user_id = ( + SELECT user_id FROM job_seeker_profiles jsp WHERE jsp.id = ja.job_seeker_id +); + +-- Add FK +ALTER TABLE job_applications ADD CONSTRAINT fk_job_applications_applicant + FOREIGN KEY (applicant_user_id) REFERENCES users(id); + +-- Remove old columns +ALTER TABLE job_applications DROP COLUMN IF EXISTS job_seeker_id; +ALTER TABLE job_applications DROP COLUMN IF EXISTS cover_letter; +ALTER TABLE job_applications DROP COLUMN IF EXISTS resume_url; +ALTER TABLE job_applications DROP COLUMN IF EXISTS contact_viewed; + +-- Rename cover_note if needed +ALTER TABLE job_applications RENAME COLUMN cover_letter TO cover_note; +``` + +### Step 5.2: Update jobs Table + +**Files:** `20260415040002_update_jobs.up.sql` + +```sql +-- Add new columns +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS posted_by_user_id UUID; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS mode_of_work VARCHAR(50); +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS budget_inr INTEGER; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS salary_range_json JSONB; + +-- Add FK +ALTER TABLE jobs ADD CONSTRAINT fk_jobs_posted_by + FOREIGN KEY (posted_by_user_id) REFERENCES users(id); + +-- Add updated_at +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +UPDATE jobs SET updated_at = COALESCE(updated_at, created_at, NOW()); +``` + +### Step 5.3: Rename requirements to leads + +**Files:** `20260415040003_rename_requirements_to_leads.up.sql` + +```sql +-- Rename table +ALTER TABLE requirements RENAME TO leads; + +-- Rename columns +ALTER TABLE leads RENAME COLUMN customer_id TO created_by_user_id; +ALTER TABLE leads RENAME COLUMN preferred_date TO required_date; + +-- Add FK +ALTER TABLE leads ADD CONSTRAINT fk_leads_created_by + FOREIGN KEY (created_by_user_id) REFERENCES users(id); + +-- Add updated_at +ALTER TABLE leads ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +``` + +### Step 5.4: Update lead_requests + +**Files:** `20260415040004_update_lead_requests.up.sql` + +```sql +-- Add user_role_profile_id +ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; + +-- Backfill from professionals +UPDATE lead_requests lr +SET user_role_profile_id = ( + SELECT id FROM user_role_profiles urp + WHERE urp.user_id = ( + SELECT user_id FROM professionals p WHERE p.id = lr.professional_id + ) +); + +-- Add FK +ALTER TABLE lead_requests ADD CONSTRAINT fk_lead_requests_profile + FOREIGN KEY (user_role_profile_id) REFERENCES user_role_profiles(id); + +-- Rename columns +ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS remarks TEXT; +ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +``` + +--- + +## Phase 6: Finance + +### Step 6.1: Create Order Tables + +**Files:** `20260415050001_create_order_tables.up.sql` + +```sql +CREATE TABLE IF NOT EXISTS orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + order_type VARCHAR(50) NOT NULL, + subtotal_inr INTEGER NOT NULL DEFAULT 0, + discount_inr INTEGER NOT NULL DEFAULT 0, + tax_inr INTEGER NOT NULL DEFAULT 0, + total_inr INTEGER NOT NULL DEFAULT 0, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS order_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + item_type VARCHAR(50) NOT NULL, + item_id UUID, + item_name TEXT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + unit_price_inr INTEGER NOT NULL, + total_price_inr INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_orders_user_id ON orders(user_id); +CREATE INDEX IF NOT EXISTS idx_order_items_order_id ON order_items(order_id); +``` + +### Step 6.2: Update Existing Finance Tables + +**Files:** `20260415050002_update_finance_tables.up.sql` + +```sql +-- Update tracecoin_wallets +ALTER TABLE tracecoin_wallets RENAME COLUMN balance TO current_balance; +ALTER TABLE tracecoin_wallets ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +-- Update tracecoin_ledger +ALTER TABLE tracecoin_ledger ADD COLUMN IF NOT EXISTS balance_after INTEGER; +ALTER TABLE tracecoin_ledger ADD COLUMN IF NOT EXISTS remarks TEXT; +ALTER TABLE tracecoin_ledger RENAME COLUMN type TO transaction_type; +ALTER TABLE tracecoin_ledger RENAME COLUMN reason TO reference_type; + +-- Update coupons +ALTER TABLE coupons ADD COLUMN IF NOT EXISTS max_discount_inr INTEGER; +ALTER TABLE coupons ADD COLUMN IF NOT EXISTS min_order_value_inr INTEGER DEFAULT 0; +ALTER TABLE coupons ADD COLUMN IF NOT EXISTS valid_from TIMESTAMPTZ DEFAULT NOW(); +ALTER TABLE coupons ADD COLUMN IF NOT EXISTS valid_to TIMESTAMPTZ; + +-- Rename coupon_uses to coupon_redemptions +ALTER TABLE coupon_uses RENAME TO coupon_redemptions; +ALTER TABLE coupon_redemptions ADD COLUMN IF NOT EXISTS order_id UUID; +ALTER TABLE coupon_redemptions ADD COLUMN IF NOT EXISTS discount_amount_inr INTEGER; +ALTER TABLE coupon_redemptions RENAME COLUMN used_at TO redeemed_at; +``` + +### Step 6.3: Create Payment Infrastructure + +**Files:** `20260415050003_create_payment_tables.up.sql` + +```sql +CREATE TABLE IF NOT EXISTS payment_gateway_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + gateway_key VARCHAR(50) NOT NULL, + display_name VARCHAR(255), + config_json JSONB, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS payment_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + payment_id UUID NOT NULL REFERENCES payments(id), + transaction_type VARCHAR(50) NOT NULL, + provider_reference TEXT, + request_payload_json JSONB, + response_payload_json JSONB, + status VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tax_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + tax_type VARCHAR(50) NOT NULL, + tax_rate DECIMAL(5,2) NOT NULL, + applies_to VARCHAR(50), + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +``` + +--- + +## Phase 7: Audit & Cleanup + +### Step 7.1: Create Audit Tables + +**Files:** `20260415060001_create_audit_tables.up.sql` + +```sql +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_user_id UUID REFERENCES users(id), + actor_employee_id UUID REFERENCES employees(id), + actor_type VARCHAR(50), + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(100), + entity_id UUID, + entity_label TEXT, + module_key VARCHAR(100), + source_type VARCHAR(50), + source_id UUID, + request_id UUID, + correlation_id UUID, + ip_address TEXT, + user_agent TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'SUCCESS', + summary TEXT, + metadata_json JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS audit_log_changes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + audit_log_id UUID NOT NULL REFERENCES audit_logs(id) ON DELETE CASCADE, + field_name TEXT NOT NULL, + old_value_text TEXT, + new_value_text TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs(actor_user_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs(entity_type, entity_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_module ON audit_logs(module_key); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at); +``` + +### Step 7.2: Create Missing KB Tables + +**Files:** `20260415060002_create_kb_tables.up.sql` + +```sql +CREATE TABLE IF NOT EXISTS kb_sections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category_id UUID NOT NULL REFERENCES kb_categories(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + description TEXT, + display_order INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS kb_article_feedback ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + article_id UUID NOT NULL REFERENCES kb_articles(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id), + is_helpful BOOLEAN, + feedback_text TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Update kb_articles +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS section_id UUID REFERENCES kb_sections(id); +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS article_type VARCHAR(50) DEFAULT 'HOW_TO'; +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS audience_type VARCHAR(50) DEFAULT 'ALL'; +ALTER TABLE kb_articles RENAME COLUMN body TO content_markdown; +ALTER TABLE kb_articles RENAME COLUMN created_by TO author_user_id; +ALTER TABLE kb_articles RENAME COLUMN is_published TO status; +``` + +### Step 7.3: Create Notification Infrastructure + +**Files:** `20260415060003_create_notification_tables.up.sql` + +```sql +CREATE TABLE IF NOT EXISTS notification_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_key VARCHAR(100) NOT NULL UNIQUE, + channel VARCHAR(50) NOT NULL, + title_template TEXT, + body_template TEXT, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS smtp_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider_name VARCHAR(100), + host VARCHAR(255), + port INTEGER, + username TEXT, + encryption_mode VARCHAR(20), + from_name VARCHAR(255), + from_email VARCHAR(255), + is_default BOOLEAN DEFAULT false, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Update notifications +ALTER TABLE notifications ADD COLUMN IF NOT EXISTS channel VARCHAR(50) DEFAULT 'IN_APP'; +ALTER TABLE notifications ADD COLUMN IF NOT EXISTS related_entity_type VARCHAR(50); +ALTER TABLE notifications RENAME COLUMN reference_id TO related_entity_id; +ALTER TABLE notifications RENAME COLUMN is_read TO status; +``` + +### Step 7.4: Create Dashboard Widgets + +**Files:** `20260415060004_create_dashboard_tables.up.sql` + +```sql +CREATE TABLE IF NOT EXISTS dashboard_widgets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + dashboard_config_id UUID NOT NULL REFERENCES dashboard_configs(id) ON DELETE CASCADE, + widget_key VARCHAR(100) NOT NULL, + widget_title VARCHAR(255), + config_json JSONB, + display_order INTEGER DEFAULT 0, + width_units INTEGER DEFAULT 1, + height_units INTEGER DEFAULT 1, + is_visible BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_dashboard_widgets_config ON dashboard_widgets(dashboard_config_id); +``` + +--- + +## Rollback Procedures + +### Rollback Phase 1 + +```sql +-- Drop new tables +DROP TABLE IF EXISTS user_sessions; + +-- Revert column changes (use down migrations) +``` + +### Rollback Phase 2 + +```sql +-- DO NOT rollback user_role_profiles if data exists +-- Instead, mark as deprecated and keep parallel structure +``` + +--- + +## Testing Strategy + +1. **Unit Tests** — Test each migration script independently +2. **Integration Tests** — Test application functionality after each phase +3. **Data Validation** — Verify all data is correctly migrated +4. **Performance Tests** — Ensure indexes perform well +5. **Rollback Tests** — Test rollback procedures in staging + +--- + +## Pre-Migration Checklist + +- [ ] Backup production database +- [ ] Test migrations on staging environment +- [ ] Verify all foreign key relationships +- [ ] Check for circular dependencies +- [ ] Plan maintenance window for critical migrations +- [ ] Notify stakeholders of potential downtime + +--- + +## Post-Migration Checklist + +- [ ] Verify all data integrity +- [ ] Check application logs for errors +- [ ] Update documentation +- [ ] Remove deprecated tables (after full validation) +- [ ] Archive old migration files diff --git a/docs/old_to_new_mapping.md b/docs/old_to_new_mapping.md new file mode 100644 index 0000000..9e74fd2 --- /dev/null +++ b/docs/old_to_new_mapping.md @@ -0,0 +1,508 @@ +# Old to New Mapping — Nxtgauge Database Migration + +This document maps the current schema to the target schema. + +--- + +## 1. Tables to CREATE (New) + +### Core Infrastructure + +| New Table | Purpose | Priority | +| ------------------------- | --------------------------- | -------- | +| `user_sessions` | Session tracking | High | +| `user_role_profiles` | Root profile for all roles | Critical | +| `verification_requests` | Verification workflow | High | +| `verification_documents` | Verification documents | High | +| `approval_requests` | Approval workflow | High | +| `approval_logs` | Approval audit trail | High | +| `audit_logs` | Comprehensive audit logging | High | +| `audit_log_changes` | Field-level audit changes | Medium | +| `dashboard_widgets` | Dashboard widget configs | Medium | +| `kb_sections` | Knowledge base sections | Low | +| `kb_article_feedback` | KB article feedback | Low | +| `notification_templates` | Notification templates | Medium | +| `smtp_configs` | SMTP configurations | Low | +| `orders` | Order management | Medium | +| `order_items` | Order line items | Medium | +| `payment_gateway_configs` | Payment gateway configs | Low | +| `payment_transactions` | Payment transaction log | Low | +| `tax_rules` | Tax rules | Low | + +### Extension Tables (New FK Reference) + +| New Table | References | +| ------------------------------- | ----------------------------------------------- | +| `photographer_profiles` | `user_role_profiles` (via user_role_profile_id) | +| `tutor_profiles` | `user_role_profiles` | +| `makeup_artist_profiles` | `user_role_profiles` | +| `developer_profiles` | `user_role_profiles` | +| `video_editor_profiles` | `user_role_profiles` | +| `graphic_designer_profiles` | `user_role_profiles` | +| `social_media_manager_profiles` | `user_role_profiles` | +| `fitness_trainer_profiles` | `user_role_profiles` | +| `catering_service_profiles` | `user_role_profiles` | +| `ugc_content_creator_profiles` | `user_role_profiles` | + +--- + +## 2. Tables to RENAME + +| Current Name | Target Name | Notes | +| -------------- | -------------------- | --------------------- | +| `applications` | `job_applications` | Job applications | +| `requirements` | `leads` | Customer leads | +| `coupon_uses` | `coupon_redemptions` | Coupon usage tracking | + +--- + +## 3. Tables to UPDATE (Schema Changes) + +### users + +| Action | Changes | +| ------ | --------------------------------------------------- | +| ADD | `account_type` (TEXT) | +| ADD | `last_login_at` (TIMESTAMP) | +| RENAME | `full_name` → Remove (use profiles) | +| RENAME | `email_verified` → Remove (use verification_status) | +| RENAME | `phone_verified` → Remove (use verification_status) | + +### refresh_tokens + +| Action | Changes | +| ------ | ------------------- | +| ADD | `revoked` (BOOLEAN) | + +### roles + +| Action | Changes | +| ------ | -------------------------------------- | +| ADD | `description` (TEXT) | +| ADD | `department_id` (UUID) | +| ADD | `can_approve_requests` (BOOLEAN) | +| ADD | `can_manage_system_settings` (BOOLEAN) | + +### departments + +| Action | Changes | +| ------ | ----------------------------- | +| ADD | `code` (TEXT) | +| ADD | `description` (TEXT) | +| ADD | `department_head` (TEXT) | +| ADD | `department_email` (TEXT) | +| ADD | `visibility` (TEXT) | +| ADD | `transfers_enabled` (BOOLEAN) | +| ADD | `updated_at` (TIMESTAMP) | + +### designations + +| Action | Changes | +| ------ | --------------------------- | +| ADD | `code` (TEXT) | +| ADD | `department_id` (UUID) | +| ADD | `description` (TEXT) | +| ADD | `level` (TEXT) | +| ADD | `can_manage_team` (BOOLEAN) | +| ADD | `can_approve` (BOOLEAN) | +| ADD | `is_active` (BOOLEAN) | +| ADD | `updated_at` (TIMESTAMP) | + +### employees + +| Action | Changes | +| ------ | ---------------------------------- | +| ADD | `joining_date` (DATE) | +| ADD | `employment_status` (TEXT) | +| ADD | `manager_employee_id` (UUID) | +| ADD | `updated_at` (TIMESTAMP) | +| RENAME | `user_id` → Keep (points to users) | + +--- + +## 4. Extension Tables — Update FK Reference + +**Current State:** Extension tables reference `users.id` via `user_id` +**Target State:** Extension tables reference `user_role_profiles.id` via `user_role_profile_id` + +### photographer_profiles + +| Action | Changes | +| ------ | ------------------------------------------- | +| RENAME | `user_id` → `user_role_profile_id` | +| REMOVE | `portfolio_url` | +| REMOVE | `equipment_list` | +| REMOVE | `years_of_experience` (use root profile) | +| REMOVE | `hourly_rate` (use `starting_price_inr`) | +| REMOVE | `custom_data` (preserve to JSONB if needed) | + +### tutor_profiles + +| Action | Changes | +| ------ | --------------------------------------- | +| RENAME | `user_id` → `user_role_profile_id` | +| RENAME | `subjects_taught` → `subjects` | +| REMOVE | `education_level` (use `qualification`) | +| REMOVE | `custom_data` | + +### makeup_artist_profiles + +| Action | Changes | +| ------ | ---------------------------------- | +| RENAME | `user_id` → `user_role_profile_id` | +| REMOVE | `custom_data` | + +### developer_profiles + +| Action | Changes | +| ------ | ---------------------------------- | +| RENAME | `user_id` → `user_role_profile_id` | +| REMOVE | `github_url` (FORBIDDEN) | +| REMOVE | `portfolio_url` (FORBIDDEN) | +| REMOVE | `custom_data` | + +### video_editor_profiles + +| Action | Changes | +| ------ | ---------------------------------- | +| RENAME | `user_id` → `user_role_profile_id` | +| REMOVE | `reel_url` (FORBIDDEN) | +| REMOVE | `custom_data` | + +### graphic_designer_profiles + +| Action | Changes | +| ------ | ---------------------------------- | +| RENAME | `user_id` → `user_role_profile_id` | +| REMOVE | `portfolio_url` (FORBIDDEN) | +| REMOVE | `custom_data` | + +### social_media_manager_profiles + +| Action | Changes | +| ------ | ---------------------------------- | +| RENAME | `user_id` → `user_role_profile_id` | +| REMOVE | `custom_data` | + +### fitness_trainer_profiles + +| Action | Changes | +| ------ | ---------------------------------- | +| RENAME | `user_id` → `user_role_profile_id` | +| REMOVE | `custom_data` | + +### catering_service_profiles + +| Action | Changes | +| ------ | ---------------------------------- | +| RENAME | `user_id` → `user_role_profile_id` | +| REMOVE | `custom_data` | + +### ugc_content_creator_profiles + +| Action | Changes | +| ------ | ---------------------------------- | +| RENAME | `user_id` → `user_role_profile_id` | + +--- + +## 5. Other Profile Tables + +### company_profiles + +| Action | Changes | +| ------ | ----------------------------------------- | +| ADD | `verification_status` (TEXT) | +| ADD | `approval_status` (TEXT) | +| RENAME | `website_url` → Remove | +| RENAME | `registration_number` → Remove | +| RENAME | `employee_count` → Remove | +| REMOVE | Legacy fields merged into JSONB if needed | + +### customer_profiles + +| Action | Changes | +| ------ | ---------------------------------------------------- | +| ADD | `email` (TEXT) | +| RENAME | `experience_years` → Remove (use candidate_profiles) | +| RENAME | `custom_data` → Remove | + +### job_seeker_profiles + +| Action | Changes | +| ------ | ---------------------- | +| RENAME | → `candidate_profiles` | +| REMOVE | `custom_data` | + +--- + +## 6. Portfolio Domain + +### portfolio_items + +| Action | Changes | +| ------ | ------------------------------------------------ | +| RENAME | `professional_id` → `user_role_profile_id` | +| ADD | `display_order` (INTEGER) | +| REMOVE | `profession_key` (derive from user_role_profile) | + +### services + +| Action | Changes | +| ------ | ------------------------------------------------ | +| RENAME | `professional_id` → `user_role_profile_id` | +| REMOVE | `profession_key` (derive from user_role_profile) | + +--- + +## 7. Marketplace Domain + +### jobs + +| Action | Changes | +| ------ | -------------------------------------------------- | +| ADD | `posted_by_user_id` (UUID) | +| ADD | `mode_of_work` (TEXT) | +| ADD | `budget_inr` (INTEGER) | +| ADD | `salary_range_json` (JSONB) | +| RENAME | `company_id` → `company_profile_id` | +| REMOVE | `category` (use tags) | +| REMOVE | `skills` (use tags) | +| REMOVE | `salary_min`, `salary_max` (use salary_range_json) | +| REMOVE | `experience_years` | +| REMOVE | `rejection_reason` (use approval_requests) | + +### job_applications + +| Action | Changes | +| ------ | ----------------------------------------------------- | +| RENAME | `applications` → `job_applications` | +| RENAME | `job_seeker_id` → Remove (use `applicant_user_id`) | +| ADD | `applicant_user_id` (UUID) | +| RENAME | `cover_letter` → `cover_note` | +| REMOVE | `resume_url` (use candidate_profiles.resume_file_url) | +| REMOVE | `contact_viewed` | + +### leads + +| Action | Changes | +| ------ | ------------------------------------------ | +| RENAME | `requirements` → `leads` | +| RENAME | `customer_id` → `created_by_user_id` | +| ADD | `required_date` (DATE) | +| REMOVE | `profession_key` (use leads directly) | +| REMOVE | `extra_data_json` | +| REMOVE | `rejection_reason` (use approval_requests) | +| REMOVE | `request_count`, `accepted_count` | +| REMOVE | `expires_at` | + +### lead_requests + +| Action | Changes | +| ------ | ------------------------------------------ | +| RENAME | `professional_id` → `user_role_profile_id` | +| ADD | `remarks` (TEXT) | +| REMOVE | `tracecoins_reserved` | +| REMOVE | `resolved_at` | + +--- + +## 8. Verification & Approval + +### Current: onboarding_submissions + +| Action | Migration | +| ------ | ---------------------------------------------- | +| Map to | `verification_requests` OR `approval_requests` | + +### Current: verification_requests (exists) + +| Action | Changes | +| ------ | ----------------------------- | +| UPDATE | Align fields to target schema | + +### Current: verification_logs + +| Action | Changes | +| ------ | ----------------------------- | +| UPDATE | Align fields to target schema | + +--- + +## 9. Reviews + +### reviews + +| Action | Changes | +| ------ | -------------------------------------------------- | +| ADD | `entity_type` (TEXT) | +| ADD | `entity_id` (UUID) | +| RENAME | `professional_id` → `entity_id` (with entity_type) | +| RENAME | `customer_id` → `reviewer_user_id` | +| REMOVE | `lead_request_id` (reference through entity_id) | +| ADD | `status` (TEXT) | + +--- + +## 10. Finance Domain + +### tracecoin_wallets + +| Action | Changes | +| ------ | ----------------------------- | +| RENAME | `balance` → `current_balance` | +| ADD | `updated_at` (TIMESTAMP) | + +### tracecoin_ledger + +| Action | Changes | +| ------ | --------------------------- | +| ADD | `balance_after` (INTEGER) | +| RENAME | `type` → `transaction_type` | +| RENAME | `reason` → `reference_type` | +| ADD | `remarks` (TEXT) | + +### payments + +| Action | Changes | +| ------ | ---------------------------------- | +| ADD | `payment_gateway_config_id` (UUID) | +| ADD | `payment_method` (TEXT) | +| ADD | `currency_code` (TEXT) | +| RENAME | `verified_at` → Remove | +| REMOVE | `package_id` (use order_items) | + +### invoices + +| Action | Changes | +| ------ | --------------------------- | +| ADD | `order_id` (UUID) | +| RENAME | `subtotal` → `subtotal_inr` | +| RENAME | `gst_amount` → `tax_inr` | +| RENAME | `total` → `total_inr` | +| ADD | `discount_inr` (INTEGER) | +| ADD | `due_at` (TIMESTAMP) | +| ADD | `paid_at` (TIMESTAMP) | + +### coupons + +| Action | Changes | +| ------ | ----------------------------------------- | +| ADD | `max_discount_inr` (INTEGER) | +| ADD | `min_order_value_inr` (INTEGER) | +| ADD | `valid_from` (TIMESTAMP) | +| ADD | `valid_to` (TIMESTAMP) | +| RENAME | `applies_to` → Keep | +| REMOVE | `max_uses` (use `usage_limit`) | +| REMOVE | `uses_count` (tracked in redemptions) | +| REMOVE | `per_user_limit` (tracked in redemptions) | + +### coupon_redemptions + +| Action | Changes | +| ------ | ------------------------------------ | +| RENAME | `coupon_uses` → `coupon_redemptions` | +| ADD | `order_id` (UUID) | +| RENAME | `used_at` → `redeemed_at` | +| ADD | `discount_amount_inr` (INTEGER) | + +--- + +## 11. Knowledge Base + +### kb_articles + +| Action | Changes | +| ------ | ------------------------------------------- | +| ADD | `section_id` (UUID) | +| RENAME | `body` → `content_markdown` | +| ADD | `article_type` (TEXT) | +| ADD | `audience_type` (TEXT) | +| RENAME | `is_published` → `status` | +| RENAME | `created_by` → `author_user_id` | +| REMOVE | `target_roles` | +| REMOVE | `views` (can add back as separate tracking) | + +--- + +## 12. Support + +### support_tickets + +| Action | Changes | +| ------ | ------------------------------------- | +| RENAME | `user_id` → `created_by_user_id` | +| RENAME | `assigned_to` → `assigned_to_user_id` | +| RENAME | `description` → Keep | +| ADD | `related_entity_type` (TEXT) | +| ADD | `related_entity_id` (UUID) | +| ADD | `closed_at` (TIMESTAMP) | + +### support_ticket_messages + +| Action | Changes | +| ------ | ------------------------------ | +| RENAME | `sender_id` → `sender_user_id` | +| RENAME | `body` → `message_body` | +| ADD | `attachment_url` (TEXT) | + +--- + +## 13. Notifications + +### notifications + +| Action | Changes | +| ------ | ------------------------------------ | +| ADD | `channel` (TEXT) | +| RENAME | `type` → Keep (add channel) | +| RENAME | `reference_id` → `related_entity_id` | +| ADD | `related_entity_type` (TEXT) | +| RENAME | `is_read` → `status` | + +--- + +## 14. Tables to DEPRECATE (Keep for Backward Compatibility) + +| Table | Reason | Action | +| ------------------------ | ------------------------------- | ----------------------------- | +| `onboarding_submissions` | Legacy onboarding flow | Map to verification/approval | +| `onboarding_configs` | Legacy onboarding flow | Map to dashboard_configs | +| `onboarding_states` | Legacy onboarding flow | Remove after migration | +| `submission_documents` | Legacy onboarding flow | Map to verification_documents | +| `professionals` | Replaced by user_role_profiles | Keep until migration complete | +| `user_settings` | Already exists, align structure | Align columns | + +--- + +## 15. Tables to DROP (After Migration) + +| Table | Condition | +| ------------------------ | --------------------------------------------- | +| `professionals` | After all data migrated to user_role_profiles | +| `onboarding_submissions` | After verification_requests populated | +| `onboarding_configs` | After dashboard_configs populated | +| `onboarding_states` | After migration complete | +| `submission_documents` | After verification_documents populated | + +--- + +## 16. Foreign Key Changes Summary + +### From users.id to user_role_profiles.id + +**Tables changing FK:** + +- `photographer_profiles.user_id` → `user_role_profile_id` +- `tutor_profiles.user_id` → `user_role_profile_id` +- `makeup_artist_profiles.user_id` → `user_role_profile_id` +- `developer_profiles.user_id` → `user_role_profile_id` +- `video_editor_profiles.user_id` → `user_role_profile_id` +- `graphic_designer_profiles.user_id` → `user_role_profile_id` +- `social_media_manager_profiles.user_id` → `user_role_profile_id` +- `fitness_trainer_profiles.user_id` → `user_role_profile_id` +- `catering_service_profiles.user_id` → `user_role_profile_id` +- `ugc_content_creator_profiles.user_id` → `user_role_profile_id` +- `portfolio_items.professional_id` → `user_role_profile_id` +- `services.professional_id` → `user_role_profile_id` +- `lead_requests.professional_id` → `user_role_profile_id` diff --git a/docs/schema_audit.md b/docs/schema_audit.md new file mode 100644 index 0000000..3103cf1 --- /dev/null +++ b/docs/schema_audit.md @@ -0,0 +1,216 @@ +# Schema Audit — Nxtgauge Database + +## Current State Overview + +The Nxtgauge database contains **45+ tables** spread across migrations and init-db.sql. Below is the complete audit. + +--- + +## 1. Current Table Inventory + +### Identity & Access Control + +| Table | Status | Notes | +| --------------------------- | -------- | --------------------------------------------------- | +| `users` | ✅ Good | Has email, phone, password_hash, status, timestamps | +| `refresh_tokens` | ✅ Good | Token storage for auth | +| `roles` | ✅ Good | Role definitions | +| `role_permissions` | ✅ Good | Permission assignments | +| `user_roles` | ✅ Good | User-role associations | +| `employees` | ✅ Good | Internal staff records | +| `departments` | ✅ Good | Organization structure | +| `designations` | ✅ Good | Job titles | +| `user_settings` | ✅ Added | User preferences | +| `account_deletion_requests` | ✅ Added | Deletion tracking | + +### User Profiles — DUPLICATED STRUCTURE (PROBLEM) + +| Table | Status | Issue | +| ------------------------------- | ------------- | ---------------------------------------------- | +| `photographer_profiles` | ⚠️ | Has duplicated common fields | +| `tutor_profiles` | ⚠️ | Has duplicated common fields | +| `makeup_artist_profiles` | ⚠️ | Has duplicated common fields | +| `developer_profiles` | ⚠️ | Has external links (github_url, portfolio_url) | +| `video_editor_profiles` | ⚠️ | Has external links (reel_url) | +| `graphic_designer_profiles` | ⚠️ | Has external links (portfolio_url) | +| `social_media_manager_profiles` | ⚠️ | Has duplicated common fields | +| `fitness_trainer_profiles` | ⚠️ | Has duplicated common fields | +| `catering_service_profiles` | ⚠️ | Has duplicated common fields | +| `ugc_content_creator_profiles` | ⚠️ | Has duplicated common fields | +| `company_profiles` | ⚠️ | Has duplicated fields | +| `customer_profiles` | ⚠️ | Has duplicated fields | +| `job_seeker_profiles` | ⚠️ | Has duplicated fields | +| `professionals` | ❌ Deprecated | Old root table, references user_id | + +### Portfolio Domain + +| Table | Status | Issue | +| ------------------ | ------- | ---------------------------------------------- | +| `portfolio_items` | ⚠️ | References `professionals` table, needs update | +| `portfolio_images` | ✅ Good | Simple image storage | +| `services` | ⚠️ | References `professionals` table, needs update | + +### Marketplace + +| Table | Status | Notes | +| --------------- | ------- | --------------------------------------- | +| `jobs` | ✅ Good | Job postings | +| `applications` | ⚠️ | Should be renamed to `job_applications` | +| `requirements` | ⚠️ | Should be renamed to `leads` | +| `lead_requests` | ⚠️ | References `professionals` table | +| `reviews` | ✅ Good | Reviews system | + +### Verification & Approval (Mixed with Onboarding) + +| Table | Status | Issue | +| ------------------------ | --------- | ------------------------ | +| `onboarding_submissions` | ❌ Legacy | Tied to onboarding flow | +| `submission_documents` | ❌ Legacy | Tied to onboarding | +| `onboarding_states` | ❌ Legacy | Tied to onboarding | +| `onboarding_configs` | ❌ Legacy | Tied to onboarding | +| `verifications` | ⚠️ | Basic verification table | +| `verification_logs` | ⚠️ | Basic verification logs | + +### Finance Domain + +| Table | Status | Notes | +| ------------------- | ------- | ------------------------------ | +| `tracecoin_wallets` | ✅ Good | Wallet per user | +| `tracecoin_ledger` | ✅ Good | Immutable ledger | +| `pricing_packages` | ✅ Good | Package definitions | +| `payments` | ✅ Good | Payment records | +| `invoices` | ✅ Good | Invoice records | +| `coupons` | ✅ Good | Coupon system | +| `coupon_uses` | ⚠️ | Should be `coupon_redemptions` | +| `discounts` | ✅ Good | Discount rules | + +### Communication & Support + +| Table | Status | Notes | +| ------------------------- | ------- | -------------------- | +| `notifications` | ✅ Good | In-app notifications | +| `email_logs` | ✅ Good | Email audit trail | +| `support_tickets` | ✅ Good | Support system | +| `support_ticket_messages` | ✅ Good | Ticket messages | + +### Knowledge Base + +| Table | Status | Notes | +| --------------- | ------- | ------------------------------------------- | +| `kb_categories` | ✅ Good | KB categories | +| `kb_articles` | ⚠️ | Missing `section_id`, `kb_article_feedback` | + +### Dashboard & Config + +| Table | Status | Notes | +| ------------------- | ------- | ------------------------ | +| `dashboard_configs` | ✅ Good | Dashboard configurations | +| `runtime_configs` | ✅ Good | Runtime feature flags | + +### Audit & Logging + +| Table | Status | Issue | +| ------------------- | ---------- | ------------------------- | +| `activity_logs` | ⚠️ | Basic logging, incomplete | +| `audit_logs` | ❌ Missing | Not implemented | +| `audit_log_changes` | ❌ Missing | Not implemented | + +--- + +## 2. Problems Identified + +### Problem 1: Duplicated Profile Fields + +Every profile table has these duplicated fields: + +- `display_name`, `bio`, `location`, `status`, `rejection_reason`, `approved_at` + +**Impact**: Data redundancy, inconsistent updates, maintenance burden. + +### Problem 2: Extension Tables Reference `user_id` Instead of Root Profile + +Current: `photographer_profiles.user_id` → `users.id` +Target: `photographer_profiles.user_role_profile_id` → `user_role_profiles.id` + +**Impact**: Cannot support multiple role profiles per user properly. + +### Problem 3: External Portfolio Links + +Tables with forbidden external links: + +- `developer_profiles`: `github_url`, `portfolio_url` +- `video_editor_profiles`: `reel_url` +- `graphic_designer_profiles`: `portfolio_url` + +**Impact**: Violates platform-native portfolio requirement. + +### Problem 4: Table Names Not Aligned with Target + +| Current | Target | +| -------------- | -------------------- | +| `applications` | `job_applications` | +| `requirements` | `leads` | +| `coupon_uses` | `coupon_redemptions` | + +### Problem 5: Missing Root Profile Table + +`user_role_profiles` does not exist. Users cannot have multiple role profiles properly. + +### Problem 6: Missing Audit Infrastructure + +- No `audit_logs` table +- No `audit_log_changes` table +- Current `activity_logs` is incomplete + +### Problem 7: Verification/Approval Tied to Onboarding + +- `onboarding_submissions`, `onboarding_configs`, `onboarding_states` are legacy +- Need separate `verification_requests`, `approval_requests` + +### Problem 8: Incomplete Knowledge Base + +- Missing `kb_sections` +- Missing `kb_article_feedback` +- Missing `kb_article_related` + +### Problem 9: Missing Finance Tables + +- No `orders` / `order_items` +- No `tax_rules` +- No `payment_transactions` +- No `payment_gateway_configs` + +--- + +## 3. Page-Based Anti-Patterns + +The schema was influenced by admin pages rather than domain entities: + +| Page | Problem Table(s) | +| --------------------- | ------------------------------------------------------------------- | +| Onboarding Management | `onboarding_submissions`, `onboarding_configs`, `onboarding_states` | +| Account Settings | Should be in `user_settings` | +| Reviews | Mix of UI and domain | + +--- + +## 4. Inconsistent Naming + +| Issue | Examples | +| ----------------------- | ----------------------------------------------------------- | +| Mixed naming | `job_type` vs `employment_type` | +| Inconsistent timestamps | Some tables have `created_at`, some don't have `updated_at` | +| UUID vs text | Some IDs are UUID, some referenced tables use text | + +--- + +## 5. Recommendations + +1. **Create `user_role_profiles`** as the root profile table +2. **Update extension tables** to reference `user_role_profile_id` +3. **Remove external links** from profile tables +4. **Rename tables** to match target schema +5. **Create audit tables** for compliance +6. **Separate verification from approval** domains +7. **Add missing finance tables** for proper order management +8. **Complete KB structure** with sections and feedback diff --git a/docs/target_schema.md b/docs/target_schema.md new file mode 100644 index 0000000..5607343 --- /dev/null +++ b/docs/target_schema.md @@ -0,0 +1,1052 @@ +# Target Schema — Nxtgauge Database + +This document defines the **target state** for the Nxtgauge PostgreSQL schema based on the single source of truth. + +--- + +## 1. Identity & Access Control + +### users + +| Column | Type | Notes | +| ------------- | --------- | -------------------------- | +| id | UUID | Primary key | +| email | TEXT | Unique | +| phone | TEXT | Unique, nullable | +| password_hash | TEXT | | +| account_type | TEXT | INDIVIDUAL, COMPANY | +| status | TEXT | ACTIVE, PENDING, SUSPENDED | +| last_login_at | TIMESTAMP | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### refresh_tokens + +| Column | Type | Notes | +| ---------- | --------- | ----------- | +| id | UUID | Primary key | +| user_id | UUID | FK → users | +| token_hash | TEXT | Unique | +| expires_at | TIMESTAMP | | +| revoked | BOOLEAN | | +| created_at | TIMESTAMP | | + +### user_sessions + +| Column | Type | Notes | +| ------------- | --------- | ----------- | +| id | UUID | Primary key | +| user_id | UUID | FK → users | +| session_token | TEXT | Unique | +| ip_address | TEXT | | +| user_agent | TEXT | | +| expires_at | TIMESTAMP | | +| created_at | TIMESTAMP | | + +### user_settings + +| Column | Type | Notes | +| ------------- | --------- | ------------------ | +| id | UUID | Primary key | +| user_id | UUID | FK → users, Unique | +| settings_json | JSONB | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### roles + +| Column | Type | Notes | +| -------------------------- | --------- | ------------------ | +| id | UUID | Primary key | +| key | TEXT | Unique | +| name | TEXT | | +| audience | TEXT | INTERNAL, EXTERNAL | +| description | TEXT | | +| department_id | UUID | FK → departments | +| can_approve_requests | BOOLEAN | | +| can_manage_system_settings | BOOLEAN | | +| is_active | BOOLEAN | | +| created_at | TIMESTAMP | | + +### permissions + +| Column | Type | Notes | +| ----------- | --------- | ----------- | +| id | UUID | Primary key | +| key | TEXT | Unique | +| name | TEXT | | +| description | TEXT | | +| created_at | TIMESTAMP | | + +### role_permissions + +| Column | Type | Notes | +| -------------- | --------- | ---------------- | +| id | UUID | Primary key | +| role_id | UUID | FK → roles | +| permission_key | TEXT | FK → permissions | +| created_at | TIMESTAMP | | + +### user_roles + +| Column | Type | Notes | +| ----------- | --------- | --------------------------- | +| id | UUID | Primary key | +| user_id | UUID | FK → users | +| role_id | UUID | FK → roles | +| status | TEXT | PENDING, APPROVED, REJECTED | +| approved_at | TIMESTAMP | | +| created_at | TIMESTAMP | | + +### employees + +| Column | Type | Notes | +| ------------------- | --------- | ---------------------------- | +| id | UUID | Primary key | +| user_id | UUID | FK → users, Unique | +| employee_code | TEXT | | +| department_id | UUID | FK → departments | +| designation_id | UUID | FK → designations | +| joining_date | DATE | | +| employment_status | TEXT | ACTIVE, INACTIVE, TERMINATED | +| manager_employee_id | UUID | Self-reference | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### departments + +| Column | Type | Notes | +| ----------------- | --------- | ----------- | +| id | UUID | Primary key | +| name | TEXT | Unique | +| code | TEXT | Unique | +| description | TEXT | | +| department_head | TEXT | | +| department_email | TEXT | | +| is_active | BOOLEAN | | +| visibility | TEXT | INTERNAL | +| transfers_enabled | BOOLEAN | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### designations + +| Column | Type | Notes | +| --------------- | --------- | ---------------- | +| id | UUID | Primary key | +| name | TEXT | Unique | +| code | TEXT | Unique | +| department_id | UUID | FK → departments | +| description | TEXT | | +| level | TEXT | | +| can_manage_team | BOOLEAN | | +| can_approve | BOOLEAN | | +| is_active | BOOLEAN | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +--- + +## 2. User Role Profiles (NEW ROOT) + +### user_role_profiles + +| Column | Type | Notes | +| ------------------- | --------- | ------------------------------------ | +| id | UUID | Primary key | +| user_id | UUID | FK → users | +| role_key | TEXT | photographer, tutor, developer, etc. | +| display_name | TEXT | | +| bio | TEXT | | +| location | TEXT | | +| avatar_url | TEXT | | +| phone | TEXT | | +| email | TEXT | | +| status | TEXT | DRAFT, ACTIVE, SUSPENDED | +| verification_status | TEXT | PENDING, VERIFIED, REJECTED | +| approval_status | TEXT | PENDING, APPROVED, REJECTED | +| rejection_reason | TEXT | | +| approved_at | TIMESTAMP | | +| verified_at | TIMESTAMP | | +| is_profile_public | BOOLEAN | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +**Indexes:** + +- `UNIQUE(user_id, role_key)` +- `INDEX(status)` +- `INDEX(verification_status)` +- `INDEX(approval_status)` + +--- + +## 3. Role Extension Tables + +### photographer_profiles + +| Column | Type | Notes | +| -------------------- | --------- | ------------------------------- | +| id | UUID | Primary key | +| user_role_profile_id | UUID | FK → user_role_profiles, Unique | +| specialties | TEXT[] | | +| camera_brands | TEXT[] | | +| studio_available | BOOLEAN | | +| outdoor_shoots | BOOLEAN | | +| travel_radius_km | INTEGER | | +| starting_price_inr | INTEGER | in paise | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### tutor_profiles + +| Column | Type | Notes | +| -------------------- | --------- | ------------------------------- | +| id | UUID | Primary key | +| user_role_profile_id | UUID | FK → user_role_profiles, Unique | +| subjects | TEXT[] | | +| board_types | TEXT[] | | +| qualification | TEXT | | +| teaches_online | BOOLEAN | | +| teaches_offline | BOOLEAN | | +| experience_years | INTEGER | | +| hourly_rate_inr | INTEGER | in paise | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### makeup_artist_profiles + +| Column | Type | Notes | +| -------------------- | --------- | ------------------------------- | +| id | UUID | Primary key | +| user_role_profile_id | UUID | FK → user_role_profiles, Unique | +| specializations | TEXT[] | | +| kit_brands | TEXT[] | | +| home_service | BOOLEAN | | +| studio_available | BOOLEAN | | +| starting_price_inr | INTEGER | in paise | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### developer_profiles + +| Column | Type | Notes | +| -------------------- | --------- | ------------------------------- | +| id | UUID | Primary key | +| user_role_profile_id | UUID | FK → user_role_profiles, Unique | +| tech_stack | TEXT[] | | +| experience_years | INTEGER | | +| availability | TEXT | FULL_TIME, PART_TIME, FREELANCE | +| hourly_rate_inr | INTEGER | in paise | +| remote_ok | BOOLEAN | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +**NO external links (github_url, portfolio_url removed)** + +### video_editor_profiles + +| Column | Type | Notes | +| -------------------- | --------- | ------------------------------- | +| id | UUID | Primary key | +| user_role_profile_id | UUID | FK → user_role_profiles, Unique | +| software_skills | TEXT[] | | +| style_tags | TEXT[] | | +| turnaround_days | INTEGER | | +| starting_price_inr | INTEGER | in paise | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +**NO reel_url** + +### graphic_designer_profiles + +| Column | Type | Notes | +| -------------------- | --------- | ------------------------------- | +| id | UUID | Primary key | +| user_role_profile_id | UUID | FK → user_role_profiles, Unique | +| design_tools | TEXT[] | | +| style_tags | TEXT[] | | +| brand_experience | BOOLEAN | | +| starting_price_inr | INTEGER | in paise | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +**NO portfolio_url** + +### social_media_manager_profiles + +| Column | Type | Notes | +| ----------------------- | --------- | ------------------------------- | +| id | UUID | Primary key | +| user_role_profile_id | UUID | FK → user_role_profiles, Unique | +| platforms | TEXT[] | | +| industries | TEXT[] | | +| content_types | TEXT[] | | +| avg_follower_growth_pct | INTEGER | | +| starting_price_inr | INTEGER | in paise | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### fitness_trainer_profiles + +| Column | Type | Notes | +| -------------------- | --------- | ------------------------------- | +| id | UUID | Primary key | +| user_role_profile_id | UUID | FK → user_role_profiles, Unique | +| disciplines | TEXT[] | | +| certifications | TEXT[] | | +| online_sessions | BOOLEAN | | +| home_visits | BOOLEAN | | +| gym_based | BOOLEAN | | +| per_session_rate_inr | INTEGER | in paise | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### catering_service_profiles + +| Column | Type | Notes | +| -------------------- | --------- | ------------------------------- | +| id | UUID | Primary key | +| user_role_profile_id | UUID | FK → user_role_profiles, Unique | +| business_name | TEXT | | +| cuisine_types | TEXT[] | | +| event_types | TEXT[] | | +| min_guests | INTEGER | | +| max_guests | INTEGER | | +| has_setup_team | BOOLEAN | | +| has_serving_staff | BOOLEAN | | +| price_per_head_inr | INTEGER | in paise | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### ugc_content_creator_profiles + +| Column | Type | Notes | +| -------------------- | --------- | ------------------------------- | +| id | UUID | Primary key | +| user_role_profile_id | UUID | FK → user_role_profiles, Unique | +| niche_tags | TEXT[] | | +| content_formats | TEXT[] | | +| platforms | TEXT[] | | +| turnaround_days | INTEGER | | +| starting_price_inr | INTEGER | in paise | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +--- + +## 4. Other Profiles + +### company_profiles + +| Column | Type | Notes | +| ------------------- | --------- | ------------------ | +| id | UUID | Primary key | +| user_id | UUID | FK → users, Unique | +| company_name | TEXT | | +| business_type | TEXT | | +| industry | TEXT | | +| contact_person_name | TEXT | | +| email | TEXT | | +| phone | TEXT | | +| location | TEXT | | +| bio | TEXT | | +| status | TEXT | | +| verification_status | TEXT | | +| approval_status | TEXT | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### customer_profiles + +| Column | Type | Notes | +| ------------ | --------- | ------------------ | +| id | UUID | Primary key | +| user_id | UUID | FK → users, Unique | +| display_name | TEXT | | +| phone | TEXT | | +| email | TEXT | | +| location | TEXT | | +| status | TEXT | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### candidate_profiles + +| Column | Type | Notes | +| ------------------- | --------- | ------------------ | +| id | UUID | Primary key | +| user_id | UUID | FK → users, Unique | +| display_name | TEXT | | +| bio | TEXT | | +| location | TEXT | | +| experience_years | INTEGER | | +| preferred_roles | TEXT[] | | +| expected_salary_inr | INTEGER | | +| resume_file_url | TEXT | | +| status | TEXT | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +--- + +## 5. Portfolio Domain + +### portfolio_items + +| Column | Type | Notes | +| -------------------- | --------- | ----------------------- | +| id | UUID | Primary key | +| user_role_profile_id | UUID | FK → user_role_profiles | +| title | TEXT | | +| description | TEXT | | +| tags | TEXT[] | | +| display_order | INTEGER | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +**NO external links** + +### portfolio_images + +| Column | Type | Notes | +| ----------------- | --------- | -------------------- | +| id | UUID | Primary key | +| portfolio_item_id | UUID | FK → portfolio_items | +| file_url | TEXT | | +| display_order | INTEGER | | +| created_at | TIMESTAMP | | + +### services + +| Column | Type | Notes | +| -------------------- | --------- | ----------------------- | +| id | UUID | Primary key | +| user_role_profile_id | UUID | FK → user_role_profiles | +| name | TEXT | | +| description | TEXT | | +| price | INTEGER | in paise | +| duration_minutes | INTEGER | | +| is_active | BOOLEAN | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +--- + +## 6. Verification Domain + +### verification_requests + +| Column | Type | Notes | +| -------------------- | --------- | ---------------------------- | +| id | UUID | Primary key | +| user_role_profile_id | UUID | FK → user_role_profiles | +| verification_type | TEXT | IDENTITY, BUSINESS, DOCUMENT | +| status | TEXT | PENDING, APPROVED, REJECTED | +| submitted_at | TIMESTAMP | | +| reviewed_at | TIMESTAMP | | +| reviewed_by_user_id | UUID | FK → users | +| remarks | TEXT | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### verification_documents + +| Column | Type | Notes | +| ----------------------- | --------- | --------------------------- | +| id | UUID | Primary key | +| verification_request_id | UUID | FK → verification_requests | +| document_type | TEXT | | +| file_url | TEXT | | +| file_name | TEXT | | +| mime_type | TEXT | | +| status | TEXT | PENDING, APPROVED, REJECTED | +| uploaded_at | TIMESTAMP | | +| reviewed_at | TIMESTAMP | | +| reviewed_by_user_id | UUID | FK → users | +| remarks | TEXT | | +| created_at | TIMESTAMP | | + +### verification_logs + +| Column | Type | Notes | +| ----------------------- | --------- | ------------------------------------------------- | +| id | UUID | Primary key | +| verification_request_id | UUID | FK → verification_requests | +| action | TEXT | SUBMITTED, APPROVED, REJECTED, DOCUMENT_REQUESTED | +| old_status | TEXT | | +| new_status | TEXT | | +| acted_by_user_id | UUID | FK → users | +| remarks | TEXT | | +| created_at | TIMESTAMP | | + +--- + +## 7. Approval Domain + +### approval_requests + +| Column | Type | Notes | +| -------------------- | --------- | ------------------------------ | +| id | UUID | Primary key | +| entity_type | TEXT | job, lead, profile, company | +| entity_id | UUID | | +| approval_type | TEXT | CONTENT, VERIFICATION, FEATURE | +| status | TEXT | PENDING, APPROVED, REJECTED | +| submitted_by_user_id | UUID | FK → users | +| reviewed_by_user_id | UUID | FK → users | +| submitted_at | TIMESTAMP | | +| reviewed_at | TIMESTAMP | | +| remarks | TEXT | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### approval_logs + +| Column | Type | Notes | +| ------------------- | --------- | ----------------------------- | +| id | UUID | Primary key | +| approval_request_id | UUID | FK → approval_requests | +| action | TEXT | SUBMITTED, APPROVED, REJECTED | +| old_status | TEXT | | +| new_status | TEXT | | +| acted_by_user_id | UUID | FK → users | +| remarks | TEXT | | +| created_at | TIMESTAMP | | + +--- + +## 8. Marketplace Domain + +### jobs + +| Column | Type | Notes | +| ------------------ | --------- | ------------------------------ | +| id | UUID | Primary key | +| company_profile_id | UUID | FK → company_profiles | +| posted_by_user_id | UUID | FK → users | +| title | TEXT | | +| description | TEXT | | +| location | TEXT | | +| employment_type | TEXT | FULL_TIME, PART_TIME, CONTRACT | +| mode_of_work | TEXT | ONSITE, REMOTE, HYBRID | +| budget_inr | INTEGER | | +| salary_range_json | JSONB | | +| status | TEXT | DRAFT, PENDING, LIVE, CLOSED | +| approved_at | TIMESTAMP | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### job_applications (RENAMED from applications) + +| Column | Type | Notes | +| ----------------- | --------- | -------------------------------------------------- | +| id | UUID | Primary key | +| job_id | UUID | FK → jobs | +| applicant_user_id | UUID | FK → users | +| status | TEXT | APPLIED, SHORTLISTED, INTERVIEW, OFFERED, REJECTED | +| cover_note | TEXT | | +| applied_at | TIMESTAMP | | +| created_at | TIMESTAMP | | + +### leads (RENAMED from requirements) + +| Column | Type | Notes | +| ------------------ | --------- | ---------------------------- | +| id | UUID | Primary key | +| created_by_user_id | UUID | FK → users | +| profession_key | TEXT | | +| title | TEXT | | +| description | TEXT | | +| location | TEXT | | +| budget_inr | INTEGER | | +| required_date | DATE | | +| status | TEXT | DRAFT, PENDING, OPEN, CLOSED | +| approved_at | TIMESTAMP | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### lead_requests + +| Column | Type | Notes | +| -------------------- | --------- | --------------------------- | +| id | UUID | Primary key | +| lead_id | UUID | FK → leads | +| user_role_profile_id | UUID | FK → user_role_profiles | +| status | TEXT | PENDING, ACCEPTED, REJECTED | +| remarks | TEXT | | +| requested_at | TIMESTAMP | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### reviews + +| Column | Type | Notes | +| ---------------- | --------- | -------------------------- | +| id | UUID | Primary key | +| reviewer_user_id | UUID | FK → users | +| entity_type | TEXT | professional, company | +| entity_id | UUID | | +| rating | SMALLINT | 1-5 | +| review_text | TEXT | | +| status | TEXT | PENDING, PUBLISHED, HIDDEN | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +--- + +## 9. Finance Domain + +### pricing_packages + +| Column | Type | Notes | +| ------------------- | --------- | -------------------------------------------- | +| id | UUID | Primary key | +| name | TEXT | | +| description | TEXT | | +| package_type | TEXT | JOB_POSTING, CONTACT_VIEWS, TRACECOIN_BUNDLE | +| price_inr | INTEGER | in paise | +| tracecoins_included | INTEGER | | +| validity_days | INTEGER | | +| is_active | BOOLEAN | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### tracecoin_wallets + +| Column | Type | Notes | +| --------------- | --------- | ------------------ | +| id | UUID | Primary key | +| user_id | UUID | FK → users, Unique | +| current_balance | INTEGER | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### tracecoin_ledger (IMMUTABLE) + +| Column | Type | Notes | +| ---------------- | --------- | -------------------------- | +| id | UUID | Primary key | +| wallet_id | UUID | FK → tracecoin_wallets | +| transaction_type | TEXT | CREDIT, DEBIT | +| amount | INTEGER | | +| balance_after | INTEGER | | +| reference_type | TEXT | JOB, LEAD, PURCHASE, BONUS | +| reference_id | UUID | | +| remarks | TEXT | | +| created_at | TIMESTAMP | | + +### orders + +| Column | Type | Notes | +| ------------ | --------- | ----------------------------- | +| id | UUID | Primary key | +| user_id | UUID | FK → users | +| order_type | TEXT | PACKAGE, SERVICE | +| subtotal_inr | INTEGER | | +| discount_inr | INTEGER | | +| tax_inr | INTEGER | | +| total_inr | INTEGER | | +| status | TEXT | PENDING, COMPLETED, CANCELLED | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### order_items + +| Column | Type | Notes | +| --------------- | --------- | ---------------- | +| id | UUID | Primary key | +| order_id | UUID | FK → orders | +| item_type | TEXT | PACKAGE, SERVICE | +| item_id | UUID | | +| item_name | TEXT | | +| quantity | INTEGER | | +| unit_price_inr | INTEGER | | +| total_price_inr | INTEGER | | +| created_at | TIMESTAMP | | + +### invoices + +| Column | Type | Notes | +| -------------- | --------- | ------------ | +| id | UUID | Primary key | +| order_id | UUID | FK → orders | +| user_id | UUID | FK → users | +| invoice_number | TEXT | Unique | +| subtotal_inr | INTEGER | | +| discount_inr | INTEGER | | +| tax_inr | INTEGER | | +| total_inr | INTEGER | | +| status | TEXT | ISSUED, PAID | +| issued_at | TIMESTAMP | | +| due_at | TIMESTAMP | | +| paid_at | TIMESTAMP | | +| created_at | TIMESTAMP | | + +### payments + +| Column | Type | Notes | +| ------------------------- | --------- | ---------------------------- | +| id | UUID | Primary key | +| order_id | UUID | FK → orders | +| invoice_id | UUID | FK → invoices | +| user_id | UUID | FK → users | +| payment_gateway_config_id | UUID | FK → payment_gateway_configs | +| payment_method | TEXT | | +| provider_payment_ref | TEXT | | +| amount_inr | INTEGER | | +| currency_code | TEXT | INR | +| status | TEXT | PENDING, SUCCESS, FAILED | +| initiated_at | TIMESTAMP | | +| completed_at | TIMESTAMP | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### payment_gateway_configs + +| Column | Type | Notes | +| ------------ | --------- | ---------------- | +| id | UUID | Primary key | +| gateway_key | TEXT | RAZORPAY, STRIPE | +| display_name | TEXT | | +| config_json | JSONB | | +| is_active | BOOLEAN | | +| created_at | TIMESTAMP | | + +### payment_transactions + +| Column | Type | Notes | +| --------------------- | --------- | ---------------------------- | +| id | UUID | Primary key | +| payment_id | UUID | FK → payments | +| transaction_type | TEXT | INITIATED, COMPLETED, FAILED | +| provider_reference | TEXT | | +| request_payload_json | JSONB | | +| response_payload_json | JSONB | | +| status | TEXT | | +| created_at | TIMESTAMP | | + +### coupons + +| Column | Type | Notes | +| ------------------- | --------- | ---------------- | +| id | UUID | Primary key | +| code | TEXT | Unique | +| description | TEXT | | +| discount_type | TEXT | PERCENT, FLAT | +| discount_value | INTEGER | | +| max_discount_inr | INTEGER | | +| min_order_value_inr | INTEGER | | +| valid_from | TIMESTAMP | | +| valid_to | TIMESTAMP | | +| usage_limit | INTEGER | NULL = unlimited | +| is_active | BOOLEAN | | +| created_at | TIMESTAMP | | + +### coupon_redemptions (RENAMED from coupon_uses) + +| Column | Type | Notes | +| ------------------- | --------- | ------------ | +| id | UUID | Primary key | +| coupon_id | UUID | FK → coupons | +| user_id | UUID | FK → users | +| order_id | UUID | FK → orders | +| redeemed_at | TIMESTAMP | | +| discount_amount_inr | INTEGER | | +| created_at | TIMESTAMP | | + +### discount_rules + +| Column | Type | Notes | +| -------------- | --------- | ------------------------- | +| id | UUID | Primary key | +| name | TEXT | | +| scope_type | TEXT | GLOBAL, CATEGORY, SERVICE | +| scope_id | UUID | | +| discount_type | TEXT | PERCENT, FLAT | +| discount_value | INTEGER | | +| starts_at | TIMESTAMP | | +| ends_at | TIMESTAMP | | +| is_active | BOOLEAN | | +| created_at | TIMESTAMP | | + +### tax_rules + +| Column | Type | Notes | +| ---------- | --------- | ----------- | +| id | UUID | Primary key | +| name | TEXT | | +| tax_type | TEXT | GST, TCS | +| tax_rate | DECIMAL | | +| applies_to | TEXT | | +| is_active | BOOLEAN | | +| created_at | TIMESTAMP | | + +--- + +## 10. Knowledge Base + +### kb_categories + +| Column | Type | Notes | +| ------------- | --------- | ----------- | +| id | UUID | Primary key | +| name | TEXT | | +| slug | TEXT | Unique | +| description | TEXT | | +| display_order | INTEGER | | +| is_active | BOOLEAN | | +| created_at | TIMESTAMP | | + +### kb_sections + +| Column | Type | Notes | +| ------------- | --------- | ------------------ | +| id | UUID | Primary key | +| category_id | UUID | FK → kb_categories | +| name | TEXT | | +| slug | TEXT | | +| description | TEXT | | +| display_order | INTEGER | | +| is_active | BOOLEAN | | +| created_at | TIMESTAMP | | + +### kb_articles + +| Column | Type | Notes | +| ------------------- | --------- | --------------------------------------------- | +| id | UUID | Primary key | +| category_id | UUID | FK → kb_categories | +| section_id | UUID | FK → kb_sections | +| title | TEXT | | +| slug | TEXT | Unique | +| summary | TEXT | | +| content_markdown | TEXT | | +| article_type | TEXT | HOW_TO, TROUBLESHOOTING, FAQ, FEATURE, POLICY | +| status | TEXT | DRAFT, PUBLISHED | +| audience_type | TEXT | INTERNAL, EXTERNAL, ALL | +| tags | TEXT[] | | +| author_user_id | UUID | FK → users | +| reviewed_by_user_id | UUID | FK → users | +| published_at | TIMESTAMP | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### kb_article_feedback + +| Column | Type | Notes | +| ------------- | --------- | ---------------- | +| id | UUID | Primary key | +| article_id | UUID | FK → kb_articles | +| user_id | UUID | FK → users | +| is_helpful | BOOLEAN | | +| feedback_text | TEXT | | +| created_at | TIMESTAMP | | + +--- + +## 11. Support System + +### support_tickets + +| Column | Type | Notes | +| ------------------- | --------- | ------------------------------------ | +| id | UUID | Primary key | +| created_by_user_id | UUID | FK → users | +| assigned_to_user_id | UUID | FK → users | +| category | TEXT | GENERAL, BILLING, TECHNICAL, ACCOUNT | +| priority | TEXT | LOW, NORMAL, HIGH, URGENT | +| status | TEXT | OPEN, IN_PROGRESS, RESOLVED, CLOSED | +| subject | TEXT | | +| description | TEXT | | +| related_entity_type | TEXT | | +| related_entity_id | UUID | | +| closed_at | TIMESTAMP | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### support_ticket_messages + +| Column | Type | Notes | +| ----------------- | --------- | -------------------- | +| id | UUID | Primary key | +| support_ticket_id | UUID | FK → support_tickets | +| sender_user_id | UUID | FK → users | +| message_body | TEXT | | +| attachment_url | TEXT | | +| is_internal | BOOLEAN | | +| created_at | TIMESTAMP | | + +--- + +## 12. Notifications & Communication + +### notification_templates + +| Column | Type | Notes | +| -------------- | --------- | ---------------- | +| id | UUID | Primary key | +| template_key | TEXT | Unique | +| channel | TEXT | EMAIL, SMS, PUSH | +| title_template | TEXT | | +| body_template | TEXT | | +| is_active | BOOLEAN | | +| created_at | TIMESTAMP | | + +### notifications + +| Column | Type | Notes | +| ------------------- | --------- | ------------------------ | +| id | UUID | Primary key | +| user_id | UUID | FK → users | +| channel | TEXT | EMAIL, SMS, PUSH, IN_APP | +| title | TEXT | | +| body | TEXT | | +| status | TEXT | PENDING, SENT, READ | +| related_entity_type | TEXT | | +| related_entity_id | UUID | | +| sent_at | TIMESTAMP | | +| read_at | TIMESTAMP | | +| created_at | TIMESTAMP | | + +### email_logs + +| Column | Type | Notes | +| ------------------------ | --------- | --------------------------- | +| id | UUID | Primary key | +| user_id | UUID | FK → users | +| notification_template_id | UUID | FK → notification_templates | +| to_email | TEXT | | +| subject | TEXT | | +| body_snapshot | TEXT | | +| status | TEXT | PENDING, SENT, FAILED | +| provider_reference | TEXT | | +| error_message | TEXT | | +| sent_at | TIMESTAMP | | +| created_at | TIMESTAMP | | + +### smtp_configs + +| Column | Type | Notes | +| --------------- | --------- | ----------- | +| id | UUID | Primary key | +| provider_name | TEXT | | +| host | TEXT | | +| port | INTEGER | | +| username | TEXT | | +| encryption_mode | TEXT | SSL, TLS | +| from_name | TEXT | | +| from_email | TEXT | | +| is_default | BOOLEAN | | +| is_active | BOOLEAN | | +| created_at | TIMESTAMP | | + +--- + +## 13. Dashboard & Config + +### dashboard_configs + +| Column | Type | Notes | +| -------------- | --------- | ------------------ | +| id | UUID | Primary key | +| dashboard_type | TEXT | INTERNAL, EXTERNAL | +| owner_type | TEXT | ROLE, USER | +| owner_id | UUID | | +| name | TEXT | | +| layout_json | JSONB | | +| is_default | BOOLEAN | | +| is_active | BOOLEAN | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### dashboard_widgets + +| Column | Type | Notes | +| ------------------- | --------- | ---------------------- | +| id | UUID | Primary key | +| dashboard_config_id | UUID | FK → dashboard_configs | +| widget_key | TEXT | | +| widget_title | TEXT | | +| config_json | JSONB | | +| display_order | INTEGER | | +| width_units | INTEGER | | +| height_units | INTEGER | | +| is_visible | BOOLEAN | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +### runtime_configs + +| Column | Type | Notes | +| ----------------- | --------- | ----------- | +| id | UUID | Primary key | +| config_group | TEXT | | +| config_key | TEXT | | +| config_value_json | JSONB | | +| is_active | BOOLEAN | | +| description | TEXT | | +| created_at | TIMESTAMP | | +| updated_at | TIMESTAMP | | + +--- + +## 14. Audit Management + +### audit_logs + +| Column | Type | Notes | +| ----------------- | --------- | ----------------------------------------- | +| id | UUID | Primary key | +| actor_user_id | UUID | FK → users | +| actor_employee_id | UUID | FK → employees | +| actor_type | TEXT | USER, EMPLOYEE, SYSTEM, CRON | +| action | TEXT | CREATE, UPDATE, DELETE, APPROVE, REJECT | +| entity_type | TEXT | | +| entity_id | UUID | | +| entity_label | TEXT | | +| module_key | TEXT | users, verification, jobs, leads, finance | +| source_type | TEXT | UI, API, WORKER, CRON | +| source_id | UUID | | +| request_id | UUID | | +| correlation_id | UUID | | +| ip_address | TEXT | | +| user_agent | TEXT | | +| status | TEXT | SUCCESS, FAILED | +| summary | TEXT | | +| metadata_json | JSONB | | +| created_at | TIMESTAMP | | + +### audit_log_changes + +| Column | Type | Notes | +| -------------- | --------- | --------------- | +| id | UUID | Primary key | +| audit_log_id | UUID | FK → audit_logs | +| field_name | TEXT | | +| old_value_text | TEXT | | +| new_value_text | TEXT | | +| created_at | TIMESTAMP | | + +--- + +## 15. Summary of Table Counts + +| Domain | Tables | Status | +| ------------------ | ------ | --------------------------------------- | +| Identity & Access | 10 | Existing + user_sessions, user_settings | +| User Role Profiles | 1 | NEW | +| Role Extensions | 10 | Updated FK | +| Other Profiles | 3 | company, customer, candidate | +| Portfolio | 3 | Updated FK | +| Verification | 3 | NEW structure | +| Approval | 2 | NEW structure | +| Marketplace | 5 | Renamed tables | +| Finance | 12 | Expanded | +| Knowledge Base | 4 | Added sections, feedback | +| Support | 2 | Enhanced | +| Notifications | 4 | Added templates, smtp | +| Dashboard | 3 | Added widgets | +| Audit | 2 | NEW | +| **TOTAL** | **64** | | From 2e283e5d67a56db84f4a6df008033e2eff65e5b6 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 12 Apr 2026 23:55:08 +0200 Subject: [PATCH 012/182] feat(db): add complete migration and update extension models to use user_role_profile_id MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Add comprehensive migration script for database schema redesign - Update all extension profile models to reference user_role_profile_id - Create user_role_profiles as root table for all role profiles - Remove external portfolio links (github_url, portfolio_url, reel_url) - Rename applications→job_applications, requirements→leads - Drop deprecated tables (professionals, onboarding_submissions, etc.) --- ...20260415000000_complete_migration.down.sql | 18 + .../20260415000000_complete_migration.up.sql | 778 ++++++++++++++++++ crates/db/src/models/catering_service.rs | 84 +- crates/db/src/models/developer.rs | 72 +- crates/db/src/models/fitness_trainer.rs | 76 +- crates/db/src/models/graphic_designer.rs | 66 +- crates/db/src/models/makeup_artist.rs | 74 +- crates/db/src/models/social_media_manager.rs | 72 +- crates/db/src/models/tutor.rs | 82 +- crates/db/src/models/ugc_content_creator.rs | 72 +- crates/db/src/models/video_editor.rs | 66 +- 11 files changed, 1147 insertions(+), 313 deletions(-) create mode 100644 crates/db/migrations/20260415000000_complete_migration.down.sql create mode 100644 crates/db/migrations/20260415000000_complete_migration.up.sql diff --git a/crates/db/migrations/20260415000000_complete_migration.down.sql b/crates/db/migrations/20260415000000_complete_migration.down.sql new file mode 100644 index 0000000..c8d5dda --- /dev/null +++ b/crates/db/migrations/20260415000000_complete_migration.down.sql @@ -0,0 +1,18 @@ +-- ============================================================================ +-- DOWN MIGRATION: This migration is NOT REVERSIBLE +-- +-- This migration performs a COMPLETE schema transformation and CANNOT be +-- rolled back. All old tables have been dropped, columns removed, and data +-- restructured. +-- +-- DO NOT ATTEMPT TO RUN THIS FILE +-- +-- To restore from backup: +-- 1. Restore PostgreSQL database from backup +-- 2. Re-run the original init-db.sql and all previous migrations +-- ============================================================================ + +-- THIS FILE INTENTIONALLY LEFT EMPTY +-- This migration is NOT REVERSIBLE + +SELECT 'MIGRATION CANNOT BE REVERSED - Restore from backup' AS warning; diff --git a/crates/db/migrations/20260415000000_complete_migration.up.sql b/crates/db/migrations/20260415000000_complete_migration.up.sql new file mode 100644 index 0000000..73d9bdc --- /dev/null +++ b/crates/db/migrations/20260415000000_complete_migration.up.sql @@ -0,0 +1,778 @@ +-- ============================================================================ +-- Nxtgauge Database Complete Migration +-- Version: 20260415000000 +-- This migration performs a COMPLETE schema transformation +-- NO FALLBACKS - This is a one-way migration +-- ============================================================================ + +BEGIN; + +-- ============================================================================ +-- PHASE 1: Create New Core Tables +-- ============================================================================ + +-- 1.1 user_sessions (new) +CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_token TEXT UNIQUE NOT NULL, + ip_address TEXT, + user_agent TEXT, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(session_token); +CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at); + +-- 1.2 user_role_profiles (NEW ROOT - CRITICAL) +CREATE TABLE IF NOT EXISTS user_role_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_key VARCHAR(50) NOT NULL, + display_name TEXT, + bio TEXT, + location TEXT, + avatar_url TEXT, + phone TEXT, + email TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + verification_status VARCHAR(50) DEFAULT 'PENDING', + approval_status VARCHAR(50) DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + verified_at TIMESTAMPTZ, + is_profile_public BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, role_key) +); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_user_id ON user_role_profiles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_role_key ON user_role_profiles(role_key); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_status ON user_role_profiles(status); + +-- ============================================================================ +-- PHASE 2: Backfill user_role_profiles from ALL existing profile tables +-- ============================================================================ + +-- Backfill from photographer_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT gen_random_uuid(), p.user_id, 'photographer', p.display_name, p.bio, p.location, + COALESCE(p.status, 'ACTIVE'), COALESCE(p.status, 'PENDING'), p.rejection_reason, p.approved_at, p.created_at, COALESCE(p.updated_at, NOW()) +FROM photographer_profiles p +WHERE NOT EXISTS (SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'photographer'); + +-- Backfill from tutor_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT gen_random_uuid(), p.user_id, 'tutor', p.display_name, p.bio, p.location, + COALESCE(p.status, 'ACTIVE'), COALESCE(p.status, 'PENDING'), p.rejection_reason, p.approved_at, p.created_at, COALESCE(p.updated_at, NOW()) +FROM tutor_profiles p +WHERE NOT EXISTS (SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'tutor'); + +-- Backfill from makeup_artist_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT gen_random_uuid(), p.user_id, 'makeup_artist', p.display_name, p.bio, p.location, + COALESCE(p.status, 'ACTIVE'), COALESCE(p.status, 'PENDING'), p.rejection_reason, p.approved_at, p.created_at, COALESCE(p.updated_at, NOW()) +FROM makeup_artist_profiles p +WHERE NOT EXISTS (SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'makeup_artist'); + +-- Backfill from developer_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT gen_random_uuid(), p.user_id, 'developer', p.display_name, p.bio, p.location, + COALESCE(p.status, 'ACTIVE'), COALESCE(p.status, 'PENDING'), p.rejection_reason, p.approved_at, p.created_at, COALESCE(p.updated_at, NOW()) +FROM developer_profiles p +WHERE NOT EXISTS (SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'developer'); + +-- Backfill from video_editor_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT gen_random_uuid(), p.user_id, 'video_editor', p.display_name, p.bio, p.location, + COALESCE(p.status, 'ACTIVE'), COALESCE(p.status, 'PENDING'), p.rejection_reason, p.approved_at, p.created_at, COALESCE(p.updated_at, NOW()) +FROM video_editor_profiles p +WHERE NOT EXISTS (SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'video_editor'); + +-- Backfill from graphic_designer_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT gen_random_uuid(), p.user_id, 'graphic_designer', p.display_name, p.bio, p.location, + COALESCE(p.status, 'ACTIVE'), COALESCE(p.status, 'PENDING'), p.rejection_reason, p.approved_at, p.created_at, COALESCE(p.updated_at, NOW()) +FROM graphic_designer_profiles p +WHERE NOT EXISTS (SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'graphic_designer'); + +-- Backfill from social_media_manager_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT gen_random_uuid(), p.user_id, 'social_media_manager', p.display_name, p.bio, p.location, + COALESCE(p.status, 'ACTIVE'), COALESCE(p.status, 'PENDING'), p.rejection_reason, p.approved_at, p.created_at, COALESCE(p.updated_at, NOW()) +FROM social_media_manager_profiles p +WHERE NOT EXISTS (SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'social_media_manager'); + +-- Backfill from fitness_trainer_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT gen_random_uuid(), p.user_id, 'fitness_trainer', p.display_name, p.bio, p.location, + COALESCE(p.status, 'ACTIVE'), COALESCE(p.status, 'PENDING'), p.rejection_reason, p.approved_at, p.created_at, COALESCE(p.updated_at, NOW()) +FROM fitness_trainer_profiles p +WHERE NOT EXISTS (SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'fitness_trainer'); + +-- Backfill from catering_service_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT gen_random_uuid(), p.user_id, 'catering_service', p.business_name, p.bio, p.location, + COALESCE(p.status, 'ACTIVE'), COALESCE(p.status, 'PENDING'), p.rejection_reason, p.approved_at, p.created_at, COALESCE(p.updated_at, NOW()) +FROM catering_service_profiles p +WHERE NOT EXISTS (SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'catering_service'); + +-- Backfill from ugc_content_creator_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, approval_status, rejection_reason, approved_at, created_at, updated_at) +SELECT gen_random_uuid(), p.user_id, 'ugc_content_creator', p.display_name, p.bio, p.location, + COALESCE(p.status, 'ACTIVE'), COALESCE(p.status, 'PENDING'), p.rejection_reason, p.approved_at, p.created_at, COALESCE(p.updated_at, NOW()) +FROM ugc_content_creator_profiles p +WHERE NOT EXISTS (SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'ugc_content_creator'); + +-- Backfill from company_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, created_at, updated_at) +SELECT gen_random_uuid(), cp.user_id, 'company', cp.company_name, cp.bio, NULL, + COALESCE(cp.status, 'ACTIVE'), cp.created_at, COALESCE(cp.updated_at, NOW()) +FROM company_profiles cp +WHERE NOT EXISTS (SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = cp.user_id AND urp.role_key = 'company'); + +-- Backfill from customer_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, location, status, created_at, updated_at) +SELECT gen_random_uuid(), cp.user_id, 'customer', COALESCE(cp.full_name, ''), cp.city, + COALESCE(cp.status, 'ACTIVE'), cp.created_at, COALESCE(cp.updated_at, NOW()) +FROM customer_profiles cp +WHERE NOT EXISTS (SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = cp.user_id AND urp.role_key = 'customer'); + +-- Backfill from job_seeker_profiles +INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, created_at, updated_at) +SELECT gen_random_uuid(), jsp.user_id, 'candidate', COALESCE(jsp.full_name, ''), jsp.bio, jsp.location, + COALESCE(jsp.status, 'ACTIVE'), jsp.created_at, COALESCE(jsp.updated_at, NOW()) +FROM job_seeker_profiles jsp +WHERE NOT EXISTS (SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = jsp.user_id AND urp.role_key = 'candidate'); + +-- ============================================================================ +-- PHASE 3: Update Extension Tables to Use user_role_profile_id +-- ============================================================================ + +-- Add user_role_profile_id column to ALL extension tables +ALTER TABLE photographer_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +ALTER TABLE tutor_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +ALTER TABLE makeup_artist_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +ALTER TABLE developer_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +ALTER TABLE video_editor_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +ALTER TABLE graphic_designer_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +ALTER TABLE social_media_manager_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +ALTER TABLE fitness_trainer_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +ALTER TABLE catering_service_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +ALTER TABLE ugc_content_creator_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; + +-- Backfill user_role_profile_id for photographer_profiles +UPDATE photographer_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'photographer' AND p.user_role_profile_id IS NULL; + +-- Backfill user_role_profile_id for tutor_profiles +UPDATE tutor_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'tutor' AND p.user_role_profile_id IS NULL; + +-- Backfill user_role_profile_id for makeup_artist_profiles +UPDATE makeup_artist_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'makeup_artist' AND p.user_role_profile_id IS NULL; + +-- Backfill user_role_profile_id for developer_profiles +UPDATE developer_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'developer' AND p.user_role_profile_id IS NULL; + +-- Backfill user_role_profile_id for video_editor_profiles +UPDATE video_editor_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'video_editor' AND p.user_role_profile_id IS NULL; + +-- Backfill user_role_profile_id for graphic_designer_profiles +UPDATE graphic_designer_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'graphic_designer' AND p.user_role_profile_id IS NULL; + +-- Backfill user_role_profile_id for social_media_manager_profiles +UPDATE social_media_manager_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'social_media_manager' AND p.user_role_profile_id IS NULL; + +-- Backfill user_role_profile_id for fitness_trainer_profiles +UPDATE fitness_trainer_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'fitness_trainer' AND p.user_role_profile_id IS NULL; + +-- Backfill user_role_profile_id for catering_service_profiles +UPDATE catering_service_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'catering_service' AND p.user_role_profile_id IS NULL; + +-- Backfill user_role_profile_id for ugc_content_creator_profiles +UPDATE ugc_content_creator_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'ugc_content_creator' AND p.user_role_profile_id IS NULL; + +-- ============================================================================ +-- PHASE 4: Remove Forbidden External Portfolio Links +-- ============================================================================ + +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS github_url; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS portfolio_url; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS reel_url; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS portfolio_url; +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS portfolio_url; + +-- ============================================================================ +-- PHASE 5: Update Portfolio Tables +-- ============================================================================ + +-- Add user_role_profile_id to portfolio_items +ALTER TABLE portfolio_items ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +ALTER TABLE portfolio_items ADD COLUMN IF NOT EXISTS display_order INTEGER DEFAULT 0; + +-- Backfill portfolio_items from professionals +UPDATE portfolio_items pi SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE pi.professional_id IS NOT NULL + AND urp.user_id = (SELECT user_id FROM professionals p WHERE p.id = pi.professional_id); + +-- Update remaining using user_id +UPDATE portfolio_items pi SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE pi.user_id IS NOT NULL AND pi.user_role_profile_id IS NULL + AND EXISTS (SELECT 1 FROM user_role_profiles urp2 WHERE urp2.user_id = pi.user_id AND urp2.role_key = pi.profession_key); + +-- Add user_role_profile_id to services +ALTER TABLE services ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; + +-- Backfill services +UPDATE services s SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE s.professional_id IS NOT NULL + AND urp.user_id = (SELECT user_id FROM professionals p WHERE p.id = s.professional_id); + +UPDATE services s SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE s.user_id IS NOT NULL AND s.user_role_profile_id IS NULL + AND EXISTS (SELECT 1 FROM user_role_profiles urp2 WHERE urp2.user_id = s.user_id AND urp2.role_key = s.profession_key); + +-- ============================================================================ +-- PHASE 6: Rename Tables +-- ============================================================================ + +-- Rename applications -> job_applications +ALTER TABLE applications RENAME TO job_applications; + +-- Rename requirements -> leads +ALTER TABLE requirements RENAME TO leads; + +-- Rename coupon_uses -> coupon_redemptions +ALTER TABLE coupon_uses RENAME TO coupon_redemptions; + +-- ============================================================================ +-- PHASE 7: Create New Domain Tables +-- ============================================================================ + +-- 7.1 verification_requests +CREATE TABLE IF NOT EXISTS verification_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID REFERENCES user_role_profiles(id), + verification_type VARCHAR(50) NOT NULL DEFAULT 'IDENTITY', + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + reviewed_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.2 verification_documents +CREATE TABLE IF NOT EXISTS verification_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_request_id UUID NOT NULL REFERENCES verification_requests(id) ON DELETE CASCADE, + document_type VARCHAR(100) NOT NULL, + file_url TEXT NOT NULL, + file_name TEXT, + mime_type TEXT, + status VARCHAR(50) DEFAULT 'PENDING', + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + reviewed_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.3 verification_logs +CREATE TABLE IF NOT EXISTS verification_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_request_id UUID NOT NULL REFERENCES verification_requests(id), + action VARCHAR(50) NOT NULL, + old_status VARCHAR(50), + new_status VARCHAR(50), + acted_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.4 approval_requests +CREATE TABLE IF NOT EXISTS approval_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type VARCHAR(50) NOT NULL, + entity_id UUID NOT NULL, + approval_type VARCHAR(50), + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + submitted_by_user_id UUID REFERENCES users(id), + reviewed_by_user_id UUID REFERENCES users(id), + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.5 approval_logs +CREATE TABLE IF NOT EXISTS approval_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + approval_request_id UUID NOT NULL REFERENCES approval_requests(id), + action VARCHAR(50) NOT NULL, + old_status VARCHAR(50), + new_status VARCHAR(50), + acted_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.6 audit_logs +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_user_id UUID REFERENCES users(id), + actor_employee_id UUID, + actor_type VARCHAR(50), + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(100), + entity_id UUID, + entity_label TEXT, + module_key VARCHAR(100), + source_type VARCHAR(50), + source_id UUID, + request_id UUID, + correlation_id UUID, + ip_address TEXT, + user_agent TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'SUCCESS', + summary TEXT, + metadata_json JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.7 audit_log_changes +CREATE TABLE IF NOT EXISTS audit_log_changes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + audit_log_id UUID NOT NULL REFERENCES audit_logs(id) ON DELETE CASCADE, + field_name TEXT NOT NULL, + old_value_text TEXT, + new_value_text TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs(actor_user_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs(entity_type, entity_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_module ON audit_logs(module_key); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at); + +-- 7.8 orders +CREATE TABLE IF NOT EXISTS orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + order_type VARCHAR(50) NOT NULL DEFAULT 'PACKAGE', + subtotal_inr INTEGER NOT NULL DEFAULT 0, + discount_inr INTEGER NOT NULL DEFAULT 0, + tax_inr INTEGER NOT NULL DEFAULT 0, + total_inr INTEGER NOT NULL DEFAULT 0, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.9 order_items +CREATE TABLE IF NOT EXISTS order_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + item_type VARCHAR(50) NOT NULL, + item_id UUID, + item_name TEXT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + unit_price_inr INTEGER NOT NULL, + total_price_inr INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.10 payment_gateway_configs +CREATE TABLE IF NOT EXISTS payment_gateway_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + gateway_key VARCHAR(50) NOT NULL, + display_name VARCHAR(255), + config_json JSONB, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.11 payment_transactions +CREATE TABLE IF NOT EXISTS payment_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + payment_id UUID NOT NULL REFERENCES payments(id), + transaction_type VARCHAR(50) NOT NULL, + provider_reference TEXT, + request_payload_json JSONB, + response_payload_json JSONB, + status VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.12 tax_rules +CREATE TABLE IF NOT EXISTS tax_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + tax_type VARCHAR(50) NOT NULL, + tax_rate DECIMAL(5,2) NOT NULL, + applies_to VARCHAR(50), + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.13 kb_sections +CREATE TABLE IF NOT EXISTS kb_sections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category_id UUID NOT NULL REFERENCES kb_categories(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + description TEXT, + display_order INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.14 kb_article_feedback +CREATE TABLE IF NOT EXISTS kb_article_feedback ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + article_id UUID NOT NULL REFERENCES kb_articles(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id), + is_helpful BOOLEAN, + feedback_text TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.15 notification_templates +CREATE TABLE IF NOT EXISTS notification_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_key VARCHAR(100) NOT NULL UNIQUE, + channel VARCHAR(50) NOT NULL DEFAULT 'EMAIL', + title_template TEXT, + body_template TEXT, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.16 smtp_configs +CREATE TABLE IF NOT EXISTS smtp_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider_name VARCHAR(100), + host VARCHAR(255), + port INTEGER, + username TEXT, + encryption_mode VARCHAR(20), + from_name VARCHAR(255), + from_email VARCHAR(255), + is_default BOOLEAN DEFAULT false, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.17 dashboard_widgets +CREATE TABLE IF NOT EXISTS dashboard_widgets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + dashboard_config_id UUID NOT NULL REFERENCES dashboard_configs(id) ON DELETE CASCADE, + widget_key VARCHAR(100) NOT NULL, + widget_title VARCHAR(255), + config_json JSONB, + display_order INTEGER DEFAULT 0, + width_units INTEGER DEFAULT 1, + height_units INTEGER DEFAULT 1, + is_visible BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- PHASE 8: Update Existing Tables +-- ============================================================================ + +-- Update users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS account_type TEXT DEFAULT 'INDIVIDUAL'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ; +ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +UPDATE users SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +-- Update roles table +ALTER TABLE roles ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS can_approve_requests BOOLEAN DEFAULT false; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS can_manage_system_settings BOOLEAN DEFAULT false; + +-- Update departments table +ALTER TABLE departments ADD COLUMN IF NOT EXISTS code VARCHAR(64); +ALTER TABLE departments ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE departments ADD COLUMN IF NOT EXISTS department_head VARCHAR(255); +ALTER TABLE departments ADD COLUMN IF NOT EXISTS department_email VARCHAR(255); +ALTER TABLE departments ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) DEFAULT 'INTERNAL'; +ALTER TABLE departments ADD COLUMN IF NOT EXISTS transfers_enabled BOOLEAN DEFAULT false; +ALTER TABLE departments ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +UPDATE departments SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +-- Update designations table +ALTER TABLE designations ADD COLUMN IF NOT EXISTS code VARCHAR(64); +ALTER TABLE designations ADD COLUMN IF NOT EXISTS department_id UUID REFERENCES departments(id); +ALTER TABLE designations ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS level VARCHAR(100); +ALTER TABLE designations ADD COLUMN IF NOT EXISTS can_manage_team BOOLEAN DEFAULT false; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS can_approve BOOLEAN DEFAULT false; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT true; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +UPDATE designations SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +-- Update employees table +ALTER TABLE employees ADD COLUMN IF NOT EXISTS joining_date DATE; +ALTER TABLE employees ADD COLUMN IF NOT EXISTS employment_status VARCHAR(50) DEFAULT 'ACTIVE'; +ALTER TABLE employees ADD COLUMN IF NOT EXISTS manager_employee_id UUID REFERENCES employees(id); +ALTER TABLE employees ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +UPDATE employees SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +-- Update lead_requests table +ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE lead_requests lr SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE lr.professional_id IS NOT NULL + AND urp.user_id = (SELECT user_id FROM professionals p WHERE p.id = lr.professional_id); +ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS remarks TEXT; +ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +-- Update job_applications table +ALTER TABLE job_applications ADD COLUMN IF NOT EXISTS applicant_user_id UUID; +UPDATE job_applications ja SET applicant_user_id = ( + SELECT user_id FROM job_seeker_profiles jsp WHERE jsp.id = ja.job_seeker_id +); +ALTER TABLE job_applications ADD COLUMN IF NOT EXISTS cover_note TEXT; +ALTER TABLE job_applications DROP COLUMN IF EXISTS job_seeker_id; +ALTER TABLE job_applications DROP COLUMN IF EXISTS cover_letter; +ALTER TABLE job_applications DROP COLUMN IF EXISTS resume_url; +ALTER TABLE job_applications DROP COLUMN IF EXISTS contact_viewed; + +-- Update leads table (formerly requirements) +ALTER TABLE leads ADD COLUMN IF NOT EXISTS created_by_user_id UUID; +UPDATE leads l SET created_by_user_id = ( + SELECT user_id FROM customer_profiles cp WHERE cp.id = l.customer_id +); +ALTER TABLE leads ADD COLUMN IF NOT EXISTS required_date DATE; +ALTER TABLE leads ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +ALTER TABLE leads DROP COLUMN IF EXISTS customer_id; + +-- Update jobs table +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS posted_by_user_id UUID; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS mode_of_work VARCHAR(50); +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS budget_inr INTEGER; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS salary_range_json JSONB; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +UPDATE jobs SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +-- Update tracecoin_wallets +ALTER TABLE tracecoin_wallets RENAME COLUMN balance TO current_balance; +ALTER TABLE tracecoin_wallets ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +-- Update tracecoin_ledger +ALTER TABLE tracecoin_ledger ADD COLUMN IF NOT EXISTS balance_after INTEGER; +ALTER TABLE tracecoin_ledger ADD COLUMN IF NOT EXISTS remarks TEXT; +ALTER TABLE tracecoin_ledger RENAME COLUMN type TO transaction_type; +ALTER TABLE tracecoin_ledger RENAME COLUMN reason TO reference_type; + +-- Update coupons +ALTER TABLE coupons ADD COLUMN IF NOT EXISTS max_discount_inr INTEGER; +ALTER TABLE coupons ADD COLUMN IF NOT EXISTS min_order_value_inr INTEGER DEFAULT 0; +ALTER TABLE coupons ADD COLUMN IF NOT EXISTS valid_from TIMESTAMPTZ DEFAULT NOW(); +ALTER TABLE coupons ADD COLUMN IF NOT EXISTS valid_to TIMESTAMPTZ; + +-- Update coupon_redemptions +ALTER TABLE coupon_redemptions ADD COLUMN IF NOT EXISTS order_id UUID REFERENCES orders(id); +ALTER TABLE coupon_redemptions ADD COLUMN IF NOT EXISTS discount_amount_inr INTEGER; +ALTER TABLE coupon_redemptions RENAME COLUMN used_at TO redeemed_at; + +-- Update invoices +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS order_id UUID REFERENCES orders(id); +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS discount_inr INTEGER DEFAULT 0; +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS due_at TIMESTAMPTZ; +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS paid_at TIMESTAMPTZ; + +-- Update payments +ALTER TABLE payments ADD COLUMN IF NOT EXISTS payment_gateway_config_id UUID REFERENCES payment_gateway_configs(id); +ALTER TABLE payments ADD COLUMN IF NOT EXISTS payment_method VARCHAR(50); +ALTER TABLE payments ADD COLUMN IF NOT EXISTS currency_code VARCHAR(10) DEFAULT 'INR'; +ALTER TABLE payments ADD COLUMN IF NOT EXISTS initiated_at TIMESTAMPTZ DEFAULT NOW(); +ALTER TABLE payments ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ; + +-- Update kb_articles +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS section_id UUID REFERENCES kb_sections(id); +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS article_type VARCHAR(50) DEFAULT 'HOW_TO'; +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS audience_type VARCHAR(50) DEFAULT 'ALL'; +ALTER TABLE kb_articles RENAME COLUMN body TO content_markdown; +ALTER TABLE kb_articles RENAME COLUMN created_by TO author_user_id; + +-- Update support_tickets +ALTER TABLE support_tickets ADD COLUMN IF NOT EXISTS created_by_user_id UUID; +UPDATE support_tickets SET created_by_user_id = user_id; +ALTER TABLE support_tickets RENAME COLUMN assigned_to TO assigned_to_user_id; +ALTER TABLE support_tickets ADD COLUMN IF NOT EXISTS related_entity_type VARCHAR(50); +ALTER TABLE support_tickets ADD COLUMN IF NOT EXISTS related_entity_id UUID; +ALTER TABLE support_tickets ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ; + +-- Update support_ticket_messages +ALTER TABLE support_ticket_messages ADD COLUMN IF NOT EXISTS sender_user_id UUID; +UPDATE support_ticket_messages SET sender_user_id = sender_id; +ALTER TABLE support_ticket_messages RENAME COLUMN body TO message_body; +ALTER TABLE support_ticket_messages ADD COLUMN IF NOT EXISTS attachment_url TEXT; + +-- Update notifications +ALTER TABLE notifications ADD COLUMN IF NOT EXISTS channel VARCHAR(50) DEFAULT 'IN_APP'; +ALTER TABLE notifications ADD COLUMN IF NOT EXISTS related_entity_type VARCHAR(50); +ALTER TABLE notifications RENAME COLUMN reference_id TO related_entity_id; + +-- Update reviews +ALTER TABLE reviews ADD COLUMN IF NOT EXISTS entity_type VARCHAR(50) DEFAULT 'professional'; +ALTER TABLE reviews RENAME COLUMN customer_id TO reviewer_user_id; +ALTER TABLE reviews ADD COLUMN IF NOT EXISTS status VARCHAR(50) DEFAULT 'PUBLISHED'; + +-- ============================================================================ +-- PHASE 9: Drop Deprecated Tables +-- ============================================================================ + +DROP TABLE IF EXISTS professionals CASCADE; +DROP TABLE IF EXISTS onboarding_submissions CASCADE; +DROP TABLE IF EXISTS onboarding_configs CASCADE; +DROP TABLE IF EXISTS onboarding_states CASCADE; +DROP TABLE IF EXISTS submission_documents CASCADE; + +-- ============================================================================ +-- PHASE 10: Drop Deprecated Columns from Extension Tables +-- ============================================================================ + +-- Drop old user_id columns from extension tables (AFTER backfilling user_role_profile_id) +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS user_id; + +-- Drop old columns from portfolio_items +ALTER TABLE portfolio_items DROP COLUMN IF EXISTS professional_id; +ALTER TABLE portfolio_items DROP COLUMN IF EXISTS user_id; +ALTER TABLE portfolio_items DROP COLUMN IF EXISTS profession_key; + +-- Drop old columns from services +ALTER TABLE services DROP COLUMN IF EXISTS professional_id; +ALTER TABLE services DROP COLUMN IF EXISTS user_id; +ALTER TABLE services DROP COLUMN IF EXISTS profession_key; + +-- Drop old columns from lead_requests +ALTER TABLE lead_requests DROP COLUMN IF EXISTS professional_id; +ALTER TABLE lead_requests DROP COLUMN IF EXISTS requirement_id; + +-- Drop old custom_data columns +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS custom_data; + +-- Drop old profile columns that are now in user_role_profiles +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS approved_at; + +COMMIT; + +-- ============================================================================ +-- Migration Complete +-- ============================================================================ diff --git a/crates/db/src/models/catering_service.rs b/crates/db/src/models/catering_service.rs index 57e812a..1f2e930 100644 --- a/crates/db/src/models/catering_service.rs +++ b/crates/db/src/models/catering_service.rs @@ -3,63 +3,77 @@ use serde::{Deserialize, Serialize}; use sqlx::{FromRow, PgPool}; use uuid::Uuid; -// catering_service_profiles uses "business_name" instead of "display_name" - -#[derive(Debug, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct CateringServiceProfile { pub id: Uuid, - pub user_id: Uuid, + pub user_role_profile_id: Uuid, pub business_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, - pub status: String, + pub cuisine_types: Vec, + pub event_types: Vec, + pub min_guests: Option, + pub max_guests: Option, + pub has_setup_team: bool, + pub has_serving_staff: bool, + pub price_per_head_inr: Option, pub created_at: DateTime, pub updated_at: DateTime, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpsertCateringServiceProfilePayload { pub business_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, + pub cuisine_types: Vec, + pub event_types: Vec, + pub min_guests: Option, + pub max_guests: Option, + pub has_setup_team: bool, + pub has_serving_staff: bool, + pub price_per_head_inr: Option, } pub struct CateringServiceRepository; impl CateringServiceRepository { - pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, CateringServiceProfile>( - r#"SELECT id, user_id, business_name, bio, location, - custom_data, - status, created_at, updated_at - FROM catering_service_profiles WHERE user_id = $1"#, + r#"SELECT id, user_role_profile_id, business_name, cuisine_types, event_types, + min_guests, max_guests, has_setup_team, has_serving_staff, + price_per_head_inr, created_at, updated_at + FROM catering_service_profiles WHERE user_role_profile_id = $1"#, ) - .bind(user_id) + .bind(user_role_profile_id) .fetch_optional(pool) .await } - pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertCateringServiceProfilePayload) -> Result { + pub async fn upsert(pool: &PgPool, user_role_profile_id: Uuid, p: UpsertCateringServiceProfilePayload) -> Result { sqlx::query_as::<_, CateringServiceProfile>( - r#"INSERT INTO catering_service_profiles (user_id, business_name, bio, location, custom_data) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id) DO UPDATE SET - business_name = COALESCE(EXCLUDED.business_name, catering_service_profiles.business_name), - bio = EXCLUDED.bio, - location = EXCLUDED.location, - custom_data = EXCLUDED.custom_data, - updated_at = NOW() - RETURNING id, user_id, business_name, bio, location, - custom_data, - status, created_at, updated_at"#, + r#"INSERT INTO catering_service_profiles (user_role_profile_id, business_name, cuisine_types, event_types, + min_guests, max_guests, has_setup_team, has_serving_staff, price_per_head_inr) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + business_name = EXCLUDED.business_name, + cuisine_types = COALESCE(EXCLUDED.cuisine_types, catering_service_profiles.cuisine_types), + event_types = COALESCE(EXCLUDED.event_types, catering_service_profiles.event_types), + min_guests = EXCLUDED.min_guests, + max_guests = EXCLUDED.max_guests, + has_setup_team = EXCLUDED.has_setup_team, + has_serving_staff = EXCLUDED.has_serving_staff, + price_per_head_inr = EXCLUDED.price_per_head_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, business_name, cuisine_types, event_types, + min_guests, max_guests, has_setup_team, has_serving_staff, + price_per_head_inr, created_at, updated_at"#, ) - .bind(user_id) - .bind(p.business_name) - .bind(p.bio) - .bind(p.location) - .bind(p.custom_data) + .bind(user_role_profile_id) + .bind(&p.business_name) + .bind(&p.cuisine_types) + .bind(&p.event_types) + .bind(p.min_guests) + .bind(p.max_guests) + .bind(p.has_setup_team) + .bind(p.has_serving_staff) + .bind(p.price_per_head_inr) .fetch_one(pool) .await } diff --git a/crates/db/src/models/developer.rs b/crates/db/src/models/developer.rs index 2ac2c82..ece2fdc 100644 --- a/crates/db/src/models/developer.rs +++ b/crates/db/src/models/developer.rs @@ -3,61 +3,63 @@ use serde::{Deserialize, Serialize}; use sqlx::{FromRow, PgPool}; use uuid::Uuid; -#[derive(Debug, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct DeveloperProfile { pub id: Uuid, - pub user_id: Uuid, - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, - pub status: String, + pub user_role_profile_id: Uuid, + pub tech_stack: Vec, + pub experience_years: Option, + pub availability: String, + pub hourly_rate_inr: Option, + pub remote_ok: bool, pub created_at: DateTime, pub updated_at: DateTime, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpsertDeveloperProfilePayload { - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, + pub tech_stack: Vec, + pub experience_years: Option, + pub availability: String, + pub hourly_rate_inr: Option, + pub remote_ok: bool, } pub struct DeveloperRepository; impl DeveloperRepository { - pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, DeveloperProfile>( - r#"SELECT id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at - FROM developer_profiles WHERE user_id = $1"#, + r#"SELECT id, user_role_profile_id, tech_stack, experience_years, availability, + hourly_rate_inr, remote_ok, created_at, updated_at + FROM developer_profiles WHERE user_role_profile_id = $1"#, ) - .bind(user_id) + .bind(user_role_profile_id) .fetch_optional(pool) .await } - pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertDeveloperProfilePayload) -> Result { + pub async fn upsert(pool: &PgPool, user_role_profile_id: Uuid, p: UpsertDeveloperProfilePayload) -> Result { sqlx::query_as::<_, DeveloperProfile>( - r#"INSERT INTO developer_profiles (user_id, display_name, bio, location, custom_data) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id) DO UPDATE SET - display_name = COALESCE(EXCLUDED.display_name, developer_profiles.display_name), - bio = EXCLUDED.bio, - location = EXCLUDED.location, - custom_data = EXCLUDED.custom_data, - updated_at = NOW() - RETURNING id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at"#, + r#"INSERT INTO developer_profiles (user_role_profile_id, tech_stack, experience_years, + availability, hourly_rate_inr, remote_ok) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + tech_stack = COALESCE(EXCLUDED.tech_stack, developer_profiles.tech_stack), + experience_years = EXCLUDED.experience_years, + availability = EXCLUDED.availability, + hourly_rate_inr = EXCLUDED.hourly_rate_inr, + remote_ok = EXCLUDED.remote_ok, + updated_at = NOW() + RETURNING id, user_role_profile_id, tech_stack, experience_years, availability, + hourly_rate_inr, remote_ok, created_at, updated_at"#, ) - .bind(user_id) - .bind(p.display_name) - .bind(p.bio) - .bind(p.location) - .bind(p.custom_data) + .bind(user_role_profile_id) + .bind(&p.tech_stack) + .bind(p.experience_years) + .bind(&p.availability) + .bind(p.hourly_rate_inr) + .bind(p.remote_ok) .fetch_one(pool) .await } diff --git a/crates/db/src/models/fitness_trainer.rs b/crates/db/src/models/fitness_trainer.rs index 6100dfc..a97fde8 100644 --- a/crates/db/src/models/fitness_trainer.rs +++ b/crates/db/src/models/fitness_trainer.rs @@ -3,61 +3,67 @@ use serde::{Deserialize, Serialize}; use sqlx::{FromRow, PgPool}; use uuid::Uuid; -#[derive(Debug, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct FitnessTrainerProfile { pub id: Uuid, - pub user_id: Uuid, - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, - pub status: String, + pub user_role_profile_id: Uuid, + pub disciplines: Vec, + pub certifications: Vec, + pub online_sessions: bool, + pub home_visits: bool, + pub gym_based: bool, + pub per_session_rate_inr: Option, pub created_at: DateTime, pub updated_at: DateTime, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpsertFitnessTrainerProfilePayload { - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, + pub disciplines: Vec, + pub certifications: Vec, + pub online_sessions: bool, + pub home_visits: bool, + pub gym_based: bool, + pub per_session_rate_inr: Option, } pub struct FitnessTrainerRepository; impl FitnessTrainerRepository { - pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, FitnessTrainerProfile>( - r#"SELECT id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at - FROM fitness_trainer_profiles WHERE user_id = $1"#, + r#"SELECT id, user_role_profile_id, disciplines, certifications, online_sessions, + home_visits, gym_based, per_session_rate_inr, created_at, updated_at + FROM fitness_trainer_profiles WHERE user_role_profile_id = $1"#, ) - .bind(user_id) + .bind(user_role_profile_id) .fetch_optional(pool) .await } - pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertFitnessTrainerProfilePayload) -> Result { + pub async fn upsert(pool: &PgPool, user_role_profile_id: Uuid, p: UpsertFitnessTrainerProfilePayload) -> Result { sqlx::query_as::<_, FitnessTrainerProfile>( - r#"INSERT INTO fitness_trainer_profiles (user_id, display_name, bio, location, custom_data) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id) DO UPDATE SET - display_name = COALESCE(EXCLUDED.display_name, fitness_trainer_profiles.display_name), - bio = EXCLUDED.bio, - location = EXCLUDED.location, - custom_data = EXCLUDED.custom_data, - updated_at = NOW() - RETURNING id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at"#, + r#"INSERT INTO fitness_trainer_profiles (user_role_profile_id, disciplines, certifications, + online_sessions, home_visits, gym_based, per_session_rate_inr) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + disciplines = COALESCE(EXCLUDED.disciplines, fitness_trainer_profiles.disciplines), + certifications = COALESCE(EXCLUDED.certifications, fitness_trainer_profiles.certifications), + online_sessions = EXCLUDED.online_sessions, + home_visits = EXCLUDED.home_visits, + gym_based = EXCLUDED.gym_based, + per_session_rate_inr = EXCLUDED.per_session_rate_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, disciplines, certifications, online_sessions, + home_visits, gym_based, per_session_rate_inr, created_at, updated_at"#, ) - .bind(user_id) - .bind(p.display_name) - .bind(p.bio) - .bind(p.location) - .bind(p.custom_data) + .bind(user_role_profile_id) + .bind(&p.disciplines) + .bind(&p.certifications) + .bind(p.online_sessions) + .bind(p.home_visits) + .bind(p.gym_based) + .bind(p.per_session_rate_inr) .fetch_one(pool) .await } diff --git a/crates/db/src/models/graphic_designer.rs b/crates/db/src/models/graphic_designer.rs index 1dc169b..8135da0 100644 --- a/crates/db/src/models/graphic_designer.rs +++ b/crates/db/src/models/graphic_designer.rs @@ -3,61 +3,59 @@ use serde::{Deserialize, Serialize}; use sqlx::{FromRow, PgPool}; use uuid::Uuid; -#[derive(Debug, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct GraphicDesignerProfile { pub id: Uuid, - pub user_id: Uuid, - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, - pub status: String, + pub user_role_profile_id: Uuid, + pub design_tools: Vec, + pub style_tags: Vec, + pub brand_experience: bool, + pub starting_price_inr: Option, pub created_at: DateTime, pub updated_at: DateTime, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpsertGraphicDesignerProfilePayload { - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, + pub design_tools: Vec, + pub style_tags: Vec, + pub brand_experience: bool, + pub starting_price_inr: Option, } pub struct GraphicDesignerRepository; impl GraphicDesignerRepository { - pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, GraphicDesignerProfile>( - r#"SELECT id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at - FROM graphic_designer_profiles WHERE user_id = $1"#, + r#"SELECT id, user_role_profile_id, design_tools, style_tags, brand_experience, + starting_price_inr, created_at, updated_at + FROM graphic_designer_profiles WHERE user_role_profile_id = $1"#, ) - .bind(user_id) + .bind(user_role_profile_id) .fetch_optional(pool) .await } - pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertGraphicDesignerProfilePayload) -> Result { + pub async fn upsert(pool: &PgPool, user_role_profile_id: Uuid, p: UpsertGraphicDesignerProfilePayload) -> Result { sqlx::query_as::<_, GraphicDesignerProfile>( - r#"INSERT INTO graphic_designer_profiles (user_id, display_name, bio, location, custom_data) + r#"INSERT INTO graphic_designer_profiles (user_role_profile_id, design_tools, style_tags, + brand_experience, starting_price_inr) VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id) DO UPDATE SET - display_name = COALESCE(EXCLUDED.display_name, graphic_designer_profiles.display_name), - bio = EXCLUDED.bio, - location = EXCLUDED.location, - custom_data = EXCLUDED.custom_data, - updated_at = NOW() - RETURNING id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at"#, + ON CONFLICT (user_role_profile_id) DO UPDATE SET + design_tools = COALESCE(EXCLUDED.design_tools, graphic_designer_profiles.design_tools), + style_tags = COALESCE(EXCLUDED.style_tags, graphic_designer_profiles.style_tags), + brand_experience = EXCLUDED.brand_experience, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, design_tools, style_tags, brand_experience, + starting_price_inr, created_at, updated_at"#, ) - .bind(user_id) - .bind(p.display_name) - .bind(p.bio) - .bind(p.location) - .bind(p.custom_data) + .bind(user_role_profile_id) + .bind(&p.design_tools) + .bind(&p.style_tags) + .bind(p.brand_experience) + .bind(p.starting_price_inr) .fetch_one(pool) .await } diff --git a/crates/db/src/models/makeup_artist.rs b/crates/db/src/models/makeup_artist.rs index dbcca26..9230e78 100644 --- a/crates/db/src/models/makeup_artist.rs +++ b/crates/db/src/models/makeup_artist.rs @@ -3,61 +3,65 @@ use serde::{Deserialize, Serialize}; use sqlx::{FromRow, PgPool}; use uuid::Uuid; -#[derive(Debug, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct MakeupArtistProfile { pub id: Uuid, - pub user_id: Uuid, - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, - pub status: String, + pub user_role_profile_id: Uuid, + pub specializations: Vec, + pub kit_brands: Vec, + pub home_service: bool, + pub studio_available: bool, + pub starting_price_inr: Option, pub created_at: DateTime, pub updated_at: DateTime, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpsertMakeupArtistProfilePayload { - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, + pub specializations: Vec, + pub kit_brands: Vec, + pub home_service: bool, + pub studio_available: bool, + pub starting_price_inr: Option, } pub struct MakeupArtistRepository; impl MakeupArtistRepository { - pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, MakeupArtistProfile>( - r#"SELECT id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at - FROM makeup_artist_profiles WHERE user_id = $1"#, + r#"SELECT id, user_role_profile_id, specializations, kit_brands, + home_service, studio_available, starting_price_inr, + created_at, updated_at + FROM makeup_artist_profiles WHERE user_role_profile_id = $1"#, ) - .bind(user_id) + .bind(user_role_profile_id) .fetch_optional(pool) .await } - pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertMakeupArtistProfilePayload) -> Result { + pub async fn upsert(pool: &PgPool, user_role_profile_id: Uuid, p: UpsertMakeupArtistProfilePayload) -> Result { sqlx::query_as::<_, MakeupArtistProfile>( - r#"INSERT INTO makeup_artist_profiles (user_id, display_name, bio, location, custom_data) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id) DO UPDATE SET - display_name = COALESCE(EXCLUDED.display_name, makeup_artist_profiles.display_name), - bio = EXCLUDED.bio, - location = EXCLUDED.location, - custom_data = EXCLUDED.custom_data, - updated_at = NOW() - RETURNING id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at"#, + r#"INSERT INTO makeup_artist_profiles (user_role_profile_id, specializations, kit_brands, + home_service, studio_available, starting_price_inr) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + specializations = COALESCE(EXCLUDED.specializations, makeup_artist_profiles.specializations), + kit_brands = COALESCE(EXCLUDED.kit_brands, makeup_artist_profiles.kit_brands), + home_service = EXCLUDED.home_service, + studio_available = EXCLUDED.studio_available, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, specializations, kit_brands, + home_service, studio_available, starting_price_inr, + created_at, updated_at"#, ) - .bind(user_id) - .bind(p.display_name) - .bind(p.bio) - .bind(p.location) - .bind(p.custom_data) + .bind(user_role_profile_id) + .bind(&p.specializations) + .bind(&p.kit_brands) + .bind(p.home_service) + .bind(p.studio_available) + .bind(p.starting_price_inr) .fetch_one(pool) .await } diff --git a/crates/db/src/models/social_media_manager.rs b/crates/db/src/models/social_media_manager.rs index c5093e2..2da8416 100644 --- a/crates/db/src/models/social_media_manager.rs +++ b/crates/db/src/models/social_media_manager.rs @@ -3,61 +3,63 @@ use serde::{Deserialize, Serialize}; use sqlx::{FromRow, PgPool}; use uuid::Uuid; -#[derive(Debug, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct SocialMediaManagerProfile { pub id: Uuid, - pub user_id: Uuid, - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, - pub status: String, + pub user_role_profile_id: Uuid, + pub platforms: Vec, + pub industries: Vec, + pub content_types: Vec, + pub avg_follower_growth_pct: Option, + pub starting_price_inr: Option, pub created_at: DateTime, pub updated_at: DateTime, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpsertSocialMediaManagerProfilePayload { - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, + pub platforms: Vec, + pub industries: Vec, + pub content_types: Vec, + pub avg_follower_growth_pct: Option, + pub starting_price_inr: Option, } pub struct SocialMediaManagerRepository; impl SocialMediaManagerRepository { - pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, SocialMediaManagerProfile>( - r#"SELECT id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at - FROM social_media_manager_profiles WHERE user_id = $1"#, + r#"SELECT id, user_role_profile_id, platforms, industries, content_types, + avg_follower_growth_pct, starting_price_inr, created_at, updated_at + FROM social_media_manager_profiles WHERE user_role_profile_id = $1"#, ) - .bind(user_id) + .bind(user_role_profile_id) .fetch_optional(pool) .await } - pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertSocialMediaManagerProfilePayload) -> Result { + pub async fn upsert(pool: &PgPool, user_role_profile_id: Uuid, p: UpsertSocialMediaManagerProfilePayload) -> Result { sqlx::query_as::<_, SocialMediaManagerProfile>( - r#"INSERT INTO social_media_manager_profiles (user_id, display_name, bio, location, custom_data) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id) DO UPDATE SET - display_name = COALESCE(EXCLUDED.display_name, social_media_manager_profiles.display_name), - bio = EXCLUDED.bio, - location = EXCLUDED.location, - custom_data = EXCLUDED.custom_data, - updated_at = NOW() - RETURNING id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at"#, + r#"INSERT INTO social_media_manager_profiles (user_role_profile_id, platforms, industries, + content_types, avg_follower_growth_pct, starting_price_inr) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + platforms = COALESCE(EXCLUDED.platforms, social_media_manager_profiles.platforms), + industries = COALESCE(EXCLUDED.industries, social_media_manager_profiles.industries), + content_types = COALESCE(EXCLUDED.content_types, social_media_manager_profiles.content_types), + avg_follower_growth_pct = EXCLUDED.avg_follower_growth_pct, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, platforms, industries, content_types, + avg_follower_growth_pct, starting_price_inr, created_at, updated_at"#, ) - .bind(user_id) - .bind(p.display_name) - .bind(p.bio) - .bind(p.location) - .bind(p.custom_data) + .bind(user_role_profile_id) + .bind(&p.platforms) + .bind(&p.industries) + .bind(&p.content_types) + .bind(p.avg_follower_growth_pct) + .bind(p.starting_price_inr) .fetch_one(pool) .await } diff --git a/crates/db/src/models/tutor.rs b/crates/db/src/models/tutor.rs index f4d0e25..aa5ea58 100644 --- a/crates/db/src/models/tutor.rs +++ b/crates/db/src/models/tutor.rs @@ -3,61 +3,73 @@ use serde::{Deserialize, Serialize}; use sqlx::{FromRow, PgPool}; use uuid::Uuid; -#[derive(Debug, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct TutorProfile { pub id: Uuid, - pub user_id: Uuid, - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, - pub status: String, + pub user_role_profile_id: Uuid, + pub subjects: Vec, + pub board_types: Vec, + pub qualification: Option, + pub teaches_online: bool, + pub teaches_offline: bool, + pub experience_years: Option, + pub hourly_rate_inr: Option, pub created_at: DateTime, pub updated_at: DateTime, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpsertTutorProfilePayload { - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, + pub subjects: Vec, + pub board_types: Vec, + pub qualification: Option, + pub teaches_online: bool, + pub teaches_offline: bool, + pub experience_years: Option, + pub hourly_rate_inr: Option, } pub struct TutorRepository; impl TutorRepository { - pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, TutorProfile>( - r#"SELECT id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at - FROM tutor_profiles WHERE user_id = $1"#, + r#"SELECT id, user_role_profile_id, subjects, board_types, qualification, + teaches_online, teaches_offline, experience_years, hourly_rate_inr, + created_at, updated_at + FROM tutor_profiles WHERE user_role_profile_id = $1"#, ) - .bind(user_id) + .bind(user_role_profile_id) .fetch_optional(pool) .await } - pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertTutorProfilePayload) -> Result { + pub async fn upsert(pool: &PgPool, user_role_profile_id: Uuid, p: UpsertTutorProfilePayload) -> Result { sqlx::query_as::<_, TutorProfile>( - r#"INSERT INTO tutor_profiles (user_id, display_name, bio, location, custom_data) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id) DO UPDATE SET - display_name = COALESCE(EXCLUDED.display_name, tutor_profiles.display_name), - bio = EXCLUDED.bio, - location = EXCLUDED.location, - custom_data = EXCLUDED.custom_data, - updated_at = NOW() - RETURNING id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at"#, + r#"INSERT INTO tutor_profiles (user_role_profile_id, subjects, board_types, qualification, + teaches_online, teaches_offline, experience_years, hourly_rate_inr) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + subjects = COALESCE(EXCLUDED.subjects, tutor_profiles.subjects), + board_types = COALESCE(EXCLUDED.board_types, tutor_profiles.board_types), + qualification = EXCLUDED.qualification, + teaches_online = EXCLUDED.teaches_online, + teaches_offline = EXCLUDED.teaches_offline, + experience_years = EXCLUDED.experience_years, + hourly_rate_inr = EXCLUDED.hourly_rate_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, subjects, board_types, qualification, + teaches_online, teaches_offline, experience_years, hourly_rate_inr, + created_at, updated_at"#, ) - .bind(user_id) - .bind(p.display_name) - .bind(p.bio) - .bind(p.location) - .bind(p.custom_data) + .bind(user_role_profile_id) + .bind(&p.subjects) + .bind(&p.board_types) + .bind(&p.qualification) + .bind(p.teaches_online) + .bind(p.teaches_offline) + .bind(p.experience_years) + .bind(p.hourly_rate_inr) .fetch_one(pool) .await } diff --git a/crates/db/src/models/ugc_content_creator.rs b/crates/db/src/models/ugc_content_creator.rs index a1ecebd..e03fede 100644 --- a/crates/db/src/models/ugc_content_creator.rs +++ b/crates/db/src/models/ugc_content_creator.rs @@ -3,61 +3,63 @@ use serde::{Deserialize, Serialize}; use sqlx::{FromRow, PgPool}; use uuid::Uuid; -#[derive(Debug, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct UgcContentCreatorProfile { pub id: Uuid, - pub user_id: Uuid, - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, - pub status: String, + pub user_role_profile_id: Uuid, + pub niche_tags: Vec, + pub content_formats: Vec, + pub platforms: Vec, + pub turnaround_days: Option, + pub starting_price_inr: Option, pub created_at: DateTime, pub updated_at: DateTime, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpsertUgcContentCreatorProfilePayload { - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, + pub niche_tags: Vec, + pub content_formats: Vec, + pub platforms: Vec, + pub turnaround_days: Option, + pub starting_price_inr: Option, } pub struct UgcContentCreatorRepository; impl UgcContentCreatorRepository { - pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, UgcContentCreatorProfile>( - r#"SELECT id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at - FROM ugc_content_creator_profiles WHERE user_id = $1"#, + r#"SELECT id, user_role_profile_id, niche_tags, content_formats, platforms, + turnaround_days, starting_price_inr, created_at, updated_at + FROM ugc_content_creator_profiles WHERE user_role_profile_id = $1"#, ) - .bind(user_id) + .bind(user_role_profile_id) .fetch_optional(pool) .await } - pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertUgcContentCreatorProfilePayload) -> Result { + pub async fn upsert(pool: &PgPool, user_role_profile_id: Uuid, p: UpsertUgcContentCreatorProfilePayload) -> Result { sqlx::query_as::<_, UgcContentCreatorProfile>( - r#"INSERT INTO ugc_content_creator_profiles (user_id, display_name, bio, location, custom_data) - VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id) DO UPDATE SET - display_name = COALESCE(EXCLUDED.display_name, ugc_content_creator_profiles.display_name), - bio = EXCLUDED.bio, - location = EXCLUDED.location, - custom_data = EXCLUDED.custom_data, - updated_at = NOW() - RETURNING id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at"#, + r#"INSERT INTO ugc_content_creator_profiles (user_role_profile_id, niche_tags, content_formats, + platforms, turnaround_days, starting_price_inr) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + niche_tags = COALESCE(EXCLUDED.niche_tags, ugc_content_creator_profiles.niche_tags), + content_formats = COALESCE(EXCLUDED.content_formats, ugc_content_creator_profiles.content_formats), + platforms = COALESCE(EXCLUDED.platforms, ugc_content_creator_profiles.platforms), + turnaround_days = EXCLUDED.turnaround_days, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, niche_tags, content_formats, platforms, + turnaround_days, starting_price_inr, created_at, updated_at"#, ) - .bind(user_id) - .bind(p.display_name) - .bind(p.bio) - .bind(p.location) - .bind(p.custom_data) + .bind(user_role_profile_id) + .bind(&p.niche_tags) + .bind(&p.content_formats) + .bind(&p.platforms) + .bind(p.turnaround_days) + .bind(p.starting_price_inr) .fetch_one(pool) .await } diff --git a/crates/db/src/models/video_editor.rs b/crates/db/src/models/video_editor.rs index 285b4fc..376affd 100644 --- a/crates/db/src/models/video_editor.rs +++ b/crates/db/src/models/video_editor.rs @@ -3,61 +3,59 @@ use serde::{Deserialize, Serialize}; use sqlx::{FromRow, PgPool}; use uuid::Uuid; -#[derive(Debug, Serialize, Deserialize, FromRow)] +#[derive(Debug, Clone, Serialize, Deserialize, FromRow)] pub struct VideoEditorProfile { pub id: Uuid, - pub user_id: Uuid, - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, - pub status: String, + pub user_role_profile_id: Uuid, + pub software_skills: Vec, + pub style_tags: Vec, + pub turnaround_days: Option, + pub starting_price_inr: Option, pub created_at: DateTime, pub updated_at: DateTime, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Clone, Serialize, Deserialize)] pub struct UpsertVideoEditorProfilePayload { - pub display_name: Option, - pub bio: Option, - pub location: Option, - pub custom_data: Option, + pub software_skills: Vec, + pub style_tags: Vec, + pub turnaround_days: Option, + pub starting_price_inr: Option, } pub struct VideoEditorRepository; impl VideoEditorRepository { - pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, VideoEditorProfile>( - r#"SELECT id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at - FROM video_editor_profiles WHERE user_id = $1"#, + r#"SELECT id, user_role_profile_id, software_skills, style_tags, turnaround_days, + starting_price_inr, created_at, updated_at + FROM video_editor_profiles WHERE user_role_profile_id = $1"#, ) - .bind(user_id) + .bind(user_role_profile_id) .fetch_optional(pool) .await } - pub async fn upsert(pool: &PgPool, user_id: Uuid, p: UpsertVideoEditorProfilePayload) -> Result { + pub async fn upsert(pool: &PgPool, user_role_profile_id: Uuid, p: UpsertVideoEditorProfilePayload) -> Result { sqlx::query_as::<_, VideoEditorProfile>( - r#"INSERT INTO video_editor_profiles (user_id, display_name, bio, location, custom_data) + r#"INSERT INTO video_editor_profiles (user_role_profile_id, software_skills, style_tags, + turnaround_days, starting_price_inr) VALUES ($1, $2, $3, $4, $5) - ON CONFLICT (user_id) DO UPDATE SET - display_name = COALESCE(EXCLUDED.display_name, video_editor_profiles.display_name), - bio = EXCLUDED.bio, - location = EXCLUDED.location, - custom_data = EXCLUDED.custom_data, - updated_at = NOW() - RETURNING id, user_id, display_name, bio, location, - custom_data, - status, created_at, updated_at"#, + ON CONFLICT (user_role_profile_id) DO UPDATE SET + software_skills = COALESCE(EXCLUDED.software_skills, video_editor_profiles.software_skills), + style_tags = COALESCE(EXCLUDED.style_tags, video_editor_profiles.style_tags), + turnaround_days = EXCLUDED.turnaround_days, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, software_skills, style_tags, turnaround_days, + starting_price_inr, created_at, updated_at"#, ) - .bind(user_id) - .bind(p.display_name) - .bind(p.bio) - .bind(p.location) - .bind(p.custom_data) + .bind(user_role_profile_id) + .bind(&p.software_skills) + .bind(&p.style_tags) + .bind(p.turnaround_days) + .bind(p.starting_price_inr) .fetch_one(pool) .await } From c433ab5fed2a3408fad9231fad8a32e14a37aa06 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Mon, 13 Apr 2026 00:29:44 +0200 Subject: [PATCH 013/182] feat(db): update service handlers and models for new schema - Update leads service to use 'leads' table - Update extension models to use user_role_profile_id - Update ProfessionalRepository to work with new schema - Create TracecoinWalletRepository for wallet operations - Update all handlers to use new model fields - Rename Application fields (job_seeker_id -> applicant_user_id) - Update cron tasks for new schema - Fix compilation errors across all services --- Cargo.lock | 47 ++++ apps/catering_services/src/admin.rs | 35 ++- apps/catering_services/src/handlers.rs | 2 +- apps/companies/src/handlers/admin.rs | 14 +- apps/companies/src/handlers/mod.rs | 42 +--- apps/cron/src/main.rs | 8 +- apps/cron/src/tasks/leads.rs | 27 +-- apps/cron/src/tasks/requirements.rs | 27 +-- apps/customers/src/handlers.rs | 177 +++------------ apps/developers/src/admin.rs | 31 ++- apps/developers/src/handlers.rs | 2 +- apps/fitness_trainers/src/admin.rs | 31 ++- apps/fitness_trainers/src/handlers.rs | 2 +- apps/graphic_designers/src/admin.rs | 31 ++- apps/graphic_designers/src/handlers.rs | 2 +- apps/job_seekers/src/handlers.rs | 13 +- apps/jobs/src/main.rs | 4 +- apps/leads/src/main.rs | 10 +- apps/makeup_artists/src/admin.rs | 31 ++- apps/makeup_artists/src/handlers.rs | 2 +- apps/photographers/src/admin.rs | 31 ++- apps/photographers/src/handlers.rs | 2 +- apps/social_media_managers/src/admin.rs | 31 ++- apps/social_media_managers/src/handlers.rs | 2 +- apps/tutors/src/admin.rs | 31 ++- apps/tutors/src/handlers.rs | 2 +- apps/ugc_content_creators/src/handlers.rs | 2 +- apps/users/src/handlers/approvals.rs | 32 ++- apps/users/src/handlers/dashboard.rs | 15 +- apps/users/src/handlers/onboarding.rs | 47 +++- apps/users/src/handlers/profile.rs | 119 ++++++++-- apps/users/src/handlers/verifications.rs | 16 +- apps/video_editors/src/admin.rs | 31 ++- apps/video_editors/src/handlers.rs | 2 +- crates/contracts/src/profession_shared.rs | 92 ++++---- crates/db-migrate/src/main.rs | 1 + crates/db/src/models/application.rs | 48 ++-- crates/db/src/models/catering_service.rs | 54 +++++ crates/db/src/models/developer.rs | 47 ++++ crates/db/src/models/fitness_trainer.rs | 49 +++++ crates/db/src/models/graphic_designer.rs | 45 ++++ crates/db/src/models/lead_request.rs | 25 +-- crates/db/src/models/makeup_artist.rs | 48 ++++ crates/db/src/models/mod.rs | 1 + crates/db/src/models/photographer.rs | 51 +++++ crates/db/src/models/professional.rs | 86 ++++---- crates/db/src/models/requirement.rs | 39 ++-- crates/db/src/models/social_media_manager.rs | 47 ++++ crates/db/src/models/tracecoin_wallet.rs | 220 +++++++++++++++++++ crates/db/src/models/tutor.rs | 52 +++++ crates/db/src/models/ugc_content_creator.rs | 47 ++++ crates/db/src/models/video_editor.rs | 45 ++++ 52 files changed, 1348 insertions(+), 550 deletions(-) create mode 100644 crates/db/src/models/tracecoin_wallet.rs diff --git a/Cargo.lock b/Cargo.lock index b8db3e1..0560f80 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1054,6 +1054,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" @@ -2053,6 +2065,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" @@ -2097,6 +2126,23 @@ dependencies = [ "spin", ] +[[package]] +name = "leads" +version = "0.1.0" +dependencies = [ + "anyhow", + "axum", + "chrono", + "serde", + "serde_json", + "sqlx", + "tokio", + "tower-http", + "tracing", + "tracing-subscriber", + "uuid", +] + [[package]] name = "leb128fmt" version = "0.1.0" @@ -3852,6 +3898,7 @@ dependencies = [ "tower", "tower-layer", "tower-service", + "tracing", ] [[package]] diff --git a/apps/catering_services/src/admin.rs b/apps/catering_services/src/admin.rs index e065c42..4e2bf7d 100644 --- a/apps/catering_services/src/admin.rs +++ b/apps/catering_services/src/admin.rs @@ -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, + pub display_name: Option, pub bio: Option, pub location: Option, pub status: String, @@ -16,12 +17,13 @@ pub struct AdminCateringServiceList { pub updated_at: chrono::DateTime, } -impl From for AdminCateringServiceList { - fn from(p: CateringServiceProfile) -> Self { +impl From 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 { async fn list_catering_services( State(state): State, ) -> Result { - 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, Path(id): Path, ) -> Result { - 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) diff --git a/apps/catering_services/src/handlers.rs b/apps/catering_services/src/handlers.rs index 7bd54fb..2cefcb6 100644 --- a/apps/catering_services/src/handlers.rs +++ b/apps/catering_services/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> 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(), } diff --git a/apps/companies/src/handlers/admin.rs b/apps/companies/src/handlers/admin.rs index 69323d4..a45e335 100644 --- a/apps/companies/src/handlers/admin.rs +++ b/apps/companies/src/handlers/admin.rs @@ -106,8 +106,7 @@ pub struct AdminApplicationRow { pub applicant_name: String, pub applicant_email: String, pub status: String, - pub cover_letter: Option, - pub resume_url: Option, + pub cover_note: Option, pub applied_at: DateTime, pub created_at: DateTime, } @@ -120,12 +119,11 @@ impl From 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 { 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 "#, diff --git a/apps/companies/src/handlers/mod.rs b/apps/companies/src/handlers/mod.rs index 956ebe8..6a0df3c 100644 --- a/apps/companies/src/handlers/mod.rs +++ b/apps/companies/src/handlers/mod.rs @@ -367,9 +367,9 @@ async fn update_application_status( 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", + "SELECT u.full_name, u.email 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 { @@ -405,47 +405,15 @@ 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(); - } - - // 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); - } - } - - // Fetch job seeker contact info via job_seeker_id → job_seekers.user_id → users + // Fetch applicant contact info via applicant_user_id → users let contact = sqlx::query_as::<_, (Option, String, Option)>( r#" SELECT u.full_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; diff --git a/apps/cron/src/main.rs b/apps/cron/src/main.rs index 12abef7..752bd0a 100644 --- a/apps/cron/src/main.rs +++ b/apps/cron/src/main.rs @@ -33,16 +33,16 @@ async fn main() -> Result<(), Box> { } }); - // 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); } } }); diff --git a/apps/cron/src/tasks/leads.rs b/apps/cron/src/tasks/leads.rs index 5880a54..d0fd10a 100644 --- a/apps/cron/src/tasks/leads.rs +++ b/apps/cron/src/tasks/leads.rs @@ -18,19 +18,18 @@ pub async fn expire_stale_lead_requests( full_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 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,42 +56,36 @@ 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; tracing::info!("Expired lead request {} and refunded {} tracecoins to {}", rec.lead_request_id, rec.tracecoins_reserved, rec.email); diff --git a/apps/cron/src/tasks/requirements.rs b/apps/cron/src/tasks/requirements.rs index e504893..033a55c 100644 --- a/apps/cron/src/tasks/requirements.rs +++ b/apps/cron/src/tasks/requirements.rs @@ -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> { 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, } - 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, u.full_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); + tracing::info!("Sent expiry email to {} for lead {}", rec.email, rec.lead_id); } Ok(()) diff --git a/apps/customers/src/handlers.rs b/apps/customers/src/handlers.rs index 97e7e21..61b1972 100644 --- a/apps/customers/src/handlers.rs +++ b/apps/customers/src/handlers.rs @@ -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 { .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, ) -> 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 } @@ -130,23 +125,9 @@ async fn create_requirement( auth: AuthUser, Json(payload): Json, ) -> 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, @@ -157,10 +138,7 @@ async fn create_requirement( }; 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, Path(id): Path, - auth: AuthUser, + _auth: AuthUser, Json(payload): Json, ) -> 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, 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(), }; @@ -240,7 +202,7 @@ async fn submit_requirement( "location": updated.location, "budget": updated.budget, "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, Path(id): Path, - auth: AuthUser, + _auth: AuthUser, Query(q): Query, ) -> 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, - professional_avatar_url: Option, - } - - 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, - Path((req_id, lead_id)): Path<(Uuid, Uuid)>, + Path(lead_id): Path, 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, - Path((req_id, lead_id)): Path<(Uuid, Uuid)>, - auth: AuthUser, + Path(lead_id): Path, + _auth: AuthUser, Json(_payload): Json, ) -> 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(), diff --git a/apps/developers/src/admin.rs b/apps/developers/src/admin.rs index e7a43a6..0df6c05 100644 --- a/apps/developers/src/admin.rs +++ b/apps/developers/src/admin.rs @@ -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, pub bio: Option, @@ -16,10 +17,11 @@ pub struct AdminDeveloperList { pub updated_at: chrono::DateTime, } -impl From for AdminDeveloperList { - fn from(p: DeveloperProfile) -> Self { +impl From 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 { async fn list_developers( State(state): State, ) -> Result { - 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, Path(id): Path, ) -> Result { - 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) diff --git a/apps/developers/src/handlers.rs b/apps/developers/src/handlers.rs index c8f722d..8bd2ab2 100644 --- a/apps/developers/src/handlers.rs +++ b/apps/developers/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> 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(), } diff --git a/apps/fitness_trainers/src/admin.rs b/apps/fitness_trainers/src/admin.rs index 415aa46..bac8631 100644 --- a/apps/fitness_trainers/src/admin.rs +++ b/apps/fitness_trainers/src/admin.rs @@ -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, pub bio: Option, @@ -16,10 +17,11 @@ pub struct AdminFitnessTrainerList { pub updated_at: chrono::DateTime, } -impl From for AdminFitnessTrainerList { - fn from(p: FitnessTrainerProfile) -> Self { +impl From 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 { async fn list_fitness_trainers( State(state): State, ) -> Result { - 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, Path(id): Path, ) -> Result { - 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) diff --git a/apps/fitness_trainers/src/handlers.rs b/apps/fitness_trainers/src/handlers.rs index 8cae4ee..1630571 100644 --- a/apps/fitness_trainers/src/handlers.rs +++ b/apps/fitness_trainers/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> 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(), } diff --git a/apps/graphic_designers/src/admin.rs b/apps/graphic_designers/src/admin.rs index 30c0959..8a75891 100644 --- a/apps/graphic_designers/src/admin.rs +++ b/apps/graphic_designers/src/admin.rs @@ -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, pub bio: Option, @@ -16,10 +17,11 @@ pub struct AdminGraphicDesignerList { pub updated_at: chrono::DateTime, } -impl From for AdminGraphicDesignerList { - fn from(p: GraphicDesignerProfile) -> Self { +impl From 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 { async fn list_graphic_designers( State(state): State, ) -> Result { - 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, Path(id): Path, ) -> Result { - 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) diff --git a/apps/graphic_designers/src/handlers.rs b/apps/graphic_designers/src/handlers.rs index 49a25db..a880b19 100644 --- a/apps/graphic_designers/src/handlers.rs +++ b/apps/graphic_designers/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> 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(), } diff --git a/apps/job_seekers/src/handlers.rs b/apps/job_seekers/src/handlers.rs index 33c251e..70ac4d6 100644 --- a/apps/job_seekers/src/handlers.rs +++ b/apps/job_seekers/src/handlers.rs @@ -38,7 +38,7 @@ pub struct JobBrowseQuery { #[derive(Deserialize)] pub struct ApplyRequest { - pub cover_letter: Option, + pub cover_note: Option, pub resume_url: Option, } @@ -234,9 +234,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 { @@ -287,7 +286,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 } @@ -307,7 +306,7 @@ async fn get_my_application( }; 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 +324,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(), }; diff --git a/apps/jobs/src/main.rs b/apps/jobs/src/main.rs index 3cd4ff7..efd6519 100644 --- a/apps/jobs/src/main.rs +++ b/apps/jobs/src/main.rs @@ -1,7 +1,7 @@ use axum::{ extract::State, http::StatusCode, - routing::{get, post, put, delete}, + routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; @@ -16,7 +16,7 @@ pub struct AppState { pub pool: PgPool, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Job { pub id: uuid::Uuid, pub title: String, diff --git a/apps/leads/src/main.rs b/apps/leads/src/main.rs index 9aac078..1260a91 100644 --- a/apps/leads/src/main.rs +++ b/apps/leads/src/main.rs @@ -1,7 +1,7 @@ use axum::{ extract::State, http::StatusCode, - routing::{get, post, put, delete}, + routing::{get, post}, Json, Router, }; use serde::{Deserialize, Serialize}; @@ -16,7 +16,7 @@ pub struct AppState { pub pool: PgPool, } -#[derive(Debug, Serialize, Deserialize)] +#[derive(Debug, Serialize, Deserialize, sqlx::FromRow)] pub struct Lead { pub id: uuid::Uuid, pub title: String, @@ -37,7 +37,7 @@ pub struct CreateLead { async fn list_leads(State(state): State>) -> Result>, StatusCode> { let leads = sqlx::query_as::<_, Lead>( - "SELECT id, title, description, location, profession_key, status, created_at FROM requirements ORDER BY created_at DESC" + "SELECT id, title, description, location, profession_key, status, created_at FROM leads ORDER BY created_at DESC" ) .fetch_all(&state.pool) .await @@ -52,7 +52,7 @@ async fn create_lead( ) -> Result, StatusCode> { let lead = sqlx::query_as::<_, Lead>( r#" - INSERT INTO requirements (title, description, location, profession_key) + INSERT INTO leads (title, description, location, profession_key) VALUES ($1, $2, $3, $4) RETURNING id, title, description, location, profession_key, status, created_at "#, @@ -73,7 +73,7 @@ async fn get_lead( axum::extract::Path(id): axum::extract::Path, ) -> Result, StatusCode> { let lead = sqlx::query_as::<_, Lead>( - "SELECT id, title, description, location, profession_key, status, created_at FROM requirements WHERE id = $1" + "SELECT id, title, description, location, profession_key, status, created_at FROM leads WHERE id = $1" ) .bind(id) .fetch_optional(&state.pool) diff --git a/apps/makeup_artists/src/admin.rs b/apps/makeup_artists/src/admin.rs index 704440d..5873212 100644 --- a/apps/makeup_artists/src/admin.rs +++ b/apps/makeup_artists/src/admin.rs @@ -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, pub bio: Option, @@ -16,10 +17,11 @@ pub struct AdminMakeupArtistList { pub updated_at: chrono::DateTime, } -impl From for AdminMakeupArtistList { - fn from(p: MakeupArtistProfile) -> Self { +impl From 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 { async fn list_makeup_artists( State(state): State, ) -> Result { - 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, Path(id): Path, ) -> Result { - 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) diff --git a/apps/makeup_artists/src/handlers.rs b/apps/makeup_artists/src/handlers.rs index db25439..bd8f75c 100644 --- a/apps/makeup_artists/src/handlers.rs +++ b/apps/makeup_artists/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> 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(), } diff --git a/apps/photographers/src/admin.rs b/apps/photographers/src/admin.rs index 2375b0e..0d87a81 100644 --- a/apps/photographers/src/admin.rs +++ b/apps/photographers/src/admin.rs @@ -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, pub bio: Option, @@ -16,10 +17,11 @@ pub struct AdminPhotographerList { pub updated_at: chrono::DateTime, } -impl From for AdminPhotographerList { - fn from(p: PhotographerProfile) -> Self { +impl From 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 { async fn list_photographers( State(state): State, ) -> Result { - 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, Path(id): Path, ) -> Result { - 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) diff --git a/apps/photographers/src/handlers.rs b/apps/photographers/src/handlers.rs index 05a1353..dce61b5 100644 --- a/apps/photographers/src/handlers.rs +++ b/apps/photographers/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> 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(), } diff --git a/apps/social_media_managers/src/admin.rs b/apps/social_media_managers/src/admin.rs index 8c357d8..02daad2 100644 --- a/apps/social_media_managers/src/admin.rs +++ b/apps/social_media_managers/src/admin.rs @@ -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, pub bio: Option, @@ -16,10 +17,11 @@ pub struct AdminSocialMediaManagerList { pub updated_at: chrono::DateTime, } -impl From for AdminSocialMediaManagerList { - fn from(p: SocialMediaManagerProfile) -> Self { +impl From 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 { async fn list_social_media_managers( State(state): State, ) -> Result { - 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, Path(id): Path, ) -> Result { - 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) diff --git a/apps/social_media_managers/src/handlers.rs b/apps/social_media_managers/src/handlers.rs index b023a08..a7d54bb 100644 --- a/apps/social_media_managers/src/handlers.rs +++ b/apps/social_media_managers/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> 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(), } diff --git a/apps/tutors/src/admin.rs b/apps/tutors/src/admin.rs index f0ed453..0773ca6 100644 --- a/apps/tutors/src/admin.rs +++ b/apps/tutors/src/admin.rs @@ -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, pub bio: Option, @@ -16,10 +17,11 @@ pub struct AdminTutorList { pub updated_at: chrono::DateTime, } -impl From for AdminTutorList { - fn from(p: TutorProfile) -> Self { +impl From 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 { async fn list_tutors( State(state): State, ) -> Result { - 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, Path(id): Path, ) -> Result { - 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) diff --git a/apps/tutors/src/handlers.rs b/apps/tutors/src/handlers.rs index 14aac07..87ee758 100644 --- a/apps/tutors/src/handlers.rs +++ b/apps/tutors/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> 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(), } diff --git a/apps/ugc_content_creators/src/handlers.rs b/apps/ugc_content_creators/src/handlers.rs index d77ef6e..10c1f2c 100644 --- a/apps/ugc_content_creators/src/handlers.rs +++ b/apps/ugc_content_creators/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> 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(), } diff --git a/apps/users/src/handlers/approvals.rs b/apps/users/src/handlers/approvals.rs index e716223..bba409e 100644 --- a/apps/users/src/handlers/approvals.rs +++ b/apps/users/src/handlers/approvals.rs @@ -205,11 +205,23 @@ 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 verification_status = 'APPROVED', 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?; sqlx::query( "UPDATE users SET status = 'ACTIVE', updated_at = NOW() WHERE id = $1 AND status = 'PENDING'", @@ -267,11 +279,23 @@ 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 verification_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); diff --git a/apps/users/src/handlers/dashboard.rs b/apps/users/src/handlers/dashboard.rs index 2343ee7..04682d3 100644 --- a/apps/users/src/handlers/dashboard.rs +++ b/apps/users/src/handlers/dashboard.rs @@ -28,7 +28,7 @@ async fn get_metrics(State(state): State) -> Json( - "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) -> Json( 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 "#, ) @@ -132,9 +126,8 @@ async fn get_metrics(State(state): State) -> Json Result { + 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, role_id, status) + VALUES ($1, $2, $3, 'DRAFT') + ON CONFLICT (user_id, role_key) DO UPDATE SET updated_at = NOW() + RETURNING id + "#, + ) + .bind(user_id) + .bind(role_key) + .bind(role_id) + .fetch_one(pool) + .await +} diff --git a/apps/users/src/handlers/profile.rs b/apps/users/src/handlers/profile.rs index d69bbbd..d86c494 100644 --- a/apps/users/src/handlers/profile.rs +++ b/apps/users/src/handlers/profile.rs @@ -161,12 +161,28 @@ async fn get_profile( }; let query = format!( - r#"SELECT "profileData", verification_status FROM {} WHERE user_id = $1"#, + r#"SELECT "profileData", verification_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 { @@ -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, "profileData", verification_status, updated_at) VALUES ($1, $2, 'DRAFT', NOW()) - ON CONFLICT (user_id) DO UPDATE SET + ON CONFLICT (id) DO UPDATE SET "profileData" = EXCLUDED."profileData", 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 @@ -434,18 +455,8 @@ async fn fetch_saved_profile( }; } - 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::("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()) @@ -464,16 +475,86 @@ 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 verification_status = $1, submitted_at = NOW(), 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, 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 { + 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, role_id, status) + VALUES ($1, $2, $3, 'DRAFT') + ON CONFLICT (user_id, role_key) DO UPDATE SET updated_at = NOW() + RETURNING id + "#, + ) + .bind(user_id) + .bind(role_key) + .bind(role.id) + .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 "profileData" 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::("profileData") + .unwrap_or(serde_json::Value::Object(Default::default())); + } + } + serde_json::Value::Object(Default::default()) +} diff --git a/apps/users/src/handlers/verifications.rs b/apps/users/src/handlers/verifications.rs index ec96007..e4fad13 100644 --- a/apps/users/src/handlers/verifications.rs +++ b/apps/users/src/handlers/verifications.rs @@ -123,11 +123,23 @@ 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 verification_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 { diff --git a/apps/video_editors/src/admin.rs b/apps/video_editors/src/admin.rs index 9853946..e665f55 100644 --- a/apps/video_editors/src/admin.rs +++ b/apps/video_editors/src/admin.rs @@ -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, pub bio: Option, @@ -16,10 +17,11 @@ pub struct AdminVideoEditorList { pub updated_at: chrono::DateTime, } -impl From for AdminVideoEditorList { - fn from(p: VideoEditorProfile) -> Self { +impl From 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 { async fn list_video_editors( State(state): State, ) -> Result { - 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, Path(id): Path, ) -> Result { - 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) diff --git a/apps/video_editors/src/handlers.rs b/apps/video_editors/src/handlers.rs index 7ddf86f..5b97986 100644 --- a/apps/video_editors/src/handlers.rs +++ b/apps/video_editors/src/handlers.rs @@ -21,7 +21,7 @@ async fn update_profile( auth: AuthUser, Json(payload): Json, ) -> 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(), } diff --git a/crates/contracts/src/profession_shared.rs b/crates/contracts/src/profession_shared.rs index 3e4c542..4e75df3 100644 --- a/crates/contracts/src/profession_shared.rs +++ b/crates/contracts/src/profession_shared.rs @@ -9,6 +9,8 @@ use chrono::Utc; use serde::Deserialize; use uuid::Uuid; use db::models::lead_request::{CreateLeadRequestPayload, LeadRequestRepository}; +use db::models::tracecoin_wallet::TracecoinWalletRepository; +use db::models::requirement::RequirementRepository; use db::models::professional::{ CreatePortfolioItemPayload, CreateServicePayload, @@ -16,7 +18,7 @@ use db::models::professional::{ UpdatePortfolioItemPayload, UpdateServicePayload, }; -use db::models::requirement::RequirementRepository; +use db::models::user_role_profile::UserRoleProfileRepository; use crate::auth_middleware::AuthUser; use crate::ProfessionState; @@ -35,7 +37,10 @@ pub struct LeadRequestPayload { /// `profession_key` must be a `'static str` matching the role key, e.g. `"PHOTOGRAPHER"`. pub fn shared_routes(profession_key: &'static str) -> Router { Router::new() - .route("/profile/submit", post(submit_for_verification)) + .route("/profile/submit", post({ + let pk = profession_key; + move |state, auth| submit_for_verification(state, auth, pk) + })) // ── Marketplace (Redis-cached) ──────────────────────────────────────── .route( "/marketplace", @@ -129,9 +134,10 @@ async fn send_lead_request( return (StatusCode::TOO_MANY_REQUESTS, "Too many lead requests. Try again later.").into_response(); } - let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await { - Ok(p) => p, - Err(_) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), + let user_role_profile = match UserRoleProfileRepository::get_by_user_and_role(&state.pool, auth.user_id, profession_key).await { + Ok(Some(p)) => p, + Ok(None) => return (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }; match is_professional_profile_approved(&state.pool, auth.user_id, profession_key).await { @@ -152,7 +158,7 @@ async fn send_lead_request( // ── Deduplication: one lead per requirement per professional (24 h) ──────── let duplicate = cache::lead::is_duplicate( &mut redis, - &prof.id.to_string(), + &user_role_profile.id.to_string(), &payload.requirement_id.to_string(), ) .await @@ -172,24 +178,23 @@ async fn send_lead_request( return (StatusCode::CONFLICT, "Requirement reached max requests").into_response(); } - let wallet = match ProfessionalRepository::get_wallet(&state.pool, auth.user_id).await { + let wallet = match TracecoinWalletRepository::get_by_user_id(&state.pool, auth.user_id).await { Ok(w) => w, Err(_) => return (StatusCode::BAD_REQUEST, "Wallet not found").into_response(), }; - if wallet.balance < 25 { + if wallet.current_balance < 25 { return (StatusCode::PAYMENT_REQUIRED, "Insufficient Tracecoin balance").into_response(); } let db_payload = CreateLeadRequestPayload { - requirement_id: req.id, - professional_id: prof.id, + user_role_profile_id: user_role_profile.id, expires_at: Utc::now() + chrono::Duration::hours(24), }; match LeadRequestRepository::create(&state.pool, db_payload).await { Ok(lead) => { - let reserved = ProfessionalRepository::try_reserve_tracecoins( + let reserved = TracecoinWalletRepository::try_reserve_tracecoins( &state.pool, auth.user_id, lead.tracecoins_reserved, @@ -213,7 +218,7 @@ async fn send_lead_request( // Mark dedup in Redis so this professional can't spam the same requirement let _ = cache::lead::mark_sent( &mut redis, - &prof.id.to_string(), + &user_role_profile.id.to_string(), &payload.requirement_id.to_string(), ) .await; @@ -427,9 +432,10 @@ async fn cancel_request( auth: AuthUser, Path(id): Path, ) -> impl IntoResponse { - let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await { - Ok(p) => p, - Err(_) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), + let user_role_profile = match UserRoleProfileRepository::get_by_user_and_role(&state.pool, auth.user_id, "PHOTOGRAPHER").await { + Ok(Some(p)) => p, + Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), }; let lead = match LeadRequestRepository::get_by_id(&state.pool, id).await { @@ -438,7 +444,7 @@ async fn cancel_request( Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), }; - if lead.professional_id != prof.id { + if lead.user_role_profile_id != user_role_profile.id { return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Access denied" }))).into_response(); } @@ -450,7 +456,7 @@ async fn cancel_request( // Release reserved Tracecoins back to balance if lead.tracecoins_reserved > 0 { - let _ = ProfessionalRepository::try_release_reserved_tracecoins( + let _ = TracecoinWalletRepository::try_release_reserved_tracecoins( &state.pool, auth.user_id, lead.tracecoins_reserved, @@ -470,51 +476,42 @@ async fn accepted_leads( auth: AuthUser, Query(q): Query, ) -> impl IntoResponse { - let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await { - Ok(p) => p, - Err(_) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), + let user_role_profile = match UserRoleProfileRepository::get_by_user_and_role(&state.pool, auth.user_id, "PHOTOGRAPHER").await { + Ok(Some(p)) => p, + Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), + Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), }; let page = q.page.unwrap_or(1).max(1); let limit = q.limit.unwrap_or(20).clamp(1, 100); let offset = (page - 1) * limit; - // Join lead_requests → requirements → customers → users to get full contact info let rows = sqlx::query( r#" SELECT - lr.id AS lead_id, + lr.id AS lead_request_id, lr.status, lr.requested_at, lr.resolved_at, - r.id AS requirement_id, - r.title AS requirement_title, - r.description AS requirement_description, - r.location AS requirement_location, - r.profession_key, - u.full_name AS customer_name, - u.email AS customer_email, - u.phone AS customer_phone + lr.tracecoins_reserved, + lr.user_role_profile_id FROM lead_requests lr - INNER JOIN requirements r ON r.id = lr.requirement_id - INNER JOIN customers c ON c.id = r.customer_id - INNER JOIN users u ON u.id = c.user_id - WHERE lr.professional_id = $1 + WHERE lr.user_role_profile_id = $1 AND lr.status = 'ACCEPTED' ORDER BY lr.resolved_at DESC LIMIT $2 OFFSET $3 "# ) - .bind(prof.id) + .bind(user_role_profile.id) .bind(limit) .bind(offset) .fetch_all(&state.pool) .await; let total: i64 = sqlx::query_scalar( - "SELECT COUNT(*) FROM lead_requests WHERE professional_id = $1 AND status = 'ACCEPTED'" + "SELECT COUNT(*) FROM lead_requests WHERE user_role_profile_id = $1 AND status = 'ACCEPTED'" ) - .bind(prof.id) + .bind(user_role_profile.id) .fetch_one(&state.pool) .await .unwrap_or(0); @@ -524,20 +521,12 @@ async fn accepted_leads( use sqlx::Row; let data: Vec = rows.iter().map(|row| { serde_json::json!({ - "lead_id": row.get::("lead_id"), - "status": row.get::("status"), - "requested_at": row.get::, _>("requested_at"), + "lead_request_id": row.get::("lead_request_id"), + "status": row.get::("status"), + "requested_at": row.get::, _>("requested_at"), "resolved_at": row.try_get::, _>("resolved_at").ok(), - "requirement_id": row.get::("requirement_id"), - "requirement_title": row.get::("requirement_title"), - "requirement_description": row.try_get::("requirement_description").ok(), - "requirement_location": row.try_get::("requirement_location").ok(), - "profession_key": row.get::("profession_key"), - "customer": { - "name": row.try_get::("customer_name").ok(), - "email": row.get::("customer_email"), - "phone": row.try_get::("customer_phone").ok(), - } + "tracecoins_reserved": row.get::("tracecoins_reserved"), + "user_role_profile_id": row.get::("user_role_profile_id"), }) }).collect(); @@ -779,6 +768,7 @@ async fn wallet_invoice_detail( async fn submit_for_verification( State(state): State, auth: AuthUser, + profession_key: &'static str, ) -> impl IntoResponse { let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await { Ok(p) => p, @@ -789,7 +779,7 @@ async fn submit_for_verification( return (StatusCode::BAD_REQUEST, format!("Profile is already {}", prof.status)).into_response(); } - match ProfessionalRepository::submit_for_verification(&state.pool, auth.user_id).await { + match ProfessionalRepository::submit_for_verification(&state.pool, auth.user_id, profession_key).await { Ok(profile) => (StatusCode::OK, Json(serde_json::json!({ "status": profile.status, "message": "Profile submitted for verification" diff --git a/crates/db-migrate/src/main.rs b/crates/db-migrate/src/main.rs index a250a20..50faf88 100644 --- a/crates/db-migrate/src/main.rs +++ b/crates/db-migrate/src/main.rs @@ -1,5 +1,6 @@ use std::path::Path; use anyhow::{Context, Result}; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[tokio::main] async fn main() -> Result<()> { diff --git a/crates/db/src/models/application.rs b/crates/db/src/models/application.rs index 136573e..f2d27d7 100644 --- a/crates/db/src/models/application.rs +++ b/crates/db/src/models/application.rs @@ -7,21 +7,18 @@ use uuid::Uuid; pub struct Application { pub id: Uuid, pub job_id: Uuid, - pub job_seeker_id: Uuid, - pub cover_letter: Option, - pub resume_url: Option, - pub status: String, // APPLIED, SHORTLISTED, INTERVIEW, OFFERED, HIRED, REJECTED, WITHDRAWN + pub applicant_user_id: Uuid, + pub cover_note: Option, + pub status: String, pub applied_at: DateTime, pub updated_at: DateTime, - pub contact_viewed: bool, } #[derive(Debug, Serialize, Deserialize)] pub struct CreateApplicationPayload { pub job_id: Uuid, - pub job_seeker_id: Uuid, - pub cover_letter: Option, - pub resume_url: Option, + pub applicant_user_id: Uuid, + pub cover_note: Option, } pub struct ApplicationRepository; @@ -33,15 +30,14 @@ impl ApplicationRepository { ) -> Result { let app = sqlx::query_as::<_, Application>( r#" - INSERT INTO applications (job_id, job_seeker_id, cover_letter, resume_url) - VALUES ($1, $2, $3, $4) + INSERT INTO job_applications (job_id, applicant_user_id, cover_note) + VALUES ($1, $2, $3) RETURNING * "#, ) .bind(payload.job_id) - .bind(payload.job_seeker_id) - .bind(payload.cover_letter) - .bind(payload.resume_url) + .bind(payload.applicant_user_id) + .bind(payload.cover_note) .fetch_one(pool) .await?; @@ -49,7 +45,7 @@ impl ApplicationRepository { } pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { - sqlx::query_as::<_, Application>("SELECT * FROM applications WHERE id = $1") + sqlx::query_as::<_, Application>("SELECT * FROM job_applications WHERE id = $1") .bind(id) .fetch_optional(pool) .await @@ -65,7 +61,7 @@ impl ApplicationRepository { let offset = (page - 1) * limit; let apps = sqlx::query_as::<_, Application>( r#" - SELECT * FROM applications + SELECT * FROM job_applications WHERE job_id = $1 AND ($2::VARCHAR IS NULL OR status = $2) ORDER BY applied_at DESC LIMIT $3 OFFSET $4 @@ -81,22 +77,22 @@ impl ApplicationRepository { Ok(apps) } - pub async fn list_by_job_seeker_id( + pub async fn list_by_user_id( pool: &PgPool, - job_seeker_id: Uuid, + applicant_user_id: Uuid, page: i64, limit: i64, ) -> Result, sqlx::Error> { let offset = (page - 1) * limit; let apps = sqlx::query_as::<_, Application>( r#" - SELECT * FROM applications - WHERE job_seeker_id = $1 + SELECT * FROM job_applications + WHERE applicant_user_id = $1 ORDER BY applied_at DESC LIMIT $2 OFFSET $3 "#, ) - .bind(job_seeker_id) + .bind(applicant_user_id) .bind(limit) .bind(offset) .fetch_all(pool) @@ -110,7 +106,7 @@ impl ApplicationRepository { status: &str, ) -> Result { let app = sqlx::query_as::<_, Application>( - "UPDATE applications SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *", + "UPDATE job_applications SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *", ) .bind(status) .bind(id) @@ -118,14 +114,4 @@ impl ApplicationRepository { .await?; Ok(app) } - - pub async fn mark_contact_viewed(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { - sqlx::query( - "UPDATE applications SET contact_viewed = true WHERE id = $1", - ) - .bind(id) - .execute(pool) - .await?; - Ok(()) - } } diff --git a/crates/db/src/models/catering_service.rs b/crates/db/src/models/catering_service.rs index 1f2e930..1145ce2 100644 --- a/crates/db/src/models/catering_service.rs +++ b/crates/db/src/models/catering_service.rs @@ -34,6 +34,60 @@ pub struct UpsertCateringServiceProfilePayload { pub struct CateringServiceRepository; impl CateringServiceRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, CateringServiceProfile>( + r#"SELECT csp.id, csp.user_role_profile_id, csp.business_name, csp.cuisine_types, csp.event_types, + csp.min_guests, csp.max_guests, csp.has_setup_team, csp.has_serving_staff, + csp.price_per_head_inr, csp.created_at, csp.updated_at + FROM catering_service_profiles csp + INNER JOIN user_role_profiles urp ON urp.id = csp.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'catering_service'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertCateringServiceProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'catering_service'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, CateringServiceProfile>( + r#"INSERT INTO catering_service_profiles (user_role_profile_id, business_name, cuisine_types, event_types, + min_guests, max_guests, has_setup_team, has_serving_staff, price_per_head_inr) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + business_name = EXCLUDED.business_name, + cuisine_types = COALESCE(EXCLUDED.cuisine_types, catering_service_profiles.cuisine_types), + event_types = COALESCE(EXCLUDED.event_types, catering_service_profiles.event_types), + min_guests = EXCLUDED.min_guests, + max_guests = EXCLUDED.max_guests, + has_setup_team = EXCLUDED.has_setup_team, + has_serving_staff = EXCLUDED.has_serving_staff, + price_per_head_inr = EXCLUDED.price_per_head_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, business_name, cuisine_types, event_types, + min_guests, max_guests, has_setup_team, has_serving_staff, + price_per_head_inr, created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.business_name) + .bind(&p.cuisine_types) + .bind(&p.event_types) + .bind(p.min_guests) + .bind(p.max_guests) + .bind(p.has_setup_team) + .bind(p.has_serving_staff) + .bind(p.price_per_head_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, CateringServiceProfile>( r#"SELECT id, user_role_profile_id, business_name, cuisine_types, event_types, diff --git a/crates/db/src/models/developer.rs b/crates/db/src/models/developer.rs index ece2fdc..d51d1be 100644 --- a/crates/db/src/models/developer.rs +++ b/crates/db/src/models/developer.rs @@ -28,6 +28,53 @@ pub struct UpsertDeveloperProfilePayload { pub struct DeveloperRepository; impl DeveloperRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, DeveloperProfile>( + r#"SELECT dp.id, dp.user_role_profile_id, dp.tech_stack, dp.experience_years, dp.availability, + dp.hourly_rate_inr, dp.remote_ok, + dp.created_at, dp.updated_at + FROM developer_profiles dp + INNER JOIN user_role_profiles urp ON urp.id = dp.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'developer'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertDeveloperProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'developer'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, DeveloperProfile>( + r#"INSERT INTO developer_profiles (user_role_profile_id, tech_stack, experience_years, + availability, hourly_rate_inr, remote_ok) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + tech_stack = COALESCE(EXCLUDED.tech_stack, developer_profiles.tech_stack), + experience_years = EXCLUDED.experience_years, + availability = EXCLUDED.availability, + hourly_rate_inr = EXCLUDED.hourly_rate_inr, + remote_ok = EXCLUDED.remote_ok, + updated_at = NOW() + RETURNING id, user_role_profile_id, tech_stack, experience_years, availability, + hourly_rate_inr, remote_ok, created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.tech_stack) + .bind(p.experience_years) + .bind(&p.availability) + .bind(p.hourly_rate_inr) + .bind(p.remote_ok) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, DeveloperProfile>( r#"SELECT id, user_role_profile_id, tech_stack, experience_years, availability, diff --git a/crates/db/src/models/fitness_trainer.rs b/crates/db/src/models/fitness_trainer.rs index a97fde8..0668ce9 100644 --- a/crates/db/src/models/fitness_trainer.rs +++ b/crates/db/src/models/fitness_trainer.rs @@ -30,6 +30,55 @@ pub struct UpsertFitnessTrainerProfilePayload { pub struct FitnessTrainerRepository; impl FitnessTrainerRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, FitnessTrainerProfile>( + r#"SELECT ftp.id, ftp.user_role_profile_id, ftp.disciplines, ftp.certifications, ftp.online_sessions, + ftp.home_visits, ftp.gym_based, ftp.per_session_rate_inr, + ftp.created_at, ftp.updated_at + FROM fitness_trainer_profiles ftp + INNER JOIN user_role_profiles urp ON urp.id = ftp.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'fitness_trainer'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertFitnessTrainerProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'fitness_trainer'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, FitnessTrainerProfile>( + r#"INSERT INTO fitness_trainer_profiles (user_role_profile_id, disciplines, certifications, + online_sessions, home_visits, gym_based, per_session_rate_inr) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + disciplines = COALESCE(EXCLUDED.disciplines, fitness_trainer_profiles.disciplines), + certifications = COALESCE(EXCLUDED.certifications, fitness_trainer_profiles.certifications), + online_sessions = EXCLUDED.online_sessions, + home_visits = EXCLUDED.home_visits, + gym_based = EXCLUDED.gym_based, + per_session_rate_inr = EXCLUDED.per_session_rate_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, disciplines, certifications, online_sessions, + home_visits, gym_based, per_session_rate_inr, created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.disciplines) + .bind(&p.certifications) + .bind(p.online_sessions) + .bind(p.home_visits) + .bind(p.gym_based) + .bind(p.per_session_rate_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, FitnessTrainerProfile>( r#"SELECT id, user_role_profile_id, disciplines, certifications, online_sessions, diff --git a/crates/db/src/models/graphic_designer.rs b/crates/db/src/models/graphic_designer.rs index 8135da0..5fc945b 100644 --- a/crates/db/src/models/graphic_designer.rs +++ b/crates/db/src/models/graphic_designer.rs @@ -26,6 +26,51 @@ pub struct UpsertGraphicDesignerProfilePayload { pub struct GraphicDesignerRepository; impl GraphicDesignerRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, GraphicDesignerProfile>( + r#"SELECT gdp.id, gdp.user_role_profile_id, gdp.design_tools, gdp.style_tags, + gdp.brand_experience, gdp.starting_price_inr, + gdp.created_at, gdp.updated_at + FROM graphic_designer_profiles gdp + INNER JOIN user_role_profiles urp ON urp.id = gdp.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'graphic_designer'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertGraphicDesignerProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'graphic_designer'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, GraphicDesignerProfile>( + r#"INSERT INTO graphic_designer_profiles (user_role_profile_id, design_tools, style_tags, + brand_experience, starting_price_inr) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + design_tools = COALESCE(EXCLUDED.design_tools, graphic_designer_profiles.design_tools), + style_tags = COALESCE(EXCLUDED.style_tags, graphic_designer_profiles.style_tags), + brand_experience = EXCLUDED.brand_experience, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, design_tools, style_tags, brand_experience, + starting_price_inr, created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.design_tools) + .bind(&p.style_tags) + .bind(p.brand_experience) + .bind(p.starting_price_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, GraphicDesignerProfile>( r#"SELECT id, user_role_profile_id, design_tools, style_tags, brand_experience, diff --git a/crates/db/src/models/lead_request.rs b/crates/db/src/models/lead_request.rs index 9de8a4a..c8b1d0c 100644 --- a/crates/db/src/models/lead_request.rs +++ b/crates/db/src/models/lead_request.rs @@ -6,21 +6,19 @@ use uuid::Uuid; #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct LeadRequest { pub id: Uuid, - pub requirement_id: Uuid, - pub professional_id: Uuid, - pub status: String, // PENDING, ACCEPTED, REJECTED, EXPIRED, CANCELLED + pub user_role_profile_id: Uuid, + pub status: String, pub tracecoins_reserved: i32, pub expires_at: DateTime, pub requested_at: DateTime, pub resolved_at: Option>, - pub professional_user_id: Option, + pub remarks: Option, pub updated_at: DateTime, } #[derive(Debug, Serialize, Deserialize)] pub struct CreateLeadRequestPayload { - pub requirement_id: Uuid, - pub professional_id: Uuid, + pub user_role_profile_id: Uuid, pub expires_at: DateTime, } @@ -33,13 +31,12 @@ impl LeadRequestRepository { ) -> Result { let req = sqlx::query_as::<_, LeadRequest>( r#" - INSERT INTO lead_requests (requirement_id, professional_id, expires_at) - VALUES ($1, $2, $3) + INSERT INTO lead_requests (user_role_profile_id, expires_at) + VALUES ($1, $2) RETURNING * "#, ) - .bind(payload.requirement_id) - .bind(payload.professional_id) + .bind(payload.user_role_profile_id) .bind(payload.expires_at) .fetch_one(pool) .await?; @@ -54,9 +51,9 @@ impl LeadRequestRepository { .await } - pub async fn list_by_requirement_id( + pub async fn list_by_user_role_profile_id( pool: &PgPool, - requirement_id: Uuid, + user_role_profile_id: Uuid, page: i64, limit: i64, ) -> Result, sqlx::Error> { @@ -64,12 +61,12 @@ impl LeadRequestRepository { let reqs = sqlx::query_as::<_, LeadRequest>( r#" SELECT * FROM lead_requests - WHERE requirement_id = $1 + WHERE user_role_profile_id = $1 ORDER BY requested_at DESC LIMIT $2 OFFSET $3 "#, ) - .bind(requirement_id) + .bind(user_role_profile_id) .bind(limit) .bind(offset) .fetch_all(pool) diff --git a/crates/db/src/models/makeup_artist.rs b/crates/db/src/models/makeup_artist.rs index 9230e78..908c491 100644 --- a/crates/db/src/models/makeup_artist.rs +++ b/crates/db/src/models/makeup_artist.rs @@ -28,6 +28,54 @@ pub struct UpsertMakeupArtistProfilePayload { pub struct MakeupArtistRepository; impl MakeupArtistRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, MakeupArtistProfile>( + r#"SELECT map.id, map.user_role_profile_id, map.specializations, map.kit_brands, + map.home_service, map.studio_available, map.starting_price_inr, + map.created_at, map.updated_at + FROM makeup_artist_profiles map + INNER JOIN user_role_profiles urp ON urp.id = map.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'makeup_artist'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertMakeupArtistProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'makeup_artist'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, MakeupArtistProfile>( + r#"INSERT INTO makeup_artist_profiles (user_role_profile_id, specializations, kit_brands, + home_service, studio_available, starting_price_inr) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + specializations = COALESCE(EXCLUDED.specializations, makeup_artist_profiles.specializations), + kit_brands = COALESCE(EXCLUDED.kit_brands, makeup_artist_profiles.kit_brands), + home_service = EXCLUDED.home_service, + studio_available = EXCLUDED.studio_available, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, specializations, kit_brands, + home_service, studio_available, starting_price_inr, + created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.specializations) + .bind(&p.kit_brands) + .bind(p.home_service) + .bind(p.studio_available) + .bind(p.starting_price_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, MakeupArtistProfile>( r#"SELECT id, user_role_profile_id, specializations, kit_brands, diff --git a/crates/db/src/models/mod.rs b/crates/db/src/models/mod.rs index 5e40ef3..b8ae833 100644 --- a/crates/db/src/models/mod.rs +++ b/crates/db/src/models/mod.rs @@ -26,4 +26,5 @@ pub mod department; pub mod designation; pub mod verification; pub mod user_role_profile; +pub mod tracecoin_wallet; diff --git a/crates/db/src/models/photographer.rs b/crates/db/src/models/photographer.rs index 229ad7c..9eda354 100644 --- a/crates/db/src/models/photographer.rs +++ b/crates/db/src/models/photographer.rs @@ -30,6 +30,57 @@ pub struct UpsertPhotographerProfilePayload { pub struct PhotographerRepository; impl PhotographerRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, PhotographerProfile>( + r#"SELECT pp.id, pp.user_role_profile_id, pp.specialties, pp.camera_brands, + pp.studio_available, pp.outdoor_shoots, pp.travel_radius_km, + pp.starting_price_inr, + pp.created_at, pp.updated_at + FROM photographer_profiles pp + INNER JOIN user_role_profiles urp ON urp.id = pp.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'photographer'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertPhotographerProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'photographer'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, PhotographerProfile>( + r#"INSERT INTO photographer_profiles (user_role_profile_id, specialties, camera_brands, + studio_available, outdoor_shoots, travel_radius_km, starting_price_inr) + VALUES ($1, $2, $3, $4, $5, $6, $7) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + specialties = COALESCE(EXCLUDED.specialties, photographer_profiles.specialties), + camera_brands = COALESCE(EXCLUDED.camera_brands, photographer_profiles.camera_brands), + studio_available = EXCLUDED.studio_available, + outdoor_shoots = EXCLUDED.outdoor_shoots, + travel_radius_km = EXCLUDED.travel_radius_km, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, specialties, camera_brands, + studio_available, outdoor_shoots, travel_radius_km, + starting_price_inr, created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.specialties) + .bind(&p.camera_brands) + .bind(p.studio_available) + .bind(p.outdoor_shoots) + .bind(p.travel_radius_km) + .bind(p.starting_price_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, PhotographerProfile>( r#"SELECT id, user_role_profile_id, specialties, camera_brands, diff --git a/crates/db/src/models/professional.rs b/crates/db/src/models/professional.rs index 9cd8d31..022c760 100644 --- a/crates/db/src/models/professional.rs +++ b/crates/db/src/models/professional.rs @@ -7,11 +7,7 @@ use uuid::Uuid; pub struct Professional { pub id: Uuid, pub user_id: Uuid, - pub profession_key: String, - pub display_name: String, - pub location: Option, - pub bio: Option, - pub extra_data_json: Option, + pub role_key: String, pub status: String, pub created_at: DateTime, pub updated_at: DateTime, @@ -22,20 +18,19 @@ use super::requirement::Requirement; #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct PortfolioItem { pub id: Uuid, - pub professional_id: Uuid, + pub user_role_profile_id: Uuid, pub title: String, pub description: Option, pub tags: Option>, + pub display_order: Option, pub created_at: DateTime, pub updated_at: DateTime, - pub user_id: Option, - pub profession_key: Option, } #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct Service { pub id: Uuid, - pub professional_id: Uuid, + pub user_role_profile_id: Uuid, pub name: String, pub description: Option, pub price: i32, @@ -43,8 +38,6 @@ pub struct Service { pub is_active: bool, pub created_at: DateTime, pub updated_at: DateTime, - pub user_id: Option, - pub profession_key: Option, } #[derive(Debug, Serialize, Deserialize, FromRow)] @@ -117,7 +110,7 @@ pub struct ProfessionalRepository; impl ProfessionalRepository { pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result { sqlx::query_as::<_, Professional>( - "SELECT * FROM professionals WHERE user_id = $1", + "SELECT id, user_id, role_key as profession_key, status, created_at, updated_at FROM user_role_profiles WHERE user_id = $1 AND role_key != 'CUSTOMER' AND role_key != 'COMPANY'", ) .bind(user_id) .fetch_one(pool) @@ -133,7 +126,7 @@ impl ProfessionalRepository { let offset = (page - 1) * limit; sqlx::query_as::<_, Requirement>( r#" - SELECT * FROM requirements + SELECT * FROM leads WHERE profession_key = $1 AND status = 'OPEN' AND (expires_at IS NULL OR expires_at > NOW()) ORDER BY created_at DESC LIMIT $2 OFFSET $3 @@ -146,20 +139,20 @@ impl ProfessionalRepository { .await } - pub async fn get_portfolio(pool: &PgPool, professional_id: Uuid) -> Result, sqlx::Error> { + pub async fn get_portfolio(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, PortfolioItem>( - "SELECT * FROM portfolio_items WHERE professional_id = $1 ORDER BY created_at DESC", + "SELECT * FROM portfolio_items WHERE user_role_profile_id = $1 ORDER BY display_order, created_at DESC", ) - .bind(professional_id) + .bind(user_role_profile_id) .fetch_all(pool) .await } - pub async fn get_services(pool: &PgPool, professional_id: Uuid) -> Result, sqlx::Error> { + pub async fn get_services(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, Service>( - "SELECT * FROM services WHERE professional_id = $1 AND is_active = true ORDER BY name ASC", + "SELECT * FROM services WHERE user_role_profile_id = $1 AND is_active = true ORDER BY name ASC", ) - .bind(professional_id) + .bind(user_role_profile_id) .fetch_all(pool) .await } @@ -187,14 +180,14 @@ impl ProfessionalRepository { Ok(()) } - pub async fn get_user_id_by_professional_id( + pub async fn get_user_id_by_user_role_profile_id( pool: &PgPool, - professional_id: Uuid, + user_role_profile_id: Uuid, ) -> Result, sqlx::Error> { let row = sqlx::query_scalar::<_, Uuid>( - "SELECT user_id FROM professionals WHERE id = $1", + "SELECT user_id FROM user_role_profiles WHERE id = $1", ) - .bind(professional_id) + .bind(user_role_profile_id) .fetch_optional(pool) .await?; Ok(row) @@ -202,17 +195,17 @@ impl ProfessionalRepository { pub async fn create_portfolio_item( pool: &PgPool, - professional_id: Uuid, + user_role_profile_id: Uuid, payload: CreatePortfolioItemPayload, ) -> Result { sqlx::query_as::<_, PortfolioItem>( r#" - INSERT INTO portfolio_items (professional_id, title, description, tags) + INSERT INTO portfolio_items (user_role_profile_id, title, description, tags) VALUES ($1, $2, $3, COALESCE($4::text[], '{}')) RETURNING * "#, ) - .bind(professional_id) + .bind(user_role_profile_id) .bind(payload.title) .bind(payload.description) .bind(payload.tags) @@ -222,7 +215,7 @@ impl ProfessionalRepository { pub async fn update_portfolio_item( pool: &PgPool, - professional_id: Uuid, + user_role_profile_id: Uuid, id: Uuid, payload: UpdatePortfolioItemPayload, ) -> Result, sqlx::Error> { @@ -234,7 +227,7 @@ impl ProfessionalRepository { description = COALESCE($2, description), tags = COALESCE($3, tags), updated_at = NOW() - WHERE id = $4 AND professional_id = $5 + WHERE id = $4 AND user_role_profile_id = $5 RETURNING * "#, ) @@ -242,7 +235,7 @@ impl ProfessionalRepository { .bind(payload.description) .bind(payload.tags) .bind(id) - .bind(professional_id) + .bind(user_role_profile_id) .fetch_optional(pool) .await?; Ok(row) @@ -250,14 +243,14 @@ impl ProfessionalRepository { pub async fn delete_portfolio_item( pool: &PgPool, - professional_id: Uuid, + user_role_profile_id: Uuid, id: Uuid, ) -> Result { let result = sqlx::query( - "DELETE FROM portfolio_items WHERE id = $1 AND professional_id = $2", + "DELETE FROM portfolio_items WHERE id = $1 AND user_role_profile_id = $2", ) .bind(id) - .bind(professional_id) + .bind(user_role_profile_id) .execute(pool) .await?; Ok(result.rows_affected() > 0) @@ -265,17 +258,17 @@ impl ProfessionalRepository { pub async fn create_service( pool: &PgPool, - professional_id: Uuid, + user_role_profile_id: Uuid, payload: CreateServicePayload, ) -> Result { sqlx::query_as::<_, Service>( r#" - INSERT INTO services (professional_id, name, description, price, duration_minutes) + INSERT INTO services (user_role_profile_id, name, description, price, duration_minutes) VALUES ($1, $2, $3, $4, $5) RETURNING * "#, ) - .bind(professional_id) + .bind(user_role_profile_id) .bind(payload.name) .bind(payload.description) .bind(payload.price) @@ -286,7 +279,7 @@ impl ProfessionalRepository { pub async fn update_service( pool: &PgPool, - professional_id: Uuid, + user_role_profile_id: Uuid, id: Uuid, payload: UpdateServicePayload, ) -> Result, sqlx::Error> { @@ -300,7 +293,7 @@ impl ProfessionalRepository { duration_minutes = COALESCE($4, duration_minutes), is_active = COALESCE($5, is_active), updated_at = NOW() - WHERE id = $6 AND professional_id = $7 + WHERE id = $6 AND user_role_profile_id = $7 RETURNING * "#, ) @@ -310,7 +303,7 @@ impl ProfessionalRepository { .bind(payload.duration_minutes) .bind(payload.is_active) .bind(id) - .bind(professional_id) + .bind(user_role_profile_id) .fetch_optional(pool) .await?; Ok(row) @@ -318,12 +311,12 @@ impl ProfessionalRepository { pub async fn delete_service( pool: &PgPool, - professional_id: Uuid, + user_role_profile_id: Uuid, id: Uuid, ) -> Result { - let result = sqlx::query("DELETE FROM services WHERE id = $1 AND professional_id = $2") + let result = sqlx::query("DELETE FROM services WHERE id = $1 AND user_role_profile_id = $2") .bind(id) - .bind(professional_id) + .bind(user_role_profile_id) .execute(pool) .await?; Ok(result.rows_affected() > 0) @@ -560,11 +553,13 @@ impl ProfessionalRepository { pub async fn submit_for_verification( pool: &PgPool, user_id: Uuid, + profession_key: &str, ) -> Result { let prof = sqlx::query_as::<_, Professional>( - "SELECT * FROM professionals WHERE user_id = $1", + "SELECT id, user_id, role_key as profession_key, status, created_at, updated_at FROM user_role_profiles WHERE user_id = $1 AND role_key = $2", ) .bind(user_id) + .bind(profession_key) .fetch_one(pool) .await?; @@ -574,13 +569,14 @@ impl ProfessionalRepository { let prof = sqlx::query_as::<_, Professional>( r#" - UPDATE professionals + UPDATE user_role_profiles SET status = 'PENDING_REVIEW', updated_at = NOW() - WHERE user_id = $1 - RETURNING * + WHERE user_id = $1 AND role_key = $2 + RETURNING id, user_id, role_key as profession_key, status, created_at, updated_at "#, ) .bind(user_id) + .bind(profession_key) .fetch_one(pool) .await?; diff --git a/crates/db/src/models/requirement.rs b/crates/db/src/models/requirement.rs index 9898161..b0912d5 100644 --- a/crates/db/src/models/requirement.rs +++ b/crates/db/src/models/requirement.rs @@ -6,7 +6,6 @@ use uuid::Uuid; #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct Requirement { pub id: Uuid, - pub customer_id: Uuid, pub profession_key: String, pub title: String, pub description: String, @@ -14,7 +13,7 @@ pub struct Requirement { pub budget: Option, pub preferred_date: Option, pub extra_data_json: Option, - pub status: String, // DRAFT, PENDING_APPROVAL, OPEN, CLOSED, EXPIRED, REJECTED + pub status: String, pub rejection_reason: Option, pub request_count: i32, pub accepted_count: i32, @@ -22,12 +21,13 @@ pub struct Requirement { pub approved_at: Option>, pub approved_by: Option, pub created_at: DateTime, + pub created_by_user_id: Option, + pub required_date: Option, pub updated_at: DateTime, } #[derive(Debug, Serialize, Deserialize)] pub struct CreateRequirementPayload { - pub customer_id: Uuid, pub profession_key: String, pub title: String, pub description: String, @@ -56,15 +56,14 @@ impl RequirementRepository { ) -> Result { let req = sqlx::query_as::<_, Requirement>( r#" - INSERT INTO requirements ( - customer_id, profession_key, title, description, location, + INSERT INTO leads ( + profession_key, title, description, location, budget, preferred_date, extra_data_json ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * "#, ) - .bind(payload.customer_id) .bind(payload.profession_key) .bind(payload.title) .bind(payload.description) @@ -79,28 +78,28 @@ impl RequirementRepository { } pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { - sqlx::query_as::<_, Requirement>("SELECT * FROM requirements WHERE id = $1") + sqlx::query_as::<_, Requirement>("SELECT * FROM leads WHERE id = $1") .bind(id) .fetch_optional(pool) .await } - pub async fn list_by_customer_id( + pub async fn list_by_user_id( pool: &PgPool, - customer_id: Uuid, + user_id: Uuid, page: i64, limit: i64, ) -> Result, sqlx::Error> { let offset = (page - 1) * limit; let reqs = sqlx::query_as::<_, Requirement>( r#" - SELECT * FROM requirements - WHERE customer_id = $1 + SELECT * FROM leads + WHERE created_by_user_id = $1 ORDER BY created_at DESC LIMIT $2 OFFSET $3 "#, ) - .bind(customer_id) + .bind(user_id) .bind(limit) .bind(offset) .fetch_all(pool) @@ -116,7 +115,7 @@ impl RequirementRepository { ) -> Result { let req = sqlx::query_as::<_, Requirement>( r#" - UPDATE requirements SET + UPDATE leads SET title = COALESCE($1, title), description = COALESCE($2, description), location = COALESCE($3, location), @@ -147,7 +146,7 @@ impl RequirementRepository { status: &str, ) -> Result { let req = sqlx::query_as::<_, Requirement>( - "UPDATE requirements SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *", + "UPDATE leads SET status = $1, updated_at = NOW() WHERE id = $2 RETURNING *", ) .bind(status) .bind(id) @@ -157,7 +156,7 @@ impl RequirementRepository { } pub async fn increment_request_count(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { - sqlx::query("UPDATE requirements SET request_count = request_count + 1 WHERE id = $1") + sqlx::query("UPDATE leads SET request_count = request_count + 1 WHERE id = $1") .bind(id) .execute(pool) .await?; @@ -166,7 +165,7 @@ impl RequirementRepository { pub async fn increment_accepted_count(pool: &PgPool, id: Uuid) -> Result<(), sqlx::Error> { sqlx::query( - "UPDATE requirements SET accepted_count = accepted_count + 1 WHERE id = $1", + "UPDATE leads SET accepted_count = accepted_count + 1 WHERE id = $1", ) .bind(id) .execute(pool) @@ -180,7 +179,7 @@ impl RequirementRepository { ) -> Result { sqlx::query_as::<_, Requirement>( r#" - UPDATE requirements + UPDATE leads SET accepted_count = accepted_count + 1, updated_at = NOW() WHERE id = $1 RETURNING * @@ -198,7 +197,7 @@ impl RequirementRepository { ) -> Result { sqlx::query_as::<_, Requirement>( r#" - UPDATE requirements + UPDATE leads SET status = 'OPEN', approved_at = NOW(), approved_by = $1, rejection_reason = NULL, updated_at = NOW() WHERE id = $2 RETURNING * @@ -217,7 +216,7 @@ impl RequirementRepository { ) -> Result { sqlx::query_as::<_, Requirement>( r#" - UPDATE requirements + UPDATE leads SET status = 'REJECTED', rejection_reason = $1, approved_at = NULL, approved_by = NULL, updated_at = NOW() WHERE id = $2 RETURNING * diff --git a/crates/db/src/models/social_media_manager.rs b/crates/db/src/models/social_media_manager.rs index 2da8416..2163bba 100644 --- a/crates/db/src/models/social_media_manager.rs +++ b/crates/db/src/models/social_media_manager.rs @@ -28,6 +28,53 @@ pub struct UpsertSocialMediaManagerProfilePayload { pub struct SocialMediaManagerRepository; impl SocialMediaManagerRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, SocialMediaManagerProfile>( + r#"SELECT smmp.id, smmp.user_role_profile_id, smmp.platforms, smmp.industries, smmp.content_types, + smmp.avg_follower_growth_pct, smmp.starting_price_inr, + smmp.created_at, smmp.updated_at + FROM social_media_manager_profiles smmp + INNER JOIN user_role_profiles urp ON urp.id = smmp.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'social_media_manager'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertSocialMediaManagerProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'social_media_manager'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, SocialMediaManagerProfile>( + r#"INSERT INTO social_media_manager_profiles (user_role_profile_id, platforms, industries, + content_types, avg_follower_growth_pct, starting_price_inr) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + platforms = COALESCE(EXCLUDED.platforms, social_media_manager_profiles.platforms), + industries = COALESCE(EXCLUDED.industries, social_media_manager_profiles.industries), + content_types = COALESCE(EXCLUDED.content_types, social_media_manager_profiles.content_types), + avg_follower_growth_pct = EXCLUDED.avg_follower_growth_pct, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, platforms, industries, content_types, + avg_follower_growth_pct, starting_price_inr, created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.platforms) + .bind(&p.industries) + .bind(&p.content_types) + .bind(p.avg_follower_growth_pct) + .bind(p.starting_price_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, SocialMediaManagerProfile>( r#"SELECT id, user_role_profile_id, platforms, industries, content_types, diff --git a/crates/db/src/models/tracecoin_wallet.rs b/crates/db/src/models/tracecoin_wallet.rs new file mode 100644 index 0000000..fc4c816 --- /dev/null +++ b/crates/db/src/models/tracecoin_wallet.rs @@ -0,0 +1,220 @@ +use chrono::{DateTime, Utc}; +use serde::{Deserialize, Serialize}; +use sqlx::{FromRow, PgPool}; +use uuid::Uuid; + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct Wallet { + pub id: Uuid, + pub user_id: Uuid, + pub current_balance: i32, + pub reserved: i32, + pub updated_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize, FromRow)] +pub struct LedgerEntry { + pub id: Uuid, + pub wallet_id: Uuid, + pub transaction_type: String, + pub amount: i32, + pub reference_type: String, + pub reference_id: Option, + pub balance_after: Option, + pub remarks: Option, + pub created_at: DateTime, +} + +pub struct TracecoinWalletRepository; + +impl TracecoinWalletRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result { + sqlx::query_as::<_, Wallet>( + "SELECT * FROM tracecoin_wallets WHERE user_id = $1", + ) + .bind(user_id) + .fetch_one(pool) + .await + } + + pub async fn ensure_wallet(pool: &PgPool, user_id: Uuid) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + INSERT INTO tracecoin_wallets (user_id, current_balance, reserved) + VALUES ($1, 0, 0) + ON CONFLICT (user_id) DO NOTHING + "#, + ) + .bind(user_id) + .execute(pool) + .await?; + Ok(()) + } + + pub async fn try_reserve_tracecoins( + pool: &PgPool, + user_id: Uuid, + amount: i32, + reference_id: Uuid, + ) -> Result { + let mut tx = pool.begin().await?; + + sqlx::query( + r#" + INSERT INTO tracecoin_wallets (user_id, current_balance, reserved) + VALUES ($1, 0, 0) + ON CONFLICT (user_id) DO NOTHING + "#, + ) + .bind(user_id) + .execute(&mut *tx) + .await?; + + let wallet = sqlx::query_as::<_, Wallet>( + "SELECT * FROM tracecoin_wallets WHERE user_id = $1 FOR UPDATE", + ) + .bind(user_id) + .fetch_one(&mut *tx) + .await?; + + if wallet.current_balance < amount { + tx.rollback().await?; + return Ok(false); + } + + sqlx::query( + r#" + UPDATE tracecoin_wallets + SET current_balance = current_balance - $1, reserved = reserved + $1, updated_at = NOW() + WHERE id = $2 + "#, + ) + .bind(amount) + .bind(wallet.id) + .execute(&mut *tx) + .await?; + + sqlx::query( + r#" + INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id) + VALUES ($1, 'RESERVE', $2, 'LEAD_REQUEST', $3) + "#, + ) + .bind(wallet.id) + .bind(amount) + .bind(reference_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(true) + } + + pub async fn try_debit_reserved_tracecoins( + pool: &PgPool, + user_id: Uuid, + amount: i32, + reference_id: Uuid, + ) -> Result { + let mut tx = pool.begin().await?; + + let wallet = sqlx::query_as::<_, Wallet>( + "SELECT * FROM tracecoin_wallets WHERE user_id = $1 FOR UPDATE", + ) + .bind(user_id) + .fetch_optional(&mut *tx) + .await?; + + let Some(wallet) = wallet else { + tx.rollback().await?; + return Ok(false); + }; + + if wallet.reserved < amount { + tx.rollback().await?; + return Ok(false); + } + + sqlx::query( + r#" + UPDATE tracecoin_wallets + SET reserved = reserved - $1, updated_at = NOW() + WHERE id = $2 + "#, + ) + .bind(amount) + .bind(wallet.id) + .execute(&mut *tx) + .await?; + + sqlx::query( + r#" + INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id) + VALUES ($1, 'DEBIT', $2, 'LEAD_ACCEPTED', $3) + "#, + ) + .bind(wallet.id) + .bind(amount) + .bind(reference_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(true) + } + + pub async fn try_release_reserved_tracecoins( + pool: &PgPool, + user_id: Uuid, + amount: i32, + reference_id: Uuid, + reason: &str, + ) -> Result { + let mut tx = pool.begin().await?; + + let wallet = sqlx::query_as::<_, Wallet>( + "SELECT * FROM tracecoin_wallets WHERE user_id = $1 FOR UPDATE", + ) + .bind(user_id) + .fetch_optional(&mut *tx) + .await?; + + let Some(wallet) = wallet else { + tx.rollback().await?; + return Ok(false); + }; + + if wallet.reserved < amount { + tx.rollback().await?; + return Ok(false); + } + + sqlx::query( + r#" + UPDATE tracecoin_wallets + SET reserved = reserved - $1, current_balance = current_balance + $1, updated_at = NOW() + WHERE id = $2 + "#, + ) + .bind(amount) + .bind(wallet.id) + .execute(&mut *tx) + .await?; + + sqlx::query( + r#" + INSERT INTO tracecoin_ledger (wallet_id, transaction_type, amount, reference_type, reference_id) + VALUES ($1, 'RELEASE', $2, $3, $4) + "#, + ) + .bind(wallet.id) + .bind(amount) + .bind(reason) + .bind(reference_id) + .execute(&mut *tx) + .await?; + + tx.commit().await?; + Ok(true) + } +} diff --git a/crates/db/src/models/tutor.rs b/crates/db/src/models/tutor.rs index aa5ea58..1220f6f 100644 --- a/crates/db/src/models/tutor.rs +++ b/crates/db/src/models/tutor.rs @@ -32,6 +32,58 @@ pub struct UpsertTutorProfilePayload { pub struct TutorRepository; impl TutorRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, TutorProfile>( + r#"SELECT tp.id, tp.user_role_profile_id, tp.subjects, tp.board_types, tp.qualification, + tp.teaches_online, tp.teaches_offline, tp.experience_years, tp.hourly_rate_inr, + tp.created_at, tp.updated_at + FROM tutor_profiles tp + INNER JOIN user_role_profiles urp ON urp.id = tp.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'tutor'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertTutorProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'tutor'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, TutorProfile>( + r#"INSERT INTO tutor_profiles (user_role_profile_id, subjects, board_types, qualification, + teaches_online, teaches_offline, experience_years, hourly_rate_inr) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + subjects = COALESCE(EXCLUDED.subjects, tutor_profiles.subjects), + board_types = COALESCE(EXCLUDED.board_types, tutor_profiles.board_types), + qualification = EXCLUDED.qualification, + teaches_online = EXCLUDED.teaches_online, + teaches_offline = EXCLUDED.teaches_offline, + experience_years = EXCLUDED.experience_years, + hourly_rate_inr = EXCLUDED.hourly_rate_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, subjects, board_types, qualification, + teaches_online, teaches_offline, experience_years, hourly_rate_inr, + created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.subjects) + .bind(&p.board_types) + .bind(&p.qualification) + .bind(p.teaches_online) + .bind(p.teaches_offline) + .bind(p.experience_years) + .bind(p.hourly_rate_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, TutorProfile>( r#"SELECT id, user_role_profile_id, subjects, board_types, qualification, diff --git a/crates/db/src/models/ugc_content_creator.rs b/crates/db/src/models/ugc_content_creator.rs index e03fede..f1e4b1a 100644 --- a/crates/db/src/models/ugc_content_creator.rs +++ b/crates/db/src/models/ugc_content_creator.rs @@ -28,6 +28,53 @@ pub struct UpsertUgcContentCreatorProfilePayload { pub struct UgcContentCreatorRepository; impl UgcContentCreatorRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, UgcContentCreatorProfile>( + r#"SELECT uccp.id, uccp.user_role_profile_id, uccp.niche_tags, uccp.content_formats, uccp.platforms, + uccp.turnaround_days, uccp.starting_price_inr, + uccp.created_at, uccp.updated_at + FROM ugc_content_creator_profiles uccp + INNER JOIN user_role_profiles urp ON urp.id = uccp.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'ugc_content_creator'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertUgcContentCreatorProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'ugc_content_creator'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, UgcContentCreatorProfile>( + r#"INSERT INTO ugc_content_creator_profiles (user_role_profile_id, niche_tags, content_formats, + platforms, turnaround_days, starting_price_inr) + VALUES ($1, $2, $3, $4, $5, $6) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + niche_tags = COALESCE(EXCLUDED.niche_tags, ugc_content_creator_profiles.niche_tags), + content_formats = COALESCE(EXCLUDED.content_formats, ugc_content_creator_profiles.content_formats), + platforms = COALESCE(EXCLUDED.platforms, ugc_content_creator_profiles.platforms), + turnaround_days = EXCLUDED.turnaround_days, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, niche_tags, content_formats, platforms, + turnaround_days, starting_price_inr, created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.niche_tags) + .bind(&p.content_formats) + .bind(&p.platforms) + .bind(p.turnaround_days) + .bind(p.starting_price_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, UgcContentCreatorProfile>( r#"SELECT id, user_role_profile_id, niche_tags, content_formats, platforms, diff --git a/crates/db/src/models/video_editor.rs b/crates/db/src/models/video_editor.rs index 376affd..dafe78b 100644 --- a/crates/db/src/models/video_editor.rs +++ b/crates/db/src/models/video_editor.rs @@ -26,6 +26,51 @@ pub struct UpsertVideoEditorProfilePayload { pub struct VideoEditorRepository; impl VideoEditorRepository { + pub async fn get_by_user_id(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { + sqlx::query_as::<_, VideoEditorProfile>( + r#"SELECT vep.id, vep.user_role_profile_id, vep.software_skills, vep.style_tags, + vep.turnaround_days, vep.starting_price_inr, + vep.created_at, vep.updated_at + FROM video_editor_profiles vep + INNER JOIN user_role_profiles urp ON urp.id = vep.user_role_profile_id + WHERE urp.user_id = $1 AND urp.role_key = 'video_editor'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await + } + + pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertVideoEditorProfilePayload) -> Result { + let user_role_profile = sqlx::query_as::<_, (Uuid,)>( + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'video_editor'"#, + ) + .bind(user_id) + .fetch_optional(pool) + .await? + .ok_or(sqlx::Error::RowNotFound)?; + + sqlx::query_as::<_, VideoEditorProfile>( + r#"INSERT INTO video_editor_profiles (user_role_profile_id, software_skills, style_tags, + turnaround_days, starting_price_inr) + VALUES ($1, $2, $3, $4, $5) + ON CONFLICT (user_role_profile_id) DO UPDATE SET + software_skills = COALESCE(EXCLUDED.software_skills, video_editor_profiles.software_skills), + style_tags = COALESCE(EXCLUDED.style_tags, video_editor_profiles.style_tags), + turnaround_days = EXCLUDED.turnaround_days, + starting_price_inr = EXCLUDED.starting_price_inr, + updated_at = NOW() + RETURNING id, user_role_profile_id, software_skills, style_tags, turnaround_days, + starting_price_inr, created_at, updated_at"#, + ) + .bind(user_role_profile.0) + .bind(&p.software_skills) + .bind(&p.style_tags) + .bind(p.turnaround_days) + .bind(p.starting_price_inr) + .fetch_one(pool) + .await + } + pub async fn get_by_user_role_id(pool: &PgPool, user_role_profile_id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, VideoEditorProfile>( r#"SELECT id, user_role_profile_id, software_skills, style_tags, turnaround_days, From f7e18cd4d6d278e75cedf4cbbac88595673a412d Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Mon, 13 Apr 2026 01:36:13 +0200 Subject: [PATCH 014/182] feat: pricing packages with multi-select roles, lead requests, mock checkout --- apps/companies/src/handlers/mod.rs | 68 +- apps/leads/src/lead_requests.rs | 540 +++++++++++++ apps/leads/src/main.rs | 5 +- apps/payments/src/main.rs | 84 +- apps/payments/src/packages.rs | 418 ++++++++++ ...190200_requirements_and_leads.up.sql.skip} | 0 .../20260415000000_complete_migration.up.sql | 6 +- ...2_backfill_user_role_profiles.up.sql.skip} | 0 .../migrations_new/001_minimal_setup.up.sql | 726 ++++++++++++++++++ .../002_pricing_and_leads.up.sql | 285 +++++++ ...60415000000_complete_migration.up.sql.skip | 618 +++++++++++++++ ...260415000001_create_user_sessions.down.sql | 2 + ...20260415000001_create_user_sessions.up.sql | 16 + ...5010001_create_user_role_profiles.down.sql | 3 + ...415010001_create_user_role_profiles.up.sql | 31 + ...10002_backfill_user_role_profiles.down.sql | 2 + ...15010003_add_user_role_profile_id.down.sql | 23 + ...10003_add_user_role_profile_id.up.sql.skip | 85 ++ ...60415010004_remove_external_links.down.sql | 3 + ...15010004_remove_external_links.up.sql.skip | 31 + 20 files changed, 2892 insertions(+), 54 deletions(-) create mode 100644 apps/leads/src/lead_requests.rs create mode 100644 apps/payments/src/packages.rs rename crates/db/migrations/{20260317190200_requirements_and_leads.up.sql => 20260317190200_requirements_and_leads.up.sql.skip} (100%) rename crates/db/migrations/{20260415010002_backfill_user_role_profiles.up.sql => 20260415010002_backfill_user_role_profiles.up.sql.skip} (100%) create mode 100644 crates/db/migrations_new/001_minimal_setup.up.sql create mode 100644 crates/db/migrations_new/002_pricing_and_leads.up.sql create mode 100644 crates/db/migrations_new/20260415000000_complete_migration.up.sql.skip create mode 100644 crates/db/migrations_new/20260415000001_create_user_sessions.down.sql create mode 100644 crates/db/migrations_new/20260415000001_create_user_sessions.up.sql create mode 100644 crates/db/migrations_new/20260415010001_create_user_role_profiles.down.sql create mode 100644 crates/db/migrations_new/20260415010001_create_user_role_profiles.up.sql create mode 100644 crates/db/migrations_new/20260415010002_backfill_user_role_profiles.down.sql create mode 100644 crates/db/migrations_new/20260415010003_add_user_role_profile_id.down.sql create mode 100644 crates/db/migrations_new/20260415010003_add_user_role_profile_id.up.sql.skip create mode 100644 crates/db/migrations_new/20260415010004_remove_external_links.down.sql create mode 100644 crates/db/migrations_new/20260415010004_remove_external_links.up.sql.skip diff --git a/apps/companies/src/handlers/mod.rs b/apps/companies/src/handlers/mod.rs index 6a0df3c..75bbd36 100644 --- a/apps/companies/src/handlers/mod.rs +++ b/apps/companies/src/handlers/mod.rs @@ -405,7 +405,38 @@ async fn view_contact( return (StatusCode::FORBIDDEN, "Access denied").into_response(); } - // Fetch applicant contact info via applicant_user_id → users + let free_views = company.free_contact_views; + let purchased_views = company.purchased_contact_views; + + 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(); + } + let contact = sqlx::query_as::<_, (Option, String, Option)>( r#" SELECT u.full_name, u.email, u.phone @@ -419,14 +450,23 @@ async fn view_contact( 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)); + 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, @@ -434,12 +474,12 @@ async fn view_contact( "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) => { diff --git a/apps/leads/src/lead_requests.rs b/apps/leads/src/lead_requests.rs new file mode 100644 index 0000000..0e2efa1 --- /dev/null +++ b/apps/leads/src/lead_requests.rs @@ -0,0 +1,540 @@ +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, + pub limit: Option, + pub status: Option, +} + +#[derive(Debug, Deserialize)] +pub struct SendLeadRequestPayload { + pub lead_id: Uuid, + pub message: Option, +} + +#[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, + pub expires_at: chrono::DateTime, + pub accepted_at: Option>, + pub rejected_at: Option>, + pub rejected_reason: Option, + pub created_at: chrono::DateTime, +} + +#[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, + pub professional_role: Option, + pub customer_name: Option, + pub lead_title: Option, + pub status: String, + pub tracecoins_reserved: i32, + pub message: Option, + pub expires_at: chrono::DateTime, + pub accepted_at: Option>, + pub rejected_at: Option>, + pub rejected_reason: Option, + pub created_at: chrono::DateTime, +} + +pub fn router() -> Router> { + Router::new() + .route("/", get(list_lead_requests)) + .route("/send", post(send_lead_request)) + .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>, + Query(q): Query, +) -> 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 = 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>, + axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, + Json(payload): Json, +) -> 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 accept_lead_request( + State(state): State>, + Path(id): Path, + axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, +) -> 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>, + Path(id): Path, + axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, +) -> 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>, + Query(q): Query, + axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, +) -> 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 = 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>, + axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, +) -> 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 = 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>, + Path(lead_id): Path, + Query(q): Query, + axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, +) -> 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 = requests.into_iter().map(lead_request_to_response).collect(); + + (StatusCode::OK, Json(serde_json::json!({ + "data": requests, + "pagination": { "page": page, "limit": limit } + }))).into_response() +} diff --git a/apps/leads/src/main.rs b/apps/leads/src/main.rs index 1260a91..8cb4f89 100644 --- a/apps/leads/src/main.rs +++ b/apps/leads/src/main.rs @@ -11,6 +11,8 @@ 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, @@ -55,7 +57,7 @@ async fn create_lead( 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) @@ -120,6 +122,7 @@ async fn main() { .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); diff --git a/apps/payments/src/main.rs b/apps/payments/src/main.rs index f7a00a1..6490e9a 100644 --- a/apps/payments/src/main.rs +++ b/apps/payments/src/main.rs @@ -12,6 +12,8 @@ use uuid::Uuid; use sqlx::postgres::PgPool; use sqlx::FromRow; +pub mod packages; + #[derive(Clone)] struct AppState { beeceptor_url: String, @@ -67,7 +69,8 @@ struct PricingPackageRow { struct PaymentRow { id: Uuid, user_id: Uuid, - tracecoins_credited: i32, + package_id: Option, + tracecoins_credited: Option, } async fn create_order( @@ -77,11 +80,9 @@ async fn create_order( ) -> Result, (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 +94,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 +130,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 +160,6 @@ async fn verify_payment( ) -> Result, (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 +186,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 +203,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 +224,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 +359,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 +368,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(); diff --git a/apps/payments/src/packages.rs b/apps/payments/src/packages.rs new file mode 100644 index 0000000..0738001 --- /dev/null +++ b/apps/payments/src/packages.rs @@ -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, + pub applicable_role: Option, + pub active_only: Option, +} + +#[derive(Debug, Deserialize)] +pub struct PaginationQuery { + pub page: Option, + pub limit: Option, + pub search: Option, +} + +#[derive(Debug, Deserialize)] +pub struct CreatePackageRequest { + pub name: String, + pub description: Option, + pub package_type: String, + pub applicable_roles: Vec, + pub tracecoins_amount: i32, + pub price: i32, + pub duration_days: Option, + pub valid_from: Option>, + pub valid_until: Option>, + pub is_promotional: Option, + pub is_active: Option, + pub features: Option, +} + +#[derive(Debug, Deserialize)] +pub struct UpdatePackageRequest { + pub name: Option, + pub description: Option, + pub tracecoins_amount: Option, + pub price: Option, + pub duration_days: Option, + pub valid_from: Option>, + pub valid_until: Option>, + pub is_promotional: Option, + pub is_active: Option, + pub features: Option, +} + +#[derive(Debug, FromRow)] +pub struct PricingPackageRow { + pub id: Uuid, + pub name: String, + pub description: Option, + pub package_type: String, + pub applicable_roles: Vec, + pub tracecoins_amount: i32, + pub price: i32, + pub duration_days: Option, + pub valid_from: Option>, + pub valid_until: Option>, + pub is_promotional: bool, + pub is_active: bool, + pub features: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, +} + +#[derive(Debug, Serialize)] +pub struct PricingPackageResponse { + pub id: Uuid, + pub name: String, + pub description: Option, + pub package_type: String, + pub applicable_roles: Vec, + pub tracecoins_amount: i32, + pub price: i32, + pub duration_days: Option, + pub valid_from: Option>, + pub valid_until: Option>, + pub is_promotional: bool, + pub is_active: bool, + pub features: Option, + pub created_at: chrono::DateTime, + pub updated_at: chrono::DateTime, + pub is_available: bool, + pub is_expired: bool, +} + +impl From 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 { + 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, + Query(q): Query, +) -> 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 = 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, + Path(id): Path, +) -> 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, + Json(payload): Json, +) -> 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, + Path(id): Path, + Json(payload): Json, +) -> 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, + Path(id): Path, +) -> 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, + Query(q): Query, +) -> 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 = 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, + Query(q): Query, +) -> 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 = packages.into_iter().map(|p| p.into()).collect(); + + (StatusCode::OK, Json(serde_json::json!({ + "data": packages, + "applicable_role": applicable_role + }))).into_response() +} diff --git a/crates/db/migrations/20260317190200_requirements_and_leads.up.sql b/crates/db/migrations/20260317190200_requirements_and_leads.up.sql.skip similarity index 100% rename from crates/db/migrations/20260317190200_requirements_and_leads.up.sql rename to crates/db/migrations/20260317190200_requirements_and_leads.up.sql.skip diff --git a/crates/db/migrations/20260415000000_complete_migration.up.sql b/crates/db/migrations/20260415000000_complete_migration.up.sql index 73d9bdc..f6987fe 100644 --- a/crates/db/migrations/20260415000000_complete_migration.up.sql +++ b/crates/db/migrations/20260415000000_complete_migration.up.sql @@ -125,10 +125,10 @@ SELECT gen_random_uuid(), p.user_id, 'ugc_content_creator', p.display_name, p.bi FROM ugc_content_creator_profiles p WHERE NOT EXISTS (SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = p.user_id AND urp.role_key = 'ugc_content_creator'); --- Backfill from company_profiles +-- Backfill from company_profiles (companies don't have bio) INSERT INTO user_role_profiles (id, user_id, role_key, display_name, bio, location, status, created_at, updated_at) -SELECT gen_random_uuid(), cp.user_id, 'company', cp.company_name, cp.bio, NULL, - COALESCE(cp.status, 'ACTIVE'), cp.created_at, COALESCE(cp.updated_at, NOW()) +SELECT gen_random_uuid(), cp.user_id, 'company', cp.company_name, NULL, NULL, + 'ACTIVE', cp.created_at, COALESCE(cp.updated_at, NOW()) FROM company_profiles cp WHERE NOT EXISTS (SELECT 1 FROM user_role_profiles urp WHERE urp.user_id = cp.user_id AND urp.role_key = 'company'); diff --git a/crates/db/migrations/20260415010002_backfill_user_role_profiles.up.sql b/crates/db/migrations/20260415010002_backfill_user_role_profiles.up.sql.skip similarity index 100% rename from crates/db/migrations/20260415010002_backfill_user_role_profiles.up.sql rename to crates/db/migrations/20260415010002_backfill_user_role_profiles.up.sql.skip diff --git a/crates/db/migrations_new/001_minimal_setup.up.sql b/crates/db/migrations_new/001_minimal_setup.up.sql new file mode 100644 index 0000000..4ee67be --- /dev/null +++ b/crates/db/migrations_new/001_minimal_setup.up.sql @@ -0,0 +1,726 @@ +-- Minimal setup migration - creates essential tables needed by complete migration +BEGIN; + +-- Users table +CREATE TABLE IF NOT EXISTS users ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + email VARCHAR(255) UNIQUE NOT NULL, + phone VARCHAR(50), + password_hash TEXT, + full_name VARCHAR(255), + account_type VARCHAR(50) DEFAULT 'USER', + email_verified BOOLEAN DEFAULT false, + phone_verified BOOLEAN DEFAULT false, + status VARCHAR(50) DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + last_login_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ +); + +-- Roles table +CREATE TABLE IF NOT EXISTS roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL UNIQUE, + code VARCHAR(50) NOT NULL UNIQUE, + description TEXT, + can_approve_requests BOOLEAN DEFAULT false, + can_manage_system_settings BOOLEAN DEFAULT false, + audience VARCHAR(50) DEFAULT 'USER', + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- User roles table +CREATE TABLE IF NOT EXISTS user_roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Role permissions table +CREATE TABLE IF NOT EXISTS role_permissions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + permission VARCHAR(100) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Departments table +CREATE TABLE IF NOT EXISTS departments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + code VARCHAR(50), + description TEXT, + department_head UUID REFERENCES users(id), + department_email VARCHAR(255), + visibility VARCHAR(50) DEFAULT 'VISIBLE', + transfers_enabled BOOLEAN DEFAULT false, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Designations table +CREATE TABLE IF NOT EXISTS designations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + code VARCHAR(50), + department_id UUID REFERENCES departments(id) ON DELETE SET NULL, + description TEXT, + level INTEGER DEFAULT 1, + can_manage_team BOOLEAN DEFAULT false, + can_approve BOOLEAN DEFAULT false, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Employees table +CREATE TABLE IF NOT EXISTS employees ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + department_id UUID REFERENCES departments(id) ON DELETE SET NULL, + designation_id UUID REFERENCES designations(id) ON DELETE SET NULL, + status VARCHAR(50) DEFAULT 'ACTIVE', + joining_date DATE, + employment_status VARCHAR(50), + manager_employee_id UUID REFERENCES employees(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Refresh tokens table +CREATE TABLE IF NOT EXISTS refresh_tokens ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) NOT NULL UNIQUE, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Notification preferences table +CREATE TABLE IF NOT EXISTS notification_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + email_enabled BOOLEAN DEFAULT true, + push_enabled BOOLEAN DEFAULT true, + sms_enabled BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Profile extension tables (with user_role_profile_id for new schema) +CREATE TABLE IF NOT EXISTS photographer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + equipment_list TEXT, + years_of_experience INTEGER, + hourly_rate INTEGER, + specialties TEXT[] DEFAULT '{}', + camera_brands TEXT[] DEFAULT '{}', + studio_available BOOLEAN DEFAULT false, + outdoor_shoots BOOLEAN DEFAULT true, + travel_radius_km INTEGER DEFAULT 50, + starting_price_inr INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tutor_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + subjects TEXT[] DEFAULT '{}', + board_types TEXT[] DEFAULT '{}', + qualification VARCHAR(255), + teaches_online BOOLEAN DEFAULT true, + teaches_offline BOOLEAN DEFAULT true, + experience_years INTEGER DEFAULT 0, + hourly_rate_inr INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS makeup_artist_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + specialties TEXT[] DEFAULT '{}', + experience_years INTEGER DEFAULT 0, + hourly_rate INTEGER DEFAULT 0, + willing_to_travel BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS developer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + skills TEXT[] DEFAULT '{}', + experience_years INTEGER DEFAULT 0, + hourly_rate INTEGER DEFAULT 0, + preferred_roles TEXT[] DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS video_editor_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + software_expertise TEXT[] DEFAULT '{}', + experience_years INTEGER DEFAULT 0, + hourly_rate INTEGER DEFAULT 0, + portfolio_url TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS graphic_designer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + design_specialties TEXT[] DEFAULT '{}', + experience_years INTEGER DEFAULT 0, + hourly_rate INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS social_media_manager_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + platforms_managed TEXT[] DEFAULT '{}', + experience_years INTEGER DEFAULT 0, + hourly_rate INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS fitness_trainer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + certifications TEXT[] DEFAULT '{}', + specializations TEXT[] DEFAULT '{}', + experience_years INTEGER DEFAULT 0, + hourly_rate INTEGER DEFAULT 0, + offers_online_sessions BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS catering_service_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + cuisine_types TEXT[] DEFAULT '{}', + min_order_amount INTEGER DEFAULT 0, + experience_years INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS ugc_content_creator_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID, + platforms TEXT[] DEFAULT '{}', + follower_count INTEGER DEFAULT 0, + niche TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Company profiles +CREATE TABLE IF NOT EXISTS company_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + company_name VARCHAR(255) NOT NULL, + registration_number VARCHAR(100), + industry VARCHAR(150), + website_url VARCHAR(255), + employee_count INTEGER, + business_type VARCHAR(100), + gst_number VARCHAR(50), + contact_name VARCHAR(255), + contact_email VARCHAR(255), + contact_phone VARCHAR(50), + address_line1 TEXT, + city VARCHAR(100), + state VARCHAR(100), + country VARCHAR(100) DEFAULT 'India', + postal_code VARCHAR(20), + status VARCHAR(50) DEFAULT 'ACTIVE', + free_job_slots INTEGER DEFAULT 3, + purchased_job_slots INTEGER DEFAULT 0, + free_contact_views INTEGER DEFAULT 10, + purchased_contact_views INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id) +); + +-- Customer profiles +CREATE TABLE IF NOT EXISTS customer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + full_name VARCHAR(255), + phone VARCHAR(50), + city VARCHAR(100), + area VARCHAR(100), + preferred_professions TEXT[] DEFAULT '{}', + active_requirement_count INTEGER DEFAULT 0, + status VARCHAR(50) DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id) +); + +-- Job seeker profiles +CREATE TABLE IF NOT EXISTS job_seeker_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + full_name VARCHAR(255), + location VARCHAR(255), + summary TEXT, + experience_years INTEGER DEFAULT 0, + skills TEXT[] DEFAULT '{}', + resume_url TEXT, + active_application_count INTEGER DEFAULT 0, + status VARCHAR(50) DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id) +); + +-- Jobs table +CREATE TABLE IF NOT EXISTS jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id UUID REFERENCES company_profiles(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + description TEXT, + requirements TEXT, + location VARCHAR(255), + job_type VARCHAR(50), + experience_min INTEGER DEFAULT 0, + experience_max INTEGER DEFAULT 0, + salary_min INTEGER, + salary_max INTEGER, + status VARCHAR(50) DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Job applications table +CREATE TABLE IF NOT EXISTS job_applications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + applicant_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + cover_letter TEXT, + resume_url TEXT, + status VARCHAR(50) DEFAULT 'PENDING', + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create user_role_profiles first (needed by leads table) +CREATE TABLE IF NOT EXISTS user_role_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_key VARCHAR(50) NOT NULL, + display_name TEXT, + bio TEXT, + location TEXT, + avatar_url TEXT, + phone TEXT, + email TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + verification_status VARCHAR(50) DEFAULT 'PENDING', + approval_status VARCHAR(50) DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + verified_at TIMESTAMPTZ, + is_profile_public BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, role_key) +); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_user_id ON user_role_profiles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_role_key ON user_role_profiles(role_key); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_status ON user_role_profiles(status); + +-- Leads table (renamed from requirements) +CREATE TABLE IF NOT EXISTS leads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID REFERENCES user_role_profiles(id), + customer_id UUID REFERENCES users(id), + profession_key VARCHAR(50), + title VARCHAR(255) NOT NULL, + description TEXT, + location VARCHAR(255), + budget_min INTEGER, + budget_max INTEGER, + urgency VARCHAR(50) DEFAULT 'NORMAL', + status VARCHAR(50) DEFAULT 'OPEN', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Other essential tables +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + message TEXT NOT NULL, + notification_type VARCHAR(50), + reference_id UUID, + is_read BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID NOT NULL REFERENCES user_role_profiles(id), + reviewer_user_id UUID NOT NULL REFERENCES users(id), + rating INTEGER NOT NULL CHECK (rating >= 1 AND rating <= 5), + comment TEXT, + status VARCHAR(50) DEFAULT 'VISIBLE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tracecoin_wallets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + balance BIGINT DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tracecoin_ledger ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wallet_id UUID NOT NULL REFERENCES tracecoin_wallets(id), + transaction_type VARCHAR(50) NOT NULL, + amount BIGINT NOT NULL, + balance_after BIGINT NOT NULL, + reference_type VARCHAR(50), + reference_id UUID, + description TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS services ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID REFERENCES user_role_profiles(id), + service_name VARCHAR(255) NOT NULL, + description TEXT, + price INTEGER, + price_type VARCHAR(50) DEFAULT 'FIXED', + duration_minutes INTEGER, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS pricing_packages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID REFERENCES user_role_profiles(id), + name VARCHAR(255) NOT NULL, + description TEXT, + price INTEGER NOT NULL, + duration_days INTEGER, + features JSONB, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS coupons ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(50) NOT NULL UNIQUE, + discount_type VARCHAR(20) NOT NULL, + discount_value INTEGER NOT NULL, + min_order_amount INTEGER DEFAULT 0, + max_uses INTEGER, + used_count INTEGER DEFAULT 0, + valid_from TIMESTAMPTZ, + valid_until TIMESTAMPTZ, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS coupon_redemptions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + coupon_id UUID NOT NULL REFERENCES coupons(id), + user_id UUID NOT NULL REFERENCES users(id), + redeemed_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS discounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + package_id UUID NOT NULL REFERENCES pricing_packages(id), + discount_type VARCHAR(20) NOT NULL, + discount_value INTEGER NOT NULL, + valid_from TIMESTAMPTZ, + valid_until TIMESTAMPTZ, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + amount INTEGER NOT NULL, + currency VARCHAR(10) DEFAULT 'INR', + payment_method VARCHAR(50), + payment_status VARCHAR(50) DEFAULT 'PENDING', + reference_id VARCHAR(255), + transaction_id VARCHAR(255), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + invoice_number VARCHAR(50) NOT NULL UNIQUE, + amount INTEGER NOT NULL, + status VARCHAR(50) DEFAULT 'DRAFT', + issued_at TIMESTAMPTZ, + due_at TIMESTAMPTZ, + paid_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS portfolio_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID REFERENCES user_role_profiles(id), + title VARCHAR(255) NOT NULL, + description TEXT, + media_url TEXT, + media_type VARCHAR(50), + display_order INTEGER DEFAULT 0, + is_featured BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS verification_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID REFERENCES user_role_profiles(id), + verification_type VARCHAR(50) NOT NULL DEFAULT 'IDENTITY', + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + reviewed_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS verification_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_request_id UUID NOT NULL REFERENCES verification_requests(id) ON DELETE CASCADE, + document_type VARCHAR(100) NOT NULL, + file_url TEXT NOT NULL, + file_name TEXT, + mime_type TEXT, + status VARCHAR(50) DEFAULT 'PENDING', + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + reviewed_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS verification_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_request_id UUID NOT NULL REFERENCES verification_requests(id), + action VARCHAR(50) NOT NULL, + old_status VARCHAR(50), + new_status VARCHAR(50), + acted_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS verifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID NOT NULL REFERENCES user_role_profiles(id), + verification_type VARCHAR(50) NOT NULL, + document_url TEXT, + status VARCHAR(50) DEFAULT 'PENDING', + verified_at TIMESTAMPTZ, + verified_by UUID REFERENCES users(id), + rejection_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS user_settings ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + theme VARCHAR(20) DEFAULT 'LIGHT', + language VARCHAR(10) DEFAULT 'en', + timezone VARCHAR(50) DEFAULT 'Asia/Kolkata', + email_notifications BOOLEAN DEFAULT true, + push_notifications BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS account_deletion_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + reason TEXT, + scheduled_deletion_at TIMESTAMPTZ, + status VARCHAR(50) DEFAULT 'PENDING', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS activity_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(50), + entity_id UUID, + metadata JSONB, + ip_address TEXT, + user_agent TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS email_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + email_type VARCHAR(50) NOT NULL, + recipient_email VARCHAR(255) NOT NULL, + subject VARCHAR(255), + status VARCHAR(50) DEFAULT 'SENT', + error_message TEXT, + sent_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- KB tables +CREATE TABLE IF NOT EXISTS kb_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + display_order INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS kb_articles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category_id UUID REFERENCES kb_categories(id) ON DELETE SET NULL, + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + content TEXT, + summary TEXT, + tags TEXT[], + views_count INTEGER DEFAULT 0, + is_published BOOLEAN DEFAULT false, + is_featured BOOLEAN DEFAULT false, + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Support tables +CREATE TABLE IF NOT EXISTS support_tickets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + subject VARCHAR(255) NOT NULL, + description TEXT, + priority VARCHAR(20) DEFAULT 'MEDIUM', + status VARCHAR(50) DEFAULT 'OPEN', + assigned_to UUID REFERENCES users(id), + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS support_ticket_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE, + sender_id UUID NOT NULL REFERENCES users(id), + message TEXT NOT NULL, + is_internal BOOLEAN DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Config tables +CREATE TABLE IF NOT EXISTS runtime_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + config_key VARCHAR(100) NOT NULL UNIQUE, + config_value JSONB NOT NULL, + description TEXT, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS onboarding_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_key VARCHAR(50) NOT NULL, + steps JSONB NOT NULL, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS dashboard_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_key VARCHAR(50) NOT NULL, + audience VARCHAR(50) DEFAULT 'USER', + widgets JSONB NOT NULL, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_roles_role_id ON user_roles(role_id); +CREATE INDEX IF NOT EXISTS idx_user_roles_status ON user_roles(status); +CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); +CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications(is_read); +CREATE INDEX IF NOT EXISTS idx_reviews_user_role_profile_id ON reviews(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_services_user_role_profile_id ON services(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_pricing_packages_user_role_profile_id ON pricing_packages(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_portfolio_items_user_role_profile_id ON portfolio_items(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); +CREATE INDEX IF NOT EXISTS idx_jobs_company_id ON jobs(company_id); +CREATE INDEX IF NOT EXISTS idx_job_applications_job_id ON job_applications(job_id); +CREATE INDEX IF NOT EXISTS idx_job_applications_applicant ON job_applications(applicant_user_id); +CREATE INDEX IF NOT EXISTS idx_leads_status ON leads(status); +CREATE INDEX IF NOT EXISTS idx_leads_user_role_profile_id ON leads(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_tracecoin_ledger_wallet_id ON tracecoin_ledger(wallet_id); +CREATE INDEX IF NOT EXISTS idx_coupons_code ON coupons(code); +CREATE INDEX IF NOT EXISTS idx_support_tickets_user_id ON support_tickets(user_id); +CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status); +CREATE INDEX IF NOT EXISTS idx_activity_logs_user_id ON activity_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at ON activity_logs(created_at); +CREATE INDEX IF NOT EXISTS idx_email_logs_user_id ON email_logs(user_id); +CREATE INDEX IF NOT EXISTS idx_email_logs_sent_at ON email_logs(sent_at); +CREATE INDEX IF NOT EXISTS idx_kb_articles_category_id ON kb_articles(category_id); +CREATE INDEX IF NOT EXISTS idx_kb_articles_slug ON kb_articles(slug); +CREATE INDEX IF NOT EXISTS idx_verification_requests_user_role_profile_id ON verification_requests(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_verifications_user_role_profile_id ON verifications(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_user_settings_user_id ON user_settings(user_id); + +-- Insert default roles +INSERT INTO roles (id, name, code, description, audience, is_active) VALUES + ('00000000-0000-0000-0000-000000000001', 'Super Admin', 'SUPER_ADMIN', 'Full system access', 'ADMIN', true), + ('00000000-0000-0000-0000-000000000002', 'Admin', 'ADMIN', 'Administrative access', 'ADMIN', true), + ('00000000-0000-0000-0000-000000000003', 'User', 'USER', 'Regular user', 'USER', true), + ('00000000-0000-0000-0000-000000000004', 'Company', 'COMPANY', 'Company account', 'BUSINESS', true), + ('00000000-0000-0000-0000-000000000005', 'Customer', 'CUSTOMER', 'Customer account', 'USER', true) +ON CONFLICT (code) DO NOTHING; + +COMMIT; diff --git a/crates/db/migrations_new/002_pricing_and_leads.up.sql b/crates/db/migrations_new/002_pricing_and_leads.up.sql new file mode 100644 index 0000000..4e5e494 --- /dev/null +++ b/crates/db/migrations_new/002_pricing_and_leads.up.sql @@ -0,0 +1,285 @@ +-- ============================================================================ +-- MIGRATION: Complete Schema Updates for Pricing & Lead Requests +-- ============================================================================ + +BEGIN; + +-- ============================================================================ +-- 1. Update pricing_packages table with new columns +-- ============================================================================ + +ALTER TABLE pricing_packages + DROP COLUMN IF EXISTS user_role_profile_id, + ADD COLUMN IF NOT EXISTS package_type VARCHAR(50) NOT NULL DEFAULT 'TRACECOIN_BUNDLE', + ADD COLUMN IF NOT EXISTS applicable_roles TEXT[] DEFAULT '{}', + ADD COLUMN IF NOT EXISTS tracecoins_amount INTEGER DEFAULT 0, + ADD COLUMN IF NOT EXISTS valid_from TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS valid_until TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS is_promotional BOOLEAN DEFAULT false; + +-- Add index for package lookup +CREATE INDEX IF NOT EXISTS idx_pricing_packages_type ON pricing_packages(package_type); + +-- ============================================================================ +-- 2. Update payments table with new columns +-- ============================================================================ + +ALTER TABLE payments + ADD COLUMN IF NOT EXISTS package_id UUID, + ADD COLUMN IF NOT EXISTS razorpay_order_id VARCHAR(255), + ADD COLUMN IF NOT EXISTS razorpay_payment_id VARCHAR(255), + ADD COLUMN IF NOT EXISTS tracecoins_credited INTEGER DEFAULT 0, + ADD COLUMN IF NOT EXISTS verified_at TIMESTAMPTZ; + +-- Add foreign key if not exists +ALTER TABLE payments + ADD CONSTRAINT payments_package_id_fkey + FOREIGN KEY (package_id) REFERENCES pricing_packages(id) ON DELETE SET NULL; + +CREATE INDEX IF NOT EXISTS idx_payments_package_id ON payments(package_id); +CREATE INDEX IF NOT EXISTS idx_payments_razorpay_order_id ON payments(razorpay_order_id); + +-- ============================================================================ +-- 3. Create lead_requests table +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS lead_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + lead_id UUID NOT NULL REFERENCES leads(id) ON DELETE CASCADE, + user_role_profile_id UUID NOT NULL REFERENCES user_role_profiles(id) ON DELETE CASCADE, + customer_user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + tracecoins_reserved INTEGER NOT NULL DEFAULT 25, + message TEXT, + expires_at TIMESTAMPTZ NOT NULL, + accepted_at TIMESTAMPTZ, + rejected_at TIMESTAMPTZ, + rejected_reason TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_lead_requests_lead_id ON lead_requests(lead_id); +CREATE INDEX IF NOT EXISTS idx_lead_requests_user_role_profile_id ON lead_requests(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_lead_requests_customer_user_id ON lead_requests(customer_user_id); +CREATE INDEX IF NOT EXISTS idx_lead_requests_status ON lead_requests(status); +CREATE INDEX IF NOT EXISTS idx_lead_requests_expires_at ON lead_requests(expires_at); + +-- ============================================================================ +-- 4. Add display_code to company_profiles +-- ============================================================================ + +ALTER TABLE company_profiles + ADD COLUMN IF NOT EXISTS display_code VARCHAR(20) UNIQUE; + +-- ============================================================================ +-- 5. Add display_code to customer_profiles +-- ============================================================================ + +ALTER TABLE customer_profiles + ADD COLUMN IF NOT EXISTS display_code VARCHAR(20) UNIQUE; + +-- ============================================================================ +-- 6. Add display_code to job_seeker_profiles +-- ============================================================================ + +ALTER TABLE job_seeker_profiles + ADD COLUMN IF NOT EXISTS display_code VARCHAR(20) UNIQUE; + +-- ============================================================================ +-- 7. Add free_requirement_slots and purchased_requirement_slots to customer_profiles +-- ============================================================================ + +ALTER TABLE customer_profiles + ADD COLUMN IF NOT EXISTS free_requirement_slots INTEGER DEFAULT 2, + ADD COLUMN IF NOT EXISTS purchased_requirement_slots INTEGER DEFAULT 0; + +-- ============================================================================ +-- 8. Update leads table with new columns +-- ============================================================================ + +ALTER TABLE leads + ADD COLUMN IF NOT EXISTS customer_user_id UUID REFERENCES users(id), + ADD COLUMN IF NOT EXISTS max_acceptances INTEGER DEFAULT 10, + ADD COLUMN IF NOT EXISTS current_acceptances INTEGER DEFAULT 0, + ADD COLUMN IF NOT EXISTS expires_at TIMESTAMPTZ, + ADD COLUMN IF NOT EXISTS cover_image_url TEXT; + +-- ============================================================================ +-- 9. Add tracecoins_reserved column to lead_requests for proper tracking +-- ============================================================================ + +-- Already added in step 3 + +-- ============================================================================ +-- 10. Create function to auto-generate display codes +-- ============================================================================ + +CREATE OR REPLACE FUNCTION generate_display_code(prefix VARCHAR(10)) +RETURNS VARCHAR(20) AS $$ +DECLARE + new_code VARCHAR(20); + seq_num INTEGER; +BEGIN + -- Get the next sequence number for this prefix + SELECT COALESCE( + (SELECT MAX(CAST(SUBSTRING(code FROM 4) AS INTEGER)) + 1 + FROM ( + SELECT display_code as code FROM company_profiles WHERE display_code LIKE prefix || '%' + UNION ALL + SELECT display_code as code FROM customer_profiles WHERE display_code LIKE prefix || '%' + UNION ALL + SELECT display_code as code FROM job_seeker_profiles WHERE display_code LIKE prefix || '%' + ) all_codes + ), + 1 + ) INTO seq_num; + + new_code := prefix || LPAD(seq_num::TEXT, 4, '0'); + RETURN new_code; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- 11. Create indexes for performance +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_leads_customer_user_id ON leads(customer_user_id); +CREATE INDEX IF NOT EXISTS idx_leads_status_expires ON leads(status, expires_at); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_role_key ON user_role_profiles(role_key); + +-- ============================================================================ +-- 12. Create tracecoin ledger entry types enum support +-- ============================================================================ + +-- Add transaction_type values for ledger tracking +-- The ledger already has 'type' column, ensure we have proper entries + +-- ============================================================================ +-- 13. Add indexes for verification queries +-- ============================================================================ + +CREATE INDEX IF NOT EXISTS idx_verifications_user_role_profile_id ON verifications(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_verifications_status ON verifications(status); +CREATE INDEX IF NOT EXISTS idx_verification_requests_user_role_profile_id ON verification_requests(user_role_profile_id); + +-- ============================================================================ +-- 14. Add tracecoin refund support to ledger +-- ============================================================================ + +-- Reserve tracecoins for lead request +CREATE OR REPLACE FUNCTION reserve_tracecoins_for_lead_request( + p_user_id UUID, + p_amount INTEGER, + p_lead_request_id UUID +) RETURNS BOOLEAN AS $$ +DECLARE + v_wallet_id UUID; + v_balance BIGINT; +BEGIN + -- Get wallet + SELECT id, balance INTO v_wallet_id, v_balance + FROM tracecoin_wallets + WHERE user_id = p_user_id + FOR UPDATE; + + IF v_wallet_id IS NULL THEN + RETURN FALSE; + END IF; + + IF v_balance < p_amount THEN + RETURN FALSE; + END IF; + + -- Deduct from balance (reserved, not yet spent) + UPDATE tracecoin_wallets + SET balance = balance - p_amount, + reserved = COALESCE(reserved, 0) + p_amount, + updated_at = NOW() + WHERE id = v_wallet_id; + + -- Add ledger entry + INSERT INTO tracecoin_ledger (wallet_id, type, amount, balance_after, reference_type, reference_id, reason) + VALUES (v_wallet_id, 'RESERVE', -p_amount, v_balance - p_amount, 'LEAD_REQUEST', p_lead_request_id, 'Lead request reservation'); + + RETURN TRUE; +END; +$$ LANGUAGE plpgsql; + +-- Confirm tracecoins (when customer accepts) +CREATE OR REPLACE FUNCTION confirm_tracecoins_for_lead( + p_user_id UUID, + p_amount INTEGER, + p_lead_request_id UUID +) RETURNS BOOLEAN AS $$ +DECLARE + v_wallet_id UUID; + v_balance BIGINT; +BEGIN + -- Get wallet + SELECT id, balance, COALESCE(reserved, 0) INTO v_wallet_id, v_balance, v_balance + FROM tracecoin_wallets + WHERE user_id = p_user_id + FOR UPDATE; + + IF v_wallet_id IS NULL THEN + RETURN FALSE; + END IF; + + -- Move from reserved to spent + UPDATE tracecoin_wallets + SET reserved = reserved - p_amount, + updated_at = NOW() + WHERE id = v_wallet_id; + + -- Add ledger entry for confirmation + INSERT INTO tracecoin_ledger (wallet_id, type, amount, balance_after, reference_type, reference_id, reason) + VALUES (v_wallet_id, 'SPEND', -p_amount, v_balance, 'LEAD_REQUEST', p_lead_request_id, 'Lead request accepted'); + + RETURN TRUE; +END; +$$ LANGUAGE plpgsql; + +-- Release tracecoins (when customer rejects or request expires) +CREATE OR REPLACE FUNCTION release_tracecoins_for_lead( + p_user_id UUID, + p_amount INTEGER, + p_lead_request_id UUID +) RETURNS BOOLEAN AS $$ +DECLARE + v_wallet_id UUID; + v_balance BIGINT; +BEGIN + -- Get wallet + SELECT id, balance INTO v_wallet_id, v_balance + FROM tracecoin_wallets + WHERE user_id = p_user_id + FOR UPDATE; + + IF v_wallet_id IS NULL THEN + RETURN FALSE; + END IF; + + -- Return to available balance from reserved + UPDATE tracecoin_wallets + SET balance = balance + p_amount, + reserved = reserved - p_amount, + updated_at = NOW() + WHERE id = v_wallet_id; + + -- Add ledger entry for release + INSERT INTO tracecoin_ledger (wallet_id, type, amount, balance_after, reference_type, reference_id, reason) + VALUES (v_wallet_id, 'RELEASE', p_amount, v_balance + p_amount, 'LEAD_REQUEST', p_lead_request_id, 'Lead request rejected/expired'); + + RETURN TRUE; +END; +$$ LANGUAGE plpgsql; + +-- ============================================================================ +-- 15. Update tracecoin_wallets to have reserved column +-- ============================================================================ + +ALTER TABLE tracecoin_wallets + ADD COLUMN IF NOT EXISTS reserved BIGINT DEFAULT 0; + +COMMIT; diff --git a/crates/db/migrations_new/20260415000000_complete_migration.up.sql.skip b/crates/db/migrations_new/20260415000000_complete_migration.up.sql.skip new file mode 100644 index 0000000..ae0672c --- /dev/null +++ b/crates/db/migrations_new/20260415000000_complete_migration.up.sql.skip @@ -0,0 +1,618 @@ +-- ============================================================================ +-- Nxtgauge Database Complete Migration +-- Version: 20260415000000 +-- This migration performs a COMPLETE schema transformation +-- NO FALLBACKS - This is a one-way migration +-- ============================================================================ + +BEGIN; + +-- ============================================================================ +-- PHASE 1: Create New Core Tables +-- ============================================================================ + +-- 1.1 user_sessions (new) +CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_token TEXT UNIQUE NOT NULL, + ip_address TEXT, + user_agent TEXT, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(session_token); +CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at); + +-- 1.2 user_role_profiles (NEW ROOT - CRITICAL) +CREATE TABLE IF NOT EXISTS user_role_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_key VARCHAR(50) NOT NULL, + display_name TEXT, + bio TEXT, + location TEXT, + avatar_url TEXT, + phone TEXT, + email TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + verification_status VARCHAR(50) DEFAULT 'PENDING', + approval_status VARCHAR(50) DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + verified_at TIMESTAMPTZ, + is_profile_public BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, role_key) +); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_user_id ON user_role_profiles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_role_key ON user_role_profiles(role_key); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_status ON user_role_profiles(status); + +-- ============================================================================ +-- PHASE 2: Skip backfill for fresh database +-- ============================================================================ +-- (No data to backfill from - profile tables use user_role_profile_id directly) + +-- ============================================================================ +-- PHASE 3: Update Extension Tables to Use user_role_profile_id +-- ============================================================================ + +-- Add user_role_profile_id column to ALL extension tables (already exist in fresh schema) + +-- ============================================================================ +-- PHASE 5: Update Portfolio Tables +-- ============================================================================ + +-- Add user_role_profile_id to portfolio_items +ALTER TABLE portfolio_items ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +ALTER TABLE portfolio_items ADD COLUMN IF NOT EXISTS display_order INTEGER DEFAULT 0; + +-- Backfill portfolio_items from professionals +UPDATE portfolio_items pi SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE pi.professional_id IS NOT NULL + AND urp.user_id = (SELECT user_id FROM professionals p WHERE p.id = pi.professional_id); + +-- Update remaining using user_id +UPDATE portfolio_items pi SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE pi.user_id IS NOT NULL AND pi.user_role_profile_id IS NULL + AND EXISTS (SELECT 1 FROM user_role_profiles urp2 WHERE urp2.user_id = pi.user_id AND urp2.role_key = pi.profession_key); + +-- Add user_role_profile_id to services +ALTER TABLE services ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; + +-- Backfill services +UPDATE services s SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE s.professional_id IS NOT NULL + AND urp.user_id = (SELECT user_id FROM professionals p WHERE p.id = s.professional_id); + +UPDATE services s SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE s.user_id IS NOT NULL AND s.user_role_profile_id IS NULL + AND EXISTS (SELECT 1 FROM user_role_profiles urp2 WHERE urp2.user_id = s.user_id AND urp2.role_key = s.profession_key); + +-- ============================================================================ +-- PHASE 6: Rename Tables +-- ============================================================================ + +-- Rename applications -> job_applications +ALTER TABLE applications RENAME TO job_applications; + +-- Rename requirements -> leads +ALTER TABLE requirements RENAME TO leads; + +-- Rename coupon_uses -> coupon_redemptions +ALTER TABLE coupon_uses RENAME TO coupon_redemptions; + +-- ============================================================================ +-- PHASE 7: Create New Domain Tables +-- ============================================================================ + +-- 7.1 verification_requests +CREATE TABLE IF NOT EXISTS verification_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID REFERENCES user_role_profiles(id), + verification_type VARCHAR(50) NOT NULL DEFAULT 'IDENTITY', + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + reviewed_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.2 verification_documents +CREATE TABLE IF NOT EXISTS verification_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_request_id UUID NOT NULL REFERENCES verification_requests(id) ON DELETE CASCADE, + document_type VARCHAR(100) NOT NULL, + file_url TEXT NOT NULL, + file_name TEXT, + mime_type TEXT, + status VARCHAR(50) DEFAULT 'PENDING', + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + reviewed_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.3 verification_logs +CREATE TABLE IF NOT EXISTS verification_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_request_id UUID NOT NULL REFERENCES verification_requests(id), + action VARCHAR(50) NOT NULL, + old_status VARCHAR(50), + new_status VARCHAR(50), + acted_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.4 approval_requests +CREATE TABLE IF NOT EXISTS approval_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type VARCHAR(50) NOT NULL, + entity_id UUID NOT NULL, + approval_type VARCHAR(50), + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + submitted_by_user_id UUID REFERENCES users(id), + reviewed_by_user_id UUID REFERENCES users(id), + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.5 approval_logs +CREATE TABLE IF NOT EXISTS approval_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + approval_request_id UUID NOT NULL REFERENCES approval_requests(id), + action VARCHAR(50) NOT NULL, + old_status VARCHAR(50), + new_status VARCHAR(50), + acted_by_user_id UUID REFERENCES users(id), + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.6 audit_logs +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_user_id UUID REFERENCES users(id), + actor_employee_id UUID, + actor_type VARCHAR(50), + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(100), + entity_id UUID, + entity_label TEXT, + module_key VARCHAR(100), + source_type VARCHAR(50), + source_id UUID, + request_id UUID, + correlation_id UUID, + ip_address TEXT, + user_agent TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'SUCCESS', + summary TEXT, + metadata_json JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.7 audit_log_changes +CREATE TABLE IF NOT EXISTS audit_log_changes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + audit_log_id UUID NOT NULL REFERENCES audit_logs(id) ON DELETE CASCADE, + field_name TEXT NOT NULL, + old_value_text TEXT, + new_value_text TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs(actor_user_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs(entity_type, entity_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_module ON audit_logs(module_key); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created ON audit_logs(created_at); + +-- 7.8 orders +CREATE TABLE IF NOT EXISTS orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + order_type VARCHAR(50) NOT NULL DEFAULT 'PACKAGE', + subtotal_inr INTEGER NOT NULL DEFAULT 0, + discount_inr INTEGER NOT NULL DEFAULT 0, + tax_inr INTEGER NOT NULL DEFAULT 0, + total_inr INTEGER NOT NULL DEFAULT 0, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.9 order_items +CREATE TABLE IF NOT EXISTS order_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + item_type VARCHAR(50) NOT NULL, + item_id UUID, + item_name TEXT NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + unit_price_inr INTEGER NOT NULL, + total_price_inr INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.10 payment_gateway_configs +CREATE TABLE IF NOT EXISTS payment_gateway_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + gateway_key VARCHAR(50) NOT NULL, + display_name VARCHAR(255), + config_json JSONB, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.11 payment_transactions +CREATE TABLE IF NOT EXISTS payment_transactions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + payment_id UUID NOT NULL REFERENCES payments(id), + transaction_type VARCHAR(50) NOT NULL, + provider_reference TEXT, + request_payload_json JSONB, + response_payload_json JSONB, + status VARCHAR(50) NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.12 tax_rules +CREATE TABLE IF NOT EXISTS tax_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + tax_type VARCHAR(50) NOT NULL, + tax_rate DECIMAL(5,2) NOT NULL, + applies_to VARCHAR(50), + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.13 kb_sections +CREATE TABLE IF NOT EXISTS kb_sections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category_id UUID NOT NULL REFERENCES kb_categories(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL, + description TEXT, + display_order INTEGER DEFAULT 0, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.14 kb_article_feedback +CREATE TABLE IF NOT EXISTS kb_article_feedback ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + article_id UUID NOT NULL REFERENCES kb_articles(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id), + is_helpful BOOLEAN, + feedback_text TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.15 notification_templates +CREATE TABLE IF NOT EXISTS notification_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + template_key VARCHAR(100) NOT NULL UNIQUE, + channel VARCHAR(50) NOT NULL DEFAULT 'EMAIL', + title_template TEXT, + body_template TEXT, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.16 smtp_configs +CREATE TABLE IF NOT EXISTS smtp_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + provider_name VARCHAR(100), + host VARCHAR(255), + port INTEGER, + username TEXT, + encryption_mode VARCHAR(20), + from_name VARCHAR(255), + from_email VARCHAR(255), + is_default BOOLEAN DEFAULT false, + is_active BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- 7.17 dashboard_widgets +CREATE TABLE IF NOT EXISTS dashboard_widgets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + dashboard_config_id UUID NOT NULL REFERENCES dashboard_configs(id) ON DELETE CASCADE, + widget_key VARCHAR(100) NOT NULL, + widget_title VARCHAR(255), + config_json JSONB, + display_order INTEGER DEFAULT 0, + width_units INTEGER DEFAULT 1, + height_units INTEGER DEFAULT 1, + is_visible BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- PHASE 8: Update Existing Tables +-- ============================================================================ + +-- Update users table +ALTER TABLE users ADD COLUMN IF NOT EXISTS account_type TEXT DEFAULT 'INDIVIDUAL'; +ALTER TABLE users ADD COLUMN IF NOT EXISTS last_login_at TIMESTAMPTZ; +ALTER TABLE users ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +UPDATE users SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +-- Update roles table +ALTER TABLE roles ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS can_approve_requests BOOLEAN DEFAULT false; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS can_manage_system_settings BOOLEAN DEFAULT false; + +-- Update departments table +ALTER TABLE departments ADD COLUMN IF NOT EXISTS code VARCHAR(64); +ALTER TABLE departments ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE departments ADD COLUMN IF NOT EXISTS department_head VARCHAR(255); +ALTER TABLE departments ADD COLUMN IF NOT EXISTS department_email VARCHAR(255); +ALTER TABLE departments ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) DEFAULT 'INTERNAL'; +ALTER TABLE departments ADD COLUMN IF NOT EXISTS transfers_enabled BOOLEAN DEFAULT false; +ALTER TABLE departments ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +UPDATE departments SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +-- Update designations table +ALTER TABLE designations ADD COLUMN IF NOT EXISTS code VARCHAR(64); +ALTER TABLE designations ADD COLUMN IF NOT EXISTS department_id UUID REFERENCES departments(id); +ALTER TABLE designations ADD COLUMN IF NOT EXISTS description TEXT; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS level VARCHAR(100); +ALTER TABLE designations ADD COLUMN IF NOT EXISTS can_manage_team BOOLEAN DEFAULT false; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS can_approve BOOLEAN DEFAULT false; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS is_active BOOLEAN DEFAULT true; +ALTER TABLE designations ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +UPDATE designations SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +-- Update employees table +ALTER TABLE employees ADD COLUMN IF NOT EXISTS joining_date DATE; +ALTER TABLE employees ADD COLUMN IF NOT EXISTS employment_status VARCHAR(50) DEFAULT 'ACTIVE'; +ALTER TABLE employees ADD COLUMN IF NOT EXISTS manager_employee_id UUID REFERENCES employees(id); +ALTER TABLE employees ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +UPDATE employees SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +-- Update lead_requests table +ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE lead_requests lr SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE lr.professional_id IS NOT NULL + AND urp.user_id = (SELECT user_id FROM professionals p WHERE p.id = lr.professional_id); +ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS remarks TEXT; +ALTER TABLE lead_requests ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +-- Update job_applications table +ALTER TABLE job_applications ADD COLUMN IF NOT EXISTS applicant_user_id UUID; +UPDATE job_applications ja SET applicant_user_id = ( + SELECT user_id FROM job_seeker_profiles jsp WHERE jsp.id = ja.job_seeker_id +); +ALTER TABLE job_applications ADD COLUMN IF NOT EXISTS cover_note TEXT; +ALTER TABLE job_applications DROP COLUMN IF EXISTS job_seeker_id; +ALTER TABLE job_applications DROP COLUMN IF EXISTS cover_letter; +ALTER TABLE job_applications DROP COLUMN IF EXISTS resume_url; +ALTER TABLE job_applications DROP COLUMN IF EXISTS contact_viewed; + +-- Update leads table (formerly requirements) +ALTER TABLE leads ADD COLUMN IF NOT EXISTS created_by_user_id UUID; +UPDATE leads l SET created_by_user_id = ( + SELECT user_id FROM customer_profiles cp WHERE cp.id = l.customer_id +); +ALTER TABLE leads ADD COLUMN IF NOT EXISTS required_date DATE; +ALTER TABLE leads ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +ALTER TABLE leads DROP COLUMN IF EXISTS customer_id; + +-- Update jobs table +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS posted_by_user_id UUID; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS mode_of_work VARCHAR(50); +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS budget_inr INTEGER; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS salary_range_json JSONB; +ALTER TABLE jobs ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); +UPDATE jobs SET updated_at = COALESCE(updated_at, created_at, NOW()) WHERE updated_at IS NULL; + +-- Update tracecoin_wallets +ALTER TABLE tracecoin_wallets RENAME COLUMN balance TO current_balance; +ALTER TABLE tracecoin_wallets ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); + +-- Update tracecoin_ledger +ALTER TABLE tracecoin_ledger ADD COLUMN IF NOT EXISTS balance_after INTEGER; +ALTER TABLE tracecoin_ledger ADD COLUMN IF NOT EXISTS remarks TEXT; +ALTER TABLE tracecoin_ledger RENAME COLUMN type TO transaction_type; +ALTER TABLE tracecoin_ledger RENAME COLUMN reason TO reference_type; + +-- Update coupons +ALTER TABLE coupons ADD COLUMN IF NOT EXISTS max_discount_inr INTEGER; +ALTER TABLE coupons ADD COLUMN IF NOT EXISTS min_order_value_inr INTEGER DEFAULT 0; +ALTER TABLE coupons ADD COLUMN IF NOT EXISTS valid_from TIMESTAMPTZ DEFAULT NOW(); +ALTER TABLE coupons ADD COLUMN IF NOT EXISTS valid_to TIMESTAMPTZ; + +-- Update coupon_redemptions +ALTER TABLE coupon_redemptions ADD COLUMN IF NOT EXISTS order_id UUID REFERENCES orders(id); +ALTER TABLE coupon_redemptions ADD COLUMN IF NOT EXISTS discount_amount_inr INTEGER; +ALTER TABLE coupon_redemptions RENAME COLUMN used_at TO redeemed_at; + +-- Update invoices +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS order_id UUID REFERENCES orders(id); +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS discount_inr INTEGER DEFAULT 0; +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS due_at TIMESTAMPTZ; +ALTER TABLE invoices ADD COLUMN IF NOT EXISTS paid_at TIMESTAMPTZ; + +-- Update payments +ALTER TABLE payments ADD COLUMN IF NOT EXISTS payment_gateway_config_id UUID REFERENCES payment_gateway_configs(id); +ALTER TABLE payments ADD COLUMN IF NOT EXISTS payment_method VARCHAR(50); +ALTER TABLE payments ADD COLUMN IF NOT EXISTS currency_code VARCHAR(10) DEFAULT 'INR'; +ALTER TABLE payments ADD COLUMN IF NOT EXISTS initiated_at TIMESTAMPTZ DEFAULT NOW(); +ALTER TABLE payments ADD COLUMN IF NOT EXISTS completed_at TIMESTAMPTZ; + +-- Update kb_articles +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS section_id UUID REFERENCES kb_sections(id); +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS article_type VARCHAR(50) DEFAULT 'HOW_TO'; +ALTER TABLE kb_articles ADD COLUMN IF NOT EXISTS audience_type VARCHAR(50) DEFAULT 'ALL'; +ALTER TABLE kb_articles RENAME COLUMN body TO content_markdown; +ALTER TABLE kb_articles RENAME COLUMN created_by TO author_user_id; + +-- Update support_tickets +ALTER TABLE support_tickets ADD COLUMN IF NOT EXISTS created_by_user_id UUID; +UPDATE support_tickets SET created_by_user_id = user_id; +ALTER TABLE support_tickets RENAME COLUMN assigned_to TO assigned_to_user_id; +ALTER TABLE support_tickets ADD COLUMN IF NOT EXISTS related_entity_type VARCHAR(50); +ALTER TABLE support_tickets ADD COLUMN IF NOT EXISTS related_entity_id UUID; +ALTER TABLE support_tickets ADD COLUMN IF NOT EXISTS closed_at TIMESTAMPTZ; + +-- Update support_ticket_messages +ALTER TABLE support_ticket_messages ADD COLUMN IF NOT EXISTS sender_user_id UUID; +UPDATE support_ticket_messages SET sender_user_id = sender_id; +ALTER TABLE support_ticket_messages RENAME COLUMN body TO message_body; +ALTER TABLE support_ticket_messages ADD COLUMN IF NOT EXISTS attachment_url TEXT; + +-- Update notifications +ALTER TABLE notifications ADD COLUMN IF NOT EXISTS channel VARCHAR(50) DEFAULT 'IN_APP'; +ALTER TABLE notifications ADD COLUMN IF NOT EXISTS related_entity_type VARCHAR(50); +ALTER TABLE notifications RENAME COLUMN reference_id TO related_entity_id; + +-- Update reviews +ALTER TABLE reviews ADD COLUMN IF NOT EXISTS entity_type VARCHAR(50) DEFAULT 'professional'; +ALTER TABLE reviews RENAME COLUMN customer_id TO reviewer_user_id; +ALTER TABLE reviews ADD COLUMN IF NOT EXISTS status VARCHAR(50) DEFAULT 'PUBLISHED'; + +-- ============================================================================ +-- PHASE 9: Drop Deprecated Tables +-- ============================================================================ + +DROP TABLE IF EXISTS professionals CASCADE; +DROP TABLE IF EXISTS onboarding_submissions CASCADE; +DROP TABLE IF EXISTS onboarding_configs CASCADE; +DROP TABLE IF EXISTS onboarding_states CASCADE; +DROP TABLE IF EXISTS submission_documents CASCADE; + +-- ============================================================================ +-- PHASE 10: Drop Deprecated Columns from Extension Tables +-- ============================================================================ + +-- Drop old user_id columns from extension tables (AFTER backfilling user_role_profile_id) +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS user_id; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS user_id; + +-- Drop old columns from portfolio_items +ALTER TABLE portfolio_items DROP COLUMN IF EXISTS professional_id; +ALTER TABLE portfolio_items DROP COLUMN IF EXISTS user_id; +ALTER TABLE portfolio_items DROP COLUMN IF EXISTS profession_key; + +-- Drop old columns from services +ALTER TABLE services DROP COLUMN IF EXISTS professional_id; +ALTER TABLE services DROP COLUMN IF EXISTS user_id; +ALTER TABLE services DROP COLUMN IF EXISTS profession_key; + +-- Drop old columns from lead_requests +ALTER TABLE lead_requests DROP COLUMN IF EXISTS professional_id; +ALTER TABLE lead_requests DROP COLUMN IF EXISTS requirement_id; + +-- Drop old custom_data columns +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS custom_data; + +-- Drop old profile columns that are now in user_role_profiles +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS approved_at; + +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS display_name; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS bio; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS location; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS status; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS rejection_reason; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS approved_at; + +COMMIT; + +-- ============================================================================ +-- Migration Complete +-- ============================================================================ diff --git a/crates/db/migrations_new/20260415000001_create_user_sessions.down.sql b/crates/db/migrations_new/20260415000001_create_user_sessions.down.sql new file mode 100644 index 0000000..141c136 --- /dev/null +++ b/crates/db/migrations_new/20260415000001_create_user_sessions.down.sql @@ -0,0 +1,2 @@ +-- Rollback: Drop user_sessions table +DROP TABLE IF EXISTS user_sessions; diff --git a/crates/db/migrations_new/20260415000001_create_user_sessions.up.sql b/crates/db/migrations_new/20260415000001_create_user_sessions.up.sql new file mode 100644 index 0000000..150980b --- /dev/null +++ b/crates/db/migrations_new/20260415000001_create_user_sessions.up.sql @@ -0,0 +1,16 @@ +-- Phase 1.1: Create user_sessions table +-- Migration: 20260415000001 + +CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_token TEXT UNIQUE NOT NULL, + ip_address TEXT, + user_agent TEXT, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(session_token); +CREATE INDEX IF NOT EXISTS idx_user_sessions_expires ON user_sessions(expires_at); diff --git a/crates/db/migrations_new/20260415010001_create_user_role_profiles.down.sql b/crates/db/migrations_new/20260415010001_create_user_role_profiles.down.sql new file mode 100644 index 0000000..532bb47 --- /dev/null +++ b/crates/db/migrations_new/20260415010001_create_user_role_profiles.down.sql @@ -0,0 +1,3 @@ +-- Rollback: Drop user_role_profiles table +-- WARNING: This will fail if data exists and FK constraints are in place +DROP TABLE IF EXISTS user_role_profiles CASCADE; diff --git a/crates/db/migrations_new/20260415010001_create_user_role_profiles.up.sql b/crates/db/migrations_new/20260415010001_create_user_role_profiles.up.sql new file mode 100644 index 0000000..58ef62d --- /dev/null +++ b/crates/db/migrations_new/20260415010001_create_user_role_profiles.up.sql @@ -0,0 +1,31 @@ +-- Phase 2.1: Create user_role_profiles root table (CRITICAL) +-- Migration: 20260415010001 +-- This is the ROOT table for all user role profiles + +CREATE TABLE IF NOT EXISTS user_role_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_key VARCHAR(50) NOT NULL, + display_name TEXT, + bio TEXT, + location TEXT, + avatar_url TEXT, + phone TEXT, + email TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + verification_status VARCHAR(50) DEFAULT 'PENDING', + approval_status VARCHAR(50) DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + verified_at TIMESTAMPTZ, + is_profile_public BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, role_key) +); + +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_user_id ON user_role_profiles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_role_key ON user_role_profiles(role_key); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_status ON user_role_profiles(status); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_verification ON user_role_profiles(verification_status); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_approval ON user_role_profiles(approval_status); diff --git a/crates/db/migrations_new/20260415010002_backfill_user_role_profiles.down.sql b/crates/db/migrations_new/20260415010002_backfill_user_role_profiles.down.sql new file mode 100644 index 0000000..bb0ee89 --- /dev/null +++ b/crates/db/migrations_new/20260415010002_backfill_user_role_profiles.down.sql @@ -0,0 +1,2 @@ +-- Rollback: Clear backfilled data (run before dropping user_role_profiles) +DELETE FROM user_role_profiles WHERE created_at > '2024-04-15'; diff --git a/crates/db/migrations_new/20260415010003_add_user_role_profile_id.down.sql b/crates/db/migrations_new/20260415010003_add_user_role_profile_id.down.sql new file mode 100644 index 0000000..c5d1c76 --- /dev/null +++ b/crates/db/migrations_new/20260415010003_add_user_role_profile_id.down.sql @@ -0,0 +1,23 @@ +-- Rollback: Remove user_role_profile_id columns +-- WARNING: This will fail if FK constraints exist +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS user_role_profile_id; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS user_role_profile_id; + +DROP INDEX IF EXISTS idx_photographer_profiles_user_role; +DROP INDEX IF EXISTS idx_tutor_profiles_user_role; +DROP INDEX IF EXISTS idx_makeup_artist_profiles_user_role; +DROP INDEX IF EXISTS idx_developer_profiles_user_role; +DROP INDEX IF EXISTS idx_video_editor_profiles_user_role; +DROP INDEX IF EXISTS idx_graphic_designer_profiles_user_role; +DROP INDEX IF EXISTS idx_social_media_manager_profiles_user_role; +DROP INDEX IF EXISTS idx_fitness_trainer_profiles_user_role; +DROP INDEX IF EXISTS idx_catering_service_profiles_user_role; +DROP INDEX IF EXISTS idx_ugc_content_creator_profiles_user_role; diff --git a/crates/db/migrations_new/20260415010003_add_user_role_profile_id.up.sql.skip b/crates/db/migrations_new/20260415010003_add_user_role_profile_id.up.sql.skip new file mode 100644 index 0000000..fec1959 --- /dev/null +++ b/crates/db/migrations_new/20260415010003_add_user_role_profile_id.up.sql.skip @@ -0,0 +1,85 @@ +-- Phase 2.3: Add user_role_profile_id to extension tables +-- Migration: 20260415010003 +-- This links existing extension tables to the new user_role_profiles root + +-- photographer_profiles +ALTER TABLE photographer_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE photographer_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'photographer'; +ALTER TABLE photographer_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- tutor_profiles +ALTER TABLE tutor_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE tutor_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'tutor'; +ALTER TABLE tutor_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- makeup_artist_profiles +ALTER TABLE makeup_artist_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE makeup_artist_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'makeup_artist'; +ALTER TABLE makeup_artist_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- developer_profiles +ALTER TABLE developer_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE developer_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'developer'; +ALTER TABLE developer_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- video_editor_profiles +ALTER TABLE video_editor_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE video_editor_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'video_editor'; +ALTER TABLE video_editor_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- graphic_designer_profiles +ALTER TABLE graphic_designer_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE graphic_designer_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'graphic_designer'; +ALTER TABLE graphic_designer_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- social_media_manager_profiles +ALTER TABLE social_media_manager_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE social_media_manager_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'social_media_manager'; +ALTER TABLE social_media_manager_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- fitness_trainer_profiles +ALTER TABLE fitness_trainer_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE fitness_trainer_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'fitness_trainer'; +ALTER TABLE fitness_trainer_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- catering_service_profiles +ALTER TABLE catering_service_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE catering_service_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'catering_service'; +ALTER TABLE catering_service_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- ugc_content_creator_profiles +ALTER TABLE ugc_content_creator_profiles ADD COLUMN IF NOT EXISTS user_role_profile_id UUID; +UPDATE ugc_content_creator_profiles p SET user_role_profile_id = urp.id +FROM user_role_profiles urp +WHERE p.user_id = urp.user_id AND urp.role_key = 'ugc_content_creator'; +ALTER TABLE ugc_content_creator_profiles ALTER COLUMN user_role_profile_id SET NOT NULL; + +-- Create indexes +CREATE INDEX IF NOT EXISTS idx_photographer_profiles_user_role ON photographer_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_tutor_profiles_user_role ON tutor_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_makeup_artist_profiles_user_role ON makeup_artist_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_developer_profiles_user_role ON developer_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_video_editor_profiles_user_role ON video_editor_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_graphic_designer_profiles_user_role ON graphic_designer_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_social_media_manager_profiles_user_role ON social_media_manager_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_fitness_trainer_profiles_user_role ON fitness_trainer_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_catering_service_profiles_user_role ON catering_service_profiles(user_role_profile_id); +CREATE INDEX IF NOT EXISTS idx_ugc_content_creator_profiles_user_role ON ugc_content_creator_profiles(user_role_profile_id); diff --git a/crates/db/migrations_new/20260415010004_remove_external_links.down.sql b/crates/db/migrations_new/20260415010004_remove_external_links.down.sql new file mode 100644 index 0000000..c6d9593 --- /dev/null +++ b/crates/db/migrations_new/20260415010004_remove_external_links.down.sql @@ -0,0 +1,3 @@ +-- Rollback: Cannot easily restore removed columns +-- This migration is NOT easily reversible +-- Only run after full backup and testing diff --git a/crates/db/migrations_new/20260415010004_remove_external_links.up.sql.skip b/crates/db/migrations_new/20260415010004_remove_external_links.up.sql.skip new file mode 100644 index 0000000..334c090 --- /dev/null +++ b/crates/db/migrations_new/20260415010004_remove_external_links.up.sql.skip @@ -0,0 +1,31 @@ +-- Phase 2.4: Remove forbidden external portfolio links +-- Migration: 20260415010004 +-- Per source of truth: NO external portfolio links allowed + +-- Remove github_url, portfolio_url from developer_profiles +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS github_url; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS portfolio_url; + +-- Remove reel_url from video_editor_profiles +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS reel_url; + +-- Remove portfolio_url from graphic_designer_profiles +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS portfolio_url; + +-- Remove portfolio_url from photographer_profiles +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS portfolio_url; + +-- Remove custom_data from all extension tables (preserve as JSONB if needed) +ALTER TABLE photographer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE tutor_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE makeup_artist_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE developer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE video_editor_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE graphic_designer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE social_media_manager_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE fitness_trainer_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE catering_service_profiles DROP COLUMN IF EXISTS custom_data; +ALTER TABLE ugc_content_creator_profiles DROP COLUMN IF EXISTS custom_data; + +-- Rename inconsistent columns +ALTER TABLE tutor_profiles RENAME COLUMN subjects_taught TO subjects; From e106dff5c3063b482a130f4064fcca0b7bcbb2fa Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Mon, 13 Apr 2026 12:08:47 +0200 Subject: [PATCH 015/182] chore: retrigger woodpecker From f5130569e5dedb695dfc750f22749fc8e06f96be Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Mon, 13 Apr 2026 16:04:55 +0200 Subject: [PATCH 016/182] fix: migrate route params to Axum 0.7+ syntax ({id} instead of :id) - apps/jobs/src/main.rs: Update /jobs/:id to /jobs/{id} - apps/leads/src/main.rs: Update /leads/:id to /leads/{id} --- apps/jobs/src/main.rs | 2 +- apps/leads/src/main.rs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/apps/jobs/src/main.rs b/apps/jobs/src/main.rs index efd6519..b171306 100644 --- a/apps/jobs/src/main.rs +++ b/apps/jobs/src/main.rs @@ -119,7 +119,7 @@ async fn main() { .route("/health", get(health)) .route("/jobs", get(list_jobs)) .route("/jobs", post(create_job)) - .route("/jobs/:id", get(get_job)) + .route("/jobs/{id}", get(get_job)) .layer(cors) .with_state(state); diff --git a/apps/leads/src/main.rs b/apps/leads/src/main.rs index 8cb4f89..0d24782 100644 --- a/apps/leads/src/main.rs +++ b/apps/leads/src/main.rs @@ -121,7 +121,7 @@ async fn main() { .route("/health", get(health)) .route("/leads", get(list_leads)) .route("/leads", post(create_lead)) - .route("/leads/:id", get(get_lead)) + .route("/leads/{id}", get(get_lead)) .nest("/api/lead-requests", lead_requests::router()) .layer(cors) .with_state(state); From 231ff9530f5b91007d547e8787d22a07fe26e116 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Mon, 13 Apr 2026 16:55:09 +0200 Subject: [PATCH 017/182] fix(auth): use 'name' column instead of 'full_name', combine first_name + last_name - Replace full_name with name in User struct and all queries - RegisterPayload now takes first_name + last_name instead of full_name - Combine first_name and last_name into name before saving to DB - Update all response structs to use 'name' field instead of 'full_name' - Fix support and dashboard queries to use u.name instead of u.full_name Root cause: DB has 'name' column, code was using 'full_name' which doesn't exist. --- apps/users/src/handlers/admin.rs | 16 ++++++------ apps/users/src/handlers/approvals.rs | 10 +++---- apps/users/src/handlers/auth.rs | 33 +++++++++++++----------- apps/users/src/handlers/config.rs | 2 +- apps/users/src/handlers/dashboard.rs | 2 +- apps/users/src/handlers/settings.rs | 2 +- apps/users/src/handlers/support.rs | 8 +++--- apps/users/src/handlers/verifications.rs | 6 ++--- crates/db/src/models/user.rs | 18 ++++++------- 9 files changed, 50 insertions(+), 47 deletions(-) diff --git a/apps/users/src/handlers/admin.rs b/apps/users/src/handlers/admin.rs index 68676bc..89479e7 100644 --- a/apps/users/src/handlers/admin.rs +++ b/apps/users/src/handlers/admin.rs @@ -49,12 +49,12 @@ async fn list_users( // Generic list: users + their approved roles r#" SELECT - u.id, u.email, u.full_name, u.status, u.created_at, + u.id, u.email, u.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 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.name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') GROUP BY u.id ORDER BY u.created_at DESC LIMIT 100 @@ -80,11 +80,11 @@ async fn list_users( format!( r#" SELECT - u.id, u.email, u.full_name, p.status, u.created_at, + u.id, u.email, u.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.name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') ORDER BY u.created_at DESC LIMIT 100 "#, @@ -110,12 +110,12 @@ async fn list_customers( let sql = r#" SELECT - u.id, u.email, u.full_name, u.status, u.created_at, + u.id, u.email, u.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 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.name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') ORDER BY u.created_at DESC LIMIT 50 "#; @@ -138,12 +138,12 @@ async fn list_candidates( let sql = r#" SELECT - u.id, u.email, u.full_name, u.status, u.created_at, + u.id, u.email, u.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 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.name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') ORDER BY u.created_at DESC LIMIT 50 "#; diff --git a/apps/users/src/handlers/approvals.rs b/apps/users/src/handlers/approvals.rs index bba409e..d721358 100644 --- a/apps/users/src/handlers/approvals.rs +++ b/apps/users/src/handlers/approvals.rs @@ -94,7 +94,7 @@ async fn get_submission( Json(serde_json::json!({ "user": { "id": user.id, - "name": user.full_name, + "name": user.name, "email": user.email, "phone": user.phone, "status": user.status, @@ -247,7 +247,7 @@ async fn activate_profile_after_final_approval( .mail .send_approval_approved_email( &user.email, - user.full_name.as_deref().unwrap_or_default(), + user.name.as_deref().unwrap_or_default(), &display, ) .await; @@ -303,7 +303,7 @@ async fn reject_profile_after_final_approval( .mail .send_approval_rejected_email( &user.email, - user.full_name.as_deref().unwrap_or_default(), + user.name.as_deref().unwrap_or_default(), &display, reason.unwrap_or("Rejected by final approval"), ) @@ -440,7 +440,7 @@ 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", + "SELECT u.name, u.email FROM companies c JOIN users u ON u.id = c.user_id WHERE c.id = $1", ) .bind(existing.company_id) .fetch_optional(&state.pool) @@ -490,7 +490,7 @@ 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", + "SELECT u.name, u.email FROM companies c JOIN users u ON u.id = c.user_id WHERE c.id = $1", ) .bind(existing.company_id) .fetch_optional(&state.pool) diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index a98b9bf..377aad7 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -35,7 +35,8 @@ pub fn router() -> Router { #[derive(Deserialize)] pub struct RegisterPayload { - pub full_name: String, + pub first_name: String, + pub last_name: String, pub email: String, pub phone: Option, pub password: String, @@ -91,7 +92,7 @@ pub struct RegisterResponse { pub user_id: String, pub email: String, pub phone: Option, - pub full_name: String, + pub name: String, pub status: String, pub email_verified: bool, pub created_at: String, @@ -101,7 +102,7 @@ pub struct RegisterResponse { pub struct SessionUser { pub id: String, pub email: String, - pub full_name: String, + pub name: String, pub email_verified: bool, pub roles: Vec, pub active_role: Option, @@ -197,10 +198,12 @@ async fn register( let password_hash = hash_password(&payload.password) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?; + let full_name = format!("{} {}", payload.first_name.trim(), payload.last_name.trim()).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()), + name: full_name, + email: email.clone(), + phone: payload.phone.filter(|p| !p.trim().is_empty()), password_hash, }) .await @@ -252,13 +255,13 @@ async fn register( .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 _ = state.mail.send_verification_email(&user.email, &user.name.clone().unwrap_or_default(), &otp).await; 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(), + name: user.name.unwrap_or_default(), status: user.status, email_verified: user.email_verified, created_at: user.created_at.to_rfc3339(), @@ -327,7 +330,7 @@ async fn login( "user": { "id": user.id.to_string(), "email": user.email, - "full_name": user.full_name.unwrap_or_default(), + "full_name": user.name.unwrap_or_default(), "email_verified": user.email_verified, "active_role": active_role, "roles": user_roles, @@ -439,7 +442,7 @@ async fn session( Ok(Json(SessionUser { id: user.id.to_string(), email: user.email, - full_name: user.full_name.unwrap_or_default(), + name: user.name.unwrap_or_default(), email_verified: user.email_verified, active_role: user_roles.first().cloned(), roles: user_roles, @@ -469,7 +472,7 @@ 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 _ = state.mail.send_welcome_email(&user.email, &user.name.unwrap_or_default()).await; } Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Email verified successfully" })))) @@ -505,7 +508,7 @@ async fn resend_otp( .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 _ = state.mail.send_verification_email(&user.email, &user.name.unwrap_or_default(), &otp).await; Ok(silent_ok) } @@ -530,7 +533,7 @@ async fn forgot_password( .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 _ = state.mail.send_password_reset_email(&user.email, &user.name.unwrap_or_default(), &token).await; Ok(silent_ok) } @@ -564,7 +567,7 @@ async fn reset_password( .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; + let _ = state.mail.send_password_changed_email(&user.email, user.name.as_deref().unwrap_or_default()).await; } Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password reset successfully" })))) @@ -597,7 +600,7 @@ 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 _ = state.mail.send_password_changed_email(&user.email, user.name.as_deref().unwrap_or_default()).await; Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Password changed successfully" })))) } diff --git a/apps/users/src/handlers/config.rs b/apps/users/src/handlers/config.rs index 6171a04..c782e50 100644 --- a/apps/users/src/handlers/config.rs +++ b/apps/users/src/handlers/config.rs @@ -284,7 +284,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": user.name.unwrap_or_default(), "email": user.email, "roles": roles, "active_role": role_key, diff --git a/apps/users/src/handlers/dashboard.rs b/apps/users/src/handlers/dashboard.rs index 04682d3..c3fc52f 100644 --- a/apps/users/src/handlers/dashboard.rs +++ b/apps/users/src/handlers/dashboard.rs @@ -125,7 +125,7 @@ async fn get_metrics(State(state): State) -> Json( r#" SELECT r.id, r.title, r.status, r.created_at, - u.full_name AS requester_name + u.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') diff --git a/apps/users/src/handlers/settings.rs b/apps/users/src/handlers/settings.rs index c1a457f..c991e9b 100644 --- a/apps/users/src/handlers/settings.rs +++ b/apps/users/src/handlers/settings.rs @@ -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(), + user.name.as_deref().unwrap_or_default(), ) .await; let _ = sqlx::query( diff --git a/apps/users/src/handlers/support.rs b/apps/users/src/handlers/support.rs index e8fb858..4e75ff1 100644 --- a/apps/users/src/handlers/support.rs +++ b/apps/users/src/handlers/support.rs @@ -137,7 +137,7 @@ async fn user_create_ticket( }; let _ = state.mail.send_support_ticket_created_email( &user.email, - user.full_name.as_deref().unwrap_or_default(), + user.name.as_deref().unwrap_or_default(), &r.id.to_string(), &body.subject, &category, @@ -444,7 +444,7 @@ 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 + u.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) @@ -586,7 +586,7 @@ 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 + u.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 @@ -832,7 +832,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() + user.name.unwrap_or_default() } else { ticket.requester_name.unwrap_or_default() }; diff --git a/apps/users/src/handlers/verifications.rs b/apps/users/src/handlers/verifications.rs index e4fad13..c861803 100644 --- a/apps/users/src/handlers/verifications.rs +++ b/apps/users/src/handlers/verifications.rs @@ -146,7 +146,7 @@ async fn trigger_rejection( 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(), + user.name.as_deref().unwrap_or_default(), &display, reason_str ).await; @@ -182,7 +182,7 @@ async fn approve_verification( 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(), + user.name.as_deref().unwrap_or_default(), &display ).await; } @@ -296,7 +296,7 @@ async fn request_documents( 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(), + user.name.as_deref().unwrap_or_default(), &display, &payload.message ).await; diff --git a/crates/db/src/models/user.rs b/crates/db/src/models/user.rs index ce4edad..d7a31bd 100644 --- a/crates/db/src/models/user.rs +++ b/crates/db/src/models/user.rs @@ -10,7 +10,7 @@ pub struct User { pub id: Uuid, pub email: String, pub password_hash: String, - pub full_name: Option, + pub name: Option, pub phone: Option, pub email_verified: bool, pub phone_verified: bool, @@ -27,7 +27,7 @@ pub struct User { #[derive(Debug, Serialize, Deserialize)] pub struct CreateUserPayload { - pub full_name: String, + pub name: String, pub email: String, pub phone: Option, pub password_hash: String, @@ -51,17 +51,17 @@ impl UserRepository { pub async fn create(pool: &PgPool, payload: CreateUserPayload) -> Result { let user = sqlx::query_as::<_, User>( r#" - INSERT INTO users (full_name, email, phone, password_hash, email_verified, phone_verified) + INSERT INTO users (name, email, phone, password_hash, email_verified, phone_verified) VALUES ($1, $2, $3, $4, false, false) RETURNING - id, email, password_hash, full_name, phone, + id, email, password_hash, name, phone, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, created_at, updated_at, deleted_at "#, ) - .bind(payload.full_name) + .bind(&payload.name) .bind(payload.email.to_lowercase()) .bind(payload.phone) .bind(payload.password_hash) @@ -74,7 +74,7 @@ impl UserRepository { pub async fn get_by_email(pool: &PgPool, email: &str) -> Result { sqlx::query_as::<_, User>( r#" - SELECT id, email, password_hash, full_name, phone, + SELECT id, email, password_hash, name, phone, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, @@ -91,7 +91,7 @@ impl UserRepository { pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result { sqlx::query_as::<_, User>( r#" - SELECT id, email, password_hash, full_name, phone, + SELECT id, email, password_hash, name, phone, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, @@ -148,7 +148,7 @@ impl UserRepository { pub async fn get_by_verification_token(pool: &PgPool, token: &str) -> Result { sqlx::query_as::<_, User>( r#" - SELECT id, email, password_hash, full_name, phone, + SELECT id, email, password_hash, name, phone, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, @@ -196,7 +196,7 @@ impl UserRepository { pub async fn get_by_reset_token(pool: &PgPool, token: &str) -> Result { sqlx::query_as::<_, User>( r#" - SELECT id, email, password_hash, full_name, phone, + SELECT id, email, password_hash, name, phone, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, From 2861e7a5fe493a48c8fa1ef02260cb7e077e6718 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Mon, 13 Apr 2026 17:12:49 +0200 Subject: [PATCH 018/182] fix: replace u.full_name with u.name in remaining services - companies: user.name in email and contact queries - customers: user.name in email - job_seekers: u.name in company user query - cron tasks (jobs/leads/requirements): use u.name instead of u.full_name - contracts/profession_shared: u.name for customer_name fields --- apps/companies/src/handlers/mod.rs | 10 +++++----- apps/cron/src/tasks/jobs.rs | 6 +++--- apps/cron/src/tasks/leads.rs | 6 +++--- apps/cron/src/tasks/requirements.rs | 6 +++--- apps/customers/src/handlers.rs | 2 +- apps/job_seekers/src/handlers.rs | 6 +++--- crates/contracts/src/profession_shared.rs | 6 +++--- 7 files changed, 21 insertions(+), 21 deletions(-) diff --git a/apps/companies/src/handlers/mod.rs b/apps/companies/src/handlers/mod.rs index 75bbd36..8ee10b0 100644 --- a/apps/companies/src/handlers/mod.rs +++ b/apps/companies/src/handlers/mod.rs @@ -258,7 +258,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, user.name.as_deref().unwrap_or("User"), &updated.title).await; } // Create verification case so the request appears in Verification Management first. @@ -367,7 +367,7 @@ async fn update_application_status( 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 WHERE u.id = $1", + "SELECT u.name, u.email FROM users u WHERE u.id = $1", ) .bind(app.applicant_user_id) .fetch_optional(&state.pool) @@ -439,7 +439,7 @@ async fn view_contact( let contact = sqlx::query_as::<_, (Option, String, Option)>( r#" - SELECT u.full_name, u.email, u.phone + SELECT u.name, u.email, u.phone FROM users u WHERE u.id = $1 "#, @@ -449,7 +449,7 @@ async fn view_contact( .await; match contact { - Ok(Some((full_name, email, phone))) => { + 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 }; @@ -470,7 +470,7 @@ async fn view_contact( (StatusCode::OK, Json(serde_json::json!({ "application_id": id, - "full_name": full_name, + "name": name, "email": email, "phone": phone, "quota": { diff --git a/apps/cron/src/tasks/jobs.rs b/apps/cron/src/tasks/jobs.rs index f982cea..4a68184 100644 --- a/apps/cron/src/tasks/jobs.rs +++ b/apps/cron/src/tasks/jobs.rs @@ -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, u.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); } diff --git a/apps/cron/src/tasks/leads.rs b/apps/cron/src/tasks/leads.rs index d0fd10a..451080d 100644 --- a/apps/cron/src/tasks/leads.rs +++ b/apps/cron/src/tasks/leads.rs @@ -15,7 +15,7 @@ pub async fn expire_stale_lead_requests( tracecoins_reserved: i32, user_id: Uuid, email: String, - full_name: String, + name: String, } let records = sqlx::query_as::<_, Record>( @@ -26,7 +26,7 @@ pub async fn expire_stale_lead_requests( lr.tracecoins_reserved, urp.user_id, u.email, - u.full_name + u.name FROM lead_requests lr INNER JOIN user_role_profiles urp ON urp.id = lr.user_role_profile_id INNER JOIN users u ON u.id = urp.user_id @@ -86,7 +86,7 @@ pub async fn expire_stale_lead_requests( tx.commit().await?; - 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); } diff --git a/apps/cron/src/tasks/requirements.rs b/apps/cron/src/tasks/requirements.rs index 033a55c..526b540 100644 --- a/apps/cron/src/tasks/requirements.rs +++ b/apps/cron/src/tasks/requirements.rs @@ -15,7 +15,7 @@ pub async fn expire_stale_leads( lead_id: Uuid, title: String, email: String, - full_name: String, + name: String, } let records = sqlx::query_as::<_, LeadRecord>( @@ -26,7 +26,7 @@ pub async fn expire_stale_leads( 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, u.full_name + RETURNING leads.id as lead_id, leads.title, u.email, u.name "# ) .bind(now) @@ -40,7 +40,7 @@ pub async fn expire_stale_leads( 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; + 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); } diff --git a/apps/customers/src/handlers.rs b/apps/customers/src/handlers.rs index 61b1972..41bc22b 100644 --- a/apps/customers/src/handlers.rs +++ b/apps/customers/src/handlers.rs @@ -190,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, user.name.as_deref().unwrap_or("User"), &updated.title).await; } // Create verification case so this request enters Verification Management first. diff --git a/apps/job_seekers/src/handlers.rs b/apps/job_seekers/src/handlers.rs index 70ac4d6..a3a3197 100644 --- a/apps/job_seekers/src/handlers.rs +++ b/apps/job_seekers/src/handlers.rs @@ -245,17 +245,17 @@ 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)>( - "SELECT u.email, u.full_name FROM users u INNER JOIN companies c ON c.user_id = u.id WHERE c.id = $1" + "SELECT u.email, u.name 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 { + if let Ok(Some((email, name))) = company_user { let seeker_name = seeker.full_name.as_deref().unwrap_or("A candidate"); 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 ).await; diff --git a/crates/contracts/src/profession_shared.rs b/crates/contracts/src/profession_shared.rs index 4e75df3..13d9e48 100644 --- a/crates/contracts/src/profession_shared.rs +++ b/crates/contracts/src/profession_shared.rs @@ -374,7 +374,7 @@ async fn my_requests( sqlx::query_as::<_, RichLeadReq>( r#" SELECT lr.*, r.title as req_title, r.profession_key as req_profession_key, r.location as req_location, r.budget as req_budget, - CASE WHEN lr.status = 'ACCEPTED' THEN u.full_name ELSE NULL END as customer_name, + CASE WHEN lr.status = 'ACCEPTED' THEN u.name ELSE NULL END as customer_name, CASE WHEN lr.status = 'ACCEPTED' THEN u.email ELSE NULL END as customer_email, CASE WHEN lr.status = 'ACCEPTED' THEN u.phone ELSE NULL END as customer_phone FROM lead_requests lr @@ -390,7 +390,7 @@ async fn my_requests( sqlx::query_as::<_, RichLeadReq>( r#" SELECT lr.*, r.title as req_title, r.profession_key as req_profession_key, r.location as req_location, r.budget as req_budget, - CASE WHEN lr.status = 'ACCEPTED' THEN u.full_name ELSE NULL END as customer_name, + CASE WHEN lr.status = 'ACCEPTED' THEN u.name ELSE NULL END as customer_name, CASE WHEN lr.status = 'ACCEPTED' THEN u.email ELSE NULL END as customer_email, CASE WHEN lr.status = 'ACCEPTED' THEN u.phone ELSE NULL END as customer_phone FROM lead_requests lr @@ -567,7 +567,7 @@ async fn accepted_lead_detail( r.location AS requirement_location, r.profession_key, r.custom_fields, - u.full_name AS customer_name, + u.name AS customer_name, u.email AS customer_email, u.phone AS customer_phone FROM lead_requests lr From 63eb27a1603b28bfe9c9950041fbc82dda192d9c Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Mon, 13 Apr 2026 19:59:48 +0200 Subject: [PATCH 019/182] fix(auth): accept both full_name and first_name+last_name for backward compatibility RegisterPayload now accepts: - full_name (single field, for old frontend clients) - first_name + last_name (new format) Error returned only if none of these are provided. --- apps/users/src/handlers/auth.rs | 14 +++++++++++--- k8s-migration-job.yaml | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 44 insertions(+), 3 deletions(-) create mode 100644 k8s-migration-job.yaml diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index 377aad7..b0cd0ae 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -35,8 +35,12 @@ pub fn router() -> Router { #[derive(Deserialize)] pub struct RegisterPayload { - pub first_name: String, - pub last_name: String, + #[serde(default)] + pub first_name: Option, + #[serde(default)] + pub last_name: Option, + #[serde(default)] + pub full_name: Option, pub email: String, pub phone: Option, pub password: String, @@ -198,7 +202,11 @@ async fn register( let password_hash = hash_password(&payload.password) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?; - let full_name = format!("{} {}", payload.first_name.trim(), payload.last_name.trim()).trim().to_string(); + let full_name = match (&payload.first_name, &payload.last_name, &payload.full_name) { + (Some(fn_), Some(ln_), _) => format!("{} {}", fn_.trim(), ln_.trim()).trim().to_string(), + (_, _, Some(fn_)) => fn_.trim().to_string(), + _ => return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "first_name and last_name are required", "VALIDATION_ERROR")), + }; let user = UserRepository::create(&state.pool, CreateUserPayload { name: full_name, diff --git a/k8s-migration-job.yaml b/k8s-migration-job.yaml new file mode 100644 index 0000000..1be5bba --- /dev/null +++ b/k8s-migration-job.yaml @@ -0,0 +1,33 @@ +apiVersion: batch/v1 +kind: Job +metadata: + name: nxtgauge-db-migrate + namespace: default + labels: + app: nxtgauge-db-migrate +spec: + ttlSecondsAfterFinished: 300 + backoffLimit: 3 + template: + metadata: + labels: + app: nxtgauge-db-migrate + spec: + restartPolicy: OnFailure + containers: + - name: migrate + image: ghcr.io/traceworks2023/nxtgauge-db-migrate:high-performance-latest + imagePullPolicy: Always + envFrom: + - secretRef: + name: nxtgauge-backend-rust-secrets + env: + - name: MIGRATIONS_DIR + value: "/migrations" + resources: + requests: + memory: "64Mi" + cpu: "50m" + limits: + memory: "256Mi" + cpu: "500m" From 1d50d21f001e7f7f5d0d8f78c665aa2f9694eff0 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Mon, 13 Apr 2026 20:13:53 +0200 Subject: [PATCH 020/182] fix(auth): also accept 'name' field for signup compatibility Frontend sends 'name' field directly. RegisterPayload now accepts: - name (direct, used by frontend) - full_name (legacy) - first_name + last_name (new format) --- apps/users/src/handlers/auth.rs | 11 +++++++---- 1 file changed, 7 insertions(+), 4 deletions(-) diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index b0cd0ae..27e12a5 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -41,6 +41,8 @@ pub struct RegisterPayload { pub last_name: Option, #[serde(default)] pub full_name: Option, + #[serde(default)] + pub name: Option, pub email: String, pub phone: Option, pub password: String, @@ -202,10 +204,11 @@ async fn register( let password_hash = hash_password(&payload.password) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?; - let full_name = match (&payload.first_name, &payload.last_name, &payload.full_name) { - (Some(fn_), Some(ln_), _) => format!("{} {}", fn_.trim(), ln_.trim()).trim().to_string(), - (_, _, Some(fn_)) => fn_.trim().to_string(), - _ => return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "first_name and last_name are required", "VALIDATION_ERROR")), + let full_name = match (&payload.first_name, &payload.last_name, &payload.full_name, &payload.name) { + (Some(fn_), Some(ln_), _, _) => format!("{} {}", fn_.trim(), ln_.trim()).trim().to_string(), + (_, _, Some(fn_), _) => fn_.trim().to_string(), + (_, _, _, Some(n)) => n.trim().to_string(), + _ => return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Name is required (full_name or first_name+last_name)", "VALIDATION_ERROR")), }; let user = UserRepository::create(&state.pool, CreateUserPayload { From 3432d67cc481d8cc21011348a856e284683cf391 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Mon, 13 Apr 2026 20:15:32 +0200 Subject: [PATCH 021/182] fix(auth): remove phone from INSERT and User struct since column doesn't exist - Remove phone from INSERT INTO users (users table has no phone column) - Remove phone from User struct and CreateUserPayload - Return null for phone in API responses - Keep phone field in RegisterPayload for backward compat (just not persisted) --- apps/users/src/handlers/approvals.rs | 2 +- apps/users/src/handlers/auth.rs | 5 ++--- crates/db/src/models/user.rs | 17 +++++++---------- 3 files changed, 10 insertions(+), 14 deletions(-) diff --git a/apps/users/src/handlers/approvals.rs b/apps/users/src/handlers/approvals.rs index d721358..5b18604 100644 --- a/apps/users/src/handlers/approvals.rs +++ b/apps/users/src/handlers/approvals.rs @@ -96,7 +96,7 @@ async fn get_submission( "id": user.id, "name": user.name, "email": user.email, - "phone": user.phone, + "phone": null, "status": user.status, "email_verified": user.email_verified, "created_at": user.created_at, diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index 27e12a5..6701828 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -214,7 +214,6 @@ async fn register( let user = UserRepository::create(&state.pool, CreateUserPayload { name: full_name, email: email.clone(), - phone: payload.phone.filter(|p| !p.trim().is_empty()), password_hash, }) .await @@ -271,8 +270,8 @@ async fn register( Ok((StatusCode::CREATED, Json(RegisterResponse { user_id: user.id.to_string(), email: user.email, - phone: user.phone, - name: user.name.unwrap_or_default(), + phone: None, + name: user.name.unwrap_or_default(), status: user.status, email_verified: user.email_verified, created_at: user.created_at.to_rfc3339(), diff --git a/crates/db/src/models/user.rs b/crates/db/src/models/user.rs index d7a31bd..1e787f1 100644 --- a/crates/db/src/models/user.rs +++ b/crates/db/src/models/user.rs @@ -11,7 +11,6 @@ pub struct User { pub email: String, pub password_hash: String, pub name: Option, - pub phone: Option, pub email_verified: bool, pub phone_verified: bool, pub status: String, // ACTIVE, SUSPENDED, BANNED @@ -29,7 +28,6 @@ pub struct User { pub struct CreateUserPayload { pub name: String, pub email: String, - pub phone: Option, pub password_hash: String, } @@ -51,10 +49,10 @@ impl UserRepository { pub async fn create(pool: &PgPool, payload: CreateUserPayload) -> Result { let user = sqlx::query_as::<_, User>( r#" - INSERT INTO users (name, email, phone, password_hash, email_verified, phone_verified) - VALUES ($1, $2, $3, $4, false, false) + INSERT INTO users (name, email, password_hash, email_verified, phone_verified) + VALUES ($1, $2, $3, false, false) RETURNING - id, email, password_hash, name, phone, + id, email, password_hash, name, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, @@ -63,7 +61,6 @@ impl UserRepository { ) .bind(&payload.name) .bind(payload.email.to_lowercase()) - .bind(payload.phone) .bind(payload.password_hash) .fetch_one(pool) .await?; @@ -74,7 +71,7 @@ impl UserRepository { pub async fn get_by_email(pool: &PgPool, email: &str) -> Result { sqlx::query_as::<_, User>( r#" - SELECT id, email, password_hash, name, phone, + SELECT id, email, password_hash, name, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, @@ -91,7 +88,7 @@ impl UserRepository { pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result { sqlx::query_as::<_, User>( r#" - SELECT id, email, password_hash, name, phone, + SELECT id, email, password_hash, name, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, @@ -148,7 +145,7 @@ impl UserRepository { pub async fn get_by_verification_token(pool: &PgPool, token: &str) -> Result { sqlx::query_as::<_, User>( r#" - SELECT id, email, password_hash, name, phone, + SELECT id, email, password_hash, name, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, @@ -196,7 +193,7 @@ impl UserRepository { pub async fn get_by_reset_token(pool: &PgPool, token: &str) -> Result { sqlx::query_as::<_, User>( r#" - SELECT id, email, password_hash, name, phone, + SELECT id, email, password_hash, name, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, From 23587cdc6385639a39cc51c95750cd69de2738e4 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Mon, 13 Apr 2026 20:21:41 +0200 Subject: [PATCH 022/182] ci: add concurrency limit to Woodpecker pipeline Limit concurrent pipeline runs to 4 to control resource usage while maintaining parallel matrix builds for all 21 services --- .woodpecker.yml | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/.woodpecker.yml b/.woodpecker.yml index 53493c4..2433db0 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -1,7 +1,14 @@ +# Woodpecker CI - All 21 services + migrate run in parallel via matrix +# Woodpecker executes each matrix entry as a separate pipeline concurrently + when: branch: [main, high-performance] event: push +# Limit concurrent pipelines to avoid overwhelming resources +concurrency: + limit: 4 + matrix: SERVICE: - gateway @@ -50,6 +57,7 @@ steps: cache: false --- +# Separate pipeline for database migrations (runs independently) when: branch: [main, high-performance] event: push From 15100d20f3431b3f43c8e0cfe65d6a28db91731f Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Mon, 13 Apr 2026 23:28:23 +0200 Subject: [PATCH 023/182] ci: trigger woodpecker build From 0b71e39ce090a4c4f06a57625f1b8ba0a8947934 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 14 Apr 2026 10:57:57 +0200 Subject: [PATCH 024/182] ci: trigger woodpecker for phone/full_name fix deployment From 30d8eeb279d864100f070353f8143eca363e2329 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 14 Apr 2026 12:51:22 +0200 Subject: [PATCH 025/182] ci: force Woodpecker rebuild - signup fix deployment From d4c7fdcddd19e149672844b79627ee06f41134ba Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 14 Apr 2026 14:18:00 +0200 Subject: [PATCH 026/182] ci: add GitOps update step to Woodpecker pipeline - After building gateway/users images, update GitOps with new SHA tag - Update apps/nxtgauge-backend-rust/overlays/prod/kustomization.yaml - Requires secrets: GITOPS_REPO_URL, GITOPS_BRANCH, GITOPS_TOKEN --- .woodpecker.yml | 56 +++++++++++++++++++++++++++++++++++++++++++++---- 1 file changed, 52 insertions(+), 4 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 2433db0..1ea90b8 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -1,11 +1,13 @@ -# Woodpecker CI - All 21 services + migrate run in parallel via matrix -# Woodpecker executes each matrix entry as a separate pipeline concurrently +# Woodpecker CI - Build all services + update GitOps with image digests +# +# Secrets required in Woodpecker: +# - REGISTRY_HOSTPORT, REGISTRY_USERNAME, REGISTRY_PASSWORD (existing) +# - GITOPS_REPO_URL, GITOPS_BRANCH, GITOPS_TOKEN, GITOPS_USERNAME, GITOPS_EMAIL when: branch: [main, high-performance] event: push -# Limit concurrent pipelines to avoid overwhelming resources concurrency: limit: 4 @@ -56,8 +58,54 @@ steps: platforms: linux/amd64 cache: false + - name: update-gitops + image: alpine:latest + environment: + GITOPS_REPO_URL: + from_secret: GITOPS_REPO_URL + GITOPS_BRANCH: + from_secret: GITOPS_BRANCH + GITOPS_TOKEN: + from_secret: GITOPS_TOKEN + commands: + - | + set -e + apk add --no-cache git bash sed + + SERVICE_IMAGE="registry.nxtgauge.com:5000/nxtgauge-rust-${SERVICE}:${CI_COMMIT_SHA}" + echo "Service: ${SERVICE}, Image: ${SERVICE_IMAGE}" + + # Clone gitops repo + GIT_REPO=$(echo "${GITOPS_REPO_URL}" | sed 's|https://||') + git clone "https://x-access-token:${GITOPS_TOKEN}@${GIT_REPO}" /tmp/gitops + cd /tmp/gitops + git checkout ${GITOPS_BRANCH:-main} + + # Find and update the image in backend overlay + BACKEND_OVERLAY="apps/nxtgauge-backend-rust/overlays/prod" + if [ -f "${BACKEND_OVERLAY}/kustomization.yaml" ]; then + # Update to use SHA tag + sed -i "s|image: registry.nxtgauge.com:5000/nxtgauge-rust-${SERVICE}:.*|image: registry.nxtgauge.com:5000/nxtgauge-rust-${SERVICE}:${CI_COMMIT_SHA}|" \ + ${BACKEND_OVERLAY}/kustomization.yaml + echo "Updated ${SERVICE} in ${BACKEND_OVERLAY}/kustomization.yaml" + fi + + # Commit if changed + if ! git diff --quiet; then + git add -A + git commit -m "ci: update ${SERVICE} to ${CI_COMMIT_SHA:0:8}" + git push origin ${GITOPS_BRANCH:-main} + echo "Pushed GitOps update" + else + echo "No changes to push" + fi + when: + status: success + matrix: + SERVICE: [gateway, users] + --- -# Separate pipeline for database migrations (runs independently) +# Database migrations pipeline when: branch: [main, high-performance] event: push From 0d01e705767308b06997593dbd648d07b942fae6 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 14 Apr 2026 14:26:55 +0200 Subject: [PATCH 027/182] fix: use GHCR_USERNAME/GHCR_TOKEN instead of REGISTRY_* --- .woodpecker.yml | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 1ea90b8..e13c99f 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -49,9 +49,9 @@ steps: - latest - high-performance-latest username: - from_secret: REGISTRY_USERNAME + from_secret: GHCR_USERNAME password: - from_secret: REGISTRY_PASSWORD + from_secret: GHCR_TOKEN insecure: true insecure_pull: true skip_tls_verify: true @@ -124,9 +124,9 @@ steps: - latest - high-performance-latest username: - from_secret: REGISTRY_USERNAME + from_secret: GHCR_USERNAME password: - from_secret: REGISTRY_PASSWORD + from_secret: GHCR_TOKEN insecure: true insecure_pull: true skip_tls_verify: true From d3cdd56ba463e9a32fe8ba85a722b5705293e751 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 14 Apr 2026 14:28:21 +0200 Subject: [PATCH 028/182] ci: simplify GitOps update to use Woodpecker's Git access --- .woodpecker.yml | 29 ++++++++++------------------- 1 file changed, 10 insertions(+), 19 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index e13c99f..886bf49 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -59,42 +59,33 @@ steps: cache: false - name: update-gitops - image: alpine:latest - environment: - GITOPS_REPO_URL: - from_secret: GITOPS_REPO_URL - GITOPS_BRANCH: - from_secret: GITOPS_BRANCH - GITOPS_TOKEN: - from_secret: GITOPS_TOKEN + image: alpine/git:latest commands: - | set -e - apk add --no-cache git bash sed - SERVICE_IMAGE="registry.nxtgauge.com:5000/nxtgauge-rust-${SERVICE}:${CI_COMMIT_SHA}" - echo "Service: ${SERVICE}, Image: ${SERVICE_IMAGE}" + echo "Updating GitOps for ${SERVICE}" - # Clone gitops repo - GIT_REPO=$(echo "${GITOPS_REPO_URL}" | sed 's|https://||') - git clone "https://x-access-token:${GITOPS_TOKEN}@${GIT_REPO}" /tmp/gitops + # Clone gitops repo using cloned source from Woodpecker + git clone https://github.com/Traceworks2023/nxtgauge-gitops.git /tmp/gitops cd /tmp/gitops - git checkout ${GITOPS_BRANCH:-main} + git checkout main - # Find and update the image in backend overlay + # Update backend overlay BACKEND_OVERLAY="apps/nxtgauge-backend-rust/overlays/prod" if [ -f "${BACKEND_OVERLAY}/kustomization.yaml" ]; then - # Update to use SHA tag sed -i "s|image: registry.nxtgauge.com:5000/nxtgauge-rust-${SERVICE}:.*|image: registry.nxtgauge.com:5000/nxtgauge-rust-${SERVICE}:${CI_COMMIT_SHA}|" \ ${BACKEND_OVERLAY}/kustomization.yaml echo "Updated ${SERVICE} in ${BACKEND_OVERLAY}/kustomization.yaml" fi - # Commit if changed + # Commit and push if ! git diff --quiet; then + git config user.name "Woodpecker CI" + git config user.email "woodpecker@nxtgauge.com" git add -A git commit -m "ci: update ${SERVICE} to ${CI_COMMIT_SHA:0:8}" - git push origin ${GITOPS_BRANCH:-main} + git push origin main echo "Pushed GitOps update" else echo "No changes to push" From c5b097d20c982e561c9d781ab50e59ad1f8f9047 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 14 Apr 2026 14:30:04 +0200 Subject: [PATCH 029/182] ci: update gitops for all services (not just gateway/users) --- .woodpecker.yml | 2 -- 1 file changed, 2 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 886bf49..8e07f9b 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -92,8 +92,6 @@ steps: fi when: status: success - matrix: - SERVICE: [gateway, users] --- # Database migrations pipeline From 4d6de951bf39c6fd516c7fd50f4366855f3a686d Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 14 Apr 2026 14:32:28 +0200 Subject: [PATCH 030/182] fix: use explicit registry.nxtgauge.com:5000 with REGISTRY_* secrets --- .woodpecker.yml | 14 ++++++-------- 1 file changed, 6 insertions(+), 8 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 8e07f9b..833d879 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -38,8 +38,7 @@ steps: - name: build-and-push image: woodpeckerci/plugin-kaniko:2.1.1 settings: - registry: - from_secret: REGISTRY_HOSTPORT + registry: registry.nxtgauge.com:5000 repo: nxtgauge-rust-${SERVICE} dockerfile: Dockerfile.simple build_args: @@ -49,9 +48,9 @@ steps: - latest - high-performance-latest username: - from_secret: GHCR_USERNAME + from_secret: REGISTRY_USERNAME password: - from_secret: GHCR_TOKEN + from_secret: REGISTRY_PASSWORD insecure: true insecure_pull: true skip_tls_verify: true @@ -103,8 +102,7 @@ steps: - name: build-and-push-migrate image: woodpeckerci/plugin-kaniko:2.1.1 settings: - registry: - from_secret: REGISTRY_HOSTPORT + registry: registry.nxtgauge.com:5000 repo: nxtgauge-db-migrate dockerfile: Dockerfile.migrate context: . @@ -113,9 +111,9 @@ steps: - latest - high-performance-latest username: - from_secret: GHCR_USERNAME + from_secret: REGISTRY_USERNAME password: - from_secret: GHCR_TOKEN + from_secret: REGISTRY_PASSWORD insecure: true insecure_pull: true skip_tls_verify: true From 747a4cb108324cf61008bc19546384967eea7a5a Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 14 Apr 2026 14:34:00 +0200 Subject: [PATCH 031/182] ci: add gitops update step while keeping original registry config --- .woodpecker.yml | 20 ++++---------------- 1 file changed, 4 insertions(+), 16 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 833d879..4efea65 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -1,9 +1,3 @@ -# Woodpecker CI - Build all services + update GitOps with image digests -# -# Secrets required in Woodpecker: -# - REGISTRY_HOSTPORT, REGISTRY_USERNAME, REGISTRY_PASSWORD (existing) -# - GITOPS_REPO_URL, GITOPS_BRANCH, GITOPS_TOKEN, GITOPS_USERNAME, GITOPS_EMAIL - when: branch: [main, high-performance] event: push @@ -38,7 +32,8 @@ steps: - name: build-and-push image: woodpeckerci/plugin-kaniko:2.1.1 settings: - registry: registry.nxtgauge.com:5000 + registry: + from_secret: REGISTRY_HOSTPORT repo: nxtgauge-rust-${SERVICE} dockerfile: Dockerfile.simple build_args: @@ -62,23 +57,16 @@ steps: commands: - | set -e - echo "Updating GitOps for ${SERVICE}" - - # Clone gitops repo using cloned source from Woodpecker git clone https://github.com/Traceworks2023/nxtgauge-gitops.git /tmp/gitops cd /tmp/gitops git checkout main - - # Update backend overlay BACKEND_OVERLAY="apps/nxtgauge-backend-rust/overlays/prod" if [ -f "${BACKEND_OVERLAY}/kustomization.yaml" ]; then sed -i "s|image: registry.nxtgauge.com:5000/nxtgauge-rust-${SERVICE}:.*|image: registry.nxtgauge.com:5000/nxtgauge-rust-${SERVICE}:${CI_COMMIT_SHA}|" \ ${BACKEND_OVERLAY}/kustomization.yaml echo "Updated ${SERVICE} in ${BACKEND_OVERLAY}/kustomization.yaml" fi - - # Commit and push if ! git diff --quiet; then git config user.name "Woodpecker CI" git config user.email "woodpecker@nxtgauge.com" @@ -93,7 +81,6 @@ steps: status: success --- -# Database migrations pipeline when: branch: [main, high-performance] event: push @@ -102,7 +89,8 @@ steps: - name: build-and-push-migrate image: woodpeckerci/plugin-kaniko:2.1.1 settings: - registry: registry.nxtgauge.com:5000 + registry: + from_secret: REGISTRY_HOSTPORT repo: nxtgauge-db-migrate dockerfile: Dockerfile.migrate context: . From 99b8dc929ed03ab720ce4413c726d2b6611580a3 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 14 Apr 2026 18:27:56 +0200 Subject: [PATCH 032/182] fix: use GITHUB_TOKEN for git clone --- .woodpecker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 4efea65..b22eb7e 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -58,7 +58,7 @@ steps: - | set -e echo "Updating GitOps for ${SERVICE}" - git clone https://github.com/Traceworks2023/nxtgauge-gitops.git /tmp/gitops + git clone https://x-access-token:${GITHUB_TOKEN}@github.com/Traceworks2023/nxtgauge-gitops.git /tmp/gitops cd /tmp/gitops git checkout main BACKEND_OVERLAY="apps/nxtgauge-backend-rust/overlays/prod" From a2c995992e1e65748d54d32004693e336257cfc7 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 14 Apr 2026 18:30:50 +0200 Subject: [PATCH 033/182] fix: use GITOPS_REPO_URL secret for git clone --- .woodpecker.yml | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index b22eb7e..c525add 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -54,11 +54,14 @@ steps: - name: update-gitops image: alpine/git:latest + environment: + GITOPS_REPO_URL: + from_secret: GITOPS_REPO_URL commands: - | set -e echo "Updating GitOps for ${SERVICE}" - git clone https://x-access-token:${GITHUB_TOKEN}@github.com/Traceworks2023/nxtgauge-gitops.git /tmp/gitops + git clone ${GITOPS_REPO_URL} /tmp/gitops cd /tmp/gitops git checkout main BACKEND_OVERLAY="apps/nxtgauge-backend-rust/overlays/prod" From e133bd0f2d9e7e9f5fbc884d9b40ce8aab48334d Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 14 Apr 2026 18:31:55 +0200 Subject: [PATCH 034/182] fix: use GHCR_TOKEN/GHCR_USERNAME for gitops push --- .woodpecker.yml | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index c525add..a7ca9bd 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -55,13 +55,15 @@ steps: - name: update-gitops image: alpine/git:latest environment: - GITOPS_REPO_URL: - from_secret: GITOPS_REPO_URL + GHCR_TOKEN: + from_secret: GHCR_TOKEN + GHCR_USERNAME: + from_secret: GHCR_USERNAME commands: - | set -e echo "Updating GitOps for ${SERVICE}" - git clone ${GITOPS_REPO_URL} /tmp/gitops + git clone https://${GHCR_USERNAME}:${GHCR_TOKEN}@github.com/Traceworks2023/nxtgauge-gitops.git /tmp/gitops cd /tmp/gitops git checkout main BACKEND_OVERLAY="apps/nxtgauge-backend-rust/overlays/prod" From 37b17b8b77a13456b8e85cdfa57cc4cacb530d56 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 14 Apr 2026 18:44:09 +0200 Subject: [PATCH 035/182] fix: use GITOPS_REPO_URL with GHCR auth --- .woodpecker.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index a7ca9bd..be2bfbc 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -59,11 +59,13 @@ steps: from_secret: GHCR_TOKEN GHCR_USERNAME: from_secret: GHCR_USERNAME + GITOPS_REPO_URL: + from_secret: GITOPS_REPO_URL commands: - | set -e echo "Updating GitOps for ${SERVICE}" - git clone https://${GHCR_USERNAME}:${GHCR_TOKEN}@github.com/Traceworks2023/nxtgauge-gitops.git /tmp/gitops + git clone https://${GHCR_USERNAME}:${GHCR_TOKEN}@${GITOPS_REPO_URL} /tmp/gitops cd /tmp/gitops git checkout main BACKEND_OVERLAY="apps/nxtgauge-backend-rust/overlays/prod" From 2a65d79aeafd6770d46264e0574f9d59f074b0bc Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 14 Apr 2026 18:52:19 +0200 Subject: [PATCH 036/182] chore: remove gitops update step (handled server-side) --- .woodpecker.yml | 35 ----------------------------------- 1 file changed, 35 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index be2bfbc..702bc44 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -52,41 +52,6 @@ steps: platforms: linux/amd64 cache: false - - name: update-gitops - image: alpine/git:latest - environment: - GHCR_TOKEN: - from_secret: GHCR_TOKEN - GHCR_USERNAME: - from_secret: GHCR_USERNAME - GITOPS_REPO_URL: - from_secret: GITOPS_REPO_URL - commands: - - | - set -e - echo "Updating GitOps for ${SERVICE}" - git clone https://${GHCR_USERNAME}:${GHCR_TOKEN}@${GITOPS_REPO_URL} /tmp/gitops - cd /tmp/gitops - git checkout main - BACKEND_OVERLAY="apps/nxtgauge-backend-rust/overlays/prod" - if [ -f "${BACKEND_OVERLAY}/kustomization.yaml" ]; then - sed -i "s|image: registry.nxtgauge.com:5000/nxtgauge-rust-${SERVICE}:.*|image: registry.nxtgauge.com:5000/nxtgauge-rust-${SERVICE}:${CI_COMMIT_SHA}|" \ - ${BACKEND_OVERLAY}/kustomization.yaml - echo "Updated ${SERVICE} in ${BACKEND_OVERLAY}/kustomization.yaml" - fi - if ! git diff --quiet; then - git config user.name "Woodpecker CI" - git config user.email "woodpecker@nxtgauge.com" - git add -A - git commit -m "ci: update ${SERVICE} to ${CI_COMMIT_SHA:0:8}" - git push origin main - echo "Pushed GitOps update" - else - echo "No changes to push" - fi - when: - status: success - --- when: branch: [main, high-performance] From 92ded2b43d70bfca65987319515d157aae19a195 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Wed, 15 Apr 2026 00:16:25 +0200 Subject: [PATCH 037/182] Fix role/config schema alignment and external dashboard runtime loading --- apps/users/src/handlers/auth.rs | 95 +++++++++++++++++++++++++++----- apps/users/src/handlers/roles.rs | 14 ++--- crates/db/src/models/config.rs | 54 ++++++++++-------- crates/db/src/models/user.rs | 6 +- 4 files changed, 122 insertions(+), 47 deletions(-) diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index 6701828..e44ed50 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -157,6 +157,55 @@ fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str> vec!["JOB_SEEKER".to_string()] } +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::()), + None => String::new(), + } + }) + .collect::>() + .join(" ") +} + +async fn ensure_role_exists(pool: &sqlx::PgPool, role_code: &str) -> Option { + 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 code = $1") + .bind(&normalized) + .fetch_optional(pool) + .await + { + if found.is_some() { + return found; + } + } + + let display_name = role_display_name_from_code(&normalized); + sqlx::query_scalar::<_, Uuid>( + r#" + INSERT INTO roles (name, code, audience, is_active) + VALUES ($1, $2, 'USER', true) + ON CONFLICT (code) + DO UPDATE SET updated_at = NOW() + RETURNING id + "#, + ) + .bind(display_name) + .bind(normalized) + .fetch_one(pool) + .await + .ok() +} + // ── Handlers ────────────────────────────────────────────────────────────────── /// POST /api/auth/check-email @@ -175,11 +224,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, })), ) } @@ -234,20 +294,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_roles + 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_roles (user_id, role_id, status) + SELECT $1, $2, 'APPROVED' + WHERE NOT EXISTS ( + SELECT 1 FROM user_roles WHERE user_id = $1 AND role_id = $2 + ) "#, ) .bind(user.id) diff --git a/apps/users/src/handlers/roles.rs b/apps/users/src/handlers/roles.rs index 2f7deb1..4a80148 100644 --- a/apps/users/src/handlers/roles.rs +++ b/apps/users/src/handlers/roles.rs @@ -168,7 +168,7 @@ async fn list_roles( r#" SELECT r.id, - r.key, + r.code AS key, r.name, r.audience, r.description, @@ -182,10 +182,10 @@ async fn list_roles( COUNT(DISTINCT rp.id) AS permissions_count FROM roles r LEFT JOIN departments d ON d.id = r.department_id - LEFT JOIN employees e ON e.role_code = r.key + LEFT JOIN employees e ON e.role_code = r.code 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 || '%') + AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.code) LIKE '%' || $2 || '%') GROUP BY r.id, d.name ORDER BY r.created_at DESC LIMIT $3 OFFSET $4 @@ -203,7 +203,7 @@ async fn list_roles( 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 || '%') + AND ($2 = '' OR LOWER(r.name) LIKE '%' || $2 || '%' OR LOWER(r.code) LIKE '%' || $2 || '%') "#, ) .bind(&audience) @@ -241,7 +241,7 @@ async fn get_role( let row = sqlx::query_as::<_, RoleDetailRow>( r#" SELECT - r.id, r.key, r.name, r.audience, r.description, + r.id, r.code AS 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.created_at @@ -290,9 +290,9 @@ 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) + INSERT INTO roles (code, 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 + RETURNING id, code AS key, name, audience, description, department_id, is_active, can_approve_requests, can_manage_system_settings, created_at "#, ) .bind(&payload.key) diff --git a/crates/db/src/models/config.rs b/crates/db/src/models/config.rs index b6f3fd7..9abc25b 100644 --- a/crates/db/src/models/config.rs +++ b/crates/db/src/models/config.rs @@ -32,10 +32,9 @@ pub struct CreateOnboardingConfigPayload { #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct DashboardConfigListItem { pub id: Uuid, - pub role_id: Uuid, pub role_key: String, pub audience: String, - pub version: i32, + pub config_json: serde_json::Value, pub is_active: bool, pub updated_at: DateTime, } @@ -43,10 +42,9 @@ pub struct DashboardConfigListItem { #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct DashboardConfig { pub id: Uuid, - pub role_id: Uuid, + pub role_key: String, pub audience: String, pub config_json: serde_json::Value, - pub version: i32, pub is_active: bool, pub updated_at: DateTime, } @@ -174,15 +172,22 @@ impl ConfigRepository { pool: &PgPool, payload: CreateDashboardConfigPayload, ) -> Result { + let role_key = sqlx::query_scalar::<_, String>( + "SELECT code FROM roles WHERE id = $1", + ) + .bind(payload.role_id) + .fetch_one(pool) + .await?; + // Soft-disable previous active configs for this role sqlx::query( r#" UPDATE dashboard_configs SET is_active = false - WHERE role_id = $1 AND audience = $2::text AND is_active = true + WHERE UPPER(role_key) = UPPER($1) AND audience = $2::text AND is_active = true "#, ) - .bind(payload.role_id) + .bind(&role_key) .bind(&payload.audience) .execute(pool) .await?; @@ -190,18 +195,17 @@ impl ConfigRepository { // Insert new config let config = sqlx::query_as::<_, DashboardConfig>( r#" - INSERT INTO dashboard_configs (role_id, audience, config_json, version, is_active) + INSERT INTO dashboard_configs (role_key, audience, widgets, is_active) VALUES ( $1, $2::text, $3, - COALESCE((SELECT MAX(version) FROM dashboard_configs WHERE role_id = $1 AND audience = $2::text), 0) + 1, true ) - RETURNING id, role_id, audience, config_json, version, is_active, updated_at + RETURNING id, role_key, audience, widgets as config_json, is_active, updated_at "#, ) - .bind(payload.role_id) + .bind(&role_key) .bind(&payload.audience) .bind(payload.config_json) .fetch_one(pool) @@ -215,14 +219,21 @@ impl ConfigRepository { role_id: Uuid, audience: &str, ) -> Result { - let config = sqlx::query_as::<_, DashboardConfig>( - r#" - SELECT id, role_id, audience, config_json, version, is_active, updated_at - FROM dashboard_configs - WHERE role_id = $1 AND audience = $2 AND is_active = true - "#, + let role_key = sqlx::query_scalar::<_, String>( + "SELECT code FROM roles WHERE id = $1", ) .bind(role_id) + .fetch_one(pool) + .await?; + + let config = sqlx::query_as::<_, DashboardConfig>( + r#" + SELECT id, role_key, audience, widgets as config_json, is_active, updated_at + FROM dashboard_configs + WHERE UPPER(role_key) = UPPER($1) AND audience = $2 AND is_active = true + "#, + ) + .bind(role_key) .bind(audience) .fetch_one(pool) .await?; @@ -236,10 +247,8 @@ impl ConfigRepository { let configs = sqlx::query_as::<_, DashboardConfigListItem>( r#" SELECT - c.id, c.role_id, r.key as role_key, c.audience, - c.version, c.is_active, c.updated_at + c.id, c.role_key, c.audience, c.widgets as config_json, c.is_active, c.updated_at FROM dashboard_configs c - JOIN roles r ON c.role_id = r.id ORDER BY c.updated_at DESC "#, ) @@ -256,13 +265,12 @@ impl ConfigRepository { ) -> Result { let config = sqlx::query_as::<_, DashboardConfig>( r#" - SELECT c.id, c.role_id, c.audience, c.config_json, c.version, c.is_active, c.updated_at + SELECT c.id, c.role_key, c.audience, c.widgets as config_json, c.is_active, c.updated_at FROM dashboard_configs c - JOIN roles r ON c.role_id = r.id - WHERE r.key = $1 AND c.audience = $2 AND c.is_active = true + WHERE UPPER(c.role_key) = UPPER($1) AND c.audience = $2 AND c.is_active = true "#, ) - .bind(role_key.to_uppercase()) + .bind(role_key) .bind(audience) .fetch_one(pool) .await?; diff --git a/crates/db/src/models/user.rs b/crates/db/src/models/user.rs index 1e787f1..c3d96a9 100644 --- a/crates/db/src/models/user.rs +++ b/crates/db/src/models/user.rs @@ -102,15 +102,15 @@ impl UserRepository { .await } - /// Returns all approved role keys for a user (e.g. ["COMPANY", "JOB_SEEKER"]) + /// Returns all approved role codes for a user (e.g. ["COMPANY", "DEVELOPER"]) pub async fn get_user_role_keys(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { let rows = sqlx::query_scalar::<_, String>( r#" - SELECT r.key + SELECT r.code FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = $1 AND ur.status = 'APPROVED' - ORDER BY ur.approved_at ASC + ORDER BY ur.created_at ASC "#, ) .bind(user_id) From a3076ed5267b22f59f1944797e35a40847e083bb Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Wed, 15 Apr 2026 06:23:27 +0200 Subject: [PATCH 038/182] feat: update DB schema - split users.first_name, users.last_name, roles split --- apps/companies/src/handlers/mod.rs | 6 +- apps/cron/src/tasks/jobs.rs | 2 +- apps/cron/src/tasks/leads.rs | 2 +- apps/cron/src/tasks/requirements.rs | 2 +- apps/customers/src/admin.rs | 4 +- apps/customers/src/handlers.rs | 8 +- apps/job_seekers/src/handlers.rs | 6 +- apps/users/src/handlers/admin.rs | 19 +- apps/users/src/handlers/approvals.rs | 24 +- apps/users/src/handlers/auth.rs | 66 +- apps/users/src/handlers/config.rs | 2 +- apps/users/src/handlers/dashboard.rs | 2 +- apps/users/src/handlers/external_roles.rs | 43 +- apps/users/src/handlers/kb.rs | 42 +- apps/users/src/handlers/onboarding.rs | 22 +- apps/users/src/handlers/profile.rs | 39 +- apps/users/src/handlers/reviews.rs | 33 +- apps/users/src/handlers/roles.rs | 141 +- apps/users/src/handlers/settings.rs | 2 +- apps/users/src/handlers/support.rs | 20 +- apps/users/src/handlers/verifications.rs | 31 +- companies.pid | 1 + crates/contracts/src/profession_shared.rs | 8 +- crates/db/src/models/config.rs | 43 +- crates/db/src/models/customer.rs | 27 +- crates/db/src/models/department.rs | 28 +- crates/db/src/models/employee.rs | 10 +- crates/db/src/models/job_seeker.rs | 20 +- crates/db/src/models/requirement.rs | 27 +- crates/db/src/models/tracecoin_wallet.rs | 12 +- crates/db/src/models/user.rs | 23 +- crates/db/src/models/verification.rs | 11 +- customers.pid | 1 + gateway.pid | 2 +- job_seekers.pid | 1 + scripts/init-db.sql | 2159 +++++++++------------ scripts/seed.sql | 28 +- users.pid | 2 +- 38 files changed, 1324 insertions(+), 1595 deletions(-) create mode 100644 companies.pid create mode 100644 customers.pid create mode 100644 job_seekers.pid diff --git a/apps/companies/src/handlers/mod.rs b/apps/companies/src/handlers/mod.rs index 8ee10b0..0c6fcf2 100644 --- a/apps/companies/src/handlers/mod.rs +++ b/apps/companies/src/handlers/mod.rs @@ -258,7 +258,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.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. @@ -367,7 +367,7 @@ async fn update_application_status( Ok(updated) => { // Notify applicant of status change (ignore failures) let applicant_info = sqlx::query_as::<_, (String, String)>( - "SELECT u.name, u.email FROM users u WHERE u.id = $1", + "SELECT CONCAT(u.first_name, ' ', u.last_name) AS name, u.email, u.phone FROM users u WHERE u.id = $1", ) .bind(app.applicant_user_id) .fetch_optional(&state.pool) @@ -439,7 +439,7 @@ async fn view_contact( let contact = sqlx::query_as::<_, (Option, String, Option)>( r#" - SELECT u.name, u.email, u.phone + SELECT CONCAT(u.first_name, ' ', u.last_name) AS name, u.email, u.phone FROM users u WHERE u.id = $1 "#, diff --git a/apps/cron/src/tasks/jobs.rs b/apps/cron/src/tasks/jobs.rs index 4a68184..12ce868 100644 --- a/apps/cron/src/tasks/jobs.rs +++ b/apps/cron/src/tasks/jobs.rs @@ -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.name + RETURNING jobs.id as job_id, jobs.title, u.email, CONCAT(u.first_name, ' ', u.last_name) AS name "# ) .bind(now) diff --git a/apps/cron/src/tasks/leads.rs b/apps/cron/src/tasks/leads.rs index 451080d..5beec0e 100644 --- a/apps/cron/src/tasks/leads.rs +++ b/apps/cron/src/tasks/leads.rs @@ -26,7 +26,7 @@ pub async fn expire_stale_lead_requests( lr.tracecoins_reserved, urp.user_id, u.email, - u.name + CONCAT(u.first_name, ' ', u.last_name) AS name FROM lead_requests lr INNER JOIN user_role_profiles urp ON urp.id = lr.user_role_profile_id INNER JOIN users u ON u.id = urp.user_id diff --git a/apps/cron/src/tasks/requirements.rs b/apps/cron/src/tasks/requirements.rs index 526b540..a23e430 100644 --- a/apps/cron/src/tasks/requirements.rs +++ b/apps/cron/src/tasks/requirements.rs @@ -26,7 +26,7 @@ pub async fn expire_stale_leads( 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, u.name + RETURNING leads.id as lead_id, leads.title, u.email, CONCAT(u.first_name, ' ', u.last_name) AS name "# ) .bind(now) diff --git a/apps/customers/src/admin.rs b/apps/customers/src/admin.rs index 18b07f4..294882f 100644 --- a/apps/customers/src/admin.rs +++ b/apps/customers/src/admin.rs @@ -11,7 +11,7 @@ pub struct AdminLeadRow { pub description: Option, pub profession_key: String, pub location: String, - pub budget: Option, + pub budget_inr: Option, pub status: String, pub created_at: chrono::DateTime, pub updated_at: chrono::DateTime, @@ -25,7 +25,7 @@ impl From 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, diff --git a/apps/customers/src/handlers.rs b/apps/customers/src/handlers.rs index 41bc22b..e445a2b 100644 --- a/apps/customers/src/handlers.rs +++ b/apps/customers/src/handlers.rs @@ -132,8 +132,8 @@ async fn create_requirement( 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, }; @@ -190,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.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. @@ -200,7 +200,7 @@ 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, "created_by_user_id": updated.created_by_user_id, }); diff --git a/apps/job_seekers/src/handlers.rs b/apps/job_seekers/src/handlers.rs index a3a3197..665306d 100644 --- a/apps/job_seekers/src/handlers.rs +++ b/apps/job_seekers/src/handlers.rs @@ -245,19 +245,19 @@ 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)>( - "SELECT u.email, u.name FROM users u INNER JOIN companies c ON c.user_id = u.id WHERE c.id = $1" + "SELECT u.email, CONCAT(u.first_name, ' ', u.last_name) AS name 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, name))) = company_user { - let seeker_name = seeker.full_name.as_deref().unwrap_or("A candidate"); + let seeker_name = format!("{} {}", seeker.first_name.unwrap_or_default(), seeker.last_name.unwrap_or_default()); let _ = state.mail.send_new_application_email( &email, name.as_deref().unwrap_or("Company"), &job.title, - seeker_name + &seeker_name ).await; } diff --git a/apps/users/src/handlers/admin.rs b/apps/users/src/handlers/admin.rs index 89479e7..1ac05d2 100644 --- a/apps/users/src/handlers/admin.rs +++ b/apps/users/src/handlers/admin.rs @@ -31,7 +31,8 @@ pub struct ListQuery { pub struct AdminUserRow { pub id: Uuid, pub email: String, - pub full_name: Option, + pub first_name: Option, + pub last_name: Option, pub status: String, pub created_at: chrono::DateTime, pub roles: Vec, @@ -49,12 +50,12 @@ async fn list_users( // Generic list: users + their approved roles r#" SELECT - u.id, u.email, u.name, u.status, u.created_at, + 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 roles r ON r.id = ur.role_id - WHERE ($1 = '' OR LOWER(u.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 @@ -80,11 +81,11 @@ async fn list_users( format!( r#" SELECT - u.id, u.email, u.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.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 "#, @@ -110,12 +111,12 @@ async fn list_customers( let sql = r#" SELECT - u.id, u.email, u.name, u.status, u.created_at, + 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 roles r ON r.id = ur.role_id AND r.key = 'CUSTOMER' - WHERE ($1 = '' OR LOWER(u.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 "#; @@ -138,12 +139,12 @@ async fn list_candidates( let sql = r#" SELECT - u.id, u.email, u.name, u.status, u.created_at, + 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 roles r ON r.id = ur.role_id AND r.key = 'JOB_SEEKER' - WHERE ($1 = '' OR LOWER(u.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 "#; diff --git a/apps/users/src/handlers/approvals.rs b/apps/users/src/handlers/approvals.rs index 5b18604..be47db4 100644 --- a/apps/users/src/handlers/approvals.rs +++ b/apps/users/src/handlers/approvals.rs @@ -94,7 +94,7 @@ async fn get_submission( Json(serde_json::json!({ "user": { "id": user.id, - "name": user.name, + "name": format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()), "email": user.email, "phone": null, "status": user.status, @@ -218,7 +218,7 @@ async fn activate_profile_after_final_approval( }; let query = format!( - "UPDATE {} SET verification_status = 'APPROVED', updated_at = NOW() WHERE id = $1", + "UPDATE {} SET status = 'APPROVED', updated_at = NOW() WHERE id = $1", table ); sqlx::query(&query).bind(user_role_profile_id).execute(&state.pool).await?; @@ -243,13 +243,10 @@ 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.name.as_deref().unwrap_or_default(), - &display, - ) + .send_approval_approved_email(&user.email, &user_name, &display) .await; } @@ -292,18 +289,19 @@ async fn reject_profile_after_final_approval( }; let query = format!( - "UPDATE {} SET verification_status = 'REJECTED', updated_at = NOW() WHERE id = $1", + "UPDATE {} SET status = 'REJECTED', updated_at = NOW() WHERE id = $1", table ); 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.name.as_deref().unwrap_or_default(), + &user_name, &display, reason.unwrap_or("Rejected by final approval"), ) @@ -439,13 +437,13 @@ async fn approve_job( ) .await; - let company_info = sqlx::query_as::<_, (String, String)>( - "SELECT u.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)>( + "SELECT CONCAT(u.first_name, ' ', u.last_name) AS u_full_name, u.email 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 { let _ = state.mail.send_job_approved_email(&email, &name, &existing.title).await; } @@ -490,7 +488,7 @@ async fn reject_job( .await; let company_info = sqlx::query_as::<_, (String, String)>( - "SELECT u.name, u.email FROM companies c JOIN users u ON u.id = c.user_id WHERE c.id = $1", + "SELECT CONCAT(u.first_name, ' ', u.last_name) AS u_full_name, u.email 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) diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index e44ed50..a6c30e0 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -40,8 +40,6 @@ pub struct RegisterPayload { #[serde(default)] pub last_name: Option, #[serde(default)] - pub full_name: Option, - #[serde(default)] pub name: Option, pub email: String, pub phone: Option, @@ -179,7 +177,7 @@ async fn ensure_role_exists(pool: &sqlx::PgPool, role_code: &str) -> Option("SELECT id FROM roles WHERE code = $1") + if let Ok(found) = sqlx::query_scalar::<_, Uuid>("SELECT id FROM roles WHERE key = $1") .bind(&normalized) .fetch_optional(pool) .await @@ -190,20 +188,29 @@ async fn ensure_role_exists(pool: &sqlx::PgPool, role_code: &str) -> Option( + let role_id = sqlx::query_scalar::<_, Uuid>( r#" - INSERT INTO roles (name, code, audience, is_active) - VALUES ($1, $2, 'USER', true) - ON CONFLICT (code) - DO UPDATE SET updated_at = NOW() + 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) - .bind(normalized) .fetch_one(pool) .await - .ok() + .ok()?; + + let _ = sqlx::query( + "INSERT INTO external_roles (role_id) VALUES ($1) ON CONFLICT (role_id) DO NOTHING", + ) + .bind(role_id) + .execute(pool) + .await; + + Some(role_id) } // ── Handlers ────────────────────────────────────────────────────────────────── @@ -264,15 +271,12 @@ async fn register( let password_hash = hash_password(&payload.password) .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "INTERNAL_ERROR"))?; - let full_name = match (&payload.first_name, &payload.last_name, &payload.full_name, &payload.name) { - (Some(fn_), Some(ln_), _, _) => format!("{} {}", fn_.trim(), ln_.trim()).trim().to_string(), - (_, _, Some(fn_), _) => fn_.trim().to_string(), - (_, _, _, Some(n)) => n.trim().to_string(), - _ => return Err(err(StatusCode::UNPROCESSABLE_ENTITY, "Name is required (full_name or first_name+last_name)", "VALIDATION_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 { - name: full_name, + first_name: Some(first_name), + last_name: Some(last_name), email: email.clone(), password_hash, }) @@ -332,13 +336,14 @@ async fn register( .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.name.clone().unwrap_or_default(), &otp).await; + let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); + let _ = state.mail.send_verification_email(&user.email, &user_name, &otp).await; Ok((StatusCode::CREATED, Json(RegisterResponse { user_id: user.id.to_string(), email: user.email, phone: None, - name: user.name.unwrap_or_default(), + name: user_name, status: user.status, email_verified: user.email_verified, created_at: user.created_at.to_rfc3339(), @@ -400,6 +405,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", @@ -407,7 +413,7 @@ async fn login( "user": { "id": user.id.to_string(), "email": user.email, - "full_name": user.name.unwrap_or_default(), + "name": user_name, "email_verified": user.email_verified, "active_role": active_role, "roles": user_roles, @@ -516,10 +522,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, - name: user.name.unwrap_or_default(), + name: user_name, email_verified: user.email_verified, active_role: user_roles.first().cloned(), roles: user_roles, @@ -549,7 +556,8 @@ 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.name.unwrap_or_default()).await; + let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); + let _ = state.mail.send_welcome_email(&user.email, &user_name).await; } Ok((StatusCode::OK, Json(serde_json::json!({ "message": "Email verified successfully" })))) @@ -585,7 +593,8 @@ async fn resend_otp( .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.name.unwrap_or_default(), &otp).await; + let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); + let _ = state.mail.send_verification_email(&user.email, &user_name, &otp).await; Ok(silent_ok) } @@ -610,7 +619,8 @@ async fn forgot_password( .await .map_err(|e| err(StatusCode::INTERNAL_SERVER_ERROR, &e.to_string(), "CACHE_ERROR"))?; - let _ = state.mail.send_password_reset_email(&user.email, &user.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, &token).await; Ok(silent_ok) } @@ -643,8 +653,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.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" })))) @@ -677,7 +688,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.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" })))) } diff --git a/apps/users/src/handlers/config.rs b/apps/users/src/handlers/config.rs index c782e50..91da10e 100644 --- a/apps/users/src/handlers/config.rs +++ b/apps/users/src/handlers/config.rs @@ -284,7 +284,7 @@ async fn get_my_runtime_config( "user".to_string(), serde_json::json!({ "id": user.id.to_string(), - "name": user.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, diff --git a/apps/users/src/handlers/dashboard.rs b/apps/users/src/handlers/dashboard.rs index c3fc52f..979b615 100644 --- a/apps/users/src/handlers/dashboard.rs +++ b/apps/users/src/handlers/dashboard.rs @@ -125,7 +125,7 @@ async fn get_metrics(State(state): State) -> Json( r#" SELECT r.id, r.title, r.status, r.created_at, - u.name AS requester_name + 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') diff --git a/apps/users/src/handlers/external_roles.rs b/apps/users/src/handlers/external_roles.rs index 9fb5941..f87dbc2 100644 --- a/apps/users/src/handlers/external_roles.rs +++ b/apps/users/src/handlers/external_roles.rs @@ -20,9 +20,9 @@ pub fn router() -> Router { #[derive(Deserialize)] struct ListQuery { q: Option, - status: Option, // ACTIVE | INACTIVE - vertical: Option, // jobs | marketplace - category: Option, // provider | employer | consumer | specialist + status: Option, + vertical: Option, + category: Option, page: Option, per_page: Option, } @@ -71,7 +71,7 @@ async fn list_external_roles( auth: AuthUser, State(state): State, Query(q): Query, - ) -> Result { +) -> Result { if let Err(_e) = require_admin(&auth) { return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); } @@ -83,7 +83,6 @@ 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 @@ -95,8 +94,8 @@ async fn list_external_roles( 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 + JOIN external_roles er ON er.role_id = r.id + LEFT JOIN 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,11 +111,11 @@ 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(*) FROM roles r + JOIN external_roles er ON er.role_id = r.id 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)) @@ -149,14 +148,12 @@ 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'", ) @@ -217,14 +214,16 @@ async fn get_external_role( auth: AuthUser, State(state): State, Path(id): Path, - ) -> Result { +) -> Result { 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 + JOIN external_roles er ON er.role_id = r.id LEFT JOIN runtime_configs rc ON rc.role_id = r.id AND rc.is_active = true WHERE r.id = $1 AND r.audience = 'EXTERNAL' "#, @@ -252,7 +251,7 @@ struct CreateExternalRolePayload { name: String, code: String, is_active: Option, - runtime: JsonValue, // carries vertical/category/modules/permissions/assigned_user_types/requires/feature_limits/onboarding_schema_id + runtime: JsonValue, } #[derive(sqlx::FromRow)] @@ -274,12 +273,11 @@ async fn create_external_role( auth: AuthUser, State(state): State, Json(payload): Json, - ) -> Result { +) -> Result { 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) @@ -294,7 +292,14 @@ async fn create_external_role( .await .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; - // Create runtime config version 1 + sqlx::query( + "INSERT INTO external_roles (role_id) VALUES ($1)", + ) + .bind(role.id) + .execute(&state.pool) + .await + .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; + let rc = sqlx::query_as::<_, InsertedRc>( r#" INSERT INTO runtime_configs (role_id, config_json, version, is_active) @@ -335,11 +340,10 @@ async fn update_external_role( State(state): State, Path(id): Path, Json(payload): Json, - ) -> Result { +) -> Result { 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,7 +360,6 @@ 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#" @@ -393,7 +396,7 @@ async fn delete_external_role( auth: AuthUser, State(state): State, Path(id): Path, - ) -> Result { +) -> Result { if let Err(_e) = require_admin(&auth) { return Err((StatusCode::FORBIDDEN, "Forbidden".to_string())); } diff --git a/apps/users/src/handlers/kb.rs b/apps/users/src/handlers/kb.rs index ab9b3fd..63202a3 100644 --- a/apps/users/src/handlers/kb.rs +++ b/apps/users/src/handlers/kb.rs @@ -132,7 +132,7 @@ struct AdminArticleRow { category_id: Uuid, target_roles: Option>, tags: Vec, - is_published: bool, + status: String, views: i32, created_at: chrono::DateTime, updated_at: chrono::DateTime, @@ -149,7 +149,7 @@ struct InsertedArticleRow { category_id: Uuid, target_roles: Option>, tags: Vec, - is_published: bool, + status: String, views: i32, created_at: chrono::DateTime, updated_at: chrono::DateTime, @@ -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) @@ -569,26 +569,26 @@ async fn admin_list_articles( Query(params): Query, ) -> impl IntoResponse { let q = params.q.as_deref().unwrap_or("").to_lowercase(); - let published_filter: Option = params.status.as_deref().map(|s| s == "PUBLISHED"); + let status_filter: Option = 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 +604,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 +646,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 = body.target_roles.unwrap_or_default(); let tags: Vec = 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 +664,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 +682,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 +721,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 +744,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 +787,7 @@ async fn admin_update_article( Path(id): Path, Json(body): Json, ) -> impl IntoResponse { - let is_published: Option = body.status.as_deref().map(|s| s == "PUBLISHED"); + let status: Option = body.status.as_deref().map(|s| s.to_string()); let result = sqlx::query_as::<_, InsertedArticleRow>( r#" UPDATE kb_articles SET @@ -796,13 +796,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 +811,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 +828,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, diff --git a/apps/users/src/handlers/onboarding.rs b/apps/users/src/handlers/onboarding.rs index f2bc890..019c35a 100644 --- a/apps/users/src/handlers/onboarding.rs +++ b/apps/users/src/handlers/onboarding.rs @@ -173,12 +173,11 @@ async fn submit( let query = format!( r#" - INSERT INTO {} (id, "profileData", verification_status, submitted_at, updated_at) - VALUES ($1, $2, 'PENDING', NOW(), NOW()) + INSERT INTO {} (id, custom_data, status, updated_at) + VALUES ($1, $2, 'PENDING', NOW()) ON CONFLICT (id) DO UPDATE SET - "profileData" = EXCLUDED."profileData", - verification_status = 'PENDING', - submitted_at = NOW(), + custom_data = EXCLUDED.custom_data, + status = 'PENDING', updated_at = NOW() "#, tbl @@ -194,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) @@ -211,7 +210,7 @@ async fn submit( sqlx::query( r#" UPDATE user_roles - SET status = 'PENDING', updated_at = NOW() + SET status = 'PENDING' WHERE user_id = $1 AND role_id = $2 "#, ) @@ -283,15 +282,14 @@ async fn get_or_create_user_role_profile_id( sqlx::query_scalar::<_, uuid::Uuid>( r#" - INSERT INTO user_role_profiles (user_id, role_key, role_id, status) - VALUES ($1, $2, $3, 'DRAFT') + 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) - .bind(role_id) .fetch_one(pool) .await } diff --git a/apps/users/src/handlers/profile.rs b/apps/users/src/handlers/profile.rs index d86c494..6ad232b 100644 --- a/apps/users/src/handlers/profile.rs +++ b/apps/users/src/handlers/profile.rs @@ -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 = r.try_get("name").ok(); + let name: Option = r.try_get("company_name").ok(); let status: String = r.try_get("status").unwrap_or_default(); ( StatusCode::OK, @@ -161,7 +161,7 @@ async fn get_profile( }; let query = format!( - r#"SELECT "profileData", verification_status FROM {} WHERE id = $1"#, + r#"SELECT custom_data, status FROM {} WHERE id = $1"#, table ); @@ -189,10 +189,10 @@ async fn get_profile( 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!({ @@ -234,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) @@ -268,10 +268,10 @@ async fn save_profile( let query = format!( r#" - INSERT INTO {table} (id, "profileData", verification_status, updated_at) + INSERT INTO {table} (id, custom_data, status, updated_at) VALUES ($1, $2, 'DRAFT', NOW()) ON CONFLICT (id) DO UPDATE SET - "profileData" = EXCLUDED."profileData", + custom_data = EXCLUDED.custom_data, updated_at = NOW() "# ); @@ -441,14 +441,14 @@ 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 = r.try_get("name").ok(); + let name: Option = r.try_get("company_name").ok(); serde_json::json!({ "company_name": name }) } _ => serde_json::Value::Object(Default::default()), @@ -465,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) @@ -483,7 +483,7 @@ async fn set_profile_status(state: &AppState, user_id: Uuid, role_key: &str, sta if let Some(table) = role_to_table(role_key) { let q = format!( - "UPDATE {} SET verification_status = $1, submitted_at = NOW(), updated_at = NOW() WHERE id = $2", + "UPDATE {} SET status = $1, updated_at = NOW() WHERE id = $2", table ); sqlx::query(&q) @@ -525,15 +525,14 @@ async fn get_or_create_user_role_profile_id( sqlx::query_scalar::<_, Uuid>( r#" - INSERT INTO user_role_profiles (user_id, role_key, role_id, status) - VALUES ($1, $2, $3, 'DRAFT') + 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) - .bind(role.id) .fetch_one(pool) .await } @@ -544,7 +543,7 @@ async fn fetch_saved_profile_by_urp_id( role_key: &str, ) -> serde_json::Value { if let Some(table) = role_to_table(role_key) { - let q = format!(r#"SELECT "profileData" FROM {} WHERE id = $1"#, table); + 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) @@ -552,7 +551,7 @@ async fn fetch_saved_profile_by_urp_id( { use sqlx::Row; return row - .try_get::("profileData") + .try_get::("custom_data") .unwrap_or(serde_json::Value::Object(Default::default())); } } diff --git a/apps/users/src/handlers/reviews.rs b/apps/users/src/handlers/reviews.rs index 17ec886..ab01f14 100644 --- a/apps/users/src/handlers/reviews.rs +++ b/apps/users/src/handlers/reviews.rs @@ -31,7 +31,6 @@ struct ReviewDto { title: Option, comment: Option, status: String, - is_published: bool, created_at: chrono::DateTime, } @@ -48,7 +47,6 @@ struct CreateReviewBody { #[derive(Deserialize)] struct PatchReviewBody { status: Option, - is_published: Option, } // ── FromRow structs ────────────────────────────────────────────────────────── @@ -64,7 +62,6 @@ struct ReviewRow { title: Option, comment: Option, status: String, - is_published: bool, created_at: chrono::DateTime, } @@ -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, Json(body): Json, ) -> 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; diff --git a/apps/users/src/handlers/roles.rs b/apps/users/src/handlers/roles.rs index 4a80148..78a6801 100644 --- a/apps/users/src/handlers/roles.rs +++ b/apps/users/src/handlers/roles.rs @@ -15,18 +15,13 @@ pub fn router() -> Router { .route("/{id}", get(get_role).patch(update_role).delete(delete_role)) } -// ── Query params ───────────────────────────────────────────────────────────── - #[derive(Deserialize)] struct ListQuery { - audience: Option, q: Option, page: Option, per_page: Option, } -// ── Response types ─────────────────────────────────────────────────────────── - #[derive(Serialize)] struct RoleRow { id: Uuid, @@ -68,13 +63,10 @@ struct RoleDetail { created_at: chrono::DateTime, } -// ── Request types ──────────────────────────────────────────────────────────── - #[derive(Deserialize)] struct CreateRolePayload { key: String, name: String, - audience: String, description: Option, department_id: Option, is_active: Option, @@ -94,8 +86,6 @@ struct UpdateRolePayload { permission_keys: Option>, } -// ── FromRow structs ────────────────────────────────────────────────────────── - #[derive(sqlx::FromRow)] struct RoleListRow { id: Uuid, @@ -134,11 +124,7 @@ struct InsertedRoleRow { key: String, name: String, audience: String, - description: Option, - department_id: Option, is_active: bool, - can_approve_requests: bool, - can_manage_system_settings: bool, created_at: chrono::DateTime, } @@ -152,8 +138,6 @@ struct CurrentRoleRow { can_manage_system_settings: bool, } -// ── Handlers ───────────────────────────────────────────────────────────────── - async fn list_roles( State(state): State, Query(params): Query, @@ -162,36 +146,35 @@ 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#" SELECT r.id, - r.code AS key, + 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 - LEFT JOIN employees e ON e.role_code = r.code + JOIN internal_roles 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.code) LIKE '%' || $2 || '%') - GROUP BY r.id, d.name + 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.code) LIKE '%' || $2 || '%') + JOIN internal_roles 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.code AS 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_roles 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) @@ -290,24 +277,33 @@ async fn create_role( let role = sqlx::query_as::<_, InsertedRoleRow>( r#" - INSERT INTO roles (code, 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, code AS 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_roles (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( @@ -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, Json(payload): Json, ) -> Result { - // 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_roles 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_roles 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,7 +402,6 @@ 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") .bind(id) @@ -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, Path(id): Path, ) -> Result { - 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 diff --git a/apps/users/src/handlers/settings.rs b/apps/users/src/handlers/settings.rs index c991e9b..7fefe45 100644 --- a/apps/users/src/handlers/settings.rs +++ b/apps/users/src/handlers/settings.rs @@ -225,7 +225,7 @@ async fn create_delete_account_request( .mail .send_account_deleted_email( &user.email, - user.name.as_deref().unwrap_or_default(), + &format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()), ) .await; let _ = sqlx::query( diff --git a/apps/users/src/handlers/support.rs b/apps/users/src/handlers/support.rs index 4e75ff1..32d48c0 100644 --- a/apps/users/src/handlers/support.rs +++ b/apps/users/src/handlers/support.rs @@ -137,7 +137,7 @@ async fn user_create_ticket( }; let _ = state.mail.send_support_ticket_created_email( &user.email, - user.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 +444,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.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) @@ -586,10 +582,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.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 +832,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.name.unwrap_or_default() + format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()) } else { ticket.requester_name.unwrap_or_default() }; diff --git a/apps/users/src/handlers/verifications.rs b/apps/users/src/handlers/verifications.rs index c861803..4522787 100644 --- a/apps/users/src/handlers/verifications.rs +++ b/apps/users/src/handlers/verifications.rs @@ -136,21 +136,17 @@ async fn trigger_rejection( }; let query = format!( - "UPDATE {} SET verification_status = 'REJECTED', updated_at = NOW() WHERE id = $1", + "UPDATE {} SET status = 'REJECTED', updated_at = NOW() WHERE id = $1", table ); 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.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; + } } Ok(()) @@ -180,11 +176,8 @@ async fn approve_verification( // Send 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.name.as_deref().unwrap_or_default(), - &display - ).await; + 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_name, &display).await; } (StatusCode::OK, Json(v)).into_response() } @@ -294,12 +287,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.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() diff --git a/companies.pid b/companies.pid new file mode 100644 index 0000000..465e8a5 --- /dev/null +++ b/companies.pid @@ -0,0 +1 @@ +9692 diff --git a/crates/contracts/src/profession_shared.rs b/crates/contracts/src/profession_shared.rs index 13d9e48..c7ae631 100644 --- a/crates/contracts/src/profession_shared.rs +++ b/crates/contracts/src/profession_shared.rs @@ -183,7 +183,7 @@ async fn send_lead_request( Err(_) => return (StatusCode::BAD_REQUEST, "Wallet not found").into_response(), }; - if wallet.current_balance < 25 { + if wallet.balance < 25 { return (StatusCode::PAYMENT_REQUIRED, "Insufficient Tracecoin balance").into_response(); } @@ -374,7 +374,7 @@ async fn my_requests( sqlx::query_as::<_, RichLeadReq>( r#" SELECT lr.*, r.title as req_title, r.profession_key as req_profession_key, r.location as req_location, r.budget as req_budget, - CASE WHEN lr.status = 'ACCEPTED' THEN u.name ELSE NULL END as customer_name, + CASE WHEN lr.status = 'ACCEPTED' THEN CONCAT(u.first_name, ' ', u.last_name) AS name ELSE NULL END as customer_name, CASE WHEN lr.status = 'ACCEPTED' THEN u.email ELSE NULL END as customer_email, CASE WHEN lr.status = 'ACCEPTED' THEN u.phone ELSE NULL END as customer_phone FROM lead_requests lr @@ -390,7 +390,7 @@ async fn my_requests( sqlx::query_as::<_, RichLeadReq>( r#" SELECT lr.*, r.title as req_title, r.profession_key as req_profession_key, r.location as req_location, r.budget as req_budget, - CASE WHEN lr.status = 'ACCEPTED' THEN u.name ELSE NULL END as customer_name, + CASE WHEN lr.status = 'ACCEPTED' THEN CONCAT(u.first_name, ' ', u.last_name) AS name ELSE NULL END as customer_name, CASE WHEN lr.status = 'ACCEPTED' THEN u.email ELSE NULL END as customer_email, CASE WHEN lr.status = 'ACCEPTED' THEN u.phone ELSE NULL END as customer_phone FROM lead_requests lr @@ -567,7 +567,7 @@ async fn accepted_lead_detail( r.location AS requirement_location, r.profession_key, r.custom_fields, - u.name AS customer_name, + CONCAT(u.first_name, ' ', u.last_name) AS name AS customer_name, u.email AS customer_email, u.phone AS customer_phone FROM lead_requests lr diff --git a/crates/db/src/models/config.rs b/crates/db/src/models/config.rs index 9abc25b..9925a7b 100644 --- a/crates/db/src/models/config.rs +++ b/crates/db/src/models/config.rs @@ -32,6 +32,7 @@ pub struct CreateOnboardingConfigPayload { #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct DashboardConfigListItem { pub id: Uuid, + pub role_id: Uuid, pub role_key: String, pub audience: String, pub config_json: serde_json::Value, @@ -42,7 +43,7 @@ pub struct DashboardConfigListItem { #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct DashboardConfig { pub id: Uuid, - pub role_key: String, + pub role_id: Uuid, pub audience: String, pub config_json: serde_json::Value, pub is_active: bool, @@ -172,40 +173,31 @@ impl ConfigRepository { pool: &PgPool, payload: CreateDashboardConfigPayload, ) -> Result { - let role_key = sqlx::query_scalar::<_, String>( - "SELECT code FROM roles WHERE id = $1", - ) - .bind(payload.role_id) - .fetch_one(pool) - .await?; - - // Soft-disable previous active configs for this role sqlx::query( r#" UPDATE dashboard_configs SET is_active = false - WHERE UPPER(role_key) = UPPER($1) AND audience = $2::text AND is_active = true + WHERE role_id = $1 AND audience = $2::text AND is_active = true "#, ) - .bind(&role_key) + .bind(payload.role_id) .bind(&payload.audience) .execute(pool) .await?; - // Insert new config let config = sqlx::query_as::<_, DashboardConfig>( r#" - INSERT INTO dashboard_configs (role_key, audience, widgets, is_active) + INSERT INTO dashboard_configs (role_id, audience, config_json, is_active) VALUES ( $1, $2::text, $3, true ) - RETURNING id, role_key, audience, widgets as config_json, is_active, updated_at + RETURNING id, role_id, audience, config_json, is_active, updated_at "#, ) - .bind(&role_key) + .bind(payload.role_id) .bind(&payload.audience) .bind(payload.config_json) .fetch_one(pool) @@ -219,21 +211,14 @@ impl ConfigRepository { role_id: Uuid, audience: &str, ) -> Result { - let role_key = sqlx::query_scalar::<_, String>( - "SELECT code FROM roles WHERE id = $1", - ) - .bind(role_id) - .fetch_one(pool) - .await?; - let config = sqlx::query_as::<_, DashboardConfig>( r#" - SELECT id, role_key, audience, widgets as config_json, is_active, updated_at + SELECT id, role_id, audience, config_json, is_active, updated_at FROM dashboard_configs - WHERE UPPER(role_key) = UPPER($1) AND audience = $2 AND is_active = true + WHERE role_id = $1 AND audience = $2 AND is_active = true "#, ) - .bind(role_key) + .bind(role_id) .bind(audience) .fetch_one(pool) .await?; @@ -247,8 +232,9 @@ impl ConfigRepository { let configs = sqlx::query_as::<_, DashboardConfigListItem>( r#" SELECT - c.id, c.role_key, c.audience, c.widgets as config_json, c.is_active, c.updated_at + c.id, c.role_id, r.key as role_key, c.audience, c.config_json, c.is_active, c.updated_at FROM dashboard_configs c + JOIN roles r ON c.role_id = r.id ORDER BY c.updated_at DESC "#, ) @@ -265,9 +251,10 @@ impl ConfigRepository { ) -> Result { let config = sqlx::query_as::<_, DashboardConfig>( r#" - SELECT c.id, c.role_key, c.audience, c.widgets as config_json, c.is_active, c.updated_at + SELECT c.id, c.role_id, c.audience, c.config_json, c.is_active, c.updated_at FROM dashboard_configs c - WHERE UPPER(c.role_key) = UPPER($1) AND c.audience = $2 AND c.is_active = true + JOIN roles r ON c.role_id = r.id + WHERE r.key = $1 AND c.audience = $2 AND c.is_active = true "#, ) .bind(role_key) diff --git a/crates/db/src/models/customer.rs b/crates/db/src/models/customer.rs index 0f3a5c4..8d44fde 100644 --- a/crates/db/src/models/customer.rs +++ b/crates/db/src/models/customer.rs @@ -7,7 +7,8 @@ use uuid::Uuid; pub struct CustomerProfile { pub id: Uuid, pub user_id: Uuid, - pub full_name: Option, + pub first_name: Option, + pub last_name: Option, pub phone: Option, pub city: Option, pub area: Option, @@ -15,7 +16,6 @@ pub struct CustomerProfile { pub active_requirement_count: i32, pub status: String, pub bio: Option, - pub experience_years: Option, pub custom_data: Option, pub created_at: DateTime, pub updated_at: DateTime, @@ -23,7 +23,8 @@ pub struct CustomerProfile { #[derive(Debug, Serialize, Deserialize)] pub struct UpsertCustomerProfilePayload { - pub full_name: Option, + pub first_name: Option, + pub last_name: Option, pub phone: Option, pub city: Option, pub area: Option, @@ -42,8 +43,8 @@ impl CustomerRepository { let profile = sqlx::query_as::<_, CustomerProfile>( r#" SELECT - id, user_id, full_name, phone, city, area, preferred_professions, - active_requirement_count, status, bio, experience_years, custom_data, + id, user_id, first_name, last_name, phone, city, area, preferred_professions, + active_requirement_count, status, bio, custom_data, created_at, updated_at FROM customer_profiles WHERE user_id = $1 @@ -64,11 +65,12 @@ impl CustomerRepository { let profile = sqlx::query_as::<_, CustomerProfile>( r#" INSERT INTO customer_profiles ( - user_id, full_name, phone, city, area, preferred_professions, bio, custom_data, status + user_id, first_name, last_name, phone, city, area, preferred_professions, bio, custom_data, status ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, 'PENDING') ON CONFLICT (user_id) DO UPDATE SET - full_name = EXCLUDED.full_name, + first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name, phone = EXCLUDED.phone, city = EXCLUDED.city, area = EXCLUDED.area, @@ -81,13 +83,14 @@ impl CustomerRepository { END, updated_at = NOW() RETURNING - id, user_id, full_name, phone, city, area, preferred_professions, - active_requirement_count, status, bio, experience_years, custom_data, + id, user_id, first_name, last_name, phone, city, area, preferred_professions, + active_requirement_count, status, bio, custom_data, created_at, updated_at "#, ) .bind(user_id) - .bind(payload.full_name) + .bind(payload.first_name) + .bind(payload.last_name) .bind(payload.phone) .bind(payload.city) .bind(payload.area) @@ -125,8 +128,8 @@ impl CustomerRepository { SET status = 'PENDING_REVIEW', updated_at = NOW() WHERE user_id = $1 RETURNING - id, user_id, full_name, phone, city, area, preferred_professions, - active_requirement_count, status, bio, experience_years, custom_data, + id, user_id, first_name, last_name, phone, city, area, preferred_professions, + active_requirement_count, status, bio, custom_data, created_at, updated_at "#, ) diff --git a/crates/db/src/models/department.rs b/crates/db/src/models/department.rs index ff459d5..1c696ad 100644 --- a/crates/db/src/models/department.rs +++ b/crates/db/src/models/department.rs @@ -12,8 +12,6 @@ pub struct Department { pub department_head: Option, pub department_email: Option, pub is_active: bool, - pub visibility: String, - pub transfers_enabled: bool, pub created_at: DateTime, pub updated_at: DateTime, } @@ -25,9 +23,7 @@ pub struct CreateDepartmentPayload { pub description: Option, pub department_head: Option, pub department_email: Option, - pub status: Option, // ACTIVE | INACTIVE - pub visibility: Option, // INTERNAL | EXTERNAL - pub transfers_enabled: Option, + pub status: Option, } pub struct DepartmentRepository; @@ -35,17 +31,15 @@ pub struct DepartmentRepository; impl DepartmentRepository { pub async fn create(pool: &PgPool, payload: CreateDepartmentPayload) -> Result { let is_active = payload.status.map(|s| s.to_uppercase() == "ACTIVE").unwrap_or(true); - let visibility = payload.visibility.unwrap_or_else(|| "INTERNAL".to_string()); - let transfers_enabled = payload.transfers_enabled.unwrap_or(false); sqlx::query_as::<_, Department>( r#" INSERT INTO departments ( name, code, description, department_head, department_email, - is_active, visibility, transfers_enabled + is_active ) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) - RETURNING id, name, code, description, department_head, department_email, is_active, visibility, transfers_enabled, created_at, updated_at + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING id, name, code, description, department_head, department_email, is_active, created_at, updated_at "# ) .bind(payload.name) @@ -54,8 +48,6 @@ impl DepartmentRepository { .bind(payload.department_head) .bind(payload.department_email) .bind(is_active) - .bind(visibility) - .bind(transfers_enabled) .fetch_one(pool) .await } @@ -68,8 +60,6 @@ impl DepartmentRepository { let department_email = payload.get("department_email").map(|v| v.as_str().unwrap_or_default()); let status = payload.get("status").and_then(|v| v.as_str()); let is_active = status.map(|s| s.to_uppercase() == "ACTIVE"); - let visibility = payload.get("visibility").and_then(|v| v.as_str()); - let transfers_enabled = payload.get("transfers_enabled").and_then(|v| v.as_bool()); sqlx::query_as::<_, Department>( r#" @@ -80,11 +70,9 @@ impl DepartmentRepository { department_head = COALESCE($5, department_head), department_email = COALESCE($6, department_email), is_active = COALESCE($7, is_active), - visibility = COALESCE($8, visibility), - transfers_enabled = COALESCE($9, transfers_enabled), updated_at = NOW() WHERE id = $1 - RETURNING id, name, code, description, department_head, department_email, is_active, visibility, transfers_enabled, created_at, updated_at + RETURNING id, name, code, description, department_head, department_email, is_active, created_at, updated_at "# ) .bind(id) @@ -94,8 +82,6 @@ impl DepartmentRepository { .bind(department_head) .bind(department_email) .bind(is_active) - .bind(visibility) - .bind(transfers_enabled) .fetch_one(pool) .await } @@ -110,7 +96,7 @@ impl DepartmentRepository { pub async fn list(pool: &PgPool) -> Result, sqlx::Error> { sqlx::query_as::<_, Department>( - "SELECT id, name, code, description, department_head, department_email, is_active, visibility, transfers_enabled, created_at, updated_at FROM departments ORDER BY name ASC" + "SELECT id, name, code, description, department_head, department_email, is_active, created_at, updated_at FROM departments ORDER BY name ASC" ) .fetch_all(pool) .await @@ -118,7 +104,7 @@ impl DepartmentRepository { pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result, sqlx::Error> { sqlx::query_as::<_, Department>( - "SELECT id, name, code, description, department_head, department_email, is_active, visibility, transfers_enabled, created_at, updated_at FROM departments WHERE id = $1" + "SELECT id, name, code, description, department_head, department_email, is_active, created_at, updated_at FROM departments WHERE id = $1" ) .bind(id) .fetch_optional(pool) diff --git a/crates/db/src/models/employee.rs b/crates/db/src/models/employee.rs index 1076b4e..044be94 100644 --- a/crates/db/src/models/employee.rs +++ b/crates/db/src/models/employee.rs @@ -15,7 +15,7 @@ pub struct Employee { pub designation_id: Option, pub role_code: String, pub status: String, - pub joined_at: NaiveDate, + pub joining_date: NaiveDate, pub created_at: DateTime, pub updated_at: DateTime, } @@ -50,7 +50,7 @@ impl EmployeeRepository { r#" INSERT INTO employees (first_name, last_name, email, password_hash, department_id, designation_id, role_code) VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joined_at, created_at, updated_at + RETURNING id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at "# ) .bind(payload.first_name) @@ -88,7 +88,7 @@ impl EmployeeRepository { status = COALESCE($7, status), updated_at = NOW() WHERE id = $8 - RETURNING id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joined_at, created_at, updated_at + RETURNING id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at "# ) .bind(first_name) @@ -113,7 +113,7 @@ impl EmployeeRepository { pub async fn get_by_email(pool: &PgPool, email: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, Employee>( - "SELECT id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joined_at, created_at, updated_at FROM employees WHERE email = $1" + "SELECT id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at FROM employees WHERE email = $1" ) .bind(email.to_lowercase()) .fetch_optional(pool) @@ -124,7 +124,7 @@ impl EmployeeRepository { let search = q.unwrap_or_default().to_lowercase(); sqlx::query_as::<_, Employee>( r#" - SELECT id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joined_at, created_at, updated_at + SELECT id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at FROM employees WHERE ($1 = '' OR LOWER(first_name) LIKE '%' || $1 || '%' OR LOWER(last_name) LIKE '%' || $1 || '%' OR LOWER(email) LIKE '%' || $1 || '%') ORDER BY last_name, first_name diff --git a/crates/db/src/models/job_seeker.rs b/crates/db/src/models/job_seeker.rs index 90bc991..4080368 100644 --- a/crates/db/src/models/job_seeker.rs +++ b/crates/db/src/models/job_seeker.rs @@ -7,7 +7,8 @@ use uuid::Uuid; pub struct JobSeekerProfile { pub id: Uuid, pub user_id: Uuid, - pub full_name: Option, + pub first_name: Option, + pub last_name: Option, pub location: Option, pub summary: Option, pub experience_years: Option, @@ -23,7 +24,8 @@ pub struct JobSeekerProfile { #[derive(Debug, Serialize, Deserialize)] pub struct UpsertJobSeekerProfilePayload { - pub full_name: Option, + pub first_name: Option, + pub last_name: Option, pub location: Option, pub summary: Option, pub experience_years: Option, @@ -43,7 +45,7 @@ impl JobSeekerRepository { let profile = sqlx::query_as::<_, JobSeekerProfile>( r#" SELECT - id, user_id, full_name, location, summary, experience_years, + id, user_id, first_name, last_name, location, summary, experience_years, skills, resume_url, active_application_count, status, bio, custom_data, created_at, updated_at FROM job_seeker_profiles @@ -65,12 +67,13 @@ impl JobSeekerRepository { let profile = sqlx::query_as::<_, JobSeekerProfile>( r#" INSERT INTO job_seeker_profiles ( - user_id, full_name, location, summary, experience_years, + user_id, first_name, last_name, location, summary, experience_years, skills, resume_url, bio, custom_data ) VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) ON CONFLICT (user_id) DO UPDATE SET - full_name = EXCLUDED.full_name, + first_name = EXCLUDED.first_name, + last_name = EXCLUDED.last_name, location = EXCLUDED.location, summary = EXCLUDED.summary, experience_years = EXCLUDED.experience_years, @@ -80,13 +83,14 @@ impl JobSeekerRepository { custom_data = EXCLUDED.custom_data, updated_at = NOW() RETURNING - id, user_id, full_name, location, summary, experience_years, + id, user_id, first_name, last_name, location, summary, experience_years, skills, resume_url, active_application_count, status, bio, custom_data, created_at, updated_at "#, ) .bind(user_id) - .bind(payload.full_name) + .bind(payload.first_name) + .bind(payload.last_name) .bind(payload.location) .bind(payload.summary) .bind(payload.experience_years.unwrap_or(0)) @@ -125,7 +129,7 @@ impl JobSeekerRepository { SET status = 'PENDING_REVIEW', updated_at = NOW() WHERE user_id = $1 RETURNING - id, user_id, full_name, location, summary, experience_years, + id, user_id, first_name, last_name, location, summary, experience_years, skills, resume_url, active_application_count, status, bio, custom_data, created_at, updated_at "#, diff --git a/crates/db/src/models/requirement.rs b/crates/db/src/models/requirement.rs index b0912d5..5fe335f 100644 --- a/crates/db/src/models/requirement.rs +++ b/crates/db/src/models/requirement.rs @@ -10,8 +10,8 @@ pub struct Requirement { pub title: String, pub description: String, pub location: String, - pub budget: Option, - pub preferred_date: Option, + pub budget_inr: Option, + pub required_date: Option, pub extra_data_json: Option, pub status: String, pub rejection_reason: Option, @@ -22,7 +22,6 @@ pub struct Requirement { pub approved_by: Option, pub created_at: DateTime, pub created_by_user_id: Option, - pub required_date: Option, pub updated_at: DateTime, } @@ -32,8 +31,8 @@ pub struct CreateRequirementPayload { pub title: String, pub description: String, pub location: String, - pub budget: Option, - pub preferred_date: Option, + pub budget_inr: Option, + pub required_date: Option, pub extra_data_json: Option, } @@ -42,8 +41,8 @@ pub struct UpdateRequirementPayload { pub title: Option, pub description: Option, pub location: Option, - pub budget: Option, - pub preferred_date: Option, + pub budget_inr: Option, + pub required_date: Option, pub extra_data_json: Option, } @@ -58,7 +57,7 @@ impl RequirementRepository { r#" INSERT INTO leads ( profession_key, title, description, location, - budget, preferred_date, extra_data_json + budget_inr, required_date, extra_data_json ) VALUES ($1, $2, $3, $4, $5, $6, $7) RETURNING * @@ -68,8 +67,8 @@ impl RequirementRepository { .bind(payload.title) .bind(payload.description) .bind(payload.location) - .bind(payload.budget) - .bind(payload.preferred_date) + .bind(payload.budget_inr) + .bind(payload.required_date) .bind(payload.extra_data_json) .fetch_one(pool) .await?; @@ -119,8 +118,8 @@ impl RequirementRepository { title = COALESCE($1, title), description = COALESCE($2, description), location = COALESCE($3, location), - budget = COALESCE($4, budget), - preferred_date = COALESCE($5, preferred_date), + budget_inr = COALESCE($4, budget_inr), + required_date = COALESCE($5, required_date), extra_data_json = COALESCE($6, extra_data_json), updated_at = NOW() WHERE id = $7 @@ -130,8 +129,8 @@ impl RequirementRepository { .bind(payload.title) .bind(payload.description) .bind(payload.location) - .bind(payload.budget) - .bind(payload.preferred_date) + .bind(payload.budget_inr) + .bind(payload.required_date) .bind(payload.extra_data_json) .bind(id) .fetch_one(pool) diff --git a/crates/db/src/models/tracecoin_wallet.rs b/crates/db/src/models/tracecoin_wallet.rs index fc4c816..3fe81b9 100644 --- a/crates/db/src/models/tracecoin_wallet.rs +++ b/crates/db/src/models/tracecoin_wallet.rs @@ -7,7 +7,7 @@ use uuid::Uuid; pub struct Wallet { pub id: Uuid, pub user_id: Uuid, - pub current_balance: i32, + pub balance: i32, pub reserved: i32, pub updated_at: DateTime, } @@ -40,7 +40,7 @@ impl TracecoinWalletRepository { pub async fn ensure_wallet(pool: &PgPool, user_id: Uuid) -> Result<(), sqlx::Error> { sqlx::query( r#" - INSERT INTO tracecoin_wallets (user_id, current_balance, reserved) + INSERT INTO tracecoin_wallets (user_id, balance, reserved) VALUES ($1, 0, 0) ON CONFLICT (user_id) DO NOTHING "#, @@ -61,7 +61,7 @@ impl TracecoinWalletRepository { sqlx::query( r#" - INSERT INTO tracecoin_wallets (user_id, current_balance, reserved) + INSERT INTO tracecoin_wallets (user_id, balance, reserved) VALUES ($1, 0, 0) ON CONFLICT (user_id) DO NOTHING "#, @@ -77,7 +77,7 @@ impl TracecoinWalletRepository { .fetch_one(&mut *tx) .await?; - if wallet.current_balance < amount { + if wallet.balance < amount { tx.rollback().await?; return Ok(false); } @@ -85,7 +85,7 @@ impl TracecoinWalletRepository { sqlx::query( r#" UPDATE tracecoin_wallets - SET current_balance = current_balance - $1, reserved = reserved + $1, updated_at = NOW() + SET balance = balance - $1, reserved = reserved + $1, updated_at = NOW() WHERE id = $2 "#, ) @@ -192,7 +192,7 @@ impl TracecoinWalletRepository { sqlx::query( r#" UPDATE tracecoin_wallets - SET reserved = reserved - $1, current_balance = current_balance + $1, updated_at = NOW() + SET reserved = reserved - $1, balance = balance + $1, updated_at = NOW() WHERE id = $2 "#, ) diff --git a/crates/db/src/models/user.rs b/crates/db/src/models/user.rs index c3d96a9..24fb9c9 100644 --- a/crates/db/src/models/user.rs +++ b/crates/db/src/models/user.rs @@ -10,7 +10,8 @@ pub struct User { pub id: Uuid, pub email: String, pub password_hash: String, - pub name: Option, + pub first_name: Option, + pub last_name: Option, pub email_verified: bool, pub phone_verified: bool, pub status: String, // ACTIVE, SUSPENDED, BANNED @@ -26,7 +27,8 @@ pub struct User { #[derive(Debug, Serialize, Deserialize)] pub struct CreateUserPayload { - pub name: String, + pub first_name: Option, + pub last_name: Option, pub email: String, pub password_hash: String, } @@ -49,17 +51,18 @@ impl UserRepository { pub async fn create(pool: &PgPool, payload: CreateUserPayload) -> Result { let user = sqlx::query_as::<_, User>( r#" - INSERT INTO users (name, email, password_hash, email_verified, phone_verified) + INSERT INTO users (first_name, last_name, email, password_hash, email_verified, phone_verified) VALUES ($1, $2, $3, false, false) RETURNING - id, email, password_hash, name, + id, email, password_hash, first_name, last_name, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, created_at, updated_at, deleted_at "#, ) - .bind(&payload.name) + .bind(&payload.first_name) + .bind(&payload.last_name) .bind(payload.email.to_lowercase()) .bind(payload.password_hash) .fetch_one(pool) @@ -71,7 +74,7 @@ impl UserRepository { pub async fn get_by_email(pool: &PgPool, email: &str) -> Result { sqlx::query_as::<_, User>( r#" - SELECT id, email, password_hash, name, + SELECT id, email, password_hash, first_name, last_name, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, @@ -88,7 +91,7 @@ impl UserRepository { pub async fn get_by_id(pool: &PgPool, id: Uuid) -> Result { sqlx::query_as::<_, User>( r#" - SELECT id, email, password_hash, name, + SELECT id, email, password_hash, first_name, last_name, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, @@ -106,7 +109,7 @@ impl UserRepository { pub async fn get_user_role_keys(pool: &PgPool, user_id: Uuid) -> Result, sqlx::Error> { let rows = sqlx::query_scalar::<_, String>( r#" - SELECT r.code + SELECT r.key FROM user_roles ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = $1 AND ur.status = 'APPROVED' @@ -145,7 +148,7 @@ impl UserRepository { pub async fn get_by_verification_token(pool: &PgPool, token: &str) -> Result { sqlx::query_as::<_, User>( r#" - SELECT id, email, password_hash, name, + SELECT id, email, password_hash, first_name, last_name, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, @@ -193,7 +196,7 @@ impl UserRepository { pub async fn get_by_reset_token(pool: &PgPool, token: &str) -> Result { sqlx::query_as::<_, User>( r#" - SELECT id, email, password_hash, name, + SELECT id, email, password_hash, first_name, last_name, email_verified, phone_verified, status, email_verification_token, email_verification_expires_at, reset_password_token, reset_password_expires_at, diff --git a/crates/db/src/models/verification.rs b/crates/db/src/models/verification.rs index 757ce56..c05dfeb 100644 --- a/crates/db/src/models/verification.rs +++ b/crates/db/src/models/verification.rs @@ -23,12 +23,12 @@ pub struct Verification { #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct VerificationLog { pub id: Uuid, - pub verification_id: Uuid, + pub verification_request_id: Uuid, pub action: String, - pub actor_id: Option, + pub acted_by_user_id: Option, pub old_status: Option, pub new_status: Option, - pub message: Option, + pub remarks: Option, pub created_at: DateTime, } @@ -81,9 +81,8 @@ impl VerificationRepository { if status.is_some() { query.push_str(" AND status = $1"); } if case_type.is_some() { query.push_str(if status.is_some() { " AND case_type = $2" } else { " AND case_type = $1" }); } - query.push_str(" ORDER BY created_at DESC LIMIT $3 OFFSET $4"); // This simplified query string concatenation is for readability, handle properly in prod. + query.push_str(" ORDER BY created_at DESC LIMIT $3 OFFSET $4"); - // Actually implementing with sqlx properly: sqlx::query_as::<_, Verification>( r#" SELECT * FROM verifications @@ -135,7 +134,7 @@ impl VerificationRepository { sqlx::query( r#" - INSERT INTO verification_logs (verification_id, action, actor_id, old_status, new_status, message) + INSERT INTO verification_logs (verification_request_id, action, acted_by_user_id, old_status, new_status, remarks) VALUES ($1, 'STATUS_CHANGE', $2, $3, $4, $5) "# ) diff --git a/customers.pid b/customers.pid new file mode 100644 index 0000000..b693d20 --- /dev/null +++ b/customers.pid @@ -0,0 +1 @@ +9694 diff --git a/gateway.pid b/gateway.pid index bf2b153..30f4094 100644 --- a/gateway.pid +++ b/gateway.pid @@ -1 +1 @@ -97314 +9690 diff --git a/job_seekers.pid b/job_seekers.pid new file mode 100644 index 0000000..a1d3082 --- /dev/null +++ b/job_seekers.pid @@ -0,0 +1 @@ +9693 diff --git a/scripts/init-db.sql b/scripts/init-db.sql index d795c51..fa8ae30 100644 --- a/scripts/init-db.sql +++ b/scripts/init-db.sql @@ -1,275 +1,46 @@ --- 1. ROLES +-- ============================================================================ +-- Nxtgauge Database — Complete Schema +-- Source: nxtgauge_database_source_of_truth.md +-- No duplicates. Every domain table from the document included. +-- ============================================================================ + +BEGIN; + +-- ============================================================================ +-- 1. IDENTITY & ACCESS CONTROL (users, roles, permissions, sessions, tokens) +-- ============================================================================ + CREATE TABLE IF NOT EXISTS roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), key VARCHAR(255) UNIQUE NOT NULL, name VARCHAR(255) NOT NULL, - audience VARCHAR(50) NOT NULL, -- INTERNAL or EXTERNAL + audience VARCHAR(50) NOT NULL, is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- 2. ONBOARDING CONFIGS -CREATE TABLE IF NOT EXISTS onboarding_configs ( +CREATE TABLE IF NOT EXISTS internal_roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - schema_json JSONB NOT NULL, - version INTEGER NOT NULL DEFAULT 1, - is_active BOOLEAN NOT NULL DEFAULT true, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + role_id UUID NOT NULL UNIQUE REFERENCES roles(id) ON DELETE CASCADE, + description TEXT, + department_id UUID, + can_approve_requests BOOLEAN NOT NULL DEFAULT false, + can_manage_system_settings BOOLEAN NOT NULL DEFAULT false ); --- Only one active onboarding config per role at a time -CREATE UNIQUE INDEX IF NOT EXISTS idx_active_onboarding_per_role - ON onboarding_configs(role_id) WHERE is_active = true; - --- 3. DASHBOARD CONFIGS -CREATE TABLE IF NOT EXISTS dashboard_configs ( +CREATE TABLE IF NOT EXISTS external_roles ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - audience VARCHAR(50) NOT NULL, -- INTERNAL or EXTERNAL - config_json JSONB NOT NULL, - version INTEGER NOT NULL DEFAULT 1, - is_active BOOLEAN NOT NULL DEFAULT true, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + role_id UUID NOT NULL UNIQUE REFERENCES roles(id) ON DELETE CASCADE ); --- Only one active dashboard config per role+audience combination -CREATE UNIQUE INDEX IF NOT EXISTS idx_active_dashboard_per_role_audience - ON dashboard_configs(role_id, audience) WHERE is_active = true; - --- 4. RUNTIME CONFIGS -CREATE TABLE IF NOT EXISTS runtime_configs ( +CREATE TABLE IF NOT EXISTS permissions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - config_json JSONB NOT NULL, - version INTEGER NOT NULL DEFAULT 1, - is_active BOOLEAN NOT NULL DEFAULT true, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Only one active runtime config per role at a time -CREATE UNIQUE INDEX IF NOT EXISTS idx_active_runtime_per_role - ON runtime_configs(role_id) WHERE is_active = true; --- 1. USERS -CREATE TABLE IF NOT EXISTS users ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - email VARCHAR(255) UNIQUE NOT NULL, - password_hash VARCHAR(255) NOT NULL, - status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', -- ACTIVE, PENDING, SUSPENDED - role_id UUID REFERENCES roles(id) ON DELETE SET NULL, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 2. REFRESH TOKENS -CREATE TABLE IF NOT EXISTS refresh_tokens ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - token_hash VARCHAR(255) UNIQUE NOT NULL, - expires_at TIMESTAMPTZ NOT NULL, - revoked BOOLEAN NOT NULL DEFAULT false, + key VARCHAR(100) UNIQUE NOT NULL, + name VARCHAR(255) NOT NULL, + module VARCHAR(100), created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- Index for fast token lookups -CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash); -CREATE TABLE IF NOT EXISTS photographer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Photographer Specific Fields - portfolio_url VARCHAR(255), - equipment_list TEXT, - years_of_experience INT, - hourly_rate INTEGER, -- in paise (INR × 100) - specialties TEXT[], -- e.g., ["wedding", "portrait", "commercial"] - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Ensure a user can only have one photographer profile - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS tutor_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Tutor Specific Fields - subjects_taught TEXT[], -- e.g., ["math", "physics", "computer science"] - education_level VARCHAR(255), - certifications TEXT, - years_of_experience INT, - hourly_rate INTEGER, -- in paise (INR × 100) - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Ensure a user can only have one tutor profile - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS company_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Company Specific Fields - company_name VARCHAR(255) NOT NULL, - registration_number VARCHAR(100), - industry VARCHAR(150), - website_url VARCHAR(255), - employee_count INT, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - -- Ensure a user can only have one company profile - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS job_seeker_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Job Seeker - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS customer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Customer - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS makeup_artist_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Makeup Artist - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS developer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Developer - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS video_editor_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Video Editor - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS graphic_designer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Graphic Designer - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS social_media_manager_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Social Media Manager - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS fitness_trainer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Fitness Trainer - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); -CREATE TABLE IF NOT EXISTS catering_service_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - - -- Generic Fields for Catering Service - bio TEXT, - experience_years INT, - custom_data JSONB DEFAULT '{}'::jsonb, - - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - - UNIQUE(user_id) -); --- Add missing columns to users table -ALTER TABLE users - ADD COLUMN IF NOT EXISTS full_name VARCHAR(255), - ADD COLUMN IF NOT EXISTS phone VARCHAR(20) UNIQUE, - ADD COLUMN IF NOT EXISTS email_verified BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS phone_verified BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS deleted_at TIMESTAMPTZ; - --- user_roles: many-to-many, a user can hold multiple external roles -CREATE TABLE IF NOT EXISTS user_roles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED, SUSPENDED - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE(user_id, role_id) -); - --- role_permissions CREATE TABLE IF NOT EXISTS role_permissions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, @@ -278,32 +49,860 @@ CREATE TABLE IF NOT EXISTS role_permissions ( UNIQUE(role_id, permission_key) ); --- departments for internal staff -CREATE TABLE IF NOT EXISTS departments ( +CREATE TABLE IF NOT EXISTS users ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(100) NOT NULL UNIQUE, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + email VARCHAR(255) UNIQUE NOT NULL, + phone VARCHAR(20) UNIQUE, + password_hash VARCHAR(255) NOT NULL, + first_name VARCHAR(100), + last_name VARCHAR(100), + account_type TEXT DEFAULT 'INDIVIDUAL', + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + email_verified BOOLEAN NOT NULL DEFAULT false, + phone_verified BOOLEAN NOT NULL DEFAULT false, + role_id UUID REFERENCES roles(id) ON DELETE SET NULL, + last_login_at TIMESTAMPTZ, + email_verification_token VARCHAR(255), + email_verification_expires_at TIMESTAMPTZ, + reset_password_token VARCHAR(255), + reset_password_expires_at TIMESTAMPTZ, + deleted_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- designations for internal staff -CREATE TABLE IF NOT EXISTS designations ( +CREATE TABLE IF NOT EXISTS refresh_tokens ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(100) NOT NULL UNIQUE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + token_hash VARCHAR(255) UNIQUE NOT NULL, + expires_at TIMESTAMPTZ NOT NULL, + revoked BOOLEAN NOT NULL DEFAULT false, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE INDEX IF NOT EXISTS idx_refresh_tokens_hash ON refresh_tokens(token_hash); --- employees (internal staff records) -CREATE TABLE IF NOT EXISTS employees ( +CREATE TABLE IF NOT EXISTS user_sessions ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + session_token TEXT UNIQUE NOT NULL, + ip_address TEXT, + user_agent TEXT, + expires_at TIMESTAMPTZ NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_user_sessions_user_id ON user_sessions(user_id); +CREATE INDEX IF NOT EXISTS idx_user_sessions_token ON user_sessions(session_token); + +CREATE TABLE IF NOT EXISTS user_roles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, role_id) +); +CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id); + +CREATE TABLE IF NOT EXISTS user_settings ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - role_id UUID NOT NULL REFERENCES roles(id), - department_id UUID REFERENCES departments(id), - designation_id UUID REFERENCES designations(id), - employee_code VARCHAR(50), + settings JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 2. USER ROLE PROFILES (users can have multiple role profiles) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS user_role_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_key VARCHAR(50) NOT NULL, + display_name TEXT, + bio TEXT, + location TEXT, + avatar_url TEXT, + phone TEXT, + email TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + verification_status VARCHAR(50) DEFAULT 'PENDING', + approval_status VARCHAR(50) DEFAULT 'PENDING', + rejection_reason TEXT, + approved_at TIMESTAMPTZ, + verified_at TIMESTAMPTZ, + is_profile_public BOOLEAN DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(user_id, role_key) +); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_user_id ON user_role_profiles(user_id); +CREATE INDEX IF NOT EXISTS idx_user_role_profiles_role_key ON user_role_profiles(role_key); + +-- ============================================================================ +-- 3. ROLE EXTENSION TABLES (10 profession profiles) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS photographer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + specialties TEXT[] DEFAULT '{}', camera_brands TEXT[] DEFAULT '{}', + studio_available BOOLEAN NOT NULL DEFAULT false, outdoor_shoots BOOLEAN NOT NULL DEFAULT true, + travel_radius_km INTEGER DEFAULT 50, starting_price_inr INTEGER DEFAULT 0, + custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tutor_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + subjects TEXT[] DEFAULT '{}', board_types TEXT[] DEFAULT '{}', + qualification VARCHAR(255), teaches_online BOOLEAN NOT NULL DEFAULT true, + teaches_offline BOOLEAN NOT NULL DEFAULT true, experience_years INTEGER DEFAULT 0, + hourly_rate_inr INTEGER DEFAULT 0, custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS makeup_artist_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + specializations TEXT[] DEFAULT '{}', kit_brands TEXT[] DEFAULT '{}', + home_service BOOLEAN NOT NULL DEFAULT true, studio_available BOOLEAN NOT NULL DEFAULT false, + starting_price_inr INTEGER DEFAULT 0, custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS developer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + tech_stack TEXT[] DEFAULT '{}', experience_years INTEGER DEFAULT 0, + availability VARCHAR(50) DEFAULT 'FULL_TIME', hourly_rate_inr INTEGER DEFAULT 0, + remote_ok BOOLEAN NOT NULL DEFAULT true, custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS video_editor_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + software_skills TEXT[] DEFAULT '{}', style_tags TEXT[] DEFAULT '{}', + turnaround_days INTEGER DEFAULT 7, starting_price_inr INTEGER DEFAULT 0, + custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS graphic_designer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + design_tools TEXT[] DEFAULT '{}', style_tags TEXT[] DEFAULT '{}', + brand_experience BOOLEAN NOT NULL DEFAULT false, starting_price_inr INTEGER DEFAULT 0, + custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS social_media_manager_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + platforms TEXT[] DEFAULT '{}', industries TEXT[] DEFAULT '{}', + content_types TEXT[] DEFAULT '{}', avg_follower_growth_pct INTEGER DEFAULT 0, + starting_price_inr INTEGER DEFAULT 0, custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS fitness_trainer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + disciplines TEXT[] DEFAULT '{}', certifications TEXT[] DEFAULT '{}', + online_sessions BOOLEAN NOT NULL DEFAULT true, home_visits BOOLEAN NOT NULL DEFAULT false, + gym_based BOOLEAN NOT NULL DEFAULT false, per_session_rate_inr INTEGER DEFAULT 0, + custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS catering_service_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + business_name VARCHAR(255), bio TEXT, location VARCHAR(255), + cuisine_types TEXT[] DEFAULT '{}', event_types TEXT[] DEFAULT '{}', + min_guests INTEGER DEFAULT 10, max_guests INTEGER DEFAULT 500, + has_setup_team BOOLEAN NOT NULL DEFAULT true, has_serving_staff BOOLEAN NOT NULL DEFAULT true, + price_per_head_inr INTEGER DEFAULT 0, custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS ugc_content_creator_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE SET NULL, + display_name VARCHAR(255), bio TEXT, location VARCHAR(255), + platforms TEXT[] DEFAULT '{}', content_niches TEXT[] DEFAULT '{}', + content_formats TEXT[] DEFAULT '{}', follower_count INTEGER DEFAULT 0, + avg_views_per_post INTEGER DEFAULT 0, has_media_kit BOOLEAN NOT NULL DEFAULT false, + instagram_handle VARCHAR(100), youtube_channel_url VARCHAR(500), + starting_price_inr INTEGER DEFAULT 0, custom_data JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', rejection_reason TEXT, approved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 4. COMPANY PROFILES +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS company_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + company_name VARCHAR(255) NOT NULL, + business_type VARCHAR(100), registration_number VARCHAR(100), + industry VARCHAR(150), website_url VARCHAR(255), employee_count INT, + gst_number VARCHAR(50), contact_name VARCHAR(255), + contact_email VARCHAR(255), contact_phone VARCHAR(20), + address_line1 VARCHAR(500), city VARCHAR(100), state VARCHAR(100), + country VARCHAR(100) NOT NULL DEFAULT 'India', postal_code VARCHAR(20), + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + free_job_slots INTEGER NOT NULL DEFAULT 1, + purchased_job_slots INTEGER NOT NULL DEFAULT 0, + free_contact_views INTEGER NOT NULL DEFAULT 30, + purchased_contact_views INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 5. CUSTOMER PROFILES +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS customer_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + full_name VARCHAR(255), bio TEXT, phone VARCHAR(20), + city VARCHAR(100), area VARCHAR(100), location VARCHAR(255), + preferred_professions TEXT[] DEFAULT '{}', + active_requirement_count INTEGER NOT NULL DEFAULT 0, + custom_data JSONB DEFAULT '{}'::jsonb, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 6. JOB SEEKER PROFILES +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS job_seeker_profiles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + full_name VARCHAR(255), bio TEXT, location VARCHAR(255), summary TEXT, + experience_years INTEGER DEFAULT 0, skills TEXT[] DEFAULT '{}', + resume_url VARCHAR(500), active_application_count INTEGER NOT NULL DEFAULT 0, + custom_data JSONB DEFAULT '{}'::jsonb, + status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 7. PORTFOLIO DOMAIN (native content only, no external links) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS portfolio_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, description TEXT, + tags TEXT[] DEFAULT '{}', display_order INTEGER DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_portfolio_items_user_id ON portfolio_items(user_id); + +CREATE TABLE IF NOT EXISTS portfolio_images ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + portfolio_item_id UUID NOT NULL REFERENCES portfolio_items(id) ON DELETE CASCADE, + file_url VARCHAR(500) NOT NULL, display_order INTEGER NOT NULL DEFAULT 0, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- onboarding_submissions: tracks verification submissions +CREATE TABLE IF NOT EXISTS services ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE CASCADE, + name VARCHAR(255) NOT NULL, description TEXT, + price INTEGER NOT NULL DEFAULT 0, duration_minutes INTEGER, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 8. VERIFICATION DOMAIN +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS verification_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE CASCADE, + verification_type VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + submitted_at TIMESTAMPTZ, + reviewed_at TIMESTAMPTZ, + reviewed_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_verification_requests_status ON verification_requests(status); + +CREATE TABLE IF NOT EXISTS verification_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_request_id UUID NOT NULL REFERENCES verification_requests(id) ON DELETE CASCADE, + document_type VARCHAR(100) NOT NULL, + file_url VARCHAR(500) NOT NULL, + file_name VARCHAR(255), + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + reviewed_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS verification_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + verification_request_id UUID NOT NULL REFERENCES verification_requests(id) ON DELETE CASCADE, + action VARCHAR(50) NOT NULL, + old_status VARCHAR(50), + new_status VARCHAR(50), + acted_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- verifications table used by Rust code +CREATE TABLE IF NOT EXISTS verifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_key VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + priority VARCHAR(10) NOT NULL DEFAULT 'LOW', + case_type VARCHAR(50) NOT NULL, + payload JSONB NOT NULL DEFAULT '{}', + documents JSONB NOT NULL DEFAULT '[]', + notes TEXT, + rejection_reason TEXT, + assigned_to UUID REFERENCES users(id) ON DELETE SET NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_verifications_user_id ON verifications(user_id); +CREATE INDEX IF NOT EXISTS idx_verifications_status ON verifications(status); +CREATE INDEX IF NOT EXISTS idx_verifications_case_type ON verifications(case_type); + +-- ============================================================================ +-- 9. APPROVAL DOMAIN +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS approval_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + entity_type VARCHAR(50) NOT NULL, + entity_id UUID NOT NULL, + approval_type VARCHAR(50) NOT NULL, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + submitted_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + reviewed_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + submitted_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + reviewed_at TIMESTAMPTZ, + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_approval_requests_status ON approval_requests(status); +CREATE INDEX IF NOT EXISTS idx_approval_requests_entity ON approval_requests(entity_type, entity_id); + +CREATE TABLE IF NOT EXISTS approval_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + approval_request_id UUID NOT NULL REFERENCES approval_requests(id) ON DELETE CASCADE, + action VARCHAR(50) NOT NULL, + old_status VARCHAR(50), + new_status VARCHAR(50), + acted_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + remarks TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 10. MARKETPLACE DOMAIN (jobs, leads, reviews) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS jobs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id UUID NOT NULL REFERENCES company_profiles(id) ON DELETE CASCADE, + title VARCHAR(200) NOT NULL, category VARCHAR(100), + description TEXT NOT NULL, location VARCHAR(255) NOT NULL, + job_type VARCHAR(50) NOT NULL DEFAULT 'FULL_TIME', + mode_of_work VARCHAR(50), + salary_min INTEGER, salary_max INTEGER, budget_inr INTEGER, + experience_years INTEGER, skills TEXT[] DEFAULT '{}', + posted_by_user_id UUID REFERENCES users(id), + status VARCHAR(50) NOT NULL DEFAULT 'DRAFT', + rejection_reason TEXT, expires_at TIMESTAMPTZ, + approved_at TIMESTAMPTZ, approved_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_jobs_company_id ON jobs(company_id); +CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); + +CREATE TABLE IF NOT EXISTS job_applications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, + applicant_user_id UUID REFERENCES users(id) ON DELETE CASCADE, + cover_note TEXT, + status VARCHAR(50) NOT NULL DEFAULT 'APPLIED', + applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_job_applications_job_id ON job_applications(job_id); + +CREATE TABLE IF NOT EXISTS leads ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + created_by_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + profession_key VARCHAR(50) NOT NULL, + title VARCHAR(200) NOT NULL, description TEXT NOT NULL, + location VARCHAR(255) NOT NULL, budget_inr INTEGER, + required_date DATE, extra_data_json JSONB, + status VARCHAR(50) NOT NULL DEFAULT 'DRAFT', + rejection_reason TEXT, + request_count INTEGER NOT NULL DEFAULT 0, + accepted_count INTEGER NOT NULL DEFAULT 0, + expires_at TIMESTAMPTZ, + approved_at TIMESTAMPTZ, approved_by UUID REFERENCES users(id), + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_leads_status ON leads(status); +CREATE INDEX IF NOT EXISTS idx_leads_profession_key ON leads(profession_key); + +CREATE TABLE IF NOT EXISTS lead_requests ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + lead_id UUID REFERENCES leads(id) ON DELETE CASCADE, + user_role_profile_id UUID REFERENCES user_role_profiles(id) ON DELETE CASCADE, + professional_user_id UUID REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(50) NOT NULL DEFAULT 'PENDING', + tracecoins_reserved INTEGER NOT NULL DEFAULT 25, + remarks TEXT, + expires_at TIMESTAMPTZ NOT NULL DEFAULT (NOW() + INTERVAL '1 day'), + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_lead_requests_lead_id ON lead_requests(lead_id); +CREATE INDEX IF NOT EXISTS idx_lead_requests_status ON lead_requests(status); + +CREATE TABLE IF NOT EXISTS reviews ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + reviewer_user_id UUID REFERENCES users(id) ON DELETE SET NULL, + entity_type VARCHAR(50) NOT NULL DEFAULT 'PLATFORM', + entity_id VARCHAR(255), + title VARCHAR(255), + rating SMALLINT CHECK (rating >= 1 AND rating <= 5), + review_text TEXT, + reviewer_name VARCHAR(255), + subject_type VARCHAR(50) NOT NULL DEFAULT 'PLATFORM', + subject_id VARCHAR(255), + status VARCHAR(20) NOT NULL DEFAULT 'PUBLISHED', + is_published BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 11. FINANCE DOMAIN (wallets, ledger, pricing, payments, invoices, orders) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS tracecoin_wallets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + balance INTEGER NOT NULL DEFAULT 0, + reserved INTEGER NOT NULL DEFAULT 0, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tracecoin_ledger ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + wallet_id UUID NOT NULL REFERENCES tracecoin_wallets(id), + amount INTEGER NOT NULL, + transaction_type VARCHAR(20) NOT NULL, + reference_type VARCHAR(100), + reference_id UUID, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_tracecoin_ledger_wallet_id ON tracecoin_ledger(wallet_id); + +CREATE OR REPLACE FUNCTION prevent_tracecoin_ledger_mutation() +RETURNS trigger AS $$ +BEGIN + RAISE EXCEPTION 'tracecoin_ledger is immutable; % is not allowed', TG_OP; +END; +$$ LANGUAGE plpgsql; + +DROP TRIGGER IF EXISTS trg_prevent_tracecoin_ledger_update ON tracecoin_ledger; +CREATE TRIGGER trg_prevent_tracecoin_ledger_update + BEFORE UPDATE ON tracecoin_ledger FOR EACH ROW + EXECUTE FUNCTION prevent_tracecoin_ledger_mutation(); + +DROP TRIGGER IF EXISTS trg_prevent_tracecoin_ledger_delete ON tracecoin_ledger; +CREATE TRIGGER trg_prevent_tracecoin_ledger_delete + BEFORE DELETE ON tracecoin_ledger FOR EACH ROW + EXECUTE FUNCTION prevent_tracecoin_ledger_mutation(); + +CREATE TABLE IF NOT EXISTS pricing_packages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL, + role_key VARCHAR(50) NOT NULL, + package_type VARCHAR(50) NOT NULL, + tracecoins_amount INTEGER NOT NULL DEFAULT 0, + price_inr INTEGER NOT NULL, + description TEXT, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS payments ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id), + package_id UUID NOT NULL REFERENCES pricing_packages(id), + razorpay_order_id VARCHAR(100), + razorpay_payment_id VARCHAR(100), + amount_inr INTEGER NOT NULL, + tracecoins_credited INTEGER NOT NULL DEFAULT 0, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + verified_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments(user_id); + +CREATE TABLE IF NOT EXISTS invoices ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + payment_id UUID NOT NULL REFERENCES payments(id), + user_id UUID NOT NULL REFERENCES users(id), + invoice_number VARCHAR(50) NOT NULL UNIQUE, + subtotal INTEGER NOT NULL, + gst_amount INTEGER NOT NULL, + total INTEGER NOT NULL, + status VARCHAR(20) NOT NULL DEFAULT 'ISSUED', + issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + file_url VARCHAR(500) +); +CREATE INDEX IF NOT EXISTS idx_invoices_user_id ON invoices(user_id); + +CREATE TABLE IF NOT EXISTS orders ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + total_amount INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS order_items ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + order_id UUID NOT NULL REFERENCES orders(id) ON DELETE CASCADE, + package_id UUID REFERENCES pricing_packages(id), + item_type VARCHAR(50) NOT NULL, + quantity INTEGER NOT NULL DEFAULT 1, + unit_price INTEGER NOT NULL, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS tax_rules ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + tax_type VARCHAR(50) NOT NULL, + percentage DECIMAL(5,2) NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + applicable_to TEXT[] DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS discounts ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + title VARCHAR(255) NOT NULL, + scope VARCHAR(20) NOT NULL DEFAULT 'ROLE', + role_key VARCHAR(50), + package_id UUID REFERENCES pricing_packages(id) ON DELETE SET NULL, + discount_type VARCHAR(20) NOT NULL, + discount_value INTEGER NOT NULL, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS coupons ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + code VARCHAR(50) NOT NULL UNIQUE, + title VARCHAR(255), + description TEXT, + discount_type VARCHAR(20) NOT NULL, + discount_value INTEGER NOT NULL, + applies_to VARCHAR(50) NOT NULL DEFAULT 'ALL', + min_order_amount INTEGER NOT NULL DEFAULT 0, + max_uses INTEGER, + uses_count INTEGER NOT NULL DEFAULT 0, + per_user_limit INTEGER NOT NULL DEFAULT 1, + role_keys TEXT[] NOT NULL DEFAULT '{}', + valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(), + valid_until TIMESTAMPTZ, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS coupon_uses ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE, + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + payment_id UUID REFERENCES payments(id), + used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(coupon_id, user_id) +); +CREATE INDEX IF NOT EXISTS idx_coupon_uses_user_id ON coupon_uses(user_id); + +-- ============================================================================ +-- 12. KNOWLEDGE BASE +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS kb_categories ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(255) NOT NULL UNIQUE, + slug VARCHAR(255) NOT NULL UNIQUE, + description TEXT, + display_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS kb_sections ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category_id UUID NOT NULL REFERENCES kb_categories(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + slug VARCHAR(255) NOT NULL UNIQUE, + display_order INTEGER NOT NULL DEFAULT 0, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS kb_articles ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + category_id UUID NOT NULL REFERENCES kb_categories(id) ON DELETE CASCADE, + section_id UUID REFERENCES kb_sections(id), + title VARCHAR(500) NOT NULL, + slug VARCHAR(500) NOT NULL UNIQUE, + summary TEXT, + body TEXT NOT NULL, + content_markdown TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'DRAFT', + target_roles TEXT[] DEFAULT '{}', + tags TEXT[] DEFAULT '{}', + views INTEGER NOT NULL DEFAULT 0, + created_by UUID REFERENCES users(id), + published_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_kb_articles_category_id ON kb_articles(category_id); + +CREATE TABLE IF NOT EXISTS kb_article_feedback ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + article_id UUID NOT NULL REFERENCES kb_articles(id) ON DELETE CASCADE, + user_id UUID REFERENCES users(id) ON DELETE SET NULL, + helpful BOOLEAN NOT NULL, + comment TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 13. SUPPORT SYSTEM +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS support_tickets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id) ON DELETE CASCADE, + subject VARCHAR(500) NOT NULL, + description TEXT, + category VARCHAR(50) NOT NULL DEFAULT 'GENERAL', + status VARCHAR(20) NOT NULL DEFAULT 'OPEN', + priority VARCHAR(10) NOT NULL DEFAULT 'NORMAL', + assigned_to UUID REFERENCES users(id), + requester_name VARCHAR(255), + requester_email VARCHAR(255), + resolved_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_support_tickets_user_id ON support_tickets(user_id); +CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status); + +CREATE TABLE IF NOT EXISTS support_ticket_messages ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE, + sender_id UUID NOT NULL REFERENCES users(id), + body TEXT NOT NULL, + is_internal BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_support_ticket_messages_ticket_id ON support_ticket_messages(ticket_id); + +-- ============================================================================ +-- 14. NOTIFICATION SYSTEM +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS notifications ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + title VARCHAR(255) NOT NULL, + body TEXT, + type VARCHAR(50), + reference_id UUID, + is_read BOOLEAN NOT NULL DEFAULT false, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); + +CREATE TABLE IF NOT EXISTS notification_preferences ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, + preferences JSONB NOT NULL DEFAULT '{}', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS notification_templates ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + trigger_event VARCHAR(100) NOT NULL UNIQUE, + title_template VARCHAR(500), + body_template TEXT, + channel VARCHAR(20) NOT NULL DEFAULT 'IN_APP', + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS email_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID REFERENCES users(id), + trigger VARCHAR(100) NOT NULL, + to_email VARCHAR(255) NOT NULL, + subject VARCHAR(500), + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + sent_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS smtp_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + host VARCHAR(255) NOT NULL, + port INTEGER NOT NULL DEFAULT 587, + username VARCHAR(255) NOT NULL, + password_encrypted TEXT NOT NULL, + from_email VARCHAR(255) NOT NULL, + from_name VARCHAR(255), + use_tls BOOLEAN NOT NULL DEFAULT true, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- ============================================================================ +-- 15. DASHBOARD SYSTEM +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS dashboard_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + audience VARCHAR(50) NOT NULL, + config_json JSONB NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT true, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_active_dashboard_per_role_audience + ON dashboard_configs(role_id, audience) WHERE is_active = true; + +CREATE TABLE IF NOT EXISTS dashboard_widgets ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + dashboard_config_id UUID NOT NULL REFERENCES dashboard_configs(id) ON DELETE CASCADE, + widget_key VARCHAR(100) NOT NULL, + widget_type VARCHAR(50) NOT NULL, + title VARCHAR(255) NOT NULL, + config_json JSONB NOT NULL DEFAULT '{}', + display_order INTEGER NOT NULL DEFAULT 0, + is_visible BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +CREATE TABLE IF NOT EXISTS runtime_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + config_json JSONB NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT true, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_active_runtime_per_role + ON runtime_configs(role_id) WHERE is_active = true; + +-- ============================================================================ +-- 16. ONBOARDING (deprecated but tables kept for backward compat) +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS onboarding_configs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + schema_json JSONB NOT NULL, + version INTEGER NOT NULL DEFAULT 1, + is_active BOOLEAN NOT NULL DEFAULT true, + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_active_onboarding_per_role + ON onboarding_configs(role_id) WHERE is_active = true; + +CREATE TABLE IF NOT EXISTS onboarding_states ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, + role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + status VARCHAR(20) NOT NULL DEFAULT 'NOT_STARTED', + progress_json JSONB NOT NULL DEFAULT '{}', + completed_at TIMESTAMPTZ, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE UNIQUE INDEX IF NOT EXISTS idx_onboarding_state_user_role + ON onboarding_states(user_id, role_id); + CREATE TABLE IF NOT EXISTS onboarding_submissions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, @@ -318,8 +917,8 @@ CREATE TABLE IF NOT EXISTS onboarding_submissions ( document_request TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE INDEX IF NOT EXISTS idx_onboarding_submissions_user_id ON onboarding_submissions(user_id); --- submission_documents: uploaded files for onboarding CREATE TABLE IF NOT EXISTS submission_documents ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), submission_id UUID NOT NULL REFERENCES onboarding_submissions(id) ON DELETE CASCADE, @@ -329,843 +928,41 @@ CREATE TABLE IF NOT EXISTS submission_documents ( uploaded_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); -CREATE INDEX IF NOT EXISTS idx_user_roles_user_id ON user_roles(user_id); -CREATE INDEX IF NOT EXISTS idx_user_roles_status ON user_roles(status); -CREATE INDEX IF NOT EXISTS idx_onboarding_submissions_user_id ON onboarding_submissions(user_id); -CREATE INDEX IF NOT EXISTS idx_onboarding_submissions_status ON onboarding_submissions(status); --- Complete company profile (replacing the minimal stub) -ALTER TABLE company_profiles - ADD COLUMN IF NOT EXISTS business_type VARCHAR(100), - ADD COLUMN IF NOT EXISTS gst_number VARCHAR(50), - ADD COLUMN IF NOT EXISTS contact_name VARCHAR(255), - ADD COLUMN IF NOT EXISTS contact_email VARCHAR(255), - ADD COLUMN IF NOT EXISTS contact_phone VARCHAR(20), - ADD COLUMN IF NOT EXISTS address_line1 VARCHAR(500), - ADD COLUMN IF NOT EXISTS city VARCHAR(100), - ADD COLUMN IF NOT EXISTS state VARCHAR(100), - ADD COLUMN IF NOT EXISTS country VARCHAR(100) NOT NULL DEFAULT 'India', - ADD COLUMN IF NOT EXISTS postal_code VARCHAR(20), - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', - ADD COLUMN IF NOT EXISTS free_job_slots INTEGER NOT NULL DEFAULT 1, - ADD COLUMN IF NOT EXISTS purchased_job_slots INTEGER NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS free_contact_views INTEGER NOT NULL DEFAULT 30, - ADD COLUMN IF NOT EXISTS purchased_contact_views INTEGER NOT NULL DEFAULT 0; +-- ============================================================================ +-- 17. INTERNAL EMPLOYEES +-- ============================================================================ --- Jobs -CREATE TABLE IF NOT EXISTS jobs ( +CREATE TABLE IF NOT EXISTS departments ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - company_id UUID NOT NULL REFERENCES company_profiles(id) ON DELETE CASCADE, - title VARCHAR(200) NOT NULL, - category VARCHAR(100), - description TEXT NOT NULL, - location VARCHAR(255) NOT NULL, - job_type VARCHAR(50) NOT NULL DEFAULT 'FULL_TIME', -- FULL_TIME, PART_TIME, CONTRACT - salary_min INTEGER, -- in paise - salary_max INTEGER, -- in paise - experience_years INTEGER, - skills TEXT[] DEFAULT '{}', - status VARCHAR(50) NOT NULL DEFAULT 'DRAFT', - -- DRAFT, PENDING_APPROVAL, LIVE, EXPIRED, CLOSED, REJECTED - rejection_reason TEXT, - expires_at TIMESTAMPTZ, - approved_at TIMESTAMPTZ, - approved_by UUID REFERENCES users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Applications (Job Seeker → Job) -CREATE TABLE IF NOT EXISTS applications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - job_id UUID NOT NULL REFERENCES jobs(id) ON DELETE CASCADE, - job_seeker_id UUID NOT NULL REFERENCES job_seeker_profiles(id) ON DELETE CASCADE, - cover_letter TEXT, - resume_url VARCHAR(500), - status VARCHAR(50) NOT NULL DEFAULT 'APPLIED', - -- APPLIED, SHORTLISTED, INTERVIEW, OFFERED, HIRED, REJECTED, WITHDRAWN - applied_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - contact_viewed BOOLEAN NOT NULL DEFAULT false, - UNIQUE(job_id, job_seeker_id) -); - -CREATE INDEX IF NOT EXISTS idx_jobs_company_id ON jobs(company_id); -CREATE INDEX IF NOT EXISTS idx_jobs_status ON jobs(status); -CREATE INDEX IF NOT EXISTS idx_applications_job_id ON applications(job_id); -CREATE INDEX IF NOT EXISTS idx_applications_job_seeker_id ON applications(job_seeker_id); -CREATE INDEX IF NOT EXISTS idx_applications_status ON applications(status); --- Add missing fields to job_seeker_profiles -ALTER TABLE job_seeker_profiles - ADD COLUMN IF NOT EXISTS full_name VARCHAR(255), - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS summary TEXT, - ADD COLUMN IF NOT EXISTS experience_years INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS skills TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS resume_url VARCHAR(500), - ADD COLUMN IF NOT EXISTS active_application_count INTEGER NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE'; - --- Requirements (customer leads) -CREATE TABLE IF NOT EXISTS requirements ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - customer_id UUID NOT NULL REFERENCES customer_profiles(id) ON DELETE CASCADE, - profession_key VARCHAR(50) NOT NULL, - title VARCHAR(200) NOT NULL, - description TEXT NOT NULL, - location VARCHAR(255) NOT NULL, - budget INTEGER, -- in paise - preferred_date DATE, - extra_data_json JSONB, - status VARCHAR(50) NOT NULL DEFAULT 'DRAFT', - -- DRAFT, PENDING_APPROVAL, OPEN, CLOSED, EXPIRED, REJECTED - rejection_reason TEXT, - request_count INTEGER NOT NULL DEFAULT 0, - accepted_count INTEGER NOT NULL DEFAULT 0, - expires_at TIMESTAMPTZ, - approved_at TIMESTAMPTZ, - approved_by UUID REFERENCES users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- professionals unified table (parent for all 9 profession subtypes) -CREATE TABLE IF NOT EXISTS professionals ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - profession_key VARCHAR(50) NOT NULL, - display_name VARCHAR(255) NOT NULL, - location VARCHAR(255), - bio TEXT, - extra_data_json JSONB, - status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Lead requests (professional → requirement) -CREATE TABLE IF NOT EXISTS lead_requests ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - requirement_id UUID NOT NULL REFERENCES requirements(id) ON DELETE CASCADE, - professional_id UUID NOT NULL REFERENCES professionals(id) ON DELETE CASCADE, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - -- PENDING, ACCEPTED, REJECTED, EXPIRED, CANCELLED - tracecoins_reserved INTEGER NOT NULL DEFAULT 25, - expires_at TIMESTAMPTZ NOT NULL, - requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - resolved_at TIMESTAMPTZ, - UNIQUE(requirement_id, professional_id) -); - --- Add missing fields to customer_profiles -ALTER TABLE customer_profiles - ADD COLUMN IF NOT EXISTS full_name VARCHAR(255), - ADD COLUMN IF NOT EXISTS phone VARCHAR(20), - ADD COLUMN IF NOT EXISTS city VARCHAR(100), - ADD COLUMN IF NOT EXISTS area VARCHAR(100), - ADD COLUMN IF NOT EXISTS preferred_professions TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS active_requirement_count INTEGER NOT NULL DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE'; - -CREATE INDEX IF NOT EXISTS idx_requirements_customer_id ON requirements(customer_id); -CREATE INDEX IF NOT EXISTS idx_requirements_status ON requirements(status); -CREATE INDEX IF NOT EXISTS idx_requirements_profession_key ON requirements(profession_key); -CREATE INDEX IF NOT EXISTS idx_lead_requests_requirement_id ON lead_requests(requirement_id); -CREATE INDEX IF NOT EXISTS idx_lead_requests_professional_id ON lead_requests(professional_id); -CREATE INDEX IF NOT EXISTS idx_lead_requests_status ON lead_requests(status); -CREATE INDEX IF NOT EXISTS idx_professionals_profession_key ON professionals(profession_key); --- Portfolio items (for professionals) -CREATE TABLE IF NOT EXISTS portfolio_items ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - professional_id UUID NOT NULL REFERENCES professionals(id) ON DELETE CASCADE, - title VARCHAR(255) NOT NULL, + name VARCHAR(100) NOT NULL UNIQUE, + code VARCHAR(64), description TEXT, - tags TEXT[] DEFAULT '{}', - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Portfolio images (multiple images per portfolio item) -CREATE TABLE IF NOT EXISTS portfolio_images ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - portfolio_item_id UUID NOT NULL REFERENCES portfolio_items(id) ON DELETE CASCADE, - file_url VARCHAR(500) NOT NULL, - display_order INTEGER NOT NULL DEFAULT 0 -); - --- Services (offered by professionals) -CREATE TABLE IF NOT EXISTS services ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - professional_id UUID NOT NULL REFERENCES professionals(id) ON DELETE CASCADE, - name VARCHAR(255) NOT NULL, - description TEXT, - price INTEGER NOT NULL DEFAULT 0, -- in paise - duration_minutes INTEGER, + department_head VARCHAR(255), + department_email VARCHAR(255), is_active BOOLEAN NOT NULL DEFAULT true, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); - --- Tracecoin wallets (one per user) -CREATE TABLE IF NOT EXISTS tracecoin_wallets ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - balance INTEGER NOT NULL DEFAULT 0, - reserved INTEGER NOT NULL DEFAULT 0, - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Tracecoin ledger (IMMUTABLE — never update or delete) -CREATE TABLE IF NOT EXISTS tracecoin_ledger ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - wallet_id UUID NOT NULL REFERENCES tracecoin_wallets(id), - type VARCHAR(20) NOT NULL, -- CREDIT, DEBIT, RESERVE, RELEASE - amount INTEGER NOT NULL, - reason VARCHAR(100) NOT NULL, -- LEAD_REQUEST, LEAD_ACCEPTED, PURCHASE, ADMIN_CREDIT, LEAD_EXPIRED - reference_id UUID, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Pricing packages (Tracecoin bundles, job slots, contact views) -CREATE TABLE IF NOT EXISTS pricing_packages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - role_key VARCHAR(50) NOT NULL, - package_type VARCHAR(50) NOT NULL, -- JOB_POSTING, CONTACT_VIEWS, TRACECOIN_BUNDLE - tracecoins_amount INTEGER NOT NULL DEFAULT 0, - price_inr INTEGER NOT NULL, -- in paise - description TEXT, - is_active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Payments (Razorpay transactions) -CREATE TABLE IF NOT EXISTS payments ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id), - package_id UUID NOT NULL REFERENCES pricing_packages(id), - razorpay_order_id VARCHAR(100), - razorpay_payment_id VARCHAR(100), - amount_inr INTEGER NOT NULL, - tracecoins_credited INTEGER NOT NULL DEFAULT 0, - status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING, SUCCESS, FAILED - verified_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Invoices (generated for every successful payment) -CREATE TABLE IF NOT EXISTS invoices ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - payment_id UUID NOT NULL REFERENCES payments(id), - user_id UUID NOT NULL REFERENCES users(id), - invoice_number VARCHAR(50) NOT NULL UNIQUE, - subtotal INTEGER NOT NULL, -- in paise - gst_amount INTEGER NOT NULL, -- in paise - total INTEGER NOT NULL, -- in paise - status VARCHAR(20) NOT NULL DEFAULT 'ISSUED', -- ISSUED, PAID - issued_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - file_url VARCHAR(500) -); - -CREATE INDEX IF NOT EXISTS idx_portfolio_items_professional_id ON portfolio_items(professional_id); -CREATE INDEX IF NOT EXISTS idx_services_professional_id ON services(professional_id); -CREATE INDEX IF NOT EXISTS idx_tracecoin_ledger_wallet_id ON tracecoin_ledger(wallet_id); -CREATE INDEX IF NOT EXISTS idx_payments_user_id ON payments(user_id); -CREATE INDEX IF NOT EXISTS idx_invoices_user_id ON invoices(user_id); --- Notifications (in-app) -CREATE TABLE IF NOT EXISTS notifications ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - title VARCHAR(255) NOT NULL, - body TEXT, - type VARCHAR(50), -- APPROVAL, LEAD, JOB, PAYMENT - reference_id UUID, - is_read BOOLEAN NOT NULL DEFAULT false, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Email logs (audit trail) -CREATE TABLE IF NOT EXISTS email_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID REFERENCES users(id), - trigger VARCHAR(100) NOT NULL, -- PROFILE_APPROVED, JOB_APPROVED, etc. - to_email VARCHAR(255) NOT NULL, - subject VARCHAR(500), - status VARCHAR(20) NOT NULL DEFAULT 'PENDING', -- PENDING, SENT, FAILED - sent_at TIMESTAMPTZ -); - -CREATE INDEX IF NOT EXISTS idx_notifications_user_id ON notifications(user_id); -CREATE INDEX IF NOT EXISTS idx_notifications_is_read ON notifications(is_read); -CREATE INDEX IF NOT EXISTS idx_email_logs_user_id ON email_logs(user_id); --- Drop the generic professionals table approach; use per-profession profile tables --- Portfolio and services stay shared (referenced by user_id + profession_key) - --- 1. PHOTOGRAPHER PROFILES -CREATE TABLE IF NOT EXISTS photographer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - specialties TEXT[] DEFAULT '{}', -- e.g. ['Wedding', 'Portrait', 'Commercial'] - camera_brands TEXT[] DEFAULT '{}', -- e.g. ['Sony', 'Canon'] - studio_available BOOLEAN NOT NULL DEFAULT false, - outdoor_shoots BOOLEAN NOT NULL DEFAULT true, - travel_radius_km INTEGER DEFAULT 50, - starting_price_inr INTEGER DEFAULT 0, -- in paise - -- Verification & status - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED, SUSPENDED - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 2. TUTOR PROFILES -CREATE TABLE IF NOT EXISTS tutor_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - subjects TEXT[] DEFAULT '{}', -- e.g. ['Math', 'Physics', 'Hindi'] - board_types TEXT[] DEFAULT '{}', -- e.g. ['CBSE', 'ICSE', 'IB'] - qualification VARCHAR(255), -- e.g. 'B.Tech IIT Delhi' - teaches_online BOOLEAN NOT NULL DEFAULT true, - teaches_offline BOOLEAN NOT NULL DEFAULT true, - experience_years INTEGER DEFAULT 0, - hourly_rate_inr INTEGER DEFAULT 0, -- in paise - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 3. MAKEUP ARTIST PROFILES -CREATE TABLE IF NOT EXISTS makeup_artist_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - specializations TEXT[] DEFAULT '{}', -- e.g. ['Bridal', 'Editorial', 'SFX'] - kit_brands TEXT[] DEFAULT '{}', -- e.g. ['MAC', 'NARS', 'NYX'] - home_service BOOLEAN NOT NULL DEFAULT true, - studio_available BOOLEAN NOT NULL DEFAULT false, - starting_price_inr INTEGER DEFAULT 0, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 4. DEVELOPER PROFILES -CREATE TABLE IF NOT EXISTS developer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - tech_stack TEXT[] DEFAULT '{}', -- e.g. ['Rust', 'React', 'PostgreSQL'] - github_url VARCHAR(500), - portfolio_url VARCHAR(500), - experience_years INTEGER DEFAULT 0, - availability VARCHAR(50) DEFAULT 'FULL_TIME', -- FULL_TIME, PART_TIME, FREELANCE - hourly_rate_inr INTEGER DEFAULT 0, - remote_ok BOOLEAN NOT NULL DEFAULT true, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 5. VIDEO EDITOR PROFILES -CREATE TABLE IF NOT EXISTS video_editor_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - software_skills TEXT[] DEFAULT '{}', -- e.g. ['Premiere Pro', 'DaVinci Resolve'] - style_tags TEXT[] DEFAULT '{}', -- e.g. ['Cinematic', 'Corporate', 'Reels'] - turnaround_days INTEGER DEFAULT 7, - reel_url VARCHAR(500), - starting_price_inr INTEGER DEFAULT 0, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 6. GRAPHIC DESIGNER PROFILES -CREATE TABLE IF NOT EXISTS graphic_designer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - design_tools TEXT[] DEFAULT '{}', -- e.g. ['Figma', 'Illustrator', 'Photoshop'] - style_tags TEXT[] DEFAULT '{}', -- e.g. ['Minimalist', 'Bold', 'Corporate'] - brand_experience BOOLEAN NOT NULL DEFAULT false, - portfolio_url VARCHAR(500), - starting_price_inr INTEGER DEFAULT 0, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 7. SOCIAL MEDIA MANAGER PROFILES -CREATE TABLE IF NOT EXISTS social_media_manager_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - platforms TEXT[] DEFAULT '{}', -- e.g. ['Instagram', 'LinkedIn', 'YouTube'] - industries TEXT[] DEFAULT '{}', -- e.g. ['F&B', 'Fashion', 'Real Estate'] - content_types TEXT[] DEFAULT '{}', -- e.g. ['Reels', 'Carousels', 'Stories'] - avg_follower_growth_pct INTEGER DEFAULT 0, - starting_price_inr INTEGER DEFAULT 0, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 8. FITNESS TRAINER PROFILES -CREATE TABLE IF NOT EXISTS fitness_trainer_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - disciplines TEXT[] DEFAULT '{}', -- e.g. ['Yoga', 'HIIT', 'Zumba', 'CrossFit'] - certifications TEXT[] DEFAULT '{}', -- e.g. ['ACE', 'NASM', 'Yoga Alliance RYT'] - online_sessions BOOLEAN NOT NULL DEFAULT true, - home_visits BOOLEAN NOT NULL DEFAULT false, - gym_based BOOLEAN NOT NULL DEFAULT false, - per_session_rate_inr INTEGER DEFAULT 0, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 9. CATERING SERVICES PROFILES -CREATE TABLE IF NOT EXISTS catering_service_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - business_name VARCHAR(255) NOT NULL, - bio TEXT, - location VARCHAR(255), - -- Profession-specific - cuisine_types TEXT[] DEFAULT '{}', -- e.g. ['North Indian', 'Continental', 'Vegan'] - event_types TEXT[] DEFAULT '{}', -- e.g. ['Wedding', 'Corporate', 'Birthday'] - min_guests INTEGER DEFAULT 10, - max_guests INTEGER DEFAULT 500, - has_setup_team BOOLEAN NOT NULL DEFAULT true, - has_serving_staff BOOLEAN NOT NULL DEFAULT true, - price_per_head_inr INTEGER DEFAULT 0, -- in paise - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Shared: portfolio_items now uses user_id + profession_key (no foreign key to professionals) --- Drop the professionals-table FK if it was added before -ALTER TABLE portfolio_items - ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE, - ADD COLUMN IF NOT EXISTS profession_key VARCHAR(50); - -ALTER TABLE services - ADD COLUMN IF NOT EXISTS user_id UUID REFERENCES users(id) ON DELETE CASCADE, - ADD COLUMN IF NOT EXISTS profession_key VARCHAR(50); - --- Lead requests: use user_id instead of professional_id foreign key -ALTER TABLE lead_requests - ADD COLUMN IF NOT EXISTS professional_user_id UUID REFERENCES users(id) ON DELETE CASCADE; - --- Backfill columns when legacy minimal profile tables already exist. --- This keeps migrations idempotent while upgrading old schemas to the new profile shape. -ALTER TABLE photographer_profiles - ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS bio TEXT, - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS specialties TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS camera_brands TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS studio_available BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS outdoor_shoots BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS travel_radius_km INTEGER DEFAULT 50, - ADD COLUMN IF NOT EXISTS starting_price_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE tutor_profiles - ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS bio TEXT, - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS subjects TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS board_types TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS qualification VARCHAR(255), - ADD COLUMN IF NOT EXISTS teaches_online BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS teaches_offline BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS hourly_rate_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE makeup_artist_profiles - ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS specializations TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS kit_brands TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS home_service BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS studio_available BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS starting_price_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE developer_profiles - ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS tech_stack TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS github_url VARCHAR(500), - ADD COLUMN IF NOT EXISTS portfolio_url VARCHAR(500), - ADD COLUMN IF NOT EXISTS availability VARCHAR(50) DEFAULT 'FULL_TIME', - ADD COLUMN IF NOT EXISTS hourly_rate_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS remote_ok BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE video_editor_profiles - ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS software_skills TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS style_tags TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS turnaround_days INTEGER DEFAULT 7, - ADD COLUMN IF NOT EXISTS reel_url VARCHAR(500), - ADD COLUMN IF NOT EXISTS starting_price_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE graphic_designer_profiles - ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS design_tools TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS style_tags TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS brand_experience BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS portfolio_url VARCHAR(500), - ADD COLUMN IF NOT EXISTS starting_price_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE social_media_manager_profiles - ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS platforms TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS industries TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS content_types TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS avg_follower_growth_pct INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS starting_price_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE fitness_trainer_profiles - ADD COLUMN IF NOT EXISTS display_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS disciplines TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS certifications TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS online_sessions BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS home_visits BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS gym_based BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS per_session_rate_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE catering_service_profiles - ADD COLUMN IF NOT EXISTS business_name VARCHAR(255) NOT NULL DEFAULT '', - ADD COLUMN IF NOT EXISTS location VARCHAR(255), - ADD COLUMN IF NOT EXISTS cuisine_types TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS event_types TEXT[] DEFAULT '{}', - ADD COLUMN IF NOT EXISTS min_guests INTEGER DEFAULT 10, - ADD COLUMN IF NOT EXISTS max_guests INTEGER DEFAULT 500, - ADD COLUMN IF NOT EXISTS has_setup_team BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS has_serving_staff BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS price_per_head_inr INTEGER DEFAULT 0, - ADD COLUMN IF NOT EXISTS status VARCHAR(50) NOT NULL DEFAULT 'PENDING', - ADD COLUMN IF NOT EXISTS rejection_reason TEXT, - ADD COLUMN IF NOT EXISTS approved_at TIMESTAMPTZ; - -ALTER TABLE lead_requests - ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); --- Indexes -CREATE INDEX IF NOT EXISTS idx_photographer_profiles_status ON photographer_profiles(status); -CREATE INDEX IF NOT EXISTS idx_tutor_profiles_status ON tutor_profiles(status); -CREATE INDEX IF NOT EXISTS idx_makeup_artist_profiles_status ON makeup_artist_profiles(status); -CREATE INDEX IF NOT EXISTS idx_developer_profiles_status ON developer_profiles(status); -CREATE INDEX IF NOT EXISTS idx_video_editor_profiles_status ON video_editor_profiles(status); -CREATE INDEX IF NOT EXISTS idx_graphic_designer_profiles_status ON graphic_designer_profiles(status); -CREATE INDEX IF NOT EXISTS idx_social_media_manager_profiles_status ON social_media_manager_profiles(status); -CREATE INDEX IF NOT EXISTS idx_fitness_trainer_profiles_status ON fitness_trainer_profiles(status); -CREATE INDEX IF NOT EXISTS idx_catering_service_profiles_status ON catering_service_profiles(status); -CREATE INDEX IF NOT EXISTS idx_portfolio_items_user_id ON portfolio_items(user_id); -CREATE INDEX IF NOT EXISTS idx_services_user_id ON services(user_id); --- Add email verification and password reset columns to users table -ALTER TABLE users - ADD COLUMN IF NOT EXISTS email_verification_token VARCHAR(255), - ADD COLUMN IF NOT EXISTS email_verification_expires_at TIMESTAMPTZ, - ADD COLUMN IF NOT EXISTS reset_password_token VARCHAR(255), - ADD COLUMN IF NOT EXISTS reset_password_expires_at TIMESTAMPTZ; - --- Add index for token lookups -CREATE INDEX IF NOT EXISTS idx_users_email_verification_token ON users(email_verification_token); -CREATE INDEX IF NOT EXISTS idx_users_reset_password_token ON users(reset_password_token); --- Reviews: customers leave reviews on professionals after an accepted lead -CREATE TABLE IF NOT EXISTS reviews ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - lead_request_id UUID NOT NULL REFERENCES lead_requests(id) ON DELETE CASCADE UNIQUE, - customer_id UUID NOT NULL REFERENCES customer_profiles(id) ON DELETE CASCADE, - professional_id UUID NOT NULL REFERENCES professionals(id) ON DELETE CASCADE, - rating SMALLINT NOT NULL CHECK (rating >= 1 AND rating <= 5), - comment TEXT, - is_published BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_reviews_professional_id ON reviews(professional_id); -CREATE INDEX IF NOT EXISTS idx_reviews_customer_id ON reviews(customer_id); --- Knowledge Base categories -CREATE TABLE IF NOT EXISTS kb_categories ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - name VARCHAR(255) NOT NULL, - slug VARCHAR(255) NOT NULL UNIQUE, - description TEXT, - display_order INTEGER NOT NULL DEFAULT 0, - is_active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Knowledge Base articles -CREATE TABLE IF NOT EXISTS kb_articles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - category_id UUID NOT NULL REFERENCES kb_categories(id) ON DELETE CASCADE, - title VARCHAR(500) NOT NULL, - slug VARCHAR(500) NOT NULL UNIQUE, - body TEXT NOT NULL, - target_roles TEXT[] DEFAULT '{}', -- empty = visible to all - is_published BOOLEAN NOT NULL DEFAULT false, - views INTEGER NOT NULL DEFAULT 0, - created_by UUID REFERENCES users(id), - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_kb_articles_category_id ON kb_articles(category_id); -CREATE INDEX IF NOT EXISTS idx_kb_articles_slug ON kb_articles(slug); --- Support tickets -CREATE TABLE IF NOT EXISTS support_tickets ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - subject VARCHAR(500) NOT NULL, - category VARCHAR(50) NOT NULL DEFAULT 'GENERAL', -- GENERAL, BILLING, ACCOUNT, LEAD, JOB - status VARCHAR(20) NOT NULL DEFAULT 'OPEN', -- OPEN, IN_PROGRESS, RESOLVED, CLOSED - priority VARCHAR(10) NOT NULL DEFAULT 'NORMAL', -- LOW, NORMAL, HIGH, URGENT - assigned_to UUID REFERENCES users(id), - resolved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Support ticket messages -CREATE TABLE IF NOT EXISTS support_ticket_messages ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - ticket_id UUID NOT NULL REFERENCES support_tickets(id) ON DELETE CASCADE, - sender_id UUID NOT NULL REFERENCES users(id), - body TEXT NOT NULL, - is_internal BOOLEAN NOT NULL DEFAULT false, -- true = staff-only note - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_support_tickets_user_id ON support_tickets(user_id); -CREATE INDEX IF NOT EXISTS idx_support_tickets_status ON support_tickets(status); -CREATE INDEX IF NOT EXISTS idx_support_ticket_messages_ticket_id ON support_ticket_messages(ticket_id); --- Discount coupons for Tracecoin and package purchases -CREATE TABLE IF NOT EXISTS coupons ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - code VARCHAR(50) NOT NULL UNIQUE, - description TEXT, - discount_type VARCHAR(20) NOT NULL, -- PERCENT, FLAT - discount_value INTEGER NOT NULL, -- percent (0-100) or paise - applies_to VARCHAR(50) NOT NULL DEFAULT 'ALL', -- ALL, TRACECOIN_BUNDLE, JOB_POSTING, CONTACT_VIEWS - min_order_amount INTEGER NOT NULL DEFAULT 0, -- paise - max_uses INTEGER, -- NULL = unlimited - uses_count INTEGER NOT NULL DEFAULT 0, - per_user_limit INTEGER NOT NULL DEFAULT 1, - valid_from TIMESTAMPTZ NOT NULL DEFAULT NOW(), - valid_until TIMESTAMPTZ, - is_active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- Track which users used which coupons -CREATE TABLE IF NOT EXISTS coupon_uses ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - coupon_id UUID NOT NULL REFERENCES coupons(id) ON DELETE CASCADE, - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - payment_id UUID REFERENCES payments(id), - used_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - UNIQUE (coupon_id, user_id) -); - -CREATE INDEX IF NOT EXISTS idx_coupons_code ON coupons(code); -CREATE INDEX IF NOT EXISTS idx_coupon_uses_user_id ON coupon_uses(user_id); --- Onboarding state per user per role --- Tracks progress through the schema-driven onboarding form -CREATE TABLE IF NOT EXISTS onboarding_states ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role_id UUID NOT NULL REFERENCES roles(id) ON DELETE CASCADE, - status VARCHAR(20) NOT NULL DEFAULT 'NOT_STARTED', -- NOT_STARTED | IN_PROGRESS | COMPLETED - progress_json JSONB NOT NULL DEFAULT '{}', - completed_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- One onboarding state record per user per role -CREATE UNIQUE INDEX IF NOT EXISTS idx_onboarding_state_user_role - ON onboarding_states(user_id, role_id); --- Make display_name / business_name nullable so upserts can work --- without forcing the name on every call. --- Add custom_data JSONB to every profession table so all onboarding --- form fields are preserved even if they don't have a dedicated column. - -ALTER TABLE photographer_profiles ALTER COLUMN display_name DROP NOT NULL; -ALTER TABLE tutor_profiles ALTER COLUMN display_name DROP NOT NULL; -ALTER TABLE makeup_artist_profiles ALTER COLUMN display_name DROP NOT NULL; -ALTER TABLE developer_profiles ALTER COLUMN display_name DROP NOT NULL; -ALTER TABLE video_editor_profiles ALTER COLUMN display_name DROP NOT NULL; -ALTER TABLE graphic_designer_profiles ALTER COLUMN display_name DROP NOT NULL; -ALTER TABLE social_media_manager_profiles ALTER COLUMN display_name DROP NOT NULL; -ALTER TABLE fitness_trainer_profiles ALTER COLUMN display_name DROP NOT NULL; -ALTER TABLE catering_service_profiles ALTER COLUMN business_name DROP NOT NULL; - -ALTER TABLE photographer_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; -ALTER TABLE tutor_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; -ALTER TABLE makeup_artist_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; -ALTER TABLE developer_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; -ALTER TABLE video_editor_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; -ALTER TABLE graphic_designer_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; -ALTER TABLE social_media_manager_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; -ALTER TABLE fitness_trainer_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; -ALTER TABLE catering_service_profiles ADD COLUMN IF NOT EXISTS custom_data JSONB; --- Enforce immutable tracecoin ledger: no UPDATE/DELETE allowed. - -CREATE OR REPLACE FUNCTION prevent_tracecoin_ledger_mutation() -RETURNS trigger AS $$ -BEGIN - RAISE EXCEPTION 'tracecoin_ledger is immutable; % is not allowed', TG_OP; -END; -$$ LANGUAGE plpgsql; - -DROP TRIGGER IF EXISTS trg_prevent_tracecoin_ledger_update ON tracecoin_ledger; -CREATE TRIGGER trg_prevent_tracecoin_ledger_update -BEFORE UPDATE ON tracecoin_ledger -FOR EACH ROW -EXECUTE FUNCTION prevent_tracecoin_ledger_mutation(); - -DROP TRIGGER IF EXISTS trg_prevent_tracecoin_ledger_delete ON tracecoin_ledger; -CREATE TRIGGER trg_prevent_tracecoin_ledger_delete -BEFORE DELETE ON tracecoin_ledger -FOR EACH ROW -EXECUTE FUNCTION prevent_tracecoin_ledger_mutation(); -UPDATE company_profiles -SET status = 'APPROVED' -WHERE status = 'ACTIVE'; - -UPDATE customer_profiles -SET status = 'APPROVED' -WHERE status = 'ACTIVE'; --- Extend roles table for internal role management -ALTER TABLE roles - ADD COLUMN IF NOT EXISTS description TEXT, - ADD COLUMN IF NOT EXISTS department_id UUID REFERENCES departments(id) ON DELETE SET NULL, - ADD COLUMN IF NOT EXISTS can_approve_requests BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS can_manage_system_settings BOOLEAN NOT NULL DEFAULT false; -ALTER TABLE departments - ADD COLUMN IF NOT EXISTS code VARCHAR(64), - ADD COLUMN IF NOT EXISTS description TEXT, - ADD COLUMN IF NOT EXISTS department_head VARCHAR(255), - ADD COLUMN IF NOT EXISTS department_email VARCHAR(255), - ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS visibility VARCHAR(20) NOT NULL DEFAULT 'INTERNAL', - ADD COLUMN IF NOT EXISTS transfers_enabled BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); - -UPDATE departments -SET updated_at = COALESCE(updated_at, created_at, NOW()); - CREATE UNIQUE INDEX IF NOT EXISTS idx_departments_code_unique - ON departments (LOWER(code)) - WHERE code IS NOT NULL; - -CREATE INDEX IF NOT EXISTS idx_departments_is_active - ON departments (is_active); -ALTER TABLE designations - ADD COLUMN IF NOT EXISTS code VARCHAR(64), - ADD COLUMN IF NOT EXISTS department_id UUID REFERENCES departments(id) ON DELETE SET NULL, - ADD COLUMN IF NOT EXISTS description TEXT, - ADD COLUMN IF NOT EXISTS level VARCHAR(100), - ADD COLUMN IF NOT EXISTS can_manage_team BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS can_approve BOOLEAN NOT NULL DEFAULT false, - ADD COLUMN IF NOT EXISTS is_active BOOLEAN NOT NULL DEFAULT true, - ADD COLUMN IF NOT EXISTS updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(); - -UPDATE designations -SET updated_at = COALESCE(updated_at, created_at, NOW()); + ON departments(LOWER(code)) WHERE code IS NOT NULL; +CREATE TABLE IF NOT EXISTS designations ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + name VARCHAR(100) NOT NULL UNIQUE, + code VARCHAR(64), + department_id UUID REFERENCES departments(id) ON DELETE SET NULL, + description TEXT, + level VARCHAR(100), + can_manage_team BOOLEAN NOT NULL DEFAULT false, + can_approve BOOLEAN NOT NULL DEFAULT false, + is_active BOOLEAN NOT NULL DEFAULT true, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); CREATE UNIQUE INDEX IF NOT EXISTS idx_designations_code_unique - ON designations (LOWER(code)) - WHERE code IS NOT NULL; + ON designations(LOWER(code)) WHERE code IS NOT NULL; -CREATE INDEX IF NOT EXISTS idx_designations_is_active - ON designations (is_active); - -CREATE INDEX IF NOT EXISTS idx_designations_department_id - ON designations (department_id); --- UP: 20260402030000_strict_employee_separation.up.sql - --- Drop old employees table (was linked to users — replacing with standalone auth) -DROP TABLE IF EXISTS employees CASCADE; - --- 1. EMPLOYEES (Standalone Table - Not Linked to 'users') -CREATE TABLE employees ( +CREATE TABLE IF NOT EXISTS employees ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), first_name VARCHAR(100) NOT NULL, last_name VARCHAR(100) NOT NULL, @@ -1176,12 +973,12 @@ CREATE TABLE employees ( designation_id UUID REFERENCES designations(id) ON DELETE SET NULL, role_code VARCHAR(50) NOT NULL DEFAULT 'STAFF', status VARCHAR(50) NOT NULL DEFAULT 'ACTIVE', - joined_at DATE NOT NULL DEFAULT CURRENT_DATE, + joining_date DATE DEFAULT CURRENT_DATE, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE INDEX IF NOT EXISTS idx_employees_email ON employees(email); --- 2. EMPLOYEE SESSIONS (Standalone Auth) CREATE TABLE IF NOT EXISTS employee_sessions ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), employee_id UUID NOT NULL REFERENCES employees(id) ON DELETE CASCADE, @@ -1191,131 +988,67 @@ CREATE TABLE IF NOT EXISTS employee_sessions ( created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); --- Indexes -CREATE INDEX IF NOT EXISTS idx_employees_email ON employees(email); -CREATE INDEX IF NOT EXISTS idx_employees_status ON employees(status); -CREATE INDEX IF NOT EXISTS idx_employee_sessions_token ON employee_sessions(token_hash); --- Up migration: Create activity_logs table +-- ============================================================================ +-- 18. AUDIT MANAGEMENT +-- ============================================================================ + +CREATE TABLE IF NOT EXISTS audit_logs ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + actor_user_id UUID NOT NULL, + actor_type VARCHAR(20) NOT NULL, + action VARCHAR(100) NOT NULL, + entity_type VARCHAR(50) NOT NULL, + entity_id UUID NOT NULL, + module_key VARCHAR(100), + request_id UUID, + ip_address VARCHAR(45), + user_agent TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'SUCCESS', + summary TEXT, + metadata_json JSONB, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_audit_logs_entity ON audit_logs(entity_type, entity_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_actor ON audit_logs(actor_type, actor_user_id); +CREATE INDEX IF NOT EXISTS idx_audit_logs_created_at ON audit_logs(created_at DESC); + +CREATE TABLE IF NOT EXISTS audit_log_changes ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + audit_log_id UUID NOT NULL REFERENCES audit_logs(id) ON DELETE CASCADE, + field_name VARCHAR(255) NOT NULL, + old_value TEXT, + new_value TEXT, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); + +-- activity_logs used by Rust code CREATE TABLE IF NOT EXISTS activity_logs ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - actor_id UUID NOT NULL, -- User or Employee who performed the action - actor_type VARCHAR(20) NOT NULL, -- 'USER' or 'EMPLOYEE' - entity_id UUID NOT NULL, -- Target of the action (User ID, Job ID, etc.) - entity_type VARCHAR(50) NOT NULL, -- 'USER', 'JOB', 'REQUIREMENT', 'EMPLOYEE', etc. - action VARCHAR(100) NOT NULL, -- 'APPROVE', 'REJECT', 'STATUS_CHANGE', 'DELETE', etc. - metadata JSONB, -- Optional extra context: { "old_status": "PENDING", "new_status": "APPROVED", "reason": "..." } + actor_id UUID NOT NULL, + actor_type VARCHAR(20) NOT NULL, + entity_id UUID NOT NULL, + entity_type VARCHAR(50) NOT NULL, + action VARCHAR(100) NOT NULL, + metadata JSONB, ip_address VARCHAR(45), user_agent TEXT, created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE INDEX IF NOT EXISTS idx_activity_logs_entity ON activity_logs(entity_type, entity_id); +CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at ON activity_logs(created_at DESC); -CREATE INDEX IF NOT EXISTS idx_activity_logs_entity ON activity_logs (entity_type, entity_id); -CREATE INDEX IF NOT EXISTS idx_activity_logs_actor ON activity_logs (actor_type, actor_id); -CREATE INDEX IF NOT EXISTS idx_activity_logs_created_at ON activity_logs (created_at DESC); -ALTER TABLE kb_articles - ADD COLUMN IF NOT EXISTS summary TEXT, - ADD COLUMN IF NOT EXISTS tags TEXT[] NOT NULL DEFAULT '{}'; --- Allow admin-created tickets with no linked user -ALTER TABLE support_tickets - ALTER COLUMN user_id DROP NOT NULL; +-- ============================================================================ +-- 19. ACCOUNT DELETION +-- ============================================================================ --- Add description body and requester info for admin-created cases -ALTER TABLE support_tickets - ADD COLUMN IF NOT EXISTS description TEXT, - ADD COLUMN IF NOT EXISTS requester_name VARCHAR(255), - ADD COLUMN IF NOT EXISTS requester_email VARCHAR(255); --- Extend reviews table to support admin-created reviews and admin moderation -ALTER TABLE reviews - ALTER COLUMN lead_request_id DROP NOT NULL, - ALTER COLUMN customer_id DROP NOT NULL, - ALTER COLUMN professional_id DROP NOT NULL, - ADD COLUMN IF NOT EXISTS title VARCHAR(255), - ADD COLUMN IF NOT EXISTS subject_type VARCHAR(50) NOT NULL DEFAULT 'PLATFORM', - ADD COLUMN IF NOT EXISTS subject_id VARCHAR(255), - ADD COLUMN IF NOT EXISTS reviewer_name VARCHAR(255), - ADD COLUMN IF NOT EXISTS status VARCHAR(20) NOT NULL DEFAULT 'PUBLISHED'; - --- Sync status with is_published for existing rows -UPDATE reviews SET status = CASE WHEN is_published THEN 'PUBLISHED' ELSE 'HIDDEN' END; --- Add title and role_keys to coupons for admin UI -ALTER TABLE coupons - ADD COLUMN IF NOT EXISTS title VARCHAR(255), - ADD COLUMN IF NOT EXISTS role_keys TEXT[] NOT NULL DEFAULT '{}'; - --- Backfill title from description -UPDATE coupons SET title = description WHERE title IS NULL AND description IS NOT NULL; --- Admin-managed automatic discounts (applied before coupon codes) -CREATE TABLE IF NOT EXISTS discounts ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - title VARCHAR(255) NOT NULL, - scope VARCHAR(20) NOT NULL DEFAULT 'ROLE', -- ROLE, PACKAGE - role_key VARCHAR(50), - package_id UUID REFERENCES pricing_packages(id) ON DELETE SET NULL, - discount_type VARCHAR(20) NOT NULL, -- PERCENT, FIXED - discount_value INTEGER NOT NULL, - is_active BOOLEAN NOT NULL DEFAULT true, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); --- 10. UGC CONTENT CREATOR PROFILES -CREATE TABLE IF NOT EXISTS ugc_content_creator_profiles ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE UNIQUE, - display_name VARCHAR(255) NOT NULL DEFAULT '', - bio TEXT, - location VARCHAR(255), - -- Profession-specific - platforms TEXT[] DEFAULT '{}', -- e.g. ['Instagram', 'YouTube', 'TikTok'] - content_niches TEXT[] DEFAULT '{}', -- e.g. ['Beauty', 'Tech', 'Food', 'Lifestyle'] - content_formats TEXT[] DEFAULT '{}', -- e.g. ['Reels', 'Unboxing', 'Reviews', 'GRWM'] - follower_count INTEGER DEFAULT 0, - avg_views_per_post INTEGER DEFAULT 0, - has_media_kit BOOLEAN NOT NULL DEFAULT false, - instagram_handle VARCHAR(100), - youtube_channel_url VARCHAR(500), - portfolio_url VARCHAR(500), - starting_price_inr INTEGER DEFAULT 0, -- in paise - custom_data JSONB, - -- Verification & status - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, APPROVED, REJECTED, SUSPENDED - rejection_reason TEXT, - approved_at TIMESTAMPTZ, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - -CREATE INDEX IF NOT EXISTS idx_ugc_content_creator_profiles_status ON ugc_content_creator_profiles(status); -CREATE INDEX IF NOT EXISTS idx_ugc_content_creator_profiles_user_id ON ugc_content_creator_profiles(user_id); --- 1. VERIFICATIONS TABLE -CREATE TABLE IF NOT EXISTS verifications ( +CREATE TABLE IF NOT EXISTS account_deletion_requests ( id UUID PRIMARY KEY DEFAULT gen_random_uuid(), user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE, - role_key VARCHAR(50) NOT NULL, - status VARCHAR(50) NOT NULL DEFAULT 'PENDING', -- PENDING, UNDER_REVIEW, DOCUMENTS_REQUESTED, REVISION_REQUESTED, APPROVED, REJECTED - priority VARCHAR(10) NOT NULL DEFAULT 'LOW', -- HIGH, MEDIUM, LOW - case_type VARCHAR(50) NOT NULL, -- PROFILE, PORTFOLIO, JOB, REQUIREMENT - payload JSONB NOT NULL DEFAULT '{}', -- full submission data - documents JSONB NOT NULL DEFAULT '[]', -- list of documents [{id, title, url, status}] - notes TEXT, - rejection_reason TEXT, - assigned_to UUID REFERENCES users(id) ON DELETE SET NULL, -- Admin/Employee ID - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), - updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() + reason TEXT, + status VARCHAR(20) NOT NULL DEFAULT 'PENDING', + requested_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + processed_at TIMESTAMPTZ, + processed_by UUID REFERENCES users(id) ); --- 2. VERIFICATION LOGS (History of actions) -CREATE TABLE IF NOT EXISTS verification_logs ( - id UUID PRIMARY KEY DEFAULT gen_random_uuid(), - verification_id UUID NOT NULL REFERENCES verifications(id) ON DELETE CASCADE, - action VARCHAR(50) NOT NULL, -- STATUS_CHANGE, NOTE_ADDED, DOCS_REQUESTED, REASSIGNED - actor_id UUID REFERENCES users(id) ON DELETE SET NULL, - old_status VARCHAR(50), - new_status VARCHAR(50), - message TEXT, - created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() -); - --- 3. INDEXES -CREATE INDEX IF NOT EXISTS idx_verifications_user_id ON verifications(user_id); -CREATE INDEX IF NOT EXISTS idx_verifications_status ON verifications(status); -CREATE INDEX IF NOT EXISTS idx_verifications_case_type ON verifications(case_type); -CREATE INDEX IF NOT EXISTS idx_verification_logs_ver_id ON verification_logs(verification_id); +COMMIT; diff --git a/scripts/seed.sql b/scripts/seed.sql index 6496012..3e6727e 100644 --- a/scripts/seed.sql +++ b/scripts/seed.sql @@ -4,10 +4,26 @@ -- ── 1. Roles ───────────────────────────────────────────────────────────────── +-- Internal roles INSERT INTO roles (key, name, audience) VALUES ('SUPER_ADMIN', 'Super Admin', 'INTERNAL'), ('ADMIN', 'Admin', 'INTERNAL'), - ('SUPPORT', 'Support Agent', 'INTERNAL'), + ('SUPPORT', 'Support Agent', 'INTERNAL') +ON CONFLICT (key) DO NOTHING; + +-- Internal role extensions +INSERT INTO internal_roles (role_id, description) + SELECT id, 'Full system administrator with all permissions' FROM roles WHERE key = 'SUPER_ADMIN' + ON CONFLICT (role_id) DO NOTHING; +INSERT INTO internal_roles (role_id, description, can_approve_requests, can_manage_system_settings) + SELECT id, 'Standard administrator', true, true FROM roles WHERE key = 'ADMIN' + ON CONFLICT (role_id) DO NOTHING; +INSERT INTO internal_roles (role_id, description) + SELECT id, 'Customer support agent' FROM roles WHERE key = 'SUPPORT' + ON CONFLICT (role_id) DO NOTHING; + +-- External roles +INSERT INTO roles (key, name, audience) VALUES ('COMPANY', 'Company', 'EXTERNAL'), ('JOB_SEEKER', 'Job Seeker', 'EXTERNAL'), ('CUSTOMER', 'Customer', 'EXTERNAL'), @@ -22,6 +38,11 @@ INSERT INTO roles (key, name, audience) VALUES ('CATERING_SERVICES', 'Catering Services', 'EXTERNAL') ON CONFLICT (key) DO NOTHING; +-- External role extensions +INSERT INTO external_roles (role_id) + SELECT id FROM roles WHERE audience = 'EXTERNAL' +ON CONFLICT (role_id) DO NOTHING; + -- ── 2. Super Admin User ────────────────────────────────────────────────────── -- Default password: Admin@nxtgauge1 (bcrypt hash) -- CHANGE THIS PASSWORD IMMEDIATELY AFTER FIRST LOGIN @@ -33,13 +54,14 @@ DECLARE BEGIN SELECT id INTO super_admin_role_id FROM roles WHERE key = 'SUPER_ADMIN'; - INSERT INTO users (email, password_hash, status, role_id, full_name, email_verified) + INSERT INTO users (email, password_hash, status, role_id, first_name, last_name, email_verified) VALUES ( 'admin@nxtgauge.com', '$2b$12$LQv3c1yqBWVHxkd0LHAkCOYz6TiGniB9GSmJBGp0K7RqUi/4hY/Ii', 'ACTIVE', super_admin_role_id, - 'Super Admin', + 'Super', + 'Admin', true ) ON CONFLICT (email) DO NOTHING diff --git a/users.pid b/users.pid index cdd146d..b07474d 100644 --- a/users.pid +++ b/users.pid @@ -1 +1 @@ -96200 +9691 From 34568290634970b5bf40ab6a4727545e9d765021 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Wed, 15 Apr 2026 14:50:51 +0200 Subject: [PATCH 039/182] fix: hardcode registry with port 5000 --- .woodpecker.yml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 702bc44..d8b808b 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -32,8 +32,7 @@ steps: - name: build-and-push image: woodpeckerci/plugin-kaniko:2.1.1 settings: - registry: - from_secret: REGISTRY_HOSTPORT + registry: registry.nxtgauge.com:5000 repo: nxtgauge-rust-${SERVICE} dockerfile: Dockerfile.simple build_args: @@ -61,8 +60,7 @@ steps: - name: build-and-push-migrate image: woodpeckerci/plugin-kaniko:2.1.1 settings: - registry: - from_secret: REGISTRY_HOSTPORT + registry: registry.nxtgauge.com:5000 repo: nxtgauge-db-migrate dockerfile: Dockerfile.migrate context: . From 4fa50055598a133d1e50e9910c0968f8a67fb68d Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Wed, 15 Apr 2026 14:58:40 +0200 Subject: [PATCH 040/182] fix: use registry.nxtgauge.com without port --- .woodpecker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index d8b808b..526dfef 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -32,7 +32,7 @@ steps: - name: build-and-push image: woodpeckerci/plugin-kaniko:2.1.1 settings: - registry: registry.nxtgauge.com:5000 + registry: registry.nxtgauge.com repo: nxtgauge-rust-${SERVICE} dockerfile: Dockerfile.simple build_args: From 430711a0aee42169554c84de7beb469a5a24aa55 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Wed, 15 Apr 2026 18:19:07 +0200 Subject: [PATCH 041/182] feat: add AI endpoints for chat, tickets, form extraction via Ollama - Add /api/ai/chat/message: LLM-powered chat with intent classification - Add /api/ai/tickets/create and /api/ai/tickets/:id: AI ticket management - Add /api/ai/forms/extract: LLM-powered form field extraction - Add /api/support/tickets/ai/create: unauthenticated ticket creation for AI service - Add reqwest to workspace dependencies --- Cargo.lock | 92 ++++++++ Cargo.toml | 1 + apps/users/Cargo.toml | 1 + apps/users/src/handlers/ai.rs | 352 +++++++++++++++++++++++++++++ apps/users/src/handlers/mod.rs | 1 + apps/users/src/handlers/support.rs | 56 +++++ apps/users/src/main.rs | 2 + 7 files changed, 505 insertions(+) create mode 100644 apps/users/src/handlers/ai.rs diff --git a/Cargo.lock b/Cargo.lock index 0560f80..f6d2d86 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -788,6 +788,12 @@ version = "1.0.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9330f8b2ff13f34540b44e946ef35111825727b38d33286ef986142615121801" +[[package]] +name = "cfg_aliases" +version = "0.2.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" + [[package]] name = "chrono" version = "0.4.44" @@ -1522,9 +1528,11 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi 5.3.0", "wasip2", + "wasm-bindgen", ] [[package]] @@ -1826,6 +1834,7 @@ dependencies = [ "tokio", "tokio-rustls 0.26.4", "tower-service", + "webpki-roots 1.0.6", ] [[package]] @@ -2248,6 +2257,12 @@ dependencies = [ "hashbrown 0.15.5", ] +[[package]] +name = "lru-slab" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "112b39cec0b298b6c1999fee3e31427f74f676e4cb9879ed1a121b43661a4154" + [[package]] name = "makeup_artists" version = "0.1.0" @@ -2723,6 +2738,61 @@ dependencies = [ "cc", ] +[[package]] +name = "quinn" +version = "0.11.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b9e20a958963c291dc322d98411f541009df2ced7b5a4f2bd52337638cfccf20" +dependencies = [ + "bytes", + "cfg_aliases", + "pin-project-lite", + "quinn-proto", + "quinn-udp", + "rustc-hash", + "rustls 0.23.37", + "socket2 0.5.10", + "thiserror", + "tokio", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-proto" +version = "0.11.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "434b42fec591c96ef50e21e886936e66d3cc3f737104fdb9b737c40ffb94c098" +dependencies = [ + "bytes", + "getrandom 0.3.4", + "lru-slab", + "rand 0.9.2", + "ring", + "rustc-hash", + "rustls 0.23.37", + "rustls-pki-types", + "slab", + "thiserror", + "tinyvec", + "tracing", + "web-time", +] + +[[package]] +name = "quinn-udp" +version = "0.5.14" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "addec6a0dcad8a8d96a771f815f0eaf55f9d1805756410b39f5fa81332574cbd" +dependencies = [ + "cfg_aliases", + "libc", + "once_cell", + "socket2 0.5.10", + "tracing", + "windows-sys 0.52.0", +] + [[package]] name = "quote" version = "1.0.45" @@ -2914,6 +2984,8 @@ dependencies = [ "native-tls", "percent-encoding", "pin-project-lite", + "quinn", + "rustls 0.23.37", "rustls-pki-types", "serde", "serde_json", @@ -2921,6 +2993,7 @@ dependencies = [ "sync_wrapper", "tokio", "tokio-native-tls", + "tokio-rustls 0.26.4", "tokio-util", "tower", "tower-http", @@ -2930,6 +3003,7 @@ dependencies = [ "wasm-bindgen-futures", "wasm-streams", "web-sys", + "webpki-roots 1.0.6", ] [[package]] @@ -2977,6 +3051,12 @@ dependencies = [ "zeroize", ] +[[package]] +name = "rustc-hash" +version = "2.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "94300abf3f1ae2e2b8ffb7b58043de3d399c73fa6f4b73826402a5c457614dbe" + [[package]] name = "rustc_version" version = "0.4.1" @@ -3045,6 +3125,7 @@ version = "1.14.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "be040f8b0a225e40375822a563fa9524378b9d63112f53e19ffff34df5d33fdd" dependencies = [ + "web-time", "zeroize", ] @@ -4099,6 +4180,7 @@ dependencies = [ "db", "email", "rand 0.8.5", + "reqwest", "serde", "serde_json", "sqlx", @@ -4323,6 +4405,16 @@ dependencies = [ "wasm-bindgen", ] +[[package]] +name = "web-time" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5a6580f308b1fad9207618087a65c04e7a10bc77e02c8e84e9b00dd4b12fa0bb" +dependencies = [ + "js-sys", + "wasm-bindgen", +] + [[package]] name = "webpki-roots" version = "0.26.11" diff --git a/Cargo.toml b/Cargo.toml index dcf1d42..b074324 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -54,3 +54,4 @@ redis = { version = "0.27", features = ["tokio-comp", "connection-manager"] } async-trait = "0.1" bytes = "1" tower-http = "0.6" +reqwest = { version = "0.12", features = ["json", "rustls-tls"] } diff --git a/apps/users/Cargo.toml b/apps/users/Cargo.toml index 3428eda..f8b84e7 100644 --- a/apps/users/Cargo.toml +++ b/apps/users/Cargo.toml @@ -20,4 +20,5 @@ contracts = { path = "../../crates/contracts" } cache = { path = "../../crates/cache" } rand = "0.8" anyhow = { workspace = true } +reqwest = { workspace = true } diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs new file mode 100644 index 0000000..94d2b37 --- /dev/null +++ b/apps/users/src/handlers/ai.rs @@ -0,0 +1,352 @@ +use crate::AppState; +use axum::{ + extract::{Query, State}, + http::StatusCode, + response::IntoResponse, + routing::{get, post}, + Json, Router, +}; +use serde::{Deserialize, Serialize}; +use uuid::Uuid; + +pub fn ai_router() -> Router { + Router::new() + .route("/chat/message", post(ai_chat_message)) + .route("/tickets/create", post(ai_create_ticket)) + .route("/tickets/:id", get(ai_get_ticket)) + .route("/forms/extract", post(ai_extract_form)) +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct OllamaChatRequest { + pub model: Option, + pub message: String, + pub conversation_id: Option, + pub user_id: Option, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +pub struct OllamaChatResponse { + pub message: String, + pub conversation_id: String, + pub intent: String, + pub confidence: f32, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct OllamaGenerateRequest { + model: String, + prompt: String, + stream: bool, +} + +#[derive(Debug, Clone, Deserialize, Serialize)] +struct OllamaGenerateResponse { + response: String, +} + +async fn call_ollama(state: &AppState, model: &str, prompt: &str) -> Result { + let base_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); + let url = format!("{}/api/generate", base_url); + + let req = OllamaGenerateRequest { + model: model.to_string(), + prompt: prompt.to_string(), + stream: false, + }; + + let client = reqwest::Client::new(); + 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: OllamaGenerateResponse = response + .json() + .await + .map_err(|e| format!("failed to parse ollama response: {}", e))?; + + Ok(result.response) +} + +async fn classify_intent(message: &str, ollama_base: &str, model: &str) -> (String, f32) { + let prompt = format!( + "Classify this user message into one intent category. Categories: ticket_creation, form_filling, help_search, general. \ + Return ONLY the intent name, nothing else.\n\nMessage: {}", + message + ); + + match call_ollama_inline(ollama_base, model, &prompt).await { + Ok(response) => { + let intent = response.trim().to_lowercase(); + let confidence = if intent.is_empty() { 0.5 } else { 0.85 }; + let intent = match intent.as_str() { + "ticket_creation" => "ticket_creation", + "form_filling" => "form_filling", + "help_search" => "help_search", + _ => "general", + }; + (intent.to_string(), confidence) + } + Err(_) => ("general".to_string(), 0.5), + } +} + +async fn call_ollama_inline(base_url: &str, model: &str, prompt: &str) -> Result { + let url = format!("{}/api/generate", base_url); + let req = OllamaGenerateRequest { + model: model.to_string(), + prompt: prompt.to_string(), + stream: false, + }; + + let client = reqwest::Client::new(); + 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: OllamaGenerateResponse = response + .json() + .await + .map_err(|e| format!("failed to parse ollama response: {}", e))?; + + Ok(result.response) +} + +async fn ai_chat_message( + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); + let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "smollm2:360m".to_string()); + let default_conversation = Uuid::new_v4().to_string(); + + let conversation_id = body.conversation_id.unwrap_or_else(|| default_conversation); + + let (intent, confidence) = classify_intent(&body.message, &ollama_base, &model).await; + + let system_prompt = match intent.as_str() { + "ticket_creation" => { + "You are a support ticket assistant. Help users create clear, actionable support tickets. \ + Ask for: subject, description of issue, category, priority if not provided. \ + Summarize the ticket in a structured way." + } + "form_filling" => { + "You are a form filling assistant. Help users fill out forms by extracting relevant information \ + from their message. Extract key:value pairs when possible." + } + "help_search" => { + "You are a help center assistant. Help users find relevant help articles based on their query. \ + Ask clarifying questions to narrow down the search." + } + _ => { + "You are a helpful AI assistant for Nxtgauge platform. Provide clear, concise responses. \ + If the user needs support, guide them to create a ticket." + } + }; + + let full_prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, body.message); + + let response_text = match call_ollama(&state, &model, &full_prompt).await { + Ok(r) => r, + Err(e) => { + tracing::error!("Ollama error: {}", e); + "I'm having trouble processing your request right now. Please try again or contact support.".to_string() + } + }; + + ( + StatusCode::OK, + Json(OllamaChatResponse { + message: response_text, + conversation_id, + intent, + confidence, + }), + ) + .into_response() +} + +async fn ai_create_ticket( + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + let subject = body.get("subject").and_then(|v| v.as_str()).unwrap_or("AI Assisted Request"); + let description = body.get("description").and_then(|v| v.as_str()); + let category = body.get("category").and_then(|v| v.as_str()).unwrap_or("ai_assisted"); + let priority = body.get("priority").and_then(|v| v.as_str()).unwrap_or("medium"); + let user_id = body.get("user_id").and_then(|v| v.as_str()) + .and_then(|s| Uuid::parse_str(s).ok()) + .unwrap_or_else(Uuid::nil); + + 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(subject) + .bind(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, + "status": r.status, + "ticket_id": r.id, + })), + ) + .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() + } + } +} + +async fn ai_get_ticket( + State(state): State, + axum::extract::Path(id): axum::extract::Path, +) -> impl IntoResponse { + let result = sqlx::query_as::<_, TicketRow>( + r#" + SELECT id, subject, description, category, priority, status, + requester_name, requester_email, assigned_to, created_at, updated_at + FROM support_tickets WHERE id = $1 + "#, + ) + .bind(id) + .fetch_optional(&state.pool) + .await; + + match result { + Ok(Some(r)) => ( + StatusCode::OK, + Json(serde_json::json!({ + "id": r.id, + "subject": r.subject, + "description": r.description, + "category": r.category, + "priority": r.priority, + "status": r.status, + "requester_name": r.requester_name, + "requester_email": r.requester_email, + "assigned_to": r.assigned_to, + "created_at": r.created_at, + "updated_at": r.updated_at, + })), + ) + .into_response(), + Ok(None) => (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Ticket not found" }))).into_response(), + Err(e) => { + tracing::error!("Failed to fetch ticket {}: {}", id, e); + (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Failed to fetch ticket" }))).into_response() + } + } +} + +#[derive(Debug, Deserialize)] +struct FormExtractBody { + message: String, + form_type: Option, +} + +#[derive(Debug, Serialize)] +struct FormExtractResponse { + fields: Vec, + missing_fields: Vec, + confidence: f32, +} + +#[derive(Debug, Serialize)] +struct ExtractedField { + key: String, + value: String, + confidence: f32, +} + +async fn ai_extract_form( + State(state): State, + Json(body): Json, +) -> impl IntoResponse { + let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); + let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "smollm2:360m".to_string()); + + let form_type = body.form_type.unwrap_or_else(|| "generic".to_string()); + + let prompt = format!( + "Extract key:value pairs from this message for a {} form. \ + Return ONLY a JSON object with the fields you can identify. \ + Use camelCase for field names.\n\nMessage: {}", + form_type, body.message + ); + + let response_text = match call_ollama_inline(&ollama_base, &model, &prompt).await { + Ok(r) => r, + Err(e) => { + tracing::error!("Ollama form extraction error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Form extraction failed" }))).into_response(); + } + }; + + let extracted: serde_json::Value = serde_json::from_str(&response_text) + .unwrap_or_else(|_| serde_json::json!({})); + + let mut fields = Vec::new(); + let mut missing_fields = Vec::new(); + + if let Some(obj) = extracted.as_object() { + for (key, value) in obj { + fields.push(ExtractedField { + key: key.clone(), + value: value.to_string(), + confidence: 0.8, + }); + } + } + + let confidence = if fields.is_empty() { 0.3 } else { 0.75 }; + + (StatusCode::OK, Json(FormExtractResponse { + fields, + missing_fields, + confidence, + })).into_response() +} + +#[derive(sqlx::FromRow)] +struct TicketRow { + id: Uuid, + subject: String, + description: Option, + category: String, + priority: String, + status: String, + requester_name: Option, + requester_email: Option, + assigned_to: Option, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, +} \ No newline at end of file diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs index ed46976..b606d8f 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -3,6 +3,7 @@ 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; diff --git a/apps/users/src/handlers/support.rs b/apps/users/src/handlers/support.rs index 32d48c0..d7151b4 100644 --- a/apps/users/src/handlers/support.rs +++ b/apps/users/src/handlers/support.rs @@ -18,6 +18,7 @@ pub fn user_router() -> Router { .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, } +// ── AI Service: create ticket (no user auth required) ──────────────────────── + +#[derive(Deserialize)] +struct AiCreateTicketBody { + subject: String, + description: Option, + category: Option, + priority: Option, + #[serde(rename = "userId")] + user_id: Option, +} + +async fn ai_create_ticket( + State(state): State, + axum::extract::Json(body): axum::extract::Json, +) -> 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)] diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index 787875a..f8873db 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -104,6 +104,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); From ebc0a294377692f401caaa22c5c9180a887ffdd8 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Wed, 15 Apr 2026 19:54:58 +0200 Subject: [PATCH 042/182] fix(ai): use Ollama cluster URL and gemma3:270m model defaults - Default OLLAMA_BASE_URL to http://ollama.nxtgauge-ai.svc.cluster.local:11434 - Default OLLAMA_CHAT_MODEL to gemma3:270m (matches gitops configmap) --- apps/users/src/handlers/ai.rs | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs index 94d2b37..5935ae4 100644 --- a/apps/users/src/handlers/ai.rs +++ b/apps/users/src/handlers/ai.rs @@ -13,7 +13,7 @@ pub fn ai_router() -> Router { Router::new() .route("/chat/message", post(ai_chat_message)) .route("/tickets/create", post(ai_create_ticket)) - .route("/tickets/:id", get(ai_get_ticket)) + .route("/tickets/{id}", get(ai_get_ticket)) .route("/forms/extract", post(ai_extract_form)) } @@ -46,7 +46,7 @@ struct OllamaGenerateResponse { } async fn call_ollama(state: &AppState, model: &str, prompt: &str) -> Result { - let base_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); + let base_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); let url = format!("{}/api/generate", base_url); let req = OllamaGenerateRequest { @@ -130,8 +130,8 @@ async fn ai_chat_message( State(state): State, Json(body): Json, ) -> impl IntoResponse { - let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); - let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "smollm2:360m".to_string()); + let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); + let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); let default_conversation = Uuid::new_v4().to_string(); let conversation_id = body.conversation_id.unwrap_or_else(|| default_conversation); @@ -291,8 +291,8 @@ async fn ai_extract_form( State(state): State, Json(body): Json, ) -> impl IntoResponse { - let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://localhost:11434".to_string()); - let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "smollm2:360m".to_string()); + let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); + let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); let form_type = body.form_type.unwrap_or_else(|| "generic".to_string()); From 52ed6d7975a74c6f43a0017f4a9c9f0fee4e452e Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 16 Apr 2026 10:28:05 +0200 Subject: [PATCH 043/182] fix(support): add missing status bind parameter in admin create case - Fix INSERT statement to use , , instead of hardcoded 'new' with placeholders - Add .bind("new") for status parameter --- apps/users/src/handlers/support.rs | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/apps/users/src/handlers/support.rs b/apps/users/src/handlers/support.rs index d7151b4..e0be1a4 100644 --- a/apps/users/src/handlers/support.rs +++ b/apps/users/src/handlers/support.rs @@ -583,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; From d29c7558995a73fcffc2be3a1b4a572fdd1eb67b Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 16 Apr 2026 10:35:47 +0200 Subject: [PATCH 044/182] fix(users): add missing password_hash bind parameter in user create - INSERT statement had only 4 placeholders but 5 columns specified - Add placeholder for password_hash and bind it properly --- crates/db/src/models/user.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/crates/db/src/models/user.rs b/crates/db/src/models/user.rs index 24fb9c9..6201dae 100644 --- a/crates/db/src/models/user.rs +++ b/crates/db/src/models/user.rs @@ -52,7 +52,7 @@ impl UserRepository { let user = sqlx::query_as::<_, User>( r#" INSERT INTO users (first_name, last_name, email, password_hash, email_verified, phone_verified) - VALUES ($1, $2, $3, false, false) + VALUES ($1, $2, $3, $4, false, false) RETURNING id, email, password_hash, first_name, last_name, email_verified, phone_verified, status, From 5ca90d111bb1c1e51a02d8d095774b48da55ccdb Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 16 Apr 2026 12:06:46 +0200 Subject: [PATCH 045/182] fix(ci): use registry.nxtgauge.com for db-migrate image - Remove :5000 from registry in db-migrate build step - Ensure all images push to registry.nxtgauge.com --- .woodpecker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 526dfef..1707d15 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -60,7 +60,7 @@ steps: - name: build-and-push-migrate image: woodpeckerci/plugin-kaniko:2.1.1 settings: - registry: registry.nxtgauge.com:5000 + registry: registry.nxtgauge.com repo: nxtgauge-db-migrate dockerfile: Dockerfile.migrate context: . From f3d686d07683458a71e51f5fdffa53d5241319ff Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 16 Apr 2026 17:21:24 +0200 Subject: [PATCH 046/182] feat(email): use Nxtgauge logo image instead of text logo - Replace text 'NXTGAUGE' with actual logo image in email header - Use hosted logo URL: https://nxtgauge.com/nxtgauge-logo.png - Copy logo to email/public directory for future use --- crates/email/public/nxtgauge-logo.png | Bin 0 -> 157257 bytes crates/email/templates/base.html | 10 +++++++--- 2 files changed, 7 insertions(+), 3 deletions(-) create mode 100644 crates/email/public/nxtgauge-logo.png diff --git a/crates/email/public/nxtgauge-logo.png b/crates/email/public/nxtgauge-logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b4e7e9b820f4e6a3cc1b7d64d63412bdafd5c4e7 GIT binary patch literal 157257 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∓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 literal 0 HcmV?d00001 diff --git a/crates/email/templates/base.html b/crates/email/templates/base.html index f04241a..322b1fd 100644 --- a/crates/email/templates/base.html +++ b/crates/email/templates/base.html @@ -289,9 +289,13 @@ From d08449185ed502102b2a98de956f6ba9804184bd Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 16 Apr 2026 18:06:06 +0200 Subject: [PATCH 048/182] feat: add v1 users API routes for backward compatibility - Add /api/v1/users path routing to users service in gateway - Add v1_router() in auth.rs with resend-otp endpoint - Nest /api/v1/users route in main.rs - Support legacy /api/v1/users/resend-otp endpoint --- apps/gateway/src/main.rs | 1 + apps/users/src/handlers/auth.rs | 7 +++++++ apps/users/src/main.rs | 2 ++ 3 files changed, 10 insertions(+) diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index 3433561..1c66dde 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -84,6 +84,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") diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index a6c30e0..7ac3850 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -724,3 +724,10 @@ async fn switch_role( "expires_in": 900 })))) } + +// ── V1 API Router (for backward compatibility) ───────────────────────── + +pub fn v1_router() -> Router { + Router::new() + .route("/resend-otp", post(resend_otp)) +} diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index f8873db..6f76b66 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -54,6 +54,8 @@ 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()) From 09df0323f33b4e706dcc88cd93c6fbcfa821ad51 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 16 Apr 2026 18:37:17 +0200 Subject: [PATCH 049/182] fix(ci): use mirrored rust:alpine from private registry - Change FROM rust:alpine to FROM registry.nxtgauge.com/rust:alpine - Fixes Docker Hub rate limiting/UNAUTHORIZED errors in Woodpecker builds - Requires manually pulling and pushing rust:alpine to registry.nxtgauge.com first --- Dockerfile.simple | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Dockerfile.simple b/Dockerfile.simple index 96ca6af..a7562ed 100644 --- a/Dockerfile.simple +++ b/Dockerfile.simple @@ -3,7 +3,7 @@ ARG SERVICE_NAME -FROM rust:alpine AS builder +FROM registry.nxtgauge.com/rust:alpine AS builder ARG SERVICE_NAME # Install deps From 770ebcbfc6c5c370eb5c2999b8f66601f9b39322 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 16 Apr 2026 20:54:24 +0200 Subject: [PATCH 050/182] fix(build): remove rustup target in Dockerfile.simple - rust:alpine image already includes x86_64-unknown-linux-musl target - Remove rustup target add command causing 'not found' error --- Dockerfile.simple | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/Dockerfile.simple b/Dockerfile.simple index a7562ed..4fbbf74 100644 --- a/Dockerfile.simple +++ b/Dockerfile.simple @@ -7,8 +7,7 @@ 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 +RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static WORKDIR /app From 9444056297ce3de7b8e870319a6aad1aadac1e8f Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 16 Apr 2026 21:32:40 +0200 Subject: [PATCH 051/182] ci: mirror base images first, then build services - First step: crane copy rust:alpine, rust:1.87-alpine, alpine:3.20 to registry - Second step: kaniko build each service with mirrored base images - No DinD required --- .woodpecker.yml | 52 +++++++++++++++++++++++-------------------------- 1 file changed, 24 insertions(+), 28 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 1707d15..5e27af4 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -2,33 +2,20 @@ when: branch: [main, high-performance] event: push -concurrency: - limit: 4 - -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: mirror-base-images + image: gcr.io/go-containerregistry/crane:debug + environment: + REG_USER: + from_secret: REGISTRY_USERNAME + REG_PASS: + from_secret: REGISTRY_PASSWORD + commands: + - crane auth login registry.nxtgauge.com -u "$REG_USER" -p "$REG_PASS" + - crane copy docker.io/library/rust:alpine registry.nxtgauge.com/rust:alpine + - crane copy docker.io/library/rust:1.87-alpine registry.nxtgauge.com/rust:1.87-alpine + - crane copy docker.io/library/alpine:3.20 registry.nxtgauge.com/alpine:3.20 + - name: build-and-push image: woodpeckerci/plugin-kaniko:2.1.1 settings: @@ -38,7 +25,6 @@ steps: build_args: - SERVICE_NAME=${SERVICE} tags: - - ${CI_COMMIT_SHA} - latest - high-performance-latest username: @@ -57,6 +43,17 @@ when: event: push steps: + - name: mirror-base-images + image: gcr.io/go-containerregistry/crane:debug + environment: + REG_USER: + from_secret: REGISTRY_USERNAME + REG_PASS: + from_secret: REGISTRY_PASSWORD + commands: + - crane auth login registry.nxtgauge.com -u "$REG_USER" -p "$REG_PASS" + - crane copy docker.io/library/rust:alpine registry.nxtgauge.com/rust:alpine + - name: build-and-push-migrate image: woodpeckerci/plugin-kaniko:2.1.1 settings: @@ -65,7 +62,6 @@ steps: dockerfile: Dockerfile.migrate context: . tags: - - ${CI_COMMIT_SHA} - latest - high-performance-latest username: From 3fde2917cdfc088fe401ddd4159f5a609c99791a Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 16 Apr 2026 22:16:33 +0200 Subject: [PATCH 052/182] ci: revert backend to direct kaniko build (no crane) --- .woodpecker.yml | 52 ++++++++++++++++++++++++++----------------------- 1 file changed, 28 insertions(+), 24 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 5e27af4..1707d15 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -2,20 +2,33 @@ when: branch: [main, high-performance] event: push -steps: - - name: mirror-base-images - image: gcr.io/go-containerregistry/crane:debug - environment: - REG_USER: - from_secret: REGISTRY_USERNAME - REG_PASS: - from_secret: REGISTRY_PASSWORD - commands: - - crane auth login registry.nxtgauge.com -u "$REG_USER" -p "$REG_PASS" - - crane copy docker.io/library/rust:alpine registry.nxtgauge.com/rust:alpine - - crane copy docker.io/library/rust:1.87-alpine registry.nxtgauge.com/rust:1.87-alpine - - crane copy docker.io/library/alpine:3.20 registry.nxtgauge.com/alpine:3.20 +concurrency: + limit: 4 +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: build-and-push image: woodpeckerci/plugin-kaniko:2.1.1 settings: @@ -25,6 +38,7 @@ steps: build_args: - SERVICE_NAME=${SERVICE} tags: + - ${CI_COMMIT_SHA} - latest - high-performance-latest username: @@ -43,17 +57,6 @@ when: event: push steps: - - name: mirror-base-images - image: gcr.io/go-containerregistry/crane:debug - environment: - REG_USER: - from_secret: REGISTRY_USERNAME - REG_PASS: - from_secret: REGISTRY_PASSWORD - commands: - - crane auth login registry.nxtgauge.com -u "$REG_USER" -p "$REG_PASS" - - crane copy docker.io/library/rust:alpine registry.nxtgauge.com/rust:alpine - - name: build-and-push-migrate image: woodpeckerci/plugin-kaniko:2.1.1 settings: @@ -62,6 +65,7 @@ steps: dockerfile: Dockerfile.migrate context: . tags: + - ${CI_COMMIT_SHA} - latest - high-performance-latest username: From 7f1ca0e387d11d7d87af0342ebf0db27d9da3433 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 16 Apr 2026 22:28:29 +0200 Subject: [PATCH 053/182] ci: use kaniko from registry.nxtgauge.com --- .woodpecker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 1707d15..f8420ea 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -30,7 +30,7 @@ matrix: steps: - name: build-and-push - image: woodpeckerci/plugin-kaniko:2.1.1 + image: registry.nxtgauge.com/kaniko:2.1.1 settings: registry: registry.nxtgauge.com repo: nxtgauge-rust-${SERVICE} @@ -58,7 +58,7 @@ when: steps: - name: build-and-push-migrate - image: woodpeckerci/plugin-kaniko:2.1.1 + image: registry.nxtgauge.com/kaniko:2.1.1 settings: registry: registry.nxtgauge.com repo: nxtgauge-db-migrate From 9c8cee50b3d22bf455748b7197f742e39b1d1dcc Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 16 Apr 2026 22:38:36 +0200 Subject: [PATCH 054/182] ci: revert to woodpeckerci plugin-kaniko from Docker Hub --- .woodpecker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index f8420ea..1707d15 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -30,7 +30,7 @@ matrix: steps: - name: build-and-push - image: registry.nxtgauge.com/kaniko:2.1.1 + image: woodpeckerci/plugin-kaniko:2.1.1 settings: registry: registry.nxtgauge.com repo: nxtgauge-rust-${SERVICE} @@ -58,7 +58,7 @@ when: steps: - name: build-and-push-migrate - image: registry.nxtgauge.com/kaniko:2.1.1 + image: woodpeckerci/plugin-kaniko:2.1.1 settings: registry: registry.nxtgauge.com repo: nxtgauge-db-migrate From cc65771a77af690a5b391ec02687e55f4ff6ba94 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 16 Apr 2026 23:08:06 +0200 Subject: [PATCH 055/182] ci: revert to registry.nxtgauge.com:5000 (from 4d6de95 - working config) --- .woodpecker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 1707d15..d8b808b 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -32,7 +32,7 @@ steps: - name: build-and-push image: woodpeckerci/plugin-kaniko:2.1.1 settings: - registry: registry.nxtgauge.com + registry: registry.nxtgauge.com:5000 repo: nxtgauge-rust-${SERVICE} dockerfile: Dockerfile.simple build_args: @@ -60,7 +60,7 @@ steps: - name: build-and-push-migrate image: woodpeckerci/plugin-kaniko:2.1.1 settings: - registry: registry.nxtgauge.com + registry: registry.nxtgauge.com:5000 repo: nxtgauge-db-migrate dockerfile: Dockerfile.migrate context: . From d7ebbcb7065f0a6cb373eb1d0ee00e80b67b5df6 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 16 Apr 2026 23:21:22 +0200 Subject: [PATCH 056/182] ci: use registry.nxtgauge.com without port (server fixed) --- .woodpecker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index d8b808b..1707d15 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -32,7 +32,7 @@ steps: - name: build-and-push image: woodpeckerci/plugin-kaniko:2.1.1 settings: - registry: registry.nxtgauge.com:5000 + registry: registry.nxtgauge.com repo: nxtgauge-rust-${SERVICE} dockerfile: Dockerfile.simple build_args: @@ -60,7 +60,7 @@ steps: - name: build-and-push-migrate image: woodpeckerci/plugin-kaniko:2.1.1 settings: - registry: registry.nxtgauge.com:5000 + registry: registry.nxtgauge.com repo: nxtgauge-db-migrate dockerfile: Dockerfile.migrate context: . From 00b864787b66aabacce50d2b9b967c713d0a73f8 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 16 Apr 2026 23:58:49 +0200 Subject: [PATCH 057/182] ci: use kaniko from registry.nxtgauge.com instead of Docker Hub --- .woodpecker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 1707d15..84d1a1a 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -30,7 +30,7 @@ matrix: steps: - name: build-and-push - image: woodpeckerci/plugin-kaniko:2.1.1 + image: registry.nxtgauge.com/woodpeckerci/plugin-kaniko:2.1.1 settings: registry: registry.nxtgauge.com repo: nxtgauge-rust-${SERVICE} @@ -58,7 +58,7 @@ when: steps: - name: build-and-push-migrate - image: woodpeckerci/plugin-kaniko:2.1.1 + image: registry.nxtgauge.com/woodpeckerci/plugin-kaniko:2.1.1 settings: registry: registry.nxtgauge.com repo: nxtgauge-db-migrate From 9dae12df35081069129b7e3acdd9535c7dd3e890 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 00:05:35 +0200 Subject: [PATCH 058/182] ci: fix kaniko image path - use registry.nxtgauge.com/kaniko:2.1.1 --- .woodpecker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 84d1a1a..f8420ea 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -30,7 +30,7 @@ matrix: steps: - name: build-and-push - image: registry.nxtgauge.com/woodpeckerci/plugin-kaniko:2.1.1 + image: registry.nxtgauge.com/kaniko:2.1.1 settings: registry: registry.nxtgauge.com repo: nxtgauge-rust-${SERVICE} @@ -58,7 +58,7 @@ when: steps: - name: build-and-push-migrate - image: registry.nxtgauge.com/woodpeckerci/plugin-kaniko:2.1.1 + image: registry.nxtgauge.com/kaniko:2.1.1 settings: registry: registry.nxtgauge.com repo: nxtgauge-db-migrate From 83cacb8c62623c33c2855320c27026427acba2ea Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 00:09:29 +0200 Subject: [PATCH 059/182] ci: use docker cli instead of kaniko - more reliable with registry configuration --- .woodpecker.yml | 80 +++++++++++++------------------------------------ 1 file changed, 20 insertions(+), 60 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index f8420ea..4d75918 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -5,51 +5,20 @@ when: concurrency: limit: 4 -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: build-and-push - image: registry.nxtgauge.com/kaniko:2.1.1 - settings: - registry: registry.nxtgauge.com - repo: nxtgauge-rust-${SERVICE} - dockerfile: Dockerfile.simple - build_args: - - SERVICE_NAME=${SERVICE} - tags: - - ${CI_COMMIT_SHA} - - latest - - high-performance-latest - username: + - name: login-and-push + image: docker:28-cli + environment: + REGISTRY_USERNAME: from_secret: REGISTRY_USERNAME - password: + REGISTRY_PASSWORD: from_secret: REGISTRY_PASSWORD - insecure: true - insecure_pull: true - skip_tls_verify: true - platforms: linux/amd64 - cache: false + SERVICE_NAME: + from_secret: SERVICE_NAME + commands: + - echo "${REGISTRY_PASSWORD}" | docker login registry.nxtgauge.com -u "${REGISTRY_USERNAME}" --password-stdin + - docker build -t registry.nxtgauge.com/nxtgauge-rust-${SERVICE_NAME} --build-arg SERVICE_NAME=${SERVICE_NAME} -f Dockerfile.simple . + - docker push registry.nxtgauge.com/nxtgauge-rust-${SERVICE_NAME} --- when: @@ -57,23 +26,14 @@ when: event: push steps: - - name: build-and-push-migrate - image: registry.nxtgauge.com/kaniko:2.1.1 - settings: - registry: registry.nxtgauge.com - repo: nxtgauge-db-migrate - dockerfile: Dockerfile.migrate - context: . - tags: - - ${CI_COMMIT_SHA} - - latest - - high-performance-latest - username: + - name: login-and-push-migrate + image: docker:28-cli + environment: + REGISTRY_USERNAME: from_secret: REGISTRY_USERNAME - password: + REGISTRY_PASSWORD: from_secret: REGISTRY_PASSWORD - insecure: true - insecure_pull: true - skip_tls_verify: true - platforms: linux/amd64 - cache: false + commands: + - echo "${REGISTRY_PASSWORD}" | docker login registry.nxtgauge.com -u "${REGISTRY_USERNAME}" --password-stdin + - docker build -t registry.nxtgauge.com/nxtgauge-db-migrate -f Dockerfile.migrate . + - docker push registry.nxtgauge.com/nxtgauge-db-migrate From 5f6199290ea70e8d7b906b75bc0bc58ffd268c46 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 00:31:45 +0200 Subject: [PATCH 060/182] ci: standardize woodpecker secret names --- .woodpecker.yml | 16 ++++++++++------ .woodpecker/README.md | 18 ++++++++++++++++++ 2 files changed, 28 insertions(+), 6 deletions(-) create mode 100644 .woodpecker/README.md diff --git a/.woodpecker.yml b/.woodpecker.yml index 4d75918..2ffa762 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -9,6 +9,8 @@ steps: - name: login-and-push image: docker:28-cli environment: + REGISTRY_HOSTPORT: + from_secret: REGISTRY_HOSTPORT REGISTRY_USERNAME: from_secret: REGISTRY_USERNAME REGISTRY_PASSWORD: @@ -16,9 +18,9 @@ steps: SERVICE_NAME: from_secret: SERVICE_NAME commands: - - echo "${REGISTRY_PASSWORD}" | docker login registry.nxtgauge.com -u "${REGISTRY_USERNAME}" --password-stdin - - docker build -t registry.nxtgauge.com/nxtgauge-rust-${SERVICE_NAME} --build-arg SERVICE_NAME=${SERVICE_NAME} -f Dockerfile.simple . - - docker push registry.nxtgauge.com/nxtgauge-rust-${SERVICE_NAME} + - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOSTPORT}" -u "${REGISTRY_USERNAME}" --password-stdin + - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE_NAME}" --build-arg SERVICE_NAME=${SERVICE_NAME} -f Dockerfile.simple . + - docker push "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE_NAME}" --- when: @@ -29,11 +31,13 @@ steps: - name: login-and-push-migrate image: docker:28-cli environment: + REGISTRY_HOSTPORT: + from_secret: REGISTRY_HOSTPORT REGISTRY_USERNAME: from_secret: REGISTRY_USERNAME REGISTRY_PASSWORD: from_secret: REGISTRY_PASSWORD commands: - - echo "${REGISTRY_PASSWORD}" | docker login registry.nxtgauge.com -u "${REGISTRY_USERNAME}" --password-stdin - - docker build -t registry.nxtgauge.com/nxtgauge-db-migrate -f Dockerfile.migrate . - - docker push registry.nxtgauge.com/nxtgauge-db-migrate + - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOSTPORT}" -u "${REGISTRY_USERNAME}" --password-stdin + - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-db-migrate" -f Dockerfile.migrate . + - docker push "${REGISTRY_HOSTPORT}/nxtgauge-db-migrate" diff --git a/.woodpecker/README.md b/.woodpecker/README.md new file mode 100644 index 0000000..5376c38 --- /dev/null +++ b/.woodpecker/README.md @@ -0,0 +1,18 @@ +# 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. From 4bfbfdd865d7e6b3839ae299846ce30fded71463 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 00:32:57 +0200 Subject: [PATCH 061/182] ci: restore matrix for all Rust services --- .woodpecker.yml | 27 +++++++++++++++++++++++++-- 1 file changed, 25 insertions(+), 2 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 2ffa762..c346774 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -5,6 +5,29 @@ when: concurrency: limit: 4 +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: login-and-push image: docker:28-cli @@ -19,8 +42,8 @@ steps: from_secret: SERVICE_NAME commands: - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOSTPORT}" -u "${REGISTRY_USERNAME}" --password-stdin - - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE_NAME}" --build-arg SERVICE_NAME=${SERVICE_NAME} -f Dockerfile.simple . - - docker push "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE_NAME}" + - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE}" --build-arg SERVICE_NAME=${SERVICE} -f Dockerfile.simple . + - docker push "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE}" --- when: From 4435025421cb6fc34ec1a3133a783ea77a6b4be6 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 00:35:32 +0200 Subject: [PATCH 062/182] ci: pull docker cli from registry.nxtgauge.com instead of Docker Hub --- .woodpecker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index c346774..7d80750 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -30,7 +30,7 @@ matrix: steps: - name: login-and-push - image: docker:28-cli + image: registry.nxtgauge.com/docker:28-cli environment: REGISTRY_HOSTPORT: from_secret: REGISTRY_HOSTPORT @@ -52,7 +52,7 @@ when: steps: - name: login-and-push-migrate - image: docker:28-cli + image: registry.nxtgauge.com/docker:28-cli environment: REGISTRY_HOSTPORT: from_secret: REGISTRY_HOSTPORT From b97cc789fa365e9c7f910511a563d33958e74fd6 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 00:38:21 +0200 Subject: [PATCH 063/182] ci: use kaniko, registry host from secret, remove hardcoded values --- .woodpecker.yml | 59 +++++++++++++++++++++++++++++++------------------ 1 file changed, 37 insertions(+), 22 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 7d80750..702bc44 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -29,21 +29,28 @@ matrix: - cron steps: - - name: login-and-push - image: registry.nxtgauge.com/docker:28-cli - environment: - REGISTRY_HOSTPORT: + - name: build-and-push + image: woodpeckerci/plugin-kaniko:2.1.1 + settings: + registry: from_secret: REGISTRY_HOSTPORT - REGISTRY_USERNAME: + 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 - REGISTRY_PASSWORD: + password: from_secret: REGISTRY_PASSWORD - SERVICE_NAME: - from_secret: SERVICE_NAME - commands: - - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOSTPORT}" -u "${REGISTRY_USERNAME}" --password-stdin - - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE}" --build-arg SERVICE_NAME=${SERVICE} -f Dockerfile.simple . - - docker push "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE}" + insecure: true + insecure_pull: true + skip_tls_verify: true + platforms: linux/amd64 + cache: false --- when: @@ -51,16 +58,24 @@ when: event: push steps: - - name: login-and-push-migrate - image: registry.nxtgauge.com/docker:28-cli - environment: - REGISTRY_HOSTPORT: + - name: build-and-push-migrate + image: woodpeckerci/plugin-kaniko:2.1.1 + settings: + registry: from_secret: REGISTRY_HOSTPORT - REGISTRY_USERNAME: + repo: nxtgauge-db-migrate + dockerfile: Dockerfile.migrate + context: . + tags: + - ${CI_COMMIT_SHA} + - latest + - high-performance-latest + username: from_secret: REGISTRY_USERNAME - REGISTRY_PASSWORD: + password: from_secret: REGISTRY_PASSWORD - commands: - - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOSTPORT}" -u "${REGISTRY_USERNAME}" --password-stdin - - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-db-migrate" -f Dockerfile.migrate . - - docker push "${REGISTRY_HOSTPORT}/nxtgauge-db-migrate" + insecure: true + insecure_pull: true + skip_tls_verify: true + platforms: linux/amd64 + cache: false From b2c2e78963b5b5c0812bf29fe0c2a38e722c3d30 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 00:41:09 +0200 Subject: [PATCH 064/182] ci: use kaniko:2.1.1 from registry (not woodpeckerci/plugin-kaniko) --- .woodpecker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 702bc44..1c93285 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -30,7 +30,7 @@ matrix: steps: - name: build-and-push - image: woodpeckerci/plugin-kaniko:2.1.1 + image: kaniko:2.1.1 settings: registry: from_secret: REGISTRY_HOSTPORT From 192a90d128397534e6701be26e91fff3cdd9dd95 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 00:45:15 +0200 Subject: [PATCH 065/182] ci: use registry.nxtgauge.com/kaniko:2.1.1 explicitly --- .woodpecker.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 1c93285..385ae76 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -30,7 +30,7 @@ matrix: steps: - name: build-and-push - image: kaniko:2.1.1 + image: registry.nxtgauge.com/kaniko:2.1.1 settings: registry: from_secret: REGISTRY_HOSTPORT From b7f86356dbc0ddcf5eef67c0eea6133bd875e382 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 00:51:45 +0200 Subject: [PATCH 066/182] ci: use docker cli directly - images already in registry --- .woodpecker.yml | 53 +++++++++++++++++-------------------------------- 1 file changed, 18 insertions(+), 35 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 385ae76..72bac81 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -30,27 +30,18 @@ matrix: steps: - name: build-and-push - image: registry.nxtgauge.com/kaniko:2.1.1 - settings: - registry: + image: registry.nxtgauge.com/docker:28-cli + environment: + REGISTRY_HOSTPORT: 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: + REGISTRY_USERNAME: from_secret: REGISTRY_USERNAME - password: + REGISTRY_PASSWORD: from_secret: REGISTRY_PASSWORD - insecure: true - insecure_pull: true - skip_tls_verify: true - platforms: linux/amd64 - cache: false + commands: + - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOSTPORT}" -u "${REGISTRY_USERNAME}" --password-stdin + - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE}" --build-arg SERVICE_NAME=${SERVICE} -f Dockerfile.simple . + - docker push "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE}" --- when: @@ -59,23 +50,15 @@ when: steps: - name: build-and-push-migrate - image: woodpeckerci/plugin-kaniko:2.1.1 - settings: - registry: + image: registry.nxtgauge.com/docker:28-cli + environment: + REGISTRY_HOSTPORT: from_secret: REGISTRY_HOSTPORT - repo: nxtgauge-db-migrate - dockerfile: Dockerfile.migrate - context: . - tags: - - ${CI_COMMIT_SHA} - - latest - - high-performance-latest - username: + REGISTRY_USERNAME: from_secret: REGISTRY_USERNAME - password: + REGISTRY_PASSWORD: from_secret: REGISTRY_PASSWORD - insecure: true - insecure_pull: true - skip_tls_verify: true - platforms: linux/amd64 - cache: false + commands: + - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOSTPORT}" -u "${REGISTRY_USERNAME}" --password-stdin + - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-db-migrate" -f Dockerfile.migrate . + - docker push "${REGISTRY_HOSTPORT}/nxtgauge-db-migrate" From dd51b8ca80568f0ea25dc2b6e79ceb72bf7e63d5 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 01:01:45 +0200 Subject: [PATCH 067/182] ci: fix secrets injection using secrets: block --- .woodpecker.yml | 34 ++++++++++++++++++++++------------ 1 file changed, 22 insertions(+), 12 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 72bac81..135404c 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -28,16 +28,21 @@ matrix: - ugc-content-creators - cron +secrets: + - secret: REGISTRY_HOSTPORT + name: REGISTRY_HOSTPORT + - secret: REGISTRY_USERNAME + name: REGISTRY_USERNAME + - secret: REGISTRY_PASSWORD + name: REGISTRY_PASSWORD + steps: - name: build-and-push image: registry.nxtgauge.com/docker:28-cli environment: - REGISTRY_HOSTPORT: - from_secret: REGISTRY_HOSTPORT - REGISTRY_USERNAME: - from_secret: REGISTRY_USERNAME - REGISTRY_PASSWORD: - from_secret: REGISTRY_PASSWORD + REGISTRY_HOSTPORT: ${REGISTRY_HOSTPORT} + REGISTRY_USERNAME: ${REGISTRY_USERNAME} + REGISTRY_PASSWORD: ${REGISTRY_PASSWORD} commands: - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOSTPORT}" -u "${REGISTRY_USERNAME}" --password-stdin - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE}" --build-arg SERVICE_NAME=${SERVICE} -f Dockerfile.simple . @@ -48,16 +53,21 @@ when: branch: [main, high-performance] event: push +secrets: + - secret: REGISTRY_HOSTPORT + name: REGISTRY_HOSTPORT + - secret: REGISTRY_USERNAME + name: REGISTRY_USERNAME + - secret: REGISTRY_PASSWORD + name: REGISTRY_PASSWORD + steps: - name: build-and-push-migrate image: registry.nxtgauge.com/docker:28-cli environment: - REGISTRY_HOSTPORT: - from_secret: REGISTRY_HOSTPORT - REGISTRY_USERNAME: - from_secret: REGISTRY_USERNAME - REGISTRY_PASSWORD: - from_secret: REGISTRY_PASSWORD + REGISTRY_HOSTPORT: ${REGISTRY_HOSTPORT} + REGISTRY_USERNAME: ${REGISTRY_USERNAME} + REGISTRY_PASSWORD: ${REGISTRY_PASSWORD} commands: - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOSTPORT}" -u "${REGISTRY_USERNAME}" --password-stdin - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-db-migrate" -f Dockerfile.migrate . From 54cc66bff082cd3b6204be07a476464d11ff45d2 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 01:10:52 +0200 Subject: [PATCH 068/182] ci: explicitly wire secrets with from_secret in environment --- .woodpecker.yml | 34 ++++++++++++---------------------- 1 file changed, 12 insertions(+), 22 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 135404c..72bac81 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -28,21 +28,16 @@ matrix: - ugc-content-creators - cron -secrets: - - secret: REGISTRY_HOSTPORT - name: REGISTRY_HOSTPORT - - secret: REGISTRY_USERNAME - name: REGISTRY_USERNAME - - secret: REGISTRY_PASSWORD - name: REGISTRY_PASSWORD - steps: - name: build-and-push image: registry.nxtgauge.com/docker:28-cli environment: - REGISTRY_HOSTPORT: ${REGISTRY_HOSTPORT} - REGISTRY_USERNAME: ${REGISTRY_USERNAME} - REGISTRY_PASSWORD: ${REGISTRY_PASSWORD} + REGISTRY_HOSTPORT: + from_secret: REGISTRY_HOSTPORT + REGISTRY_USERNAME: + from_secret: REGISTRY_USERNAME + REGISTRY_PASSWORD: + from_secret: REGISTRY_PASSWORD commands: - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOSTPORT}" -u "${REGISTRY_USERNAME}" --password-stdin - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE}" --build-arg SERVICE_NAME=${SERVICE} -f Dockerfile.simple . @@ -53,21 +48,16 @@ when: branch: [main, high-performance] event: push -secrets: - - secret: REGISTRY_HOSTPORT - name: REGISTRY_HOSTPORT - - secret: REGISTRY_USERNAME - name: REGISTRY_USERNAME - - secret: REGISTRY_PASSWORD - name: REGISTRY_PASSWORD - steps: - name: build-and-push-migrate image: registry.nxtgauge.com/docker:28-cli environment: - REGISTRY_HOSTPORT: ${REGISTRY_HOSTPORT} - REGISTRY_USERNAME: ${REGISTRY_USERNAME} - REGISTRY_PASSWORD: ${REGISTRY_PASSWORD} + REGISTRY_HOSTPORT: + from_secret: REGISTRY_HOSTPORT + REGISTRY_USERNAME: + from_secret: REGISTRY_USERNAME + REGISTRY_PASSWORD: + from_secret: REGISTRY_PASSWORD commands: - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOSTPORT}" -u "${REGISTRY_USERNAME}" --password-stdin - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-db-migrate" -f Dockerfile.migrate . From ddd3d3d71281f35f305dfa22123208f0f1a4c8b0 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 01:25:09 +0200 Subject: [PATCH 069/182] ci: explicitly wire secrets with from_secret in environment --- .woodpecker.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.woodpecker.yml b/.woodpecker.yml index 72bac81..05328b2 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -38,6 +38,8 @@ steps: from_secret: REGISTRY_USERNAME REGISTRY_PASSWORD: from_secret: REGISTRY_PASSWORD + SERVICE_NAME: + from_secret: SERVICE_NAME commands: - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOSTPORT}" -u "${REGISTRY_USERNAME}" --password-stdin - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE}" --build-arg SERVICE_NAME=${SERVICE} -f Dockerfile.simple . From fb5073c8db8644403a8f4ecb341b32283317cc47 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 01:26:53 +0200 Subject: [PATCH 070/182] ci: trigger woodpecker From dde727b2c7f0887fd22aee5a677f946d35b23cd1 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 01:28:40 +0200 Subject: [PATCH 071/182] ci: fix linter errors - remove concurrency root, remove unused service_name secret --- .woodpecker.yml | 5 ----- 1 file changed, 5 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 05328b2..1ce0c6a 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -2,9 +2,6 @@ when: branch: [main, high-performance] event: push -concurrency: - limit: 4 - matrix: SERVICE: - gateway @@ -38,8 +35,6 @@ steps: from_secret: REGISTRY_USERNAME REGISTRY_PASSWORD: from_secret: REGISTRY_PASSWORD - SERVICE_NAME: - from_secret: SERVICE_NAME commands: - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOSTPORT}" -u "${REGISTRY_USERNAME}" --password-stdin - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE}" --build-arg SERVICE_NAME=${SERVICE} -f Dockerfile.simple . From f1308aebec0349bad014f9d4d893bcc093c2359f Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 01:39:21 +0200 Subject: [PATCH 072/182] ci: fix woodpecker registry secrets and base images --- .woodpecker.yml | 6 ++++++ .woodpecker/README.md | 5 ----- Dockerfile.migrate | 2 +- README.md | 9 +++++++++ 4 files changed, 16 insertions(+), 6 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 1ce0c6a..e528d72 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -36,6 +36,9 @@ steps: REGISTRY_PASSWORD: from_secret: REGISTRY_PASSWORD commands: + - test -n "${REGISTRY_HOSTPORT}" + - test -n "${REGISTRY_USERNAME}" + - test -n "${REGISTRY_PASSWORD}" - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOSTPORT}" -u "${REGISTRY_USERNAME}" --password-stdin - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE}" --build-arg SERVICE_NAME=${SERVICE} -f Dockerfile.simple . - docker push "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE}" @@ -56,6 +59,9 @@ steps: REGISTRY_PASSWORD: from_secret: REGISTRY_PASSWORD commands: + - test -n "${REGISTRY_HOSTPORT}" + - test -n "${REGISTRY_USERNAME}" + - test -n "${REGISTRY_PASSWORD}" - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOSTPORT}" -u "${REGISTRY_USERNAME}" --password-stdin - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-db-migrate" -f Dockerfile.migrate . - docker push "${REGISTRY_HOSTPORT}/nxtgauge-db-migrate" diff --git a/.woodpecker/README.md b/.woodpecker/README.md index 5376c38..eabfc95 100644 --- a/.woodpecker/README.md +++ b/.woodpecker/README.md @@ -7,11 +7,6 @@ The following Woodpecker secrets are required for CI/CD pipelines: | `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 diff --git a/Dockerfile.migrate b/Dockerfile.migrate index c5ef565..390bc2a 100644 --- a/Dockerfile.migrate +++ b/Dockerfile.migrate @@ -1,4 +1,4 @@ -FROM rust:1.75-alpine AS builder +FROM registry.nxtgauge.com/rust:alpine AS builder WORKDIR /app diff --git a/README.md b/README.md index cf98aa7..cc4382e 100644 --- a/README.md +++ b/README.md @@ -16,3 +16,12 @@ 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_HOSTPORT` +- `REGISTRY_USERNAME` +- `REGISTRY_PASSWORD` + +See `.woodpecker/README.md` for details. From 77f62cb3a3504bfe904c580a8b531199b183d1c5 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 02:02:39 +0200 Subject: [PATCH 073/182] ci: trigger woodpecker (2026-04-17) From 917d33c3a53e30747b75f6e94b2557992bf3fda9 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 02:05:51 +0200 Subject: [PATCH 074/182] ci: wire woodpecker registry secrets --- .woodpecker.yml | 16 ++++++++-------- 1 file changed, 8 insertions(+), 8 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index e528d72..3fbb822 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -36,10 +36,10 @@ steps: REGISTRY_PASSWORD: from_secret: REGISTRY_PASSWORD commands: - - test -n "${REGISTRY_HOSTPORT}" - - test -n "${REGISTRY_USERNAME}" - - test -n "${REGISTRY_PASSWORD}" - - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOSTPORT}" -u "${REGISTRY_USERNAME}" --password-stdin + - test -n "${REGISTRY_HOSTPORT:-}" || (echo "missing REGISTRY_HOSTPORT" && exit 1) + - test -n "${REGISTRY_USERNAME:-}" || (echo "missing REGISTRY_USERNAME" && exit 1) + - test -n "${REGISTRY_PASSWORD:-}" || (echo "missing REGISTRY_PASSWORD" && exit 1) + - echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOSTPORT" -u "$REGISTRY_USERNAME" --password-stdin - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE}" --build-arg SERVICE_NAME=${SERVICE} -f Dockerfile.simple . - docker push "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE}" @@ -59,9 +59,9 @@ steps: REGISTRY_PASSWORD: from_secret: REGISTRY_PASSWORD commands: - - test -n "${REGISTRY_HOSTPORT}" - - test -n "${REGISTRY_USERNAME}" - - test -n "${REGISTRY_PASSWORD}" - - echo "${REGISTRY_PASSWORD}" | docker login "${REGISTRY_HOSTPORT}" -u "${REGISTRY_USERNAME}" --password-stdin + - test -n "${REGISTRY_HOSTPORT:-}" || (echo "missing REGISTRY_HOSTPORT" && exit 1) + - test -n "${REGISTRY_USERNAME:-}" || (echo "missing REGISTRY_USERNAME" && exit 1) + - test -n "${REGISTRY_PASSWORD:-}" || (echo "missing REGISTRY_PASSWORD" && exit 1) + - echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOSTPORT" -u "$REGISTRY_USERNAME" --password-stdin - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-db-migrate" -f Dockerfile.migrate . - docker push "${REGISTRY_HOSTPORT}/nxtgauge-db-migrate" From aafbe4dada5cc9d063f85f26986417d2a37c9cc5 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 02:26:27 +0200 Subject: [PATCH 075/182] ci: trigger woodpecker (2026-04-17-2) From 828113cc47adc0ba7edc5ea0766494d84d4be42e Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 02:39:52 +0200 Subject: [PATCH 076/182] ci: stop treating registry host as secret --- .woodpecker-base.yml | 3 +-- .woodpecker-dockerhub.yml | 3 +-- .woodpecker.yml | 6 ++---- .woodpecker/README.md | 3 +-- README.md | 1 - 5 files changed, 5 insertions(+), 11 deletions(-) diff --git a/.woodpecker-base.yml b/.woodpecker-base.yml index 796439e..32261cb 100644 --- a/.woodpecker-base.yml +++ b/.woodpecker-base.yml @@ -10,8 +10,7 @@ steps: - name: build-base-image image: woodpeckerci/plugin-docker-buildx:5.0.0 settings: - registry: - from_secret: REGISTRY_HOSTPORT + registry: registry.nxtgauge.com repo: nxtgauge-rust-base context: . dockerfile: Dockerfile.base diff --git a/.woodpecker-dockerhub.yml b/.woodpecker-dockerhub.yml index 07f6b52..0738dd7 100644 --- a/.woodpecker-dockerhub.yml +++ b/.woodpecker-dockerhub.yml @@ -87,8 +87,7 @@ steps: - name: build-docker image: woodpeckerci/plugin-docker-buildx:5.0.0 settings: - registry: - from_secret: REGISTRY_HOSTPORT + registry: registry.nxtgauge.com repo: nxtgauge-rust-${SERVICE} dockerfile: Dockerfile.binary build_args: diff --git a/.woodpecker.yml b/.woodpecker.yml index 3fbb822..05318c8 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -29,8 +29,7 @@ steps: - name: build-and-push image: registry.nxtgauge.com/docker:28-cli environment: - REGISTRY_HOSTPORT: - from_secret: REGISTRY_HOSTPORT + REGISTRY_HOSTPORT: registry.nxtgauge.com REGISTRY_USERNAME: from_secret: REGISTRY_USERNAME REGISTRY_PASSWORD: @@ -52,8 +51,7 @@ steps: - name: build-and-push-migrate image: registry.nxtgauge.com/docker:28-cli environment: - REGISTRY_HOSTPORT: - from_secret: REGISTRY_HOSTPORT + REGISTRY_HOSTPORT: registry.nxtgauge.com REGISTRY_USERNAME: from_secret: REGISTRY_USERNAME REGISTRY_PASSWORD: diff --git a/.woodpecker/README.md b/.woodpecker/README.md index eabfc95..c8963b6 100644 --- a/.woodpecker/README.md +++ b/.woodpecker/README.md @@ -4,10 +4,9 @@ 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 | ## Usage -All build/push steps use these secrets via `from_secret:` references. No credentials are hardcoded in pipeline files. +Build/push steps use `REGISTRY_USERNAME` and `REGISTRY_PASSWORD` via `from_secret:` references. diff --git a/README.md b/README.md index cc4382e..79dbe09 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,6 @@ See `docs/MIGRATION_MASTER_PLAN.md` for full staged plan. ## CI (Woodpecker) Required secrets: -- `REGISTRY_HOSTPORT` - `REGISTRY_USERNAME` - `REGISTRY_PASSWORD` From 737280db1014e1658dae4446c4e2fbcfe984b12d Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 03:00:37 +0200 Subject: [PATCH 077/182] ci: build and push with kaniko --- .woodpecker.yml | 48 ++++++++++++++++++++++++------------------------ 1 file changed, 24 insertions(+), 24 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 05318c8..322ab14 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -27,20 +27,21 @@ matrix: steps: - name: build-and-push - image: registry.nxtgauge.com/docker:28-cli - environment: - REGISTRY_HOSTPORT: registry.nxtgauge.com - REGISTRY_USERNAME: + image: registry.nxtgauge.com/kaniko:2.1.1 + settings: + registry: registry.nxtgauge.com + username: from_secret: REGISTRY_USERNAME - REGISTRY_PASSWORD: + password: from_secret: REGISTRY_PASSWORD - commands: - - test -n "${REGISTRY_HOSTPORT:-}" || (echo "missing REGISTRY_HOSTPORT" && exit 1) - - test -n "${REGISTRY_USERNAME:-}" || (echo "missing REGISTRY_USERNAME" && exit 1) - - test -n "${REGISTRY_PASSWORD:-}" || (echo "missing REGISTRY_PASSWORD" && exit 1) - - echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOSTPORT" -u "$REGISTRY_USERNAME" --password-stdin - - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE}" --build-arg SERVICE_NAME=${SERVICE} -f Dockerfile.simple . - - docker push "${REGISTRY_HOSTPORT}/nxtgauge-rust-${SERVICE}" + repo: nxtgauge-rust-${SERVICE} + tags: + - ${CI_COMMIT_SHA} + - ${CI_COMMIT_BRANCH}-latest + dockerfile: Dockerfile.simple + context: . + build_args: + - SERVICE_NAME=${SERVICE} --- when: @@ -49,17 +50,16 @@ when: steps: - name: build-and-push-migrate - image: registry.nxtgauge.com/docker:28-cli - environment: - REGISTRY_HOSTPORT: registry.nxtgauge.com - REGISTRY_USERNAME: + image: registry.nxtgauge.com/kaniko:2.1.1 + settings: + registry: registry.nxtgauge.com + username: from_secret: REGISTRY_USERNAME - REGISTRY_PASSWORD: + password: from_secret: REGISTRY_PASSWORD - commands: - - test -n "${REGISTRY_HOSTPORT:-}" || (echo "missing REGISTRY_HOSTPORT" && exit 1) - - test -n "${REGISTRY_USERNAME:-}" || (echo "missing REGISTRY_USERNAME" && exit 1) - - test -n "${REGISTRY_PASSWORD:-}" || (echo "missing REGISTRY_PASSWORD" && exit 1) - - echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOSTPORT" -u "$REGISTRY_USERNAME" --password-stdin - - docker build -t "${REGISTRY_HOSTPORT}/nxtgauge-db-migrate" -f Dockerfile.migrate . - - docker push "${REGISTRY_HOSTPORT}/nxtgauge-db-migrate" + repo: nxtgauge-db-migrate + tags: + - ${CI_COMMIT_SHA} + - ${CI_COMMIT_BRANCH}-latest + dockerfile: Dockerfile.migrate + context: . From fd99e8cea1e56573eead7e2c402d43efdc449680 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 03:04:02 +0200 Subject: [PATCH 078/182] ci: fix kaniko builds and registry settings --- .woodpecker.yml | 6 ++++-- Dockerfile.migrate | 1 + Dockerfile.simple | 1 + 3 files changed, 6 insertions(+), 2 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 322ab14..55235a6 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -29,7 +29,8 @@ steps: - name: build-and-push image: registry.nxtgauge.com/kaniko:2.1.1 settings: - registry: registry.nxtgauge.com + registry: + from_secret: REGISTRY_HOSTPORT username: from_secret: REGISTRY_USERNAME password: @@ -52,7 +53,8 @@ steps: - name: build-and-push-migrate image: registry.nxtgauge.com/kaniko:2.1.1 settings: - registry: registry.nxtgauge.com + registry: + from_secret: REGISTRY_HOSTPORT username: from_secret: REGISTRY_USERNAME password: diff --git a/Dockerfile.migrate b/Dockerfile.migrate index 390bc2a..77194b5 100644 --- a/Dockerfile.migrate +++ b/Dockerfile.migrate @@ -2,6 +2,7 @@ FROM registry.nxtgauge.com/rust:alpine AS builder WORKDIR /app +RUN command -v cargo >/dev/null 2>&1 || apk add --no-cache cargo rust RUN apk add --no-cache musl-dev pkgconfig openssl-dev COPY Cargo.toml Cargo.lock ./ diff --git a/Dockerfile.simple b/Dockerfile.simple index 4fbbf74..5e38127 100644 --- a/Dockerfile.simple +++ b/Dockerfile.simple @@ -7,6 +7,7 @@ FROM registry.nxtgauge.com/rust:alpine AS builder ARG SERVICE_NAME # Install deps +RUN command -v cargo >/dev/null 2>&1 || apk add --no-cache cargo rust RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static WORKDIR /app From 3b27ddf356b99f94d4e4f62d8efcabccd517be2d Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 03:06:56 +0200 Subject: [PATCH 079/182] ci: fix rust musl target build --- Dockerfile.simple | 5 ++--- 1 file changed, 2 insertions(+), 3 deletions(-) diff --git a/Dockerfile.simple b/Dockerfile.simple index 5e38127..6ce24ae 100644 --- a/Dockerfile.simple +++ b/Dockerfile.simple @@ -46,9 +46,8 @@ ENV RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-s" ENV OPENSSL_STATIC=1 ENV OPENSSL_DIR=/usr RUN cargo build --release \ - --bin $(cat /tmp/service_bin) \ - --target x86_64-unknown-linux-musl && \ - cp /app/target/x86_64-unknown-linux-musl/release/$(cat /tmp/service_bin) /app/service + --bin $(cat /tmp/service_bin) && \ + cp /app/target/release/$(cat /tmp/service_bin) /app/service # Runtime FROM scratch From b18aca10d3329bb84196f61edb5ba378d106bc59 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 03:10:14 +0200 Subject: [PATCH 080/182] ci: use rustup toolchain for musl builds --- Dockerfile.migrate | 13 +++++++++---- Dockerfile.simple | 14 +++++++++----- 2 files changed, 18 insertions(+), 9 deletions(-) diff --git a/Dockerfile.migrate b/Dockerfile.migrate index 77194b5..5158398 100644 --- a/Dockerfile.migrate +++ b/Dockerfile.migrate @@ -2,8 +2,11 @@ FROM registry.nxtgauge.com/rust:alpine AS builder WORKDIR /app -RUN command -v cargo >/dev/null 2>&1 || apk add --no-cache cargo rust -RUN apk add --no-cache musl-dev pkgconfig openssl-dev +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 @@ -12,12 +15,14 @@ COPY crates/cache ./crates/cache COPY crates/email ./crates/email WORKDIR /app/crates/db-migrate -RUN cargo build --release --bin 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/release/db-migrate /usr/local/bin/ +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"] diff --git a/Dockerfile.simple b/Dockerfile.simple index 6ce24ae..aba206f 100644 --- a/Dockerfile.simple +++ b/Dockerfile.simple @@ -6,9 +6,12 @@ ARG SERVICE_NAME FROM registry.nxtgauge.com/rust:alpine AS builder ARG SERVICE_NAME -# Install deps -RUN command -v cargo >/dev/null 2>&1 || apk add --no-cache cargo rust -RUN apk add --no-cache musl-dev pkgconfig openssl-dev openssl-libs-static +# 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 @@ -46,8 +49,9 @@ ENV RUSTFLAGS="-C target-feature=+crt-static -C link-arg=-s" ENV OPENSSL_STATIC=1 ENV OPENSSL_DIR=/usr RUN cargo build --release \ - --bin $(cat /tmp/service_bin) && \ - cp /app/target/release/$(cat /tmp/service_bin) /app/service + --bin $(cat /tmp/service_bin) \ + --target x86_64-unknown-linux-musl && \ + cp /app/target/x86_64-unknown-linux-musl/release/$(cat /tmp/service_bin) /app/service # Runtime FROM scratch From 0e7ab9ceb87f15e8f0f6a8aabd50f1cdd26f9d14 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 12:02:26 +0200 Subject: [PATCH 081/182] fix: add v1 otp routes and fail on email send errors --- apps/users/src/handlers/auth.rs | 62 +++++++++++++++++++++++++++++++-- crates/email/src/lib.rs | 3 +- 2 files changed, 60 insertions(+), 5 deletions(-) diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index 7ac3850..11ffbf5 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -45,6 +45,7 @@ pub struct RegisterPayload { pub phone: Option, pub password: String, pub intent: Option, + #[serde(alias = "role_key", alias = "roleKey")] pub profession: Option, } @@ -337,7 +338,19 @@ async fn register( cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok(); let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); - let _ = state.mail.send_verification_email(&user.email, &user_name, &otp).await; + 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" + ); + return Err(err( + StatusCode::INTERNAL_SERVER_ERROR, + "Failed to send verification email", + "SMTP_ERROR", + )); + } Ok((StatusCode::CREATED, Json(RegisterResponse { user_id: user.id.to_string(), @@ -557,7 +570,14 @@ async fn verify_email( // Get user details for welcome email 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_welcome_email(&user.email, &user_name).await; + 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" })))) @@ -594,7 +614,19 @@ async fn resend_otp( cache::otp::record_resend(&mut redis, &user.id.to_string()).await.ok(); let user_name = format!("{} {}", user.first_name.unwrap_or_default(), user.last_name.unwrap_or_default()); - let _ = state.mail.send_verification_email(&user.email, &user_name, &otp).await; + 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) } @@ -729,5 +761,29 @@ async fn switch_role( pub fn v1_router() -> Router { 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, + Json(payload): Json, +) -> Result)> { + register(State(state), Json(payload)).await +} + +/// POST /api/v1/users/verify-otp +async fn v1_verify_otp( + State(state): State, + Json(payload): Json, +) -> Result)> { + verify_email(State(state), Json(VerifyEmailPayload { otp: payload.otp })).await +} diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs index 207aaa5..3fadd1e 100644 --- a/crates/email/src/lib.rs +++ b/crates/email/src/lib.rs @@ -155,8 +155,7 @@ impl Mailer { async fn send_html(&self, to: &str, subject: &str, html_body: String) -> Result<()> { let Some(transport) = &self.transport else { - tracing::debug!("SMTP disabled — skipping email to {}", to); - return Ok(()); + return Err(anyhow::anyhow!("SMTP transport not configured")); }; let from: Mailbox = format!("{} <{}>", self.from_name, self.from_email).parse()?; From 0d3751e7d8fa6d9507d0c223fcfb788beb6dca02 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 13:49:59 +0200 Subject: [PATCH 082/182] ci: ensure migrate image pushes to internal registry --- .woodpecker.yml | 36 ++++++++++++++++++++++++++++++++---- 1 file changed, 32 insertions(+), 4 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 55235a6..c4bc4c7 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -26,11 +26,25 @@ matrix: - cron steps: + - name: validate-registry-secrets + image: registry.nxtgauge.com/alpine:3.20 + environment: + REGISTRY_HOSTPORT: + from_secret: REGISTRY_HOSTPORT + REGISTRY_USERNAME: + from_secret: REGISTRY_USERNAME + REGISTRY_PASSWORD: + from_secret: REGISTRY_PASSWORD + commands: + - test -n "${REGISTRY_HOSTPORT:-}" || (echo "missing REGISTRY_HOSTPORT" && exit 1) + - test -n "${REGISTRY_USERNAME:-}" || (echo "missing REGISTRY_USERNAME" && exit 1) + - test -n "${REGISTRY_PASSWORD:-}" || (echo "missing REGISTRY_PASSWORD" && exit 1) + - test "${REGISTRY_HOSTPORT}" = "registry.nxtgauge.com" || (echo "REGISTRY_HOSTPORT must be registry.nxtgauge.com (got: ${REGISTRY_HOSTPORT})" && exit 1) + - name: build-and-push image: registry.nxtgauge.com/kaniko:2.1.1 settings: - registry: - from_secret: REGISTRY_HOSTPORT + registry: registry.nxtgauge.com username: from_secret: REGISTRY_USERNAME password: @@ -50,11 +64,25 @@ when: event: push steps: + - name: validate-registry-secrets + image: registry.nxtgauge.com/alpine:3.20 + environment: + REGISTRY_HOSTPORT: + from_secret: REGISTRY_HOSTPORT + REGISTRY_USERNAME: + from_secret: REGISTRY_USERNAME + REGISTRY_PASSWORD: + from_secret: REGISTRY_PASSWORD + commands: + - test -n "${REGISTRY_HOSTPORT:-}" || (echo "missing REGISTRY_HOSTPORT" && exit 1) + - test -n "${REGISTRY_USERNAME:-}" || (echo "missing REGISTRY_USERNAME" && exit 1) + - test -n "${REGISTRY_PASSWORD:-}" || (echo "missing REGISTRY_PASSWORD" && exit 1) + - test "${REGISTRY_HOSTPORT}" = "registry.nxtgauge.com" || (echo "REGISTRY_HOSTPORT must be registry.nxtgauge.com (got: ${REGISTRY_HOSTPORT})" && exit 1) + - name: build-and-push-migrate image: registry.nxtgauge.com/kaniko:2.1.1 settings: - registry: - from_secret: REGISTRY_HOSTPORT + registry: registry.nxtgauge.com username: from_secret: REGISTRY_USERNAME password: From fd74ac565b8e04eb9a409b37cfbb5ac97bbbb514 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 13:51:40 +0200 Subject: [PATCH 083/182] ci: fix woodpecker yaml quoting in validate step --- .woodpecker.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index c4bc4c7..795ef25 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -39,7 +39,7 @@ steps: - test -n "${REGISTRY_HOSTPORT:-}" || (echo "missing REGISTRY_HOSTPORT" && exit 1) - test -n "${REGISTRY_USERNAME:-}" || (echo "missing REGISTRY_USERNAME" && exit 1) - test -n "${REGISTRY_PASSWORD:-}" || (echo "missing REGISTRY_PASSWORD" && exit 1) - - test "${REGISTRY_HOSTPORT}" = "registry.nxtgauge.com" || (echo "REGISTRY_HOSTPORT must be registry.nxtgauge.com (got: ${REGISTRY_HOSTPORT})" && exit 1) + - 'test "${REGISTRY_HOSTPORT}" = "registry.nxtgauge.com" || (echo "REGISTRY_HOSTPORT must be registry.nxtgauge.com (got ${REGISTRY_HOSTPORT})" && exit 1)' - name: build-and-push image: registry.nxtgauge.com/kaniko:2.1.1 @@ -77,7 +77,7 @@ steps: - test -n "${REGISTRY_HOSTPORT:-}" || (echo "missing REGISTRY_HOSTPORT" && exit 1) - test -n "${REGISTRY_USERNAME:-}" || (echo "missing REGISTRY_USERNAME" && exit 1) - test -n "${REGISTRY_PASSWORD:-}" || (echo "missing REGISTRY_PASSWORD" && exit 1) - - test "${REGISTRY_HOSTPORT}" = "registry.nxtgauge.com" || (echo "REGISTRY_HOSTPORT must be registry.nxtgauge.com (got: ${REGISTRY_HOSTPORT})" && exit 1) + - 'test "${REGISTRY_HOSTPORT}" = "registry.nxtgauge.com" || (echo "REGISTRY_HOSTPORT must be registry.nxtgauge.com (got ${REGISTRY_HOSTPORT})" && exit 1)' - name: build-and-push-migrate image: registry.nxtgauge.com/kaniko:2.1.1 From 9fa9c2c295a3c99ef2ad3a3c0e06810c118dff7f Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 14:05:32 +0200 Subject: [PATCH 084/182] Revert backend rust to 0e7ab9ceb8 --- .woodpecker.yml | 36 ++++-------------------------------- 1 file changed, 4 insertions(+), 32 deletions(-) diff --git a/.woodpecker.yml b/.woodpecker.yml index 795ef25..55235a6 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -26,25 +26,11 @@ matrix: - cron steps: - - name: validate-registry-secrets - image: registry.nxtgauge.com/alpine:3.20 - environment: - REGISTRY_HOSTPORT: - from_secret: REGISTRY_HOSTPORT - REGISTRY_USERNAME: - from_secret: REGISTRY_USERNAME - REGISTRY_PASSWORD: - from_secret: REGISTRY_PASSWORD - commands: - - test -n "${REGISTRY_HOSTPORT:-}" || (echo "missing REGISTRY_HOSTPORT" && exit 1) - - test -n "${REGISTRY_USERNAME:-}" || (echo "missing REGISTRY_USERNAME" && exit 1) - - test -n "${REGISTRY_PASSWORD:-}" || (echo "missing REGISTRY_PASSWORD" && exit 1) - - 'test "${REGISTRY_HOSTPORT}" = "registry.nxtgauge.com" || (echo "REGISTRY_HOSTPORT must be registry.nxtgauge.com (got ${REGISTRY_HOSTPORT})" && exit 1)' - - name: build-and-push image: registry.nxtgauge.com/kaniko:2.1.1 settings: - registry: registry.nxtgauge.com + registry: + from_secret: REGISTRY_HOSTPORT username: from_secret: REGISTRY_USERNAME password: @@ -64,25 +50,11 @@ when: event: push steps: - - name: validate-registry-secrets - image: registry.nxtgauge.com/alpine:3.20 - environment: - REGISTRY_HOSTPORT: - from_secret: REGISTRY_HOSTPORT - REGISTRY_USERNAME: - from_secret: REGISTRY_USERNAME - REGISTRY_PASSWORD: - from_secret: REGISTRY_PASSWORD - commands: - - test -n "${REGISTRY_HOSTPORT:-}" || (echo "missing REGISTRY_HOSTPORT" && exit 1) - - test -n "${REGISTRY_USERNAME:-}" || (echo "missing REGISTRY_USERNAME" && exit 1) - - test -n "${REGISTRY_PASSWORD:-}" || (echo "missing REGISTRY_PASSWORD" && exit 1) - - 'test "${REGISTRY_HOSTPORT}" = "registry.nxtgauge.com" || (echo "REGISTRY_HOSTPORT must be registry.nxtgauge.com (got ${REGISTRY_HOSTPORT})" && exit 1)' - - name: build-and-push-migrate image: registry.nxtgauge.com/kaniko:2.1.1 settings: - registry: registry.nxtgauge.com + registry: + from_secret: REGISTRY_HOSTPORT username: from_secret: REGISTRY_USERNAME password: From 6c80e2b542d9e6f617531479059a869b409867cc Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 19:54:38 +0200 Subject: [PATCH 085/182] ci: trigger woodpecker From 14a6a2e5c3d9a1b567a2b59be14e1d281d46c933 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 17 Apr 2026 21:33:06 +0200 Subject: [PATCH 086/182] ci: allow insecure registry for self-signed TLS cert --- .woodpecker.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.woodpecker.yml b/.woodpecker.yml index 55235a6..ddc4f47 100644 --- a/.woodpecker.yml +++ b/.woodpecker.yml @@ -31,6 +31,7 @@ steps: settings: registry: from_secret: REGISTRY_HOSTPORT + insecure: true username: from_secret: REGISTRY_USERNAME password: @@ -55,6 +56,7 @@ steps: settings: registry: from_secret: REGISTRY_HOSTPORT + insecure: true username: from_secret: REGISTRY_USERNAME password: From 17b5e900a7ab8281021a9692ce5db2286c21138f Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sat, 18 Apr 2026 10:48:06 +0200 Subject: [PATCH 087/182] ci: trigger woodpecker From aa7f1c14d0434e3d30417e59623489788b31952e Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sat, 18 Apr 2026 13:13:04 +0200 Subject: [PATCH 088/182] ci: trigger woodpecker From ae54f4a2198fb8e8efd39c8363e6681ce9e50c76 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sat, 18 Apr 2026 16:21:56 +0200 Subject: [PATCH 089/182] ci: trigger woodpecker From 3faa23250cb24e18e30e1706589804637756c504 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sat, 18 Apr 2026 18:07:43 +0200 Subject: [PATCH 090/182] ci: trigger woodpecker From 04f9ab52fa0ee55185646b81a5480f41081d391c Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sat, 18 Apr 2026 18:30:56 +0200 Subject: [PATCH 091/182] fix: suppress dead_code warnings with #[allow(dead_code)] --- apps/customers/src/handlers.rs | 4 ++-- apps/job_seekers/src/handlers.rs | 5 +++-- apps/leads/src/lead_requests.rs | 12 ++++++------ apps/payments/src/main.rs | 3 ++- apps/payments/src/packages.rs | 2 +- apps/users/src/handlers/admin_email.rs | 5 ++++- apps/users/src/handlers/ai.rs | 8 ++++---- apps/users/src/handlers/auth.rs | 1 + apps/users/src/handlers/config.rs | 1 + apps/users/src/handlers/coupons.rs | 1 + apps/users/src/handlers/kb.rs | 2 ++ apps/users/src/handlers/onboarding.rs | 2 +- apps/users/src/handlers/profile.rs | 2 +- apps/users/src/handlers/user_roles.rs | 1 - crates/db/src/models/user_role_profile.rs | 2 +- crates/email/src/lib.rs | 8 ++++---- 16 files changed, 34 insertions(+), 25 deletions(-) diff --git a/apps/customers/src/handlers.rs b/apps/customers/src/handlers.rs index e445a2b..f5edabf 100644 --- a/apps/customers/src/handlers.rs +++ b/apps/customers/src/handlers.rs @@ -122,7 +122,7 @@ async fn list_requirements( async fn create_requirement( State(state): State, - auth: AuthUser, + _auth: AuthUser, Json(payload): Json, ) -> impl IntoResponse { let p_date = payload.preferred_date.and_then(|d| chrono::NaiveDate::parse_from_str(&d, "%Y-%m-%d").ok()); @@ -256,7 +256,7 @@ async fn list_requests( async fn approve_request( State(state): State, Path(lead_id): Path, - auth: AuthUser, + _auth: AuthUser, ) -> impl IntoResponse { let lead = match LeadRequestRepository::get_by_id(&state.pool, lead_id).await { Ok(Some(l)) => l, diff --git a/apps/job_seekers/src/handlers.rs b/apps/job_seekers/src/handlers.rs index 665306d..9c00ba1 100644 --- a/apps/job_seekers/src/handlers.rs +++ b/apps/job_seekers/src/handlers.rs @@ -37,6 +37,7 @@ pub struct JobBrowseQuery { } #[derive(Deserialize)] +#[allow(dead_code)] pub struct ApplyRequest { pub cover_note: Option, pub resume_url: Option, @@ -278,7 +279,7 @@ async fn list_my_applications( auth: AuthUser, Query(q): Query, ) -> 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(), }; @@ -300,7 +301,7 @@ async fn get_my_application( auth: AuthUser, Path(id): Path, ) -> 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(), }; diff --git a/apps/leads/src/lead_requests.rs b/apps/leads/src/lead_requests.rs index 0e2efa1..0c91de7 100644 --- a/apps/leads/src/lead_requests.rs +++ b/apps/leads/src/lead_requests.rs @@ -131,7 +131,7 @@ async fn list_lead_requests( async fn send_lead_request( State(state): State>, - axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, + axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo, Json(payload): Json, ) -> impl IntoResponse { let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); @@ -275,7 +275,7 @@ async fn send_lead_request( async fn accept_lead_request( State(state): State>, Path(id): Path, - axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, + axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo, ) -> impl IntoResponse { let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); @@ -372,7 +372,7 @@ async fn accept_lead_request( async fn reject_lead_request( State(state): State>, Path(id): Path, - axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, + axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo, ) -> impl IntoResponse { let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); @@ -436,7 +436,7 @@ async fn reject_lead_request( async fn my_requests( State(state): State>, Query(q): Query, - axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, + axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo, ) -> impl IntoResponse { let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); let page = q.page.unwrap_or(1); @@ -476,7 +476,7 @@ async fn my_requests( async fn my_pending_requests( State(state): State>, - axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, + axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo, ) -> impl IntoResponse { let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); @@ -506,7 +506,7 @@ async fn get_customer_lead_requests( State(state): State>, Path(lead_id): Path, Query(q): Query, - axum::extract::ConnectInfo(addr): axum::extract::ConnectInfo, + axum::extract::ConnectInfo(_addr): axum::extract::ConnectInfo, ) -> impl IntoResponse { let user_id = Uuid::parse_str("00000000-0000-0000-0000-000000000001").unwrap_or_default(); let page = q.page.unwrap_or(1); diff --git a/apps/payments/src/main.rs b/apps/payments/src/main.rs index 6490e9a..1f71eaf 100644 --- a/apps/payments/src/main.rs +++ b/apps/payments/src/main.rs @@ -15,7 +15,7 @@ use sqlx::FromRow; pub mod packages; #[derive(Clone)] -struct AppState { +pub struct AppState { beeceptor_url: String, client: reqwest::Client, pool: PgPool, @@ -66,6 +66,7 @@ struct PricingPackageRow { } #[derive(Debug, FromRow)] +#[allow(dead_code)] struct PaymentRow { id: Uuid, user_id: Uuid, diff --git a/apps/payments/src/packages.rs b/apps/payments/src/packages.rs index 0738001..90355de 100644 --- a/apps/payments/src/packages.rs +++ b/apps/payments/src/packages.rs @@ -271,7 +271,7 @@ async fn update_package( .fetch_optional(&state.pool) .await; - let existing = match existing { + 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(), diff --git a/apps/users/src/handlers/admin_email.rs b/apps/users/src/handlers/admin_email.rs index 5eed7d9..155ce42 100644 --- a/apps/users/src/handlers/admin_email.rs +++ b/apps/users/src/handlers/admin_email.rs @@ -419,6 +419,7 @@ async fn send_test_email( // ── SMTP Configuration ─────────────────────────────────────────────────────── #[derive(Serialize, Deserialize)] +#[allow(dead_code)] struct SmtpConfig { host: String, port: i32, @@ -461,6 +462,7 @@ async fn get_smtp_config() -> impl IntoResponse { } #[derive(Deserialize)] +#[allow(dead_code)] struct UpdateSmtpConfigRequest { host: String, port: i32, @@ -507,6 +509,7 @@ struct SmtpTestRequest { } #[derive(Deserialize)] +#[allow(dead_code)] struct SmtpTestConfig { host: String, port: i32, @@ -542,7 +545,7 @@ async fn test_smtp_connection( } } -async fn create_test_mailer(config: SmtpTestConfig) -> email::Mailer { +async fn create_test_mailer(_config: SmtpTestConfig) -> email::Mailer { // This is a simplified version - in production you'd create a new Mailer instance // For now, we just return the default mailer email::Mailer::new() diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs index 5935ae4..1b06f71 100644 --- a/apps/users/src/handlers/ai.rs +++ b/apps/users/src/handlers/ai.rs @@ -1,6 +1,6 @@ use crate::AppState; use axum::{ - extract::{Query, State}, + extract::State, http::StatusCode, response::IntoResponse, routing::{get, post}, @@ -45,7 +45,7 @@ struct OllamaGenerateResponse { response: String, } -async fn call_ollama(state: &AppState, model: &str, prompt: &str) -> Result { +async fn call_ollama(_state: &AppState, model: &str, prompt: &str) -> Result { let base_url = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); let url = format!("{}/api/generate", base_url); @@ -288,7 +288,7 @@ struct ExtractedField { } async fn ai_extract_form( - State(state): State, + State(_state): State, Json(body): Json, ) -> impl IntoResponse { let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); @@ -315,7 +315,7 @@ async fn ai_extract_form( .unwrap_or_else(|_| serde_json::json!({})); let mut fields = Vec::new(); - let mut missing_fields = Vec::new(); + let missing_fields = Vec::new(); if let Some(obj) = extracted.as_object() { for (key, value) in obj { diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index 11ffbf5..62dd5ba 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -34,6 +34,7 @@ pub fn router() -> Router { // ── DTOs ────────────────────────────────────────────────────────────────────── #[derive(Deserialize)] +#[allow(dead_code)] pub struct RegisterPayload { #[serde(default)] pub first_name: Option, diff --git a/apps/users/src/handlers/config.rs b/apps/users/src/handlers/config.rs index 91da10e..baa89db 100644 --- a/apps/users/src/handlers/config.rs +++ b/apps/users/src/handlers/config.rs @@ -239,6 +239,7 @@ async fn get_my_runtime_config( let role_key = auth.claims.active_role.clone().to_uppercase(); #[derive(sqlx::FromRow)] + #[allow(dead_code)] struct RoleRow { id: Uuid, key: String, diff --git a/apps/users/src/handlers/coupons.rs b/apps/users/src/handlers/coupons.rs index f700005..c3f3b2b 100644 --- a/apps/users/src/handlers/coupons.rs +++ b/apps/users/src/handlers/coupons.rs @@ -139,6 +139,7 @@ struct ExistingCouponRow { } #[derive(sqlx::FromRow)] +#[allow(dead_code)] struct ValidateCouponRow { id: Uuid, code: String, diff --git a/apps/users/src/handlers/kb.rs b/apps/users/src/handlers/kb.rs index 63202a3..f723cd3 100644 --- a/apps/users/src/handlers/kb.rs +++ b/apps/users/src/handlers/kb.rs @@ -523,6 +523,7 @@ async fn admin_delete_category( Path(id): Path, ) -> impl IntoResponse { #[derive(sqlx::FromRow)] + #[allow(dead_code)] struct IdRow { id: Uuid } let result = sqlx::query_as::<_, IdRow>( @@ -859,6 +860,7 @@ async fn admin_delete_article( Path(id): Path, ) -> impl IntoResponse { #[derive(sqlx::FromRow)] + #[allow(dead_code)] struct IdRow { id: Uuid } let result = sqlx::query_as::<_, IdRow>( diff --git a/apps/users/src/handlers/onboarding.rs b/apps/users/src/handlers/onboarding.rs index 019c35a..0b03307 100644 --- a/apps/users/src/handlers/onboarding.rs +++ b/apps/users/src/handlers/onboarding.rs @@ -267,7 +267,7 @@ async fn get_or_create_user_role_profile_id( pool: &sqlx::PgPool, user_id: uuid::Uuid, role_key: &str, - role_id: uuid::Uuid, + _role_id: uuid::Uuid, ) -> Result { if let Some(id) = sqlx::query_scalar::<_, uuid::Uuid>( r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2"#, diff --git a/apps/users/src/handlers/profile.rs b/apps/users/src/handlers/profile.rs index 6ad232b..3b400e5 100644 --- a/apps/users/src/handlers/profile.rs +++ b/apps/users/src/handlers/profile.rs @@ -521,7 +521,7 @@ async fn get_or_create_user_role_profile_id( return Ok(id); } - let role = RoleRepository::get_by_key(pool, role_key).await?; + let _role = RoleRepository::get_by_key(pool, role_key).await?; sqlx::query_scalar::<_, Uuid>( r#" diff --git a/apps/users/src/handlers/user_roles.rs b/apps/users/src/handlers/user_roles.rs index fb8078f..b30ee12 100644 --- a/apps/users/src/handlers/user_roles.rs +++ b/apps/users/src/handlers/user_roles.rs @@ -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 { Router::new() diff --git a/crates/db/src/models/user_role_profile.rs b/crates/db/src/models/user_role_profile.rs index 0735d1f..74de1ca 100644 --- a/crates/db/src/models/user_role_profile.rs +++ b/crates/db/src/models/user_role_profile.rs @@ -193,7 +193,7 @@ impl UserRoleProfileRepository { pub async fn approve( pool: &PgPool, id: Uuid, - approved_by: Uuid, + _approved_by: Uuid, ) -> Result { sqlx::query_as::<_, UserRoleProfile>( r#"UPDATE user_role_profiles SET diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs index 3fadd1e..4cfbb64 100644 --- a/crates/email/src/lib.rs +++ b/crates/email/src/lib.rs @@ -1,6 +1,6 @@ use anyhow::Result; use lettre::{ - message::{header::ContentType, Mailbox, MultiPart, SinglePart}, + message::{header::ContentType, Mailbox}, transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, }; @@ -453,7 +453,7 @@ impl Mailer { } pub async fn send_account_deleted_email(&self, to: &str, name: &str) -> Result<()> { - let vars = HashMap::from([ + let _vars = HashMap::from([ ("first_name", name), ]); // Use account-suspended template as base or create a simple message @@ -505,7 +505,7 @@ impl Mailer { } pub async fn send_lead_accepted_customer_email(&self, to: &str, customer_name: &str, professional_name: &str, professional_email: &str, professional_phone: &str) -> Result<()> { - let vars = HashMap::from([ + let _vars = HashMap::from([ ("first_name", customer_name), ("professional_name", professional_name), ("professional_email", professional_email), @@ -591,7 +591,7 @@ impl Mailer { } pub async fn send_support_ticket_resolved_email(&self, to: &str, name: &str, subject: &str) -> Result<()> { - let frontend_url = env::var("FRONTEND_URL").unwrap_or_else(|_| "https://nxtgauge.com".to_string()); + let _frontend_url = env::var("FRONTEND_URL").unwrap_or_else(|_| "https://nxtgauge.com".to_string()); let now = chrono::Local::now().format("%B %d, %Y").to_string(); let vars = HashMap::from([ From cb7831a04010035f38ebc4bb19cdf518955e6262 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sat, 18 Apr 2026 18:31:00 +0200 Subject: [PATCH 092/182] ci: trigger woodpecker From f20e2d901f3173ed3732d5f30b3c66d41ba801a5 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sat, 18 Apr 2026 18:33:48 +0200 Subject: [PATCH 093/182] ci: trigger woodpecker From 4862650fba5c216e5426ca11c388838e9f20e2d6 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sat, 18 Apr 2026 18:50:24 +0200 Subject: [PATCH 094/182] ci: trigger woodpecker From 11863d42f90d00991f363af332c995ad71297e45 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sat, 18 Apr 2026 19:04:39 +0200 Subject: [PATCH 095/182] ci: trigger woodpecker From 5547b9dfa4fd61c005dac9cbcbf76259e4bb70dc Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sat, 18 Apr 2026 19:09:07 +0200 Subject: [PATCH 096/182] ci: trigger woodpecker From 7b11955ecadc7103cd0f216734726bda6dd5e50f Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sat, 18 Apr 2026 19:12:05 +0200 Subject: [PATCH 097/182] ci: trigger woodpecker From aed8cf6802aaeee869ecf8560dede7359419d318 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 00:03:00 +0200 Subject: [PATCH 098/182] ci: add Gitea Actions workflow --- .gitea/workflows/build.yaml | 75 +++++++++++++++++++++++++++++++++++++ 1 file changed, 75 insertions(+) create mode 100644 .gitea/workflows/build.yaml diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml new file mode 100644 index 0000000..e3412c3 --- /dev/null +++ b/.gitea/workflows/build.yaml @@ -0,0 +1,75 @@ +name: build-and-push + +on: + push: + branches: + - main + - high-performance + +jobs: + build: + runs-on: ubuntu-latest + strategy: + matrix: + service: + - gateway + - users + - companies + - jobs + - leads + - job-seekers + - customers + - payments + - employees + - photographers + - makeup-artists + - tutors + - developers + - video-editors + - graphic-designers + - social-media-managers + - fitness-trainers + - catering-services + - ugc-content-creators + - cron + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-rust-${{ matrix.service }}:${{ github.sha }} + ${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-rust-${{ matrix.service }}:${{ github.ref_name }}-latest + file: Dockerfile.simple + build-args: | + SERVICE_NAME=${{ matrix.service }} + cache-from: type=gha + cache-to: type=gha,mode=max + insecure: true + + migrate: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + + - name: Build and push migrate + uses: docker/build-push-action@v5 + with: + context: . + push: true + tags: | + ${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-db-migrate:${{ github.sha }} + ${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-db-migrate:${{ github.ref_name }}-latest + file: Dockerfile.migrate + insecure: true From bd9bfcfbb7cc62061b7c31e48c55f21192eb06b2 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 00:05:05 +0200 Subject: [PATCH 099/182] ci: update Gitea Actions workflow with docker/build-push-action --- .gitea/workflows/build.yaml | 27 +++++++++++++++++++-------- 1 file changed, 19 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index e3412c3..f6fa90e 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -10,6 +10,7 @@ jobs: build: runs-on: ubuntu-latest strategy: + fail-fast: false matrix: service: - gateway @@ -39,20 +40,24 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Login to Registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.REGISTRY_HOSTPORT }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + - name: Build and push uses: docker/build-push-action@v5 with: context: . push: true tags: | - ${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-rust-${{ matrix.service }}:${{ github.sha }} - ${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-rust-${{ matrix.service }}:${{ github.ref_name }}-latest + ${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }} + ${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-rust-${{ matrix.service }}:${{ gitea.ref }}-latest file: Dockerfile.simple build-args: | SERVICE_NAME=${{ matrix.service }} - cache-from: type=gha - cache-to: type=gha,mode=max - insecure: true migrate: runs-on: ubuntu-latest @@ -63,13 +68,19 @@ jobs: - name: Set up Docker Buildx uses: docker/setup-buildx-action@v3 + - name: Login to Registry + uses: docker/login-action@v3 + with: + registry: ${{ secrets.REGISTRY_HOSTPORT }} + username: ${{ secrets.REGISTRY_USERNAME }} + password: ${{ secrets.REGISTRY_PASSWORD }} + - name: Build and push migrate uses: docker/build-push-action@v5 with: context: . push: true tags: | - ${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-db-migrate:${{ github.sha }} - ${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-db-migrate:${{ github.ref_name }}-latest + ${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-db-migrate:${{ gitea.sha }} + ${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-db-migrate:${{ gitea.ref }}-latest file: Dockerfile.migrate - insecure: true From 2042eba375c11d4a2f8451174aeef4ed565f4ed9 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 00:27:08 +0200 Subject: [PATCH 100/182] ci: remove woodpecker, using Gitea Actions --- .woodpecker.yml | 69 ------------------------------------------------- 1 file changed, 69 deletions(-) delete mode 100644 .woodpecker.yml diff --git a/.woodpecker.yml b/.woodpecker.yml deleted file mode 100644 index ddc4f47..0000000 --- a/.woodpecker.yml +++ /dev/null @@ -1,69 +0,0 @@ -when: - branch: [main, high-performance] - event: push - -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: build-and-push - image: registry.nxtgauge.com/kaniko:2.1.1 - settings: - registry: - from_secret: REGISTRY_HOSTPORT - insecure: true - username: - from_secret: REGISTRY_USERNAME - password: - from_secret: REGISTRY_PASSWORD - repo: nxtgauge-rust-${SERVICE} - tags: - - ${CI_COMMIT_SHA} - - ${CI_COMMIT_BRANCH}-latest - dockerfile: Dockerfile.simple - context: . - build_args: - - SERVICE_NAME=${SERVICE} - ---- -when: - branch: [main, high-performance] - event: push - -steps: - - name: build-and-push-migrate - image: registry.nxtgauge.com/kaniko:2.1.1 - settings: - registry: - from_secret: REGISTRY_HOSTPORT - insecure: true - username: - from_secret: REGISTRY_USERNAME - password: - from_secret: REGISTRY_PASSWORD - repo: nxtgauge-db-migrate - tags: - - ${CI_COMMIT_SHA} - - ${CI_COMMIT_BRANCH}-latest - dockerfile: Dockerfile.migrate - context: . From 8477996366b3af7b1aba0c57e448856fa8b23709 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 00:47:19 +0200 Subject: [PATCH 101/182] ci: trigger Gitea Actions From 007939f5fb4a33fad9d75cf0992049916506842a Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 01:00:02 +0200 Subject: [PATCH 102/182] ci: test Gitea sync From cd4edd64655a0a6a120efeb8f7bdc5cb5b4cc1ac Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 15:35:47 +0200 Subject: [PATCH 103/182] ci: add sync-to-gitea workflow --- .github/workflows/sync-to-gitea.yml | 27 +++++++++++++++++++++++++++ 1 file changed, 27 insertions(+) create mode 100644 .github/workflows/sync-to-gitea.yml diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml new file mode 100644 index 0000000..87b7b8e --- /dev/null +++ b/.github/workflows/sync-to-gitea.yml @@ -0,0 +1,27 @@ +name: sync-to-gitea + +on: + push: + branches: + - main + +jobs: + sync: + runs-on: ubuntu-latest + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Push to Gitea high-performance + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + echo "Syncing nxtgauge-backend-rust:main → Gitea high-performance" + echo "Commit: $(git rev-parse HEAD)" + + git remote add gitea "https://Admin:${GITEA_TOKEN}@ci.nxtgauge.com/Admin/nxtgauge-backend-rust.git" + git push gitea main:high-performance --force-with-lease + + echo "Sync complete!" From 992863efe77592a603112ef3c4f16076d24d0178 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 15:39:10 +0200 Subject: [PATCH 104/182] ci: fix permissions --- .github/workflows/sync-to-gitea.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index 87b7b8e..04960da 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -8,6 +8,8 @@ on: jobs: sync: runs-on: ubuntu-latest + permissions: + contents: write steps: - name: Checkout uses: actions/checkout@v4 From 4a200c6cbe20c2e3b937b469bc8c0a9ae16844d0 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 15:43:50 +0200 Subject: [PATCH 105/182] ci: use GITEA_SECRET --- .github/workflows/sync-to-gitea.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index 04960da..beeaa16 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -18,7 +18,7 @@ jobs: - name: Push to Gitea high-performance env: - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_TOKEN: ${{ secrets.GITEA_SECRET }} run: | echo "Syncing nxtgauge-backend-rust:main → Gitea high-performance" echo "Commit: $(git rev-parse HEAD)" From 575d060d607788926658f0e7a5144c1546030138 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 15:52:08 +0200 Subject: [PATCH 106/182] ci: trigger From a08075e015b10500948df6f23c1b23b5137391e2 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 16:00:05 +0200 Subject: [PATCH 107/182] ci: fetch before push, use force-with-lease --- .github/workflows/sync-to-gitea.yml | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index beeaa16..4ce2699 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -18,12 +18,16 @@ jobs: - name: Push to Gitea high-performance env: + GITEA_USERNAME: Admin GITEA_TOKEN: ${{ secrets.GITEA_SECRET }} run: | - echo "Syncing nxtgauge-backend-rust:main → Gitea high-performance" + set -euo pipefail + echo "Syncing ${{ github.event.repository.name }}:main → Gitea high-performance" echo "Commit: $(git rev-parse HEAD)" - git remote add gitea "https://Admin:${GITEA_TOKEN}@ci.nxtgauge.com/Admin/nxtgauge-backend-rust.git" - git push gitea main:high-performance --force-with-lease + git remote add gitea "https://${GITEA_USERNAME}:${GITEA_TOKEN}@ci.nxtgauge.com/Admin/${{ github.event.repository.name }}.git" + + git fetch gitea high-performance || true + git push gitea HEAD:high-performance --force-with-lease=refs/heads/high-performance echo "Sync complete!" From df35a2bb2864384a9dd762d9e0e38ca1dda0c537 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 16:26:57 +0200 Subject: [PATCH 108/182] ci: trigger sync From 119c70cd4a597c5798419d074e64d90ea9dca1d4 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 16:33:00 +0200 Subject: [PATCH 109/182] ci: trigger From 13b3cbab082e2c09cc128ed1df377cf69226c347 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 16:35:59 +0200 Subject: [PATCH 110/182] ci: confirm --- .github/workflows/sync-to-gitea.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index 4ce2699..c45546a 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -3,7 +3,7 @@ name: sync-to-gitea on: push: branches: - - main + - high-performance jobs: sync: @@ -22,7 +22,7 @@ jobs: GITEA_TOKEN: ${{ secrets.GITEA_SECRET }} run: | set -euo pipefail - echo "Syncing ${{ github.event.repository.name }}:main → Gitea high-performance" + echo "Syncing ${{ github.event.repository.name }}:high-performance → Gitea high-performance" echo "Commit: $(git rev-parse HEAD)" git remote add gitea "https://${GITEA_USERNAME}:${GITEA_TOKEN}@ci.nxtgauge.com/Admin/${{ github.event.repository.name }}.git" From f45c289369ecf8f170be29ac9ac0fae10d0d2703 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 16:37:17 +0200 Subject: [PATCH 111/182] ci: trigger From cb0ab0bb8026ef04a487245c54bd955c06d3d291 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 17:43:13 +0200 Subject: [PATCH 112/182] ci: trigger From 0d0ed6c5e8a1cdf26b919d736d66b5dd3347ced6 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 17:52:22 +0200 Subject: [PATCH 113/182] ci: trigger fresh From 6e2c0cac2b414ed7b154ed4c23c516db37371e52 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 18:06:52 +0200 Subject: [PATCH 114/182] ci: use plain docker buildx commands --- .gitea/workflows/build.yaml | 53 ++++++++++++++++--------------------- 1 file changed, 23 insertions(+), 30 deletions(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index f6fa90e..05804d1 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -38,26 +38,22 @@ jobs: uses: actions/checkout@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + run: | + docker buildx create --use || true + docker buildx inspect --bootstrap - name: Login to Registry - uses: docker/login-action@v3 - with: - registry: ${{ secrets.REGISTRY_HOSTPORT }} - username: ${{ secrets.REGISTRY_USERNAME }} - password: ${{ secrets.REGISTRY_PASSWORD }} + run: | + docker login ${{ secrets.REGISTRY_HOSTPORT }} -u ${{ secrets.REGISTRY_USERNAME }} -p ${{ secrets.REGISTRY_PASSWORD }} - name: Build and push - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: | - ${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }} - ${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-rust-${{ matrix.service }}:${{ gitea.ref }}-latest - file: Dockerfile.simple - build-args: | - SERVICE_NAME=${{ matrix.service }} + run: | + docker buildx build --push \ + -f Dockerfile.simple \ + --build-arg SERVICE_NAME=${{ matrix.service }} \ + -t "${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }}" \ + -t "${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-rust-${{ matrix.service }}:${{ gitea.ref }}-latest" \ + . migrate: runs-on: ubuntu-latest @@ -66,21 +62,18 @@ jobs: uses: actions/checkout@v4 - name: Set up Docker Buildx - uses: docker/setup-buildx-action@v3 + run: | + docker buildx create --use || true + docker buildx inspect --bootstrap - name: Login to Registry - uses: docker/login-action@v3 - with: - registry: ${{ secrets.REGISTRY_HOSTPORT }} - username: ${{ secrets.REGISTRY_USERNAME }} - password: ${{ secrets.REGISTRY_PASSWORD }} + run: | + docker login ${{ secrets.REGISTRY_HOSTPORT }} -u ${{ secrets.REGISTRY_USERNAME }} -p ${{ secrets.REGISTRY_PASSWORD }} - name: Build and push migrate - uses: docker/build-push-action@v5 - with: - context: . - push: true - tags: | - ${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-db-migrate:${{ gitea.sha }} - ${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-db-migrate:${{ gitea.ref }}-latest - file: Dockerfile.migrate + run: | + docker buildx build --push \ + -f Dockerfile.migrate \ + -t "${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-db-migrate:${{ gitea.sha }}" \ + -t "${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-db-migrate:${{ gitea.ref }}-latest" \ + . From c0db2c149e6874f6b594056981e6b6c24604ec91 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 18:07:20 +0200 Subject: [PATCH 115/182] ci: trigger From a9f4ad3ed829b933b379458cc695885440774049 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 18:10:20 +0200 Subject: [PATCH 116/182] ci: add DOCKER_HOST for DinD, use high-performance-latest tag --- .gitea/workflows/build.yaml | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 05804d1..e2c17c8 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -6,6 +6,10 @@ on: - main - high-performance +env: + DOCKER_HOST: tcp://docker-dind.gitea.svc.cluster.local:2375 + DOCKER_TLS_CERTDIR: "" + jobs: build: runs-on: ubuntu-latest @@ -52,7 +56,7 @@ jobs: -f Dockerfile.simple \ --build-arg SERVICE_NAME=${{ matrix.service }} \ -t "${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }}" \ - -t "${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-rust-${{ matrix.service }}:${{ gitea.ref }}-latest" \ + -t "${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-rust-${{ matrix.service }}:high-performance-latest" \ . migrate: @@ -75,5 +79,5 @@ jobs: docker buildx build --push \ -f Dockerfile.migrate \ -t "${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-db-migrate:${{ gitea.sha }}" \ - -t "${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-db-migrate:${{ gitea.ref }}-latest" \ + -t "${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-db-migrate:high-performance-latest" \ . From 769b837a7de4112b01af267dce3121bb625d9a29 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 18:13:48 +0200 Subject: [PATCH 117/182] ci: fix docker login with --password-stdin --- .gitea/workflows/build.yaml | 28 ++++++++++++++++++++++------ 1 file changed, 22 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index e2c17c8..c5c70a9 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -47,16 +47,24 @@ jobs: 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: | - docker login ${{ secrets.REGISTRY_HOSTPORT }} -u ${{ secrets.REGISTRY_USERNAME }} -p ${{ secrets.REGISTRY_PASSWORD }} + set -euo pipefail + test -n "$REGISTRY_HOSTPORT" + echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOSTPORT" -u "$REGISTRY_USERNAME" --password-stdin - name: Build and push + env: + REGISTRY_HOSTPORT: ${{ secrets.REGISTRY_HOSTPORT }} run: | docker buildx build --push \ -f Dockerfile.simple \ --build-arg SERVICE_NAME=${{ matrix.service }} \ - -t "${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }}" \ - -t "${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-rust-${{ matrix.service }}:high-performance-latest" \ + -t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }}" \ + -t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:high-performance-latest" \ . migrate: @@ -71,13 +79,21 @@ jobs: 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: | - docker login ${{ secrets.REGISTRY_HOSTPORT }} -u ${{ secrets.REGISTRY_USERNAME }} -p ${{ secrets.REGISTRY_PASSWORD }} + set -euo pipefail + test -n "$REGISTRY_HOSTPORT" + echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOSTPORT" -u "$REGISTRY_USERNAME" --password-stdin - name: Build and push migrate + env: + REGISTRY_HOSTPORT: ${{ secrets.REGISTRY_HOSTPORT }} run: | docker buildx build --push \ -f Dockerfile.migrate \ - -t "${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-db-migrate:${{ gitea.sha }}" \ - -t "${{ secrets.REGISTRY_HOSTPORT }}/nxtgauge-db-migrate:high-performance-latest" \ + -t "$REGISTRY_HOSTPORT/nxtgauge-db-migrate:${{ gitea.sha }}" \ + -t "$REGISTRY_HOSTPORT/nxtgauge-db-migrate:high-performance-latest" \ . From 1d55fd57ef0cb726f5c90ef11245993a738e4a42 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 18:21:14 +0200 Subject: [PATCH 118/182] ci: trigger with fixed secrets From ec4ffd4c6960a06079dddb78e8642bfa13ecf57d Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 19:27:45 +0200 Subject: [PATCH 119/182] ci: trigger From 695069f2cc3364a7f24079e76f854001fbddcea9 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 19 Apr 2026 21:31:49 +0200 Subject: [PATCH 120/182] ci: remove migrate job from workflow --- .gitea/workflows/build.yaml | 31 ------------------------------- 1 file changed, 31 deletions(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index c5c70a9..9528838 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -66,34 +66,3 @@ jobs: -t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:${{ gitea.sha }}" \ -t "$REGISTRY_HOSTPORT/nxtgauge-rust-${{ matrix.service }}:high-performance-latest" \ . - - migrate: - runs-on: ubuntu-latest - steps: - - name: Checkout - uses: actions/checkout@v4 - - - name: Set up Docker Buildx - run: | - 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 - test -n "$REGISTRY_HOSTPORT" - echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOSTPORT" -u "$REGISTRY_USERNAME" --password-stdin - - - name: Build and push migrate - env: - REGISTRY_HOSTPORT: ${{ secrets.REGISTRY_HOSTPORT }} - run: | - docker buildx build --push \ - -f Dockerfile.migrate \ - -t "$REGISTRY_HOSTPORT/nxtgauge-db-migrate:${{ gitea.sha }}" \ - -t "$REGISTRY_HOSTPORT/nxtgauge-db-migrate:high-performance-latest" \ - . From f37c48f1eef1ac67ac2194c78564f4c8ccf28689 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 21 Apr 2026 21:51:02 +0200 Subject: [PATCH 121/182] fix: get_user_role_keys returns newest role first, not oldest - models/user.rs: ORDER BY ur.created_at DESC so most recently assigned role is returned first - handlers/auth.rs: resolve_signup_role_candidates returns empty vec instead of JOB_SEEKER when no valid intent --- apps/users/src/handlers/auth.rs | 21 +++++++++------------ crates/db/src/models/user.rs | 6 +++--- 2 files changed, 12 insertions(+), 15 deletions(-) diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index 62dd5ba..bcedb1e 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -135,9 +135,13 @@ fn normalize_role_key(raw: &str) -> String { } fn resolve_signup_role_candidates(intent: Option<&str>, profession: Option<&str>) -> Vec { - 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()]; } @@ -154,7 +158,7 @@ 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 { @@ -205,13 +209,6 @@ async fn ensure_role_exists(pool: &sqlx::PgPool, role_code: &str) -> Option Result, sqlx::Error> { let rows = sqlx::query_scalar::<_, String>( r#" SELECT r.key - FROM user_roles ur + FROM user_role_assignments ur JOIN roles r ON ur.role_id = r.id WHERE ur.user_id = $1 AND ur.status = 'APPROVED' - ORDER BY ur.created_at ASC + ORDER BY ur.created_at DESC "#, ) .bind(user_id) From 1ac60f975628d8f01b78f660c8eaf0a517b5a398 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Wed, 22 Apr 2026 01:13:59 +0200 Subject: [PATCH 122/182] fix: gateway routes /api/runtime-config to users service (was missing, causing 404) --- apps/gateway/src/main.rs | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index 1c66dde..c8ac0d3 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -202,6 +202,10 @@ impl Services { 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()) From 5946bfe3a866941d5c75acfed26c89b294daf670 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Sun, 26 Apr 2026 23:58:43 +0200 Subject: [PATCH 123/182] chore: checkpoint workspace updates --- ...c7b2b88d241b9aac76dd228fd9286c158dd77.json | 40 -- Cargo.lock | 2 + apps/employees/src/handlers/employees.rs | 73 ++- apps/users/src/handlers/admin.rs | 16 +- apps/users/src/handlers/admin_email.rs | 161 +++--- apps/users/src/handlers/approvals.rs | 2 +- apps/users/src/handlers/config.rs | 28 +- apps/users/src/handlers/external_roles.rs | 42 +- apps/users/src/handlers/mod.rs | 1 + apps/users/src/handlers/modules.rs | 263 ++++++++++ apps/users/src/handlers/onboarding.rs | 2 +- apps/users/src/handlers/permissions.rs | 5 + apps/users/src/handlers/profile.rs | 2 +- apps/users/src/handlers/roles.rs | 24 +- apps/users/src/handlers/user_roles.rs | 4 +- apps/users/src/main.rs | 3 + crates/auth/examples/hash_gen.rs | 15 + crates/contracts/src/profession_shared.rs | 34 +- ...001_external_role_management_phase1.up.sql | 127 +++++ ...0260420000002_cleanup_role_tables.down.sql | 21 + .../20260420000002_cleanup_role_tables.up.sql | 45 ++ ...60420000003_external_role_modules.down.sql | 24 + ...60420000003_external_role_modules.seed.sql | 149 ++++++ ...0420000003_external_role_modules.seed2.sql | 120 +++++ ...0260420000003_external_role_modules.up.sql | 157 ++++++ .../20260422000000_seed_widgets.seed.sql | 84 +++ .../20260422000000_seed_widgets.sql | 167 ++++++ crates/db/src/models/config.rs | 20 +- crates/db/src/models/department.rs | 5 +- crates/db/src/models/employee.rs | 56 +- crates/db/src/models/photographer.rs | 4 +- crates/email/Cargo.toml | 2 + crates/email/src/lib.rs | 228 +++++++-- scripts/seed.sql | 18 +- scripts/seed_external_role_management.sql | 484 ++++++++++++++++++ start-services.pid | 1 + 36 files changed, 2195 insertions(+), 234 deletions(-) delete mode 100644 .sqlx/query-f479b3c6088810c02b09611eb2bc7b2b88d241b9aac76dd228fd9286c158dd77.json create mode 100644 apps/users/src/handlers/modules.rs create mode 100644 crates/auth/examples/hash_gen.rs create mode 100644 crates/db/migrations/20260420000001_external_role_management_phase1.up.sql create mode 100644 crates/db/migrations/20260420000002_cleanup_role_tables.down.sql create mode 100644 crates/db/migrations/20260420000002_cleanup_role_tables.up.sql create mode 100644 crates/db/migrations/20260420000003_external_role_modules.down.sql create mode 100644 crates/db/migrations/20260420000003_external_role_modules.seed.sql create mode 100644 crates/db/migrations/20260420000003_external_role_modules.seed2.sql create mode 100644 crates/db/migrations/20260420000003_external_role_modules.up.sql create mode 100644 crates/db/migrations/20260422000000_seed_widgets.seed.sql create mode 100644 crates/db/migrations/20260422000000_seed_widgets.sql create mode 100644 scripts/seed_external_role_management.sql create mode 100644 start-services.pid diff --git a/.sqlx/query-f479b3c6088810c02b09611eb2bc7b2b88d241b9aac76dd228fd9286c158dd77.json b/.sqlx/query-f479b3c6088810c02b09611eb2bc7b2b88d241b9aac76dd228fd9286c158dd77.json deleted file mode 100644 index 639d629..0000000 --- a/.sqlx/query-f479b3c6088810c02b09611eb2bc7b2b88d241b9aac76dd228fd9286c158dd77.json +++ /dev/null @@ -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" -} diff --git a/Cargo.lock b/Cargo.lock index f6d2d86..eb267e0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1203,6 +1203,8 @@ dependencies = [ "anyhow", "chrono", "lettre", + "reqwest", + "serde", "tracing", ] diff --git a/apps/employees/src/handlers/employees.rs b/apps/employees/src/handlers/employees.rs index 997800b..0fb7cb2 100644 --- a/apps/employees/src/handlers/employees.rs +++ b/apps/employees/src/handlers/employees.rs @@ -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 { 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, + pub role_code: String, + pub department_id: Option, + pub designation_id: Option, + pub employee_code: Option, + pub password: String, +} + +async fn provision_employee( + auth: AuthUser, + State(state): State, + Json(payload): Json, +) -> Result { + 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, @@ -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, + Path(id): Path, + Json(payload): Json, +) -> Result { + 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" }))) +} diff --git a/apps/users/src/handlers/admin.rs b/apps/users/src/handlers/admin.rs index 1ac05d2..5d2db03 100644 --- a/apps/users/src/handlers/admin.rs +++ b/apps/users/src/handlers/admin.rs @@ -49,11 +49,11 @@ async fn list_users( let sql = if role_filter.is_empty() { // Generic list: users + their approved roles r#" - SELECT + 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.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') GROUP BY u.id @@ -68,14 +68,14 @@ 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!( @@ -110,11 +110,11 @@ async fn list_customers( let search = q.q.unwrap_or_default().to_lowercase(); let sql = r#" - SELECT + 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.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') ORDER BY u.created_at DESC @@ -138,11 +138,11 @@ async fn list_candidates( let search = q.q.unwrap_or_default().to_lowercase(); let sql = r#" - SELECT + 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.first_name) LIKE '%' || $1 || '%' OR LOWER(u.last_name) LIKE '%' || $1 || '%' OR LOWER(u.email) LIKE '%' || $1 || '%') ORDER BY u.created_at DESC diff --git a/apps/users/src/handlers/admin_email.rs b/apps/users/src/handlers/admin_email.rs index 155ce42..cd64151 100644 --- a/apps/users/src/handlers/admin_email.rs +++ b/apps/users/src/handlers/admin_email.rs @@ -14,8 +14,8 @@ pub fn router() -> Router { .route("/templates", get(list_templates)) .route("/templates/{name}/preview", get(preview_template)) .route("/templates/{name}/test", post(send_test_email)) - .route("/smtp-config", get(get_smtp_config).post(update_smtp_config)) - .route("/smtp-test", post(test_smtp_connection)) + .route("/email-config", get(get_email_config).post(update_email_config)) + .route("/email-test", post(test_email_connection)) } #[derive(Serialize)] @@ -416,17 +416,21 @@ async fn send_test_email( } } -// ── SMTP Configuration ─────────────────────────────────────────────────────── +// ── Email Configuration ─────────────────────────────────────────────────────── #[derive(Serialize, Deserialize)] #[allow(dead_code)] -struct SmtpConfig { - host: String, - port: i32, - secure: bool, - username: String, +struct EmailConfig { + provider: String, + smtp_host: String, + smtp_port: i32, + smtp_secure: bool, + smtp_username: String, #[serde(skip_serializing)] - password: Option, + smtp_password: Option, + zeptomail_api_key: String, + zeptomail_from_email: String, + zeptomail_from_name: String, from_email: String, from_name: String, reply_to_email: Option, @@ -434,66 +438,93 @@ struct SmtpConfig { } #[derive(Serialize)] -struct SmtpConfigResponse { - host: String, - port: i32, - secure: bool, - username: String, +struct EmailConfigResponse { + provider: String, + smtp_host: String, + smtp_port: i32, + smtp_secure: bool, + smtp_username: String, from_email: String, from_name: String, reply_to_email: Option, enabled: bool, + zeptomail_configured: bool, } -async fn get_smtp_config() -> impl IntoResponse { - // Return current SMTP configuration from environment - let config = SmtpConfigResponse { - host: std::env::var("SMTP_HOST").unwrap_or_default(), - port: std::env::var("SMTP_PORT").unwrap_or_else(|_| "587".to_string()).parse().unwrap_or(587), - secure: std::env::var("SMTP_SECURE").unwrap_or_default().to_lowercase() == "true", - username: std::env::var("SMTP_USER").unwrap_or_default(), - from_email: std::env::var("SMTP_FROM_EMAIL").unwrap_or_else(|_| "noreply@nxtgauge.com".to_string()), - from_name: std::env::var("SMTP_FROM_NAME").unwrap_or_else(|_| "NXTGAUGE".to_string()), - reply_to_email: std::env::var("SMTP_REPLY_TO").ok(), - enabled: std::env::var("SMTP_HOST").is_ok() && !std::env::var("SMTP_HOST").unwrap_or_default().is_empty(), +async fn get_email_config() -> impl IntoResponse { + let provider = std::env::var("EMAIL_PROVIDER").unwrap_or_else(|_| "SMTP".to_string()); + let zeptomail_configured = std::env::var("ZEPTOMAIL_API_KEY").is_ok(); + + let config = EmailConfigResponse { + provider: provider.clone(), + smtp_host: std::env::var("SMTP_HOST").unwrap_or_default(), + smtp_port: std::env::var("SMTP_PORT").unwrap_or_else(|_| "587".to_string()).parse().unwrap_or(587), + smtp_secure: std::env::var("SMTP_SECURE").unwrap_or_default().to_lowercase() == "true", + smtp_username: std::env::var("SMTP_USER").unwrap_or_default(), + from_email: if provider == "ZEPTOMAIL" { + std::env::var("ZEPTOMAIL_FROM_EMAIL").unwrap_or_else(|_| "noreply@nxtgauge.com".to_string()) + } else { + std::env::var("SMTP_FROM_EMAIL").unwrap_or_else(|_| "noreply@nxtgauge.com".to_string()) + }, + from_name: if provider == "ZEPTOMAIL" { + std::env::var("ZEPTOMAIL_FROM_NAME").unwrap_or_else(|_| "NXTGAUGE".to_string()) + } else { + std::env::var("SMTP_FROM_NAME").unwrap_or_else(|_| "NXTGAUGE".to_string()) + }, + reply_to_email: std::env::var("SMTP_REPLY_TO") + .ok() + .or_else(|| std::env::var("ZEPTOMAIL_REPLY_TO").ok()), + enabled: (provider == "SMTP" && std::env::var("SMTP_HOST").is_ok()) + || (provider == "ZEPTOMAIL" && std::env::var("ZEPTOMAIL_API_KEY").is_ok()), + zeptomail_configured, }; - + (StatusCode::OK, Json(config)) } #[derive(Deserialize)] #[allow(dead_code)] -struct UpdateSmtpConfigRequest { - host: String, - port: i32, - secure: bool, - username: String, - password: Option, +struct UpdateEmailConfigRequest { + provider: String, + smtp_host: String, + smtp_port: i32, + smtp_secure: bool, + smtp_username: String, + smtp_password: Option, + zeptomail_api_key: String, + zeptomail_from_email: String, + zeptomail_from_name: String, from_email: String, from_name: String, reply_to_email: Option, enabled: bool, } -async fn update_smtp_config( - Json(req): Json, +async fn update_email_config( + Json(req): Json, ) -> impl IntoResponse { - // In production, this would update the database or secrets manager - // For now, we just return success (env vars need restart to take effect) - - if req.enabled && req.host.is_empty() { - return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ - "error": "SMTP host is required when enabled" - }))); + if req.enabled { + if req.provider == "SMTP" && req.smtp_host.is_empty() { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ + "error": "SMTP host is required when SMTP provider is enabled" + }))); + } + if req.provider == "ZEPTOMAIL" && req.zeptomail_api_key.is_empty() { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ + "error": "Zeptomail API key is required when Zeptomail provider is enabled" + }))); + } } - + (StatusCode::OK, Json(serde_json::json!({ - "message": "SMTP configuration updated. Restart services to apply changes.", + "message": "Email configuration updated. Restart services to apply changes.", "config": { - "host": req.host, - "port": req.port, - "secure": req.secure, - "username": req.username, + "provider": req.provider, + "smtp_host": req.smtp_host, + "smtp_port": req.smtp_port, + "smtp_secure": req.smtp_secure, + "smtp_username": req.smtp_username, + "zeptomail_api_key": if req.zeptomail_api_key.is_empty() { "[hidden]".to_string() } else { "[configured]".to_string() }, "from_email": req.from_email, "from_name": req.from_name, "reply_to_email": req.reply_to_email, @@ -503,37 +534,39 @@ async fn update_smtp_config( } #[derive(Deserialize)] -struct SmtpTestRequest { +struct EmailTestRequest { to_email: String, - config: Option, + provider: Option, + config: Option, } #[derive(Deserialize)] #[allow(dead_code)] -struct SmtpTestConfig { - host: String, - port: i32, - secure: bool, - username: String, - password: String, +struct EmailTestConfig { + provider: String, + smtp_host: String, + smtp_port: i32, + smtp_secure: bool, + smtp_username: String, + smtp_password: String, + zeptomail_api_key: String, from_email: String, from_name: String, } -async fn test_smtp_connection( +async fn test_email_connection( State(state): State, - Json(req): Json, + Json(req): Json, ) -> impl IntoResponse { // Send a test email using current or provided config let result = if let Some(test_config) = req.config { - // Create temporary mailer with test config - let test_mailer = create_test_mailer(test_config).await; - test_mailer.send_test_email(&req.to_email).await + // 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!({ "message": "Test email sent successfully", @@ -544,9 +577,3 @@ async fn test_smtp_connection( }))), } } - -async fn create_test_mailer(_config: SmtpTestConfig) -> email::Mailer { - // This is a simplified version - in production you'd create a new Mailer instance - // For now, we just return the default mailer - email::Mailer::new() -} diff --git a/apps/users/src/handlers/approvals.rs b/apps/users/src/handlers/approvals.rs index be47db4..06ac634 100644 --- a/apps/users/src/handlers/approvals.rs +++ b/apps/users/src/handlers/approvals.rs @@ -232,7 +232,7 @@ async fn activate_profile_after_final_approval( 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) diff --git a/apps/users/src/handlers/config.rs b/apps/users/src/handlers/config.rs index baa89db..4e40076 100644 --- a/apps/users/src/handlers/config.rs +++ b/apps/users/src/handlers/config.rs @@ -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, } 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,11 +232,21 @@ async fn delete_runtime_config( } Ok((StatusCode::NO_CONTENT, "".to_string())) } +#[derive(Deserialize)] +struct RuntimeConfigQuery { + role: Option, +} + async fn get_my_runtime_config( auth: contracts::auth_middleware::AuthUser, State(state): State, + Query(q): Query, ) -> Result { - 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)] @@ -297,7 +307,7 @@ async fn get_my_runtime_config( if role.audience == "INTERNAL" { let permission_keys: Vec = 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) diff --git a/apps/users/src/handlers/external_roles.rs b/apps/users/src/handlers/external_roles.rs index f87dbc2..4a03970 100644 --- a/apps/users/src/handlers/external_roles.rs +++ b/apps/users/src/handlers/external_roles.rs @@ -32,6 +32,7 @@ struct ExternalRoleRow { id: Uuid, name: String, code: String, + persona_type: Option, vertical: Option, category: Option, onboarding_schema_id: Option, @@ -61,6 +62,7 @@ struct ExternalRoleListRow { id: Uuid, name: String, code: String, + persona_type: Option, is_active: bool, created_date: chrono::DateTime, updated_at: Option>, @@ -89,13 +91,13 @@ async fn list_external_roles( 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 - JOIN external_roles er ON er.role_id = r.id - 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)) @@ -115,7 +117,6 @@ async fn list_external_roles( r#" SELECT COUNT(*) FROM roles r - JOIN external_roles er ON er.role_id = r.id 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)) @@ -155,7 +156,7 @@ async fn list_external_roles( continue; } 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) @@ -166,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, @@ -223,8 +225,7 @@ async fn get_external_role( 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 - JOIN external_roles er ON er.role_id = r.id - 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' "#, ) @@ -251,7 +252,8 @@ struct CreateExternalRolePayload { name: String, code: String, is_active: Option, - runtime: JsonValue, + persona_type: Option, + runtime: Option, } #[derive(sqlx::FromRow)] @@ -280,35 +282,29 @@ async fn create_external_role( let is_active = payload.is_active.unwrap_or(true); 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}")))?; - sqlx::query( - "INSERT INTO external_roles (role_id) VALUES ($1)", - ) - .bind(role.id) - .execute(&state.pool) - .await - .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; - + 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}")))?; @@ -321,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), }), @@ -363,7 +359,7 @@ async fn update_external_role( 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 "#, @@ -374,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 ) "#, diff --git a/apps/users/src/handlers/mod.rs b/apps/users/src/handlers/mod.rs index b606d8f..32c9174 100644 --- a/apps/users/src/handlers/mod.rs +++ b/apps/users/src/handlers/mod.rs @@ -8,6 +8,7 @@ pub mod config; pub mod coupons; pub mod dashboard; pub mod kb; +pub mod modules; pub mod notifications; pub mod onboarding; pub mod permissions; diff --git a/apps/users/src/handlers/modules.rs b/apps/users/src/handlers/modules.rs new file mode 100644 index 0000000..0b6124a --- /dev/null +++ b/apps/users/src/handlers/modules.rs @@ -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 { + Router::new() + .route("/api/admin/persona-types", get(list_persona_types)) +} + +pub fn modules_router() -> Router { + Router::new() + .route("/api/admin/modules", get(list_modules)) +} + +pub fn role_modules_router() -> Router { + 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, + is_active: bool, +} + +async fn list_persona_types( + _auth: AuthUser, + State(state): State, +) -> Result { + 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, + backend_domain: Option, + default_route: Option, + default_sidebar_label: Option, + icon_key: Option, + is_core: bool, + is_active: bool, +} + +async fn list_modules( + _auth: AuthUser, + State(state): State, +) -> Result { + 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, + route_override: Option, +} + +async fn get_role_modules( + _auth: AuthUser, + State(state): State, + Path(role_id): Path, +) -> Result { + 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, + is_sidebar_visible: Option, + sidebar_label_override: Option, + route_override: Option, +} + +async fn add_role_module( + _auth: AuthUser, + State(state): State, + Path(role_id): Path, + Json(payload): Json, +) -> Result { + 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, + Path((role_id, module_id)): Path<(Uuid, Uuid)>, +) -> Result { + 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, + Path(role_id): Path, +) -> Result { + 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, + Path(role_id): Path, + Json(payload): Json, +) -> Result { + 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) +} \ No newline at end of file diff --git a/apps/users/src/handlers/onboarding.rs b/apps/users/src/handlers/onboarding.rs index 0b03307..d86142c 100644 --- a/apps/users/src/handlers/onboarding.rs +++ b/apps/users/src/handlers/onboarding.rs @@ -209,7 +209,7 @@ async fn submit( // 3. Mark the user_role as PENDING (awaiting admin review of onboarding) sqlx::query( r#" - UPDATE user_roles + UPDATE user_role_assignments SET status = 'PENDING' WHERE user_id = $1 AND role_id = $2 "#, diff --git a/apps/users/src/handlers/permissions.rs b/apps/users/src/handlers/permissions.rs index 54df9c6..d8912b1 100644 --- a/apps/users/src/handlers/permissions.rs +++ b/apps/users/src/handlers/permissions.rs @@ -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"]; diff --git a/apps/users/src/handlers/profile.rs b/apps/users/src/handlers/profile.rs index 3b400e5..8ed3d28 100644 --- a/apps/users/src/handlers/profile.rs +++ b/apps/users/src/handlers/profile.rs @@ -342,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) diff --git a/apps/users/src/handlers/roles.rs b/apps/users/src/handlers/roles.rs index 78a6801..c5797f5 100644 --- a/apps/users/src/handlers/roles.rs +++ b/apps/users/src/handlers/roles.rs @@ -164,10 +164,10 @@ async fn list_roles( COUNT(DISTINCT e.id) AS users_assigned, COUNT(DISTINCT rp.id) AS permissions_count FROM roles r - JOIN internal_roles ir ON ir.role_id = r.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 + 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 @@ -185,7 +185,7 @@ async fn list_roles( let total: i64 = sqlx::query_scalar::<_, i64>( r#" SELECT COUNT(*) FROM roles r - JOIN internal_roles ir ON ir.role_id = r.id + 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 || '%') "#, @@ -232,7 +232,7 @@ async fn get_role( COALESCE(ir.can_manage_system_settings, false) AS can_manage_system_settings, r.created_at FROM roles r - JOIN internal_roles ir ON ir.role_id = r.id + 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' "#, @@ -244,7 +244,7 @@ async fn get_role( .ok_or((StatusCode::NOT_FOUND, "Role not found".to_string()))?; let permission_keys: Vec = 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) @@ -291,7 +291,7 @@ async fn create_role( sqlx::query( r#" - INSERT INTO internal_roles (role_id, description, department_id, can_approve_requests, can_manage_system_settings) + INSERT INTO internal_role_details (role_id, description, department_id, can_approve_requests, can_manage_system_settings) VALUES ($1, $2, $3, $4, $5) "#, ) @@ -307,7 +307,7 @@ async fn create_role( 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) @@ -318,7 +318,7 @@ async fn create_role( } let permission_keys: Vec = 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) @@ -355,7 +355,7 @@ async fn update_role( 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_roles ir ON ir.role_id = r.id + JOIN internal_role_details ir ON ir.role_id = r.id WHERE r.id = $1 AND r.audience = 'INTERNAL' "#, ) @@ -385,7 +385,7 @@ async fn update_role( sqlx::query( r#" - UPDATE internal_roles SET + UPDATE internal_role_details SET description = $1, department_id = $2, can_approve_requests = $3, @@ -403,7 +403,7 @@ async fn update_role( .map_err(|e| (StatusCode::INTERNAL_SERVER_ERROR, format!("DB error: {e}")))?; 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 @@ -411,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) diff --git a/apps/users/src/handlers/user_roles.rs b/apps/users/src/handlers/user_roles.rs index b30ee12..bd9254b 100644 --- a/apps/users/src/handlers/user_roles.rs +++ b/apps/users/src/handlers/user_roles.rs @@ -60,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 @@ -100,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() diff --git a/apps/users/src/main.rs b/apps/users/src/main.rs index 6f76b66..b1a976f 100644 --- a/apps/users/src/main.rs +++ b/apps/users/src/main.rs @@ -60,6 +60,9 @@ async fn main() { .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 ───────────────────────────────────────────────── diff --git a/crates/auth/examples/hash_gen.rs b/crates/auth/examples/hash_gen.rs new file mode 100644 index 0000000..9f4718d --- /dev/null +++ b/crates/auth/examples/hash_gen.rs @@ -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); +} diff --git a/crates/contracts/src/profession_shared.rs b/crates/contracts/src/profession_shared.rs index c7ae631..4ecdab3 100644 --- a/crates/contracts/src/profession_shared.rs +++ b/crates/contracts/src/profession_shared.rs @@ -312,7 +312,7 @@ async fn list_portfolio(State(state): State, auth: AuthUser) -> Ok(items) => (StatusCode::OK, Json(serde_json::json!({ "data": items }))).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }, - Err(_) => (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), + Err(_) => (StatusCode::OK, Json(serde_json::json!({ "data": [] }))).into_response(), } } @@ -322,13 +322,17 @@ async fn list_services(State(state): State, auth: AuthUser) -> Ok(items) => (StatusCode::OK, Json(serde_json::json!({ "data": items }))).into_response(), Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), }, - Err(_) => (StatusCode::NOT_FOUND, "Professional profile not found").into_response(), + Err(_) => (StatusCode::OK, Json(serde_json::json!({ "data": [] }))).into_response(), } } async fn wallet_balance(State(state): State, auth: AuthUser) -> impl IntoResponse { + let _ = ProfessionalRepository::ensure_wallet(&state.pool, auth.user_id).await; match ProfessionalRepository::get_wallet(&state.pool, auth.user_id).await { Ok(w) => (StatusCode::OK, Json(w)).into_response(), + Err(sqlx::Error::RowNotFound) => { + (StatusCode::OK, Json(serde_json::json!({ "balance": 0, "reserved": 0 }))).into_response() + } Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } @@ -349,7 +353,13 @@ async fn my_requests( ) -> impl IntoResponse { let prof = match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await { Ok(p) => p, - Err(_) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), + Err(_) => return ( + StatusCode::OK, + Json(serde_json::json!({ + "data": [], + "pagination": { "page": 1, "limit": 20, "total": 0, "total_pages": 1 } + })) + ).into_response(), }; let page = q.page.unwrap_or(1).max(1); @@ -381,7 +391,7 @@ async fn my_requests( LEFT JOIN requirements r ON r.id = lr.requirement_id LEFT JOIN customers c ON c.id = r.customer_id LEFT JOIN users u ON u.id = c.user_id - WHERE lr.professional_id = $1 AND lr.status = $2 + WHERE lr.user_role_profile_id = $1 AND lr.status = $2 ORDER BY lr.requested_at DESC LIMIT $3 OFFSET $4 "# ) @@ -397,7 +407,7 @@ async fn my_requests( LEFT JOIN requirements r ON r.id = lr.requirement_id LEFT JOIN customers c ON c.id = r.customer_id LEFT JOIN users u ON u.id = c.user_id - WHERE lr.professional_id = $1 + WHERE lr.user_role_profile_id = $1 ORDER BY lr.requested_at DESC LIMIT $2 OFFSET $3 "# ) @@ -405,10 +415,10 @@ async fn my_requests( }; let total: i64 = if let Some(ref status) = q.status { - sqlx::query_scalar("SELECT COUNT(*) FROM lead_requests WHERE professional_id = $1 AND status = $2") + sqlx::query_scalar("SELECT COUNT(*) FROM lead_requests WHERE user_role_profile_id = $1 AND status = $2") .bind(prof.id).bind(status).fetch_one(&state.pool).await.unwrap_or(0) } else { - sqlx::query_scalar("SELECT COUNT(*) FROM lead_requests WHERE professional_id = $1") + sqlx::query_scalar("SELECT COUNT(*) FROM lead_requests WHERE user_role_profile_id = $1") .bind(prof.id).fetch_one(&state.pool).await.unwrap_or(0) }; @@ -478,7 +488,13 @@ async fn accepted_leads( ) -> impl IntoResponse { let user_role_profile = match UserRoleProfileRepository::get_by_user_and_role(&state.pool, auth.user_id, "PHOTOGRAPHER").await { Ok(Some(p)) => p, - Ok(None) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), + Ok(None) => return ( + StatusCode::OK, + Json(serde_json::json!({ + "data": [], + "pagination": { "page": 1, "limit": 20, "total": 0, "total_pages": 1 } + })) + ).into_response(), Err(e) => return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": e.to_string() }))).into_response(), }; @@ -575,7 +591,7 @@ async fn accepted_lead_detail( INNER JOIN customers c ON c.id = r.customer_id INNER JOIN users u ON u.id = c.user_id WHERE lr.id = $1 - AND lr.professional_id = $2 + AND lr.user_role_profile_id = $2 AND lr.status = 'ACCEPTED' "# ) diff --git a/crates/db/migrations/20260420000001_external_role_management_phase1.up.sql b/crates/db/migrations/20260420000001_external_role_management_phase1.up.sql new file mode 100644 index 0000000..8fb00df --- /dev/null +++ b/crates/db/migrations/20260420000001_external_role_management_phase1.up.sql @@ -0,0 +1,127 @@ +-- Phase 1: External Role Management Module System +-- Creates base schema for persona_types, external_roles, modules, role_module_access, module_actions, role_module_permissions + +-- ============================================ +-- persona_types +-- ============================================ +CREATE TABLE IF NOT EXISTS persona_types ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(50) UNIQUE NOT NULL, + name varchar(100) NOT NULL, + description text, + is_active boolean DEFAULT true, + created_at timestamptz DEFAULT NOW(), + updated_at timestamptz DEFAULT NOW() +); + +-- ============================================ +-- external_roles +-- ============================================ +CREATE TABLE IF NOT EXISTS external_roles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + role_code varchar(50) UNIQUE NOT NULL, + role_name varchar(100) NOT NULL, + persona_type_id uuid REFERENCES persona_types(id), + description text, + is_active boolean DEFAULT true, + onboarding_schema_key varchar(100), + verification_required boolean DEFAULT true, + switch_services_enabled boolean DEFAULT false, + is_publicly_discoverable boolean DEFAULT true, + sort_order integer DEFAULT 0, + created_at timestamptz DEFAULT NOW(), + updated_at timestamptz DEFAULT NOW() +); + +CREATE INDEX idx_external_roles_persona ON external_roles(persona_type_id); +CREATE INDEX idx_external_roles_active ON external_roles(is_active); + +-- ============================================ +-- modules +-- ============================================ +CREATE TABLE IF NOT EXISTS modules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + module_key varchar(50) UNIQUE NOT NULL, + module_name varchar(100) NOT NULL, + category varchar(50), -- core/content/marketplace/work/financial + description text, + backend_domain varchar(100), + default_route varchar(255), + default_sidebar_label varchar(100), + icon_key varchar(50), + is_core boolean DEFAULT false, + is_active boolean DEFAULT true, + created_at timestamptz DEFAULT NOW(), + updated_at timestamptz DEFAULT NOW() +); + +CREATE INDEX idx_modules_category ON modules(category); +CREATE INDEX idx_modules_active ON modules(is_active); + +-- ============================================ +-- role_module_access +-- ============================================ +CREATE TABLE IF NOT EXISTS role_module_access ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + external_role_id uuid NOT NULL REFERENCES external_roles(id) ON DELETE CASCADE, + module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + is_enabled boolean DEFAULT true, + is_sidebar_visible boolean DEFAULT true, + sidebar_label_override varchar(100), + route_override varchar(255), + sort_order integer DEFAULT 0, + created_at timestamptz DEFAULT NOW(), + UNIQUE(external_role_id, module_id) +); + +CREATE INDEX idx_role_module_access_role ON role_module_access(external_role_id); + +-- ============================================ +-- module_actions +-- ============================================ +CREATE TABLE IF NOT EXISTS module_actions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + action_key varchar(50) NOT NULL, + action_name varchar(100) NOT NULL, + description text, + is_active boolean DEFAULT true, + created_at timestamptz DEFAULT NOW(), + UNIQUE(module_id, action_key) +); + +CREATE INDEX idx_module_actions_module ON module_actions(module_id); + +-- ============================================ +-- role_module_permissions +-- ============================================ +CREATE TABLE IF NOT EXISTS role_module_permissions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + external_role_id uuid NOT NULL REFERENCES external_roles(id) ON DELETE CASCADE, + module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + can_view boolean DEFAULT false, + can_list boolean DEFAULT false, + can_create boolean DEFAULT false, + can_update boolean DEFAULT false, + can_delete boolean DEFAULT false, + extra_actions_json jsonb DEFAULT '{}', + created_at timestamptz DEFAULT NOW(), + UNIQUE(external_role_id, module_id) +); + +CREATE INDEX idx_role_module_permissions_role ON role_module_permissions(external_role_id); + +-- ============================================ +-- role_module_widgets +-- ============================================ +CREATE TABLE IF NOT EXISTS role_module_widgets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + external_role_id uuid NOT NULL REFERENCES external_roles(id) ON DELETE CASCADE, + module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + widget_key varchar(50), + is_enabled boolean DEFAULT true, + sort_order integer DEFAULT 0, + created_at timestamptz DEFAULT NOW() +); + +CREATE INDEX idx_role_module_widgets_role ON role_module_widgets(external_role_id); diff --git a/crates/db/migrations/20260420000002_cleanup_role_tables.down.sql b/crates/db/migrations/20260420000002_cleanup_role_tables.down.sql new file mode 100644 index 0000000..cb93261 --- /dev/null +++ b/crates/db/migrations/20260420000002_cleanup_role_tables.down.sql @@ -0,0 +1,21 @@ +-- Rollback Phase 1 cleanup + +-- ============================================ +-- RECREATE: external_roles table +-- ============================================ +CREATE TABLE external_roles ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE +); +CREATE UNIQUE INDEX external_roles_role_id_key ON external_roles(role_id); + +-- ============================================ +-- RENAME BACK: Tables to original names +-- ============================================ +ALTER TABLE internal_role_details RENAME TO internal_roles; +ALTER TABLE role_admin_permissions RENAME TO role_permissions; +ALTER TABLE permission_definitions RENAME TO permissions; +ALTER TABLE role_sidebar_configs RENAME TO dashboard_configs; +ALTER TABLE role_runtime_configs RENAME TO runtime_configs; +ALTER TABLE user_role_assignments RENAME TO user_roles; +ALTER TABLE role_dashboard_widgets RENAME TO dashboard_widgets; diff --git a/crates/db/migrations/20260420000002_cleanup_role_tables.up.sql b/crates/db/migrations/20260420000002_cleanup_role_tables.up.sql new file mode 100644 index 0000000..361a612 --- /dev/null +++ b/crates/db/migrations/20260420000002_cleanup_role_tables.up.sql @@ -0,0 +1,45 @@ +-- Phase 1: Database cleanup - Drop redundant tables and rename for admin clarity +-- Date: 2026-04-20 + +-- ============================================ +-- DROP: Remove redundant external_roles table +-- Reason: roles.audience = 'EXTERNAL' already identifies external roles +-- This table just adds a 1:1 mapping with no extra fields +-- ============================================ +DROP TABLE IF EXISTS external_roles; + +-- ============================================ +-- RENAME: Tables for admin clarity +-- ============================================ + +-- internal_roles → internal_role_details +ALTER TABLE internal_roles RENAME TO internal_role_details; + +-- role_permissions → role_admin_permissions +ALTER TABLE role_permissions RENAME TO role_admin_permissions; + +-- permissions → permission_definitions +ALTER TABLE permissions RENAME TO permission_definitions; + +-- dashboard_configs → role_sidebar_configs +ALTER TABLE dashboard_configs RENAME TO role_sidebar_configs; + +-- runtime_configs → role_runtime_configs +ALTER TABLE runtime_configs RENAME TO role_runtime_configs; + +-- user_roles → user_role_assignments +ALTER TABLE user_roles RENAME TO user_role_assignments; + +-- dashboard_widgets → role_dashboard_widgets +ALTER TABLE dashboard_widgets RENAME TO role_dashboard_widgets; + +-- ============================================ +-- UPDATE: Sequences for renamed tables +-- ============================================ +ALTER SEQUENCE internal_roles_id_seq RENAME TO internal_role_details_id_seq; +ALTER SEQUENCE role_permissions_id_seq RENAME TO role_admin_permissions_id_seq; +ALTER SEQUENCE permissions_id_seq RENAME TO permission_definitions_id_seq; +ALTER SEQUENCE dashboard_configs_id_seq RENAME TO role_sidebar_configs_id_seq; +ALTER SEQUENCE runtime_configs_id_seq RENAME TO role_runtime_configs_id_seq; +ALTER SEQUENCE user_roles_id_seq RENAME TO user_role_assignments_id_seq; +ALTER SEQUENCE dashboard_widgets_id_seq RENAME TO role_dashboard_widgets_id_seq; diff --git a/crates/db/migrations/20260420000003_external_role_modules.down.sql b/crates/db/migrations/20260420000003_external_role_modules.down.sql new file mode 100644 index 0000000..fe5167a --- /dev/null +++ b/crates/db/migrations/20260420000003_external_role_modules.down.sql @@ -0,0 +1,24 @@ +-- Rollback Phase 3: External Role Management - Module System + +-- ============================================ +-- DROP: New module system tables +-- ============================================ +DROP TABLE IF EXISTS role_module_variant_mapping; +DROP TABLE IF EXISTS module_variants; +DROP TABLE IF EXISTS role_module_widgets; +DROP TABLE IF EXISTS role_module_permissions; +DROP TABLE IF EXISTS module_actions; +DROP TABLE IF EXISTS role_module_access; +DROP TABLE IF EXISTS modules; +DROP TABLE IF EXISTS persona_types; + +-- ============================================ +-- REMOVE COLUMNS FROM ROLES +-- ============================================ +ALTER TABLE roles DROP COLUMN IF EXISTS persona_type; +ALTER TABLE roles DROP COLUMN IF EXISTS onboarding_schema_key; +ALTER TABLE roles DROP COLUMN IF EXISTS verification_required; +ALTER TABLE roles DROP COLUMN IF EXISTS switch_services_enabled; +ALTER TABLE roles DROP COLUMN IF EXISTS is_publicly_discoverable; +ALTER TABLE roles DROP COLUMN IF EXISTS external_role_description; +ALTER TABLE roles DROP COLUMN IF EXISTS sort_order; diff --git a/crates/db/migrations/20260420000003_external_role_modules.seed.sql b/crates/db/migrations/20260420000003_external_role_modules.seed.sql new file mode 100644 index 0000000..2fdb3bf --- /dev/null +++ b/crates/db/migrations/20260420000003_external_role_modules.seed.sql @@ -0,0 +1,149 @@ +-- Seed data for External Role Management Module System +-- Phase 3 seed + +-- ============================================ +-- SEED: Persona Types +-- ============================================ +INSERT INTO persona_types (code, name, description) VALUES +('PROFESSIONAL', 'Professional', 'Freelance professionals offering services'), +('COMPANY', 'Company', 'Business accounts posting jobs'), +('JOB_SEEKER', 'Job Seeker', 'Individuals seeking employment'), +('CUSTOMER', 'Customer', 'Customers seeking services') +ON CONFLICT (code) DO NOTHING; + +-- ============================================ +-- SEED: Modules (23 total) +-- ============================================ + +-- Core Shared Modules +INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key, is_core) VALUES +('dashboard_home', 'Dashboard', 'core', 'Dashboard home page with KPIs and widgets', '/dashboard', 'My Dashboard', 'layout-dashboard', true), +('profile', 'Profile', 'core', 'User profile management', '/dashboard/profile', 'My Profile', 'user', true), +('verification', 'Verification', 'core', 'Verification status and documents', '/dashboard/verification', 'Verification', 'shield-check', true), +('help_center', 'Help Center', 'core', 'Help and support', '/dashboard/help', 'Help Center', 'help-circle', true), +('settings', 'Settings', 'core', 'Account settings', '/dashboard/settings', 'Settings', 'settings', true), +('switch_services', 'Switch Services', 'core', 'Switch to different roles', '/dashboard/switch', 'Switch Services', 'repeat', true), +('explore_nxtgauge', 'Explore Nxtgauge', 'core', 'Register for additional roles', '/dashboard/explore', 'Explore Nxtgauge', 'compass', true) +ON CONFLICT (module_key) DO NOTHING; + +-- Content and Identity Modules +INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key, is_core) VALUES +('portfolio', 'Portfolio', 'content', 'Portfolio showcase', '/dashboard/portfolio', 'My Portfolio', 'image', false), +('services', 'Services', 'content', 'Services offered', '/dashboard/services', 'My Services', 'briefcase', false) +ON CONFLICT (module_key) DO NOTHING; + +-- Marketplace and Discovery Modules +INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key, is_core) VALUES +('marketplace', 'Marketplace', 'marketplace', 'Service marketplace', '/dashboard/marketplace', 'Marketplace', 'store', false), +('browse_jobs', 'Browse Jobs', 'marketplace', 'Browse available jobs', '/dashboard/jobs', 'Browse Jobs', 'search', false), +('saved_jobs', 'Saved Jobs', 'marketplace', 'Saved job listings', '/dashboard/saved-jobs', 'Saved Jobs', 'bookmark', false) +ON CONFLICT (module_key) DO NOTHING; + +-- Work and Response Modules +INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key, is_core) VALUES +('jobs', 'Jobs', 'work', 'Job postings management', '/dashboard/jobs', 'Jobs', 'briefcase', false), +('applications', 'Applications', 'work', 'Job applications received', '/dashboard/applications', 'Applications', 'file-text', false), +('my_applications', 'My Applications', 'work', 'My job applications', '/dashboard/my-applications', 'My Applications', 'send', false), +('requirements', 'Requirements', 'work', 'Customer requirements', '/dashboard/requirements', 'Requirements', 'list', false), +('leads', 'Leads', 'work', 'Leads and inquiries', '/dashboard/leads', 'Leads', 'zap', false), +('my_responses', 'My Responses', 'work', 'My responses to requirements', '/dashboard/responses', 'My Responses', 'message-circle', false), +('received_responses', 'Received Responses', 'work', 'Responses to my requirements', '/dashboard/received-responses', 'Received Responses', 'inbox', false), +('shortlisted_candidates', 'Shortlisted Candidates', 'work', 'Shortlisted candidates', '/dashboard/shortlisted', 'Shortlisted Candidates', 'users', false), +('shortlisted_responses', 'Shortlisted Responses', 'work', 'Shortlisted responses', '/dashboard/shortlisted-responses', 'Shortlisted Responses', 'star', false) +ON CONFLICT (module_key) DO NOTHING; + +-- Financial Modules +INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key, is_core) VALUES +('wallet', 'Wallet', 'financial', 'Wallet and payments', '/dashboard/wallet', 'Wallet', 'wallet', false), +('credits', 'Credits', 'financial', 'Credits management', '/dashboard/credits', 'Credits', 'credit-card', false) +ON CONFLICT (module_key) DO NOTHING; + +-- ============================================ +-- SEED: Update external roles with persona_type +-- ============================================ +UPDATE roles SET persona_type = 'COMPANY' WHERE key = 'COMPANY'; +UPDATE roles SET persona_type = 'JOB_SEEKER' WHERE key = 'JOB_SEEKER'; +UPDATE roles SET persona_type = 'CUSTOMER' WHERE key = 'CUSTOMER'; +UPDATE roles SET persona_type = 'PROFESSIONAL' WHERE key IN ('PHOTOGRAPHER', 'MAKEUP_ARTIST', 'TUTOR', 'DEVELOPER', 'VIDEO_EDITOR', 'GRAPHIC_DESIGNER', 'SOCIAL_MEDIA_MANAGER', 'FITNESS_TRAINER', 'CATERING_SERVICES'); + +-- ============================================ +-- SEED: Module Actions (generic CRUD + domain) +-- ============================================ +DO $$ +DECLARE + mod_record RECORD; +BEGIN + FOR mod_record IN SELECT id, module_key FROM modules LOOP + -- Generic CRUD actions + INSERT INTO module_actions (module_id, action_key, action_name, description) + VALUES + (mod_record.id, 'view', 'View', 'View ' || mod_record.module_key) + ON CONFLICT DO NOTHING; + + INSERT INTO module_actions (module_id, action_key, action_name, description) + VALUES + (mod_record.id, 'list', 'List', 'List ' || mod_record.module_key) + ON CONFLICT DO NOTHING; + + INSERT INTO module_actions (module_id, action_key, action_name, description) + VALUES + (mod_record.id, 'create', 'Create', 'Create ' || mod_record.module_key) + ON CONFLICT DO NOTHING; + + INSERT INTO module_actions (module_id, action_key, action_name, description) + VALUES + (mod_record.id, 'update', 'Update', 'Update ' || mod_record.module_key) + ON CONFLICT DO NOTHING; + + INSERT INTO module_actions (module_id, action_key, action_name, description) + VALUES + (mod_record.id, 'delete', 'Delete', 'Delete ' || mod_record.module_key) + ON CONFLICT DO NOTHING; + END LOOP; +END $$; + +-- Domain-specific actions +INSERT INTO module_actions (module_id, action_key, action_name, description) +SELECT m.id, 'publish', 'Publish', 'Publish content' +FROM modules m WHERE m.module_key IN ('portfolio', 'jobs', 'services') +ON CONFLICT DO NOTHING; + +INSERT INTO module_actions (module_id, action_key, action_name, description) +SELECT m.id, 'submit', 'Submit', 'Submit for review' +FROM modules m WHERE m.module_key IN ('verification', 'applications', 'requirements') +ON CONFLICT DO NOTHING; + +INSERT INTO module_actions (module_id, action_key, action_name, description) +SELECT m.id, 'shortlist', 'Shortlist', 'Shortlist item' +FROM modules m WHERE m.module_key IN ('shortlisted_candidates', 'shortlisted_responses') +ON CONFLICT DO NOTHING; + +INSERT INTO module_actions (module_id, action_key, action_name, description) +SELECT m.id, 'respond', 'Respond', 'Respond to requirement' +FROM modules m WHERE m.module_key IN ('my_responses', 'received_responses') +ON CONFLICT DO NOTHING; + +INSERT INTO module_actions (module_id, action_key, action_name, description) +SELECT m.id, 'buy_credits', 'Buy Credits', 'Purchase credits' +FROM modules m WHERE m.module_key = 'credits' +ON CONFLICT DO NOTHING; + +INSERT INTO module_actions (module_id, action_key, action_name, description) +SELECT m.id, 'request_payout', 'Request Payout', 'Request wallet payout' +FROM modules m WHERE m.module_key = 'wallet' +ON CONFLICT DO NOTHING; + +INSERT INTO module_actions (module_id, action_key, action_name, description) +SELECT m.id, 'switch', 'Switch', 'Switch to this service' +FROM modules m WHERE m.module_key = 'switch_services' +ON CONFLICT DO NOTHING; + +INSERT INTO module_actions (module_id, action_key, action_name, description) +SELECT m.id, 'start_onboarding', 'Start Onboarding', 'Start new role onboarding' +FROM modules m WHERE m.module_key = 'explore_nxtgauge' +ON CONFLICT DO NOTHING; + +INSERT INTO module_actions (module_id, action_key, action_name, description) +SELECT m.id, 'resubmit', 'Resubmit', 'Resubmit for verification' +FROM modules m WHERE m.module_key = 'verification' +ON CONFLICT DO NOTHING; diff --git a/crates/db/migrations/20260420000003_external_role_modules.seed2.sql b/crates/db/migrations/20260420000003_external_role_modules.seed2.sql new file mode 100644 index 0000000..ebb440a --- /dev/null +++ b/crates/db/migrations/20260420000003_external_role_modules.seed2.sql @@ -0,0 +1,120 @@ +-- Seed: Default Role Module Access based on spec Section 7 +-- Fixed: removed sort_order reference + +-- ============================================ +-- PROFESSIONAL roles (PHOTOGRAPHER, MAKEUP_ARTIST, TUTOR, DEVELOPER, VIDEO_EDITOR, GRAPHIC_DESIGNER, SOCIAL_MEDIA_MANAGER, FITNESS_TRAINER, CATERING_SERVICES) +-- Enabled: dashboard_home, profile, portfolio, services, marketplace, leads, my_responses, wallet, credits, verification, help_center, settings, switch_services, explore_nxtgauge +-- ============================================ + +INSERT INTO role_module_access (role_id, module_id, is_enabled, is_sidebar_visible, sort_order) +SELECT r.id, m.id, true, true, 0 +FROM roles r +CROSS JOIN modules m +WHERE r.audience = 'EXTERNAL' +AND r.key IN ('PHOTOGRAPHER', 'MAKEUP_ARTIST', 'TUTOR', 'DEVELOPER', 'VIDEO_EDITOR', 'GRAPHIC_DESIGNER', 'SOCIAL_MEDIA_MANAGER', 'FITNESS_TRAINER', 'CATERING_SERVICES') +AND m.module_key IN ('dashboard_home', 'profile', 'portfolio', 'services', 'marketplace', 'leads', 'my_responses', 'wallet', 'credits', 'verification', 'help_center', 'settings', 'switch_services', 'explore_nxtgauge') +ON CONFLICT DO NOTHING; + +-- ============================================ +-- COMPANY role +-- Enabled: dashboard_home, profile, jobs, applications, shortlisted_candidates, credits, verification, help_center, settings, switch_services, explore_nxtgauge +-- ============================================ + +INSERT INTO role_module_access (role_id, module_id, is_enabled, is_sidebar_visible, sort_order) +SELECT r.id, m.id, true, true, 0 +FROM roles r +CROSS JOIN modules m +WHERE r.key = 'COMPANY' +AND m.module_key IN ('dashboard_home', 'profile', 'jobs', 'applications', 'shortlisted_candidates', 'credits', 'verification', 'help_center', 'settings', 'switch_services', 'explore_nxtgauge') +ON CONFLICT DO NOTHING; + +-- ============================================ +-- JOB_SEEKER role +-- Enabled: dashboard_home, profile, portfolio, browse_jobs, my_applications, saved_jobs, verification, help_center, settings, switch_services, explore_nxtgauge +-- ============================================ + +INSERT INTO role_module_access (role_id, module_id, is_enabled, is_sidebar_visible, sort_order) +SELECT r.id, m.id, true, true, 0 +FROM roles r +CROSS JOIN modules m +WHERE r.key = 'JOB_SEEKER' +AND m.module_key IN ('dashboard_home', 'profile', 'portfolio', 'browse_jobs', 'my_applications', 'saved_jobs', 'verification', 'help_center', 'settings', 'switch_services', 'explore_nxtgauge') +ON CONFLICT DO NOTHING; + +-- ============================================ +-- CUSTOMER role +-- Enabled: dashboard_home, profile, requirements, received_responses, shortlisted_responses, credits, verification, help_center, settings, switch_services, explore_nxtgauge +-- ============================================ + +INSERT INTO role_module_access (role_id, module_id, is_enabled, is_sidebar_visible, sort_order) +SELECT r.id, m.id, true, true, 0 +FROM roles r +CROSS JOIN modules m +WHERE r.key = 'CUSTOMER' +AND m.module_key IN ('dashboard_home', 'profile', 'requirements', 'received_responses', 'shortlisted_responses', 'credits', 'verification', 'help_center', 'settings', 'switch_services', 'explore_nxtgauge') +ON CONFLICT DO NOTHING; + +-- ============================================ +-- SEED: Default Role Module Permissions (basic CRUD) +-- All external roles get view/list/create/update on their enabled modules +-- ============================================ + +INSERT INTO role_module_permissions (role_id, module_id, can_view, can_list, can_create, can_update, can_delete) +SELECT rma.role_id, rma.module_id, true, true, true, true, false +FROM role_module_access rma +JOIN roles r ON r.id = rma.role_id +WHERE r.audience = 'EXTERNAL' +ON CONFLICT DO NOTHING; + +-- ============================================ +-- SEED: Module Variants for profile and portfolio +-- ============================================ + +-- Profile variants for PROFESSIONAL roles +INSERT INTO module_variants (module_id, variant_key, variant_name, role_code, persona_type, schema_key, ui_template_key) +SELECT m.id, 'profile.' || LOWER(r.key), 'Profile - ' || r.name, r.key, 'PROFESSIONAL', 'profile_' || LOWER(r.key), 'profile_professional' +FROM modules m +CROSS JOIN roles r +WHERE m.module_key = 'profile' +AND r.persona_type = 'PROFESSIONAL' +ON CONFLICT DO NOTHING; + +-- Portfolio variants for PROFESSIONAL roles +INSERT INTO module_variants (module_id, variant_key, variant_name, role_code, persona_type, schema_key, ui_template_key) +SELECT m.id, 'portfolio.' || LOWER(r.key), 'Portfolio - ' || r.name, r.key, 'PROFESSIONAL', 'portfolio_' || LOWER(r.key), 'portfolio_professional' +FROM modules m +CROSS JOIN roles r +WHERE m.module_key = 'portfolio' +AND r.persona_type = 'PROFESSIONAL' +ON CONFLICT DO NOTHING; + +-- Profile variant for COMPANY +INSERT INTO module_variants (module_id, variant_key, variant_name, role_code, schema_key, ui_template_key) +SELECT m.id, 'profile.company', 'Profile - Company', 'COMPANY', 'profile_company', 'profile_company' +FROM modules m +WHERE m.module_key = 'profile' +ON CONFLICT DO NOTHING; + +-- Profile variant for JOB_SEEKER +INSERT INTO module_variants (module_id, variant_key, variant_name, role_code, schema_key, ui_template_key) +SELECT m.id, 'profile.job_seeker', 'Profile - Job Seeker', 'JOB_SEEKER', 'profile_job_seeker', 'profile_job_seeker' +FROM modules m +WHERE m.module_key = 'profile' +ON CONFLICT DO NOTHING; + +-- Profile variant for CUSTOMER +INSERT INTO module_variants (module_id, variant_key, variant_name, role_code, schema_key, ui_template_key) +SELECT m.id, 'profile.customer', 'Profile - Customer', 'CUSTOMER', 'profile_customer', 'profile_customer' +FROM modules m +WHERE m.module_key = 'profile' +ON CONFLICT DO NOTHING; + +-- ============================================ +-- SEED: Role Module Variant Mappings +-- ============================================ + +INSERT INTO role_module_variant_mapping (role_id, module_id, module_variant_id) +SELECT r.id, mv.module_id, mv.id +FROM module_variants mv +JOIN roles r ON r.key = mv.role_code +ON CONFLICT DO NOTHING; diff --git a/crates/db/migrations/20260420000003_external_role_modules.up.sql b/crates/db/migrations/20260420000003_external_role_modules.up.sql new file mode 100644 index 0000000..e59c454 --- /dev/null +++ b/crates/db/migrations/20260420000003_external_role_modules.up.sql @@ -0,0 +1,157 @@ +-- Phase 3: External Role Management - Module System +-- This migration creates the module registry and role-module mapping +-- Note: external_roles table was dropped - external role settings are now in roles table + +-- ============================================ +-- ADD COLUMNS TO ROLES for external role settings +-- ============================================ +ALTER TABLE roles ADD COLUMN IF NOT EXISTS persona_type varchar(50); +ALTER TABLE roles ADD COLUMN IF NOT EXISTS onboarding_schema_key varchar(100); +ALTER TABLE roles ADD COLUMN IF NOT EXISTS verification_required boolean DEFAULT true; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS switch_services_enabled boolean DEFAULT false; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS is_publicly_discoverable boolean DEFAULT true; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS external_role_description text; +ALTER TABLE roles ADD COLUMN IF NOT EXISTS sort_order integer DEFAULT 0; + +-- ============================================ +-- persona_types (categories for external roles) +-- ============================================ +CREATE TABLE IF NOT EXISTS persona_types ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + code varchar(50) UNIQUE NOT NULL, + name varchar(100) NOT NULL, + description text, + is_active boolean DEFAULT true, + created_at timestamptz DEFAULT NOW(), + updated_at timestamptz DEFAULT NOW() +); + +-- ============================================ +-- modules (module registry) +-- ============================================ +CREATE TABLE IF NOT EXISTS modules ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + module_key varchar(50) UNIQUE NOT NULL, + module_name varchar(100) NOT NULL, + category varchar(50), -- core/content/marketplace/work/financial + description text, + backend_domain varchar(100), + default_route varchar(255), + default_sidebar_label varchar(100), + icon_key varchar(50), + is_core boolean DEFAULT false, + is_active boolean DEFAULT true, + created_at timestamptz DEFAULT NOW(), + updated_at timestamptz DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_modules_category ON modules(category); +CREATE INDEX IF NOT EXISTS idx_modules_active ON modules(is_active); + +-- ============================================ +-- role_module_access (module visibility per role) +-- ============================================ +CREATE TABLE IF NOT EXISTS role_module_access ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + is_enabled boolean DEFAULT true, + is_sidebar_visible boolean DEFAULT true, + sidebar_label_override varchar(100), + route_override varchar(255), + sort_order integer DEFAULT 0, + created_at timestamptz DEFAULT NOW(), + UNIQUE(role_id, module_id) +); + +CREATE INDEX IF NOT EXISTS idx_role_module_access_role ON role_module_access(role_id); +CREATE INDEX IF NOT EXISTS idx_role_module_access_module ON role_module_access(module_id); + +-- ============================================ +-- module_actions (CRUD actions per module) +-- ============================================ +CREATE TABLE IF NOT EXISTS module_actions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + action_key varchar(50) NOT NULL, + action_name varchar(100) NOT NULL, + description text, + is_active boolean DEFAULT true, + created_at timestamptz DEFAULT NOW(), + UNIQUE(module_id, action_key) +); + +CREATE INDEX IF NOT EXISTS idx_module_actions_module ON module_actions(module_id); + +-- ============================================ +-- role_module_permissions (permissions per module per role) +-- ============================================ +CREATE TABLE IF NOT EXISTS role_module_permissions ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + can_view boolean DEFAULT false, + can_list boolean DEFAULT false, + can_create boolean DEFAULT false, + can_update boolean DEFAULT false, + can_delete boolean DEFAULT false, + extra_actions_json jsonb DEFAULT '{}', + created_at timestamptz DEFAULT NOW(), + UNIQUE(role_id, module_id) +); + +CREATE INDEX IF NOT EXISTS idx_role_module_permissions_role ON role_module_permissions(role_id); +CREATE INDEX IF NOT EXISTS idx_role_module_permissions_module ON role_module_permissions(module_id); + +-- ============================================ +-- role_module_widgets (widgets per module per role) +-- ============================================ +CREATE TABLE IF NOT EXISTS role_module_widgets ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + widget_key varchar(50), + is_enabled boolean DEFAULT true, + sort_order integer DEFAULT 0, + created_at timestamptz DEFAULT NOW() +); + +CREATE INDEX IF NOT EXISTS idx_role_module_widgets_role ON role_module_widgets(role_id); +CREATE INDEX IF NOT EXISTS idx_role_module_widgets_module ON role_module_widgets(module_id); + +-- ============================================ +-- module_variants (role-specific module variants) +-- ============================================ +CREATE TABLE IF NOT EXISTS module_variants ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + variant_key varchar(50) NOT NULL, + variant_name varchar(100) NOT NULL, + role_code varchar(50), -- target role (e.g., PHOTOGRAPHER, TUTOR) + persona_type varchar(50), -- target persona (e.g., PROFESSIONAL) + schema_key varchar(100), + ui_template_key varchar(100), + is_active boolean DEFAULT true, + created_at timestamptz DEFAULT NOW(), + updated_at timestamptz DEFAULT NOW(), + UNIQUE(module_id, variant_key) +); + +CREATE INDEX IF NOT EXISTS idx_module_variants_module ON module_variants(module_id); +CREATE INDEX IF NOT EXISTS idx_module_variants_role ON module_variants(role_code); + +-- ============================================ +-- role_module_variant_mapping (which variants a role uses) +-- ============================================ +CREATE TABLE IF NOT EXISTS role_module_variant_mapping ( + id uuid PRIMARY KEY DEFAULT gen_random_uuid(), + role_id uuid NOT NULL REFERENCES roles(id) ON DELETE CASCADE, + module_id uuid NOT NULL REFERENCES modules(id) ON DELETE CASCADE, + module_variant_id uuid NOT NULL REFERENCES module_variants(id) ON DELETE CASCADE, + is_active boolean DEFAULT true, + created_at timestamptz DEFAULT NOW(), + UNIQUE(role_id, module_id, module_variant_id) +); + +CREATE INDEX IF NOT EXISTS idx_role_module_variant_mapping_role ON role_module_variant_mapping(role_id); +CREATE INDEX IF NOT EXISTS idx_role_module_variant_mapping_module ON role_module_variant_mapping(module_id); diff --git a/crates/db/migrations/20260422000000_seed_widgets.seed.sql b/crates/db/migrations/20260422000000_seed_widgets.seed.sql new file mode 100644 index 0000000..871b2e1 --- /dev/null +++ b/crates/db/migrations/20260422000000_seed_widgets.seed.sql @@ -0,0 +1,84 @@ +-- Seed widgets into existing role_sidebar_configs for EXTERNAL roles +-- This ensures the widget-based dashboard renders correctly for all roles. +-- +-- Run this AFTER migrations are applied: +-- psql $DATABASE_URL -f crates/db/migrations/YYYYMMDDTTTTTT_add_widgets_to_sidebar_configs.seed.sql + +-- Update COMPANY roles +UPDATE role_sidebar_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + '["total_jobs", "active_jobs", "pending_jobs", "applications_received", "shortlisted_candidates", "credits"]'::jsonb, + true +) +WHERE audience = 'EXTERNAL' + AND is_active = true + AND EXISTS ( + SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'COMPANY' + ); + +-- Update JOB_SEEKER roles +UPDATE role_sidebar_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + '["available_jobs", "my_applications", "shortlisted", "saved_jobs", "profile_status", "portfolio"]'::jsonb, + true +) +WHERE audience = 'EXTERNAL' + AND is_active = true + AND EXISTS ( + SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'JOB_SEEKER' + ); + +-- Update CUSTOMER roles +UPDATE role_sidebar_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + '["total_requirements", "open_requirements", "closed_requirements", "responses_received", "shortlisted_responses", "credits"]'::jsonb, + true +) +WHERE audience = 'EXTERNAL' + AND is_active = true + AND EXISTS ( + SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'CUSTOMER' + ); + +-- Update all remaining EXTERNAL roles (PROFESSIONAL: photographer, makeup, tutor, etc.) +UPDATE role_sidebar_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + '["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb, + true +) +WHERE audience = 'EXTERNAL' + AND is_active = true + AND NOT EXISTS ( + SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key IN ('COMPANY', 'JOB_SEEKER', 'CUSTOMER') + ); + +-- Also seed widgets in role_runtime_configs if they differ from role_sidebar_configs +-- (some setups read widgets from runtime_configs) +UPDATE role_runtime_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + COALESCE( + ( + SELECT config_json->'widgets' + FROM role_sidebar_configs sc + WHERE sc.role_id = role_runtime_configs.role_id + AND sc.audience = 'EXTERNAL' + AND sc.is_active = true + LIMIT 1 + ), + '["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb + ), + true +) +WHERE is_active = true; + +SELECT 'Widget seed completed.' AS status; diff --git a/crates/db/migrations/20260422000000_seed_widgets.sql b/crates/db/migrations/20260422000000_seed_widgets.sql new file mode 100644 index 0000000..d94bb0f --- /dev/null +++ b/crates/db/migrations/20260422000000_seed_widgets.sql @@ -0,0 +1,167 @@ +-- Seed widgets into existing role_sidebar_configs for all EXTERNAL roles. +-- Run this SQL directly against your database: +-- psql $DATABASE_URL -f seeds/seed_widgets.sql +-- +-- Or run individual sections below. + +-- ── PHOTOGRAPHER ─────────────────────────────────────────────────────────────── +UPDATE role_sidebar_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + '["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb, + true +) +WHERE audience = 'EXTERNAL' + AND is_active = true + AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'PHOTOGRAPHER'); + +-- ── MAKEUP ARTIST ────────────────────────────────────────────────────────────── +UPDATE role_sidebar_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + '["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb, + true +) +WHERE audience = 'EXTERNAL' + AND is_active = true + AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'MAKEUP_ARTIST'); + +-- ── TUTOR ───────────────────────────────────────────────────────────────────── +UPDATE role_sidebar_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + '["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb, + true +) +WHERE audience = 'EXTERNAL' + AND is_active = true + AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'TUTOR'); + +-- ── DEVELOPER ───────────────────────────────────────────────────────────────── +UPDATE role_sidebar_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + '["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb, + true +) +WHERE audience = 'EXTERNAL' + AND is_active = true + AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'DEVELOPER'); + +-- ── VIDEO EDITOR ────────────────────────────────────────────────────────────── +UPDATE role_sidebar_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + '["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb, + true +) +WHERE audience = 'EXTERNAL' + AND is_active = true + AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'VIDEO_EDITOR'); + +-- ── GRAPHIC DESIGNER ───────────────────────────────────────────────────────── +UPDATE role_sidebar_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + '["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb, + true +) +WHERE audience = 'EXTERNAL' + AND is_active = true + AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'GRAPHIC_DESIGNER'); + +-- ── SOCIAL MEDIA MANAGER ───────────────────────────────────────────────────── +UPDATE role_sidebar_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + '["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb, + true +) +WHERE audience = 'EXTERNAL' + AND is_active = true + AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'SOCIAL_MEDIA_MANAGER'); + +-- ── FITNESS TRAINER ────────────────────────────────────────────────────────── +UPDATE role_sidebar_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + '["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb, + true +) +WHERE audience = 'EXTERNAL' + AND is_active = true + AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'FITNESS_TRAINER'); + +-- ── CATERING SERVICES ───────────────────────────────────────────────────────── +UPDATE role_sidebar_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + '["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb, + true +) +WHERE audience = 'EXTERNAL' + AND is_active = true + AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'CATERING_SERVICES'); + +-- ── UGC CONTENT CREATOR ────────────────────────────────────────────────────── +UPDATE role_sidebar_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + '["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb, + true +) +WHERE audience = 'EXTERNAL' + AND is_active = true + AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'UGC_CONTENT_CREATOR'); + +-- ── COMPANY ──────────────────────────────────────────────────────────────────── +UPDATE role_sidebar_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + '["total_jobs", "active_jobs", "pending_jobs", "applications_received", "shortlisted_candidates", "credits"]'::jsonb, + true +) +WHERE audience = 'EXTERNAL' + AND is_active = true + AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'COMPANY'); + +-- ── JOB SEEKER ──────────────────────────────────────────────────────────────── +UPDATE role_sidebar_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + '["available_jobs", "my_applications", "shortlisted", "saved_jobs", "profile_status", "portfolio"]'::jsonb, + true +) +WHERE audience = 'EXTERNAL' + AND is_active = true + AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'JOB_SEEKER'); + +-- ── CUSTOMER ───────────────────────────────────────────────────────────────── +UPDATE role_sidebar_configs +SET config_json = jsonb_set( + COALESCE(config_json, '{}'::jsonb), + '{widgets}', + '["total_requirements", "open_requirements", "closed_requirements", "responses_received", "shortlisted_responses", "credits"]'::jsonb, + true +) +WHERE audience = 'EXTERNAL' + AND is_active = true + AND EXISTS (SELECT 1 FROM roles r WHERE r.id = role_sidebar_configs.role_id AND r.key = 'CUSTOMER'); + +-- Verify the updates +SELECT r.key AS role, sc.config_json->'widgets' AS widgets +FROM role_sidebar_configs sc +JOIN roles r ON r.id = sc.role_id +WHERE sc.audience = 'EXTERNAL' AND sc.is_active = true; diff --git a/crates/db/src/models/config.rs b/crates/db/src/models/config.rs index 9925a7b..ca54265 100644 --- a/crates/db/src/models/config.rs +++ b/crates/db/src/models/config.rs @@ -175,7 +175,7 @@ impl ConfigRepository { ) -> Result { sqlx::query( r#" - UPDATE dashboard_configs + UPDATE role_sidebar_configs SET is_active = false WHERE role_id = $1 AND audience = $2::text AND is_active = true "#, @@ -187,7 +187,7 @@ impl ConfigRepository { let config = sqlx::query_as::<_, DashboardConfig>( r#" - INSERT INTO dashboard_configs (role_id, audience, config_json, is_active) + INSERT INTO role_sidebar_configs (role_id, audience, config_json, is_active) VALUES ( $1, $2::text, @@ -214,7 +214,7 @@ impl ConfigRepository { let config = sqlx::query_as::<_, DashboardConfig>( r#" SELECT id, role_id, audience, config_json, is_active, updated_at - FROM dashboard_configs + FROM role_sidebar_configs WHERE role_id = $1 AND audience = $2 AND is_active = true "#, ) @@ -233,7 +233,7 @@ impl ConfigRepository { r#" SELECT c.id, c.role_id, r.key as role_key, c.audience, c.config_json, c.is_active, c.updated_at - FROM dashboard_configs c + FROM role_sidebar_configs c JOIN roles r ON c.role_id = r.id ORDER BY c.updated_at DESC "#, @@ -252,7 +252,7 @@ impl ConfigRepository { let config = sqlx::query_as::<_, DashboardConfig>( r#" SELECT c.id, c.role_id, c.audience, c.config_json, c.is_active, c.updated_at - FROM dashboard_configs c + FROM role_sidebar_configs c JOIN roles r ON c.role_id = r.id WHERE r.key = $1 AND c.audience = $2 AND c.is_active = true "#, @@ -272,7 +272,7 @@ impl ConfigRepository { // Soft-disable previous active configs for this role sqlx::query( r#" - UPDATE runtime_configs + UPDATE role_runtime_configs SET is_active = false WHERE role_id = $1 AND is_active = true "#, @@ -284,11 +284,11 @@ impl ConfigRepository { // Insert new config let config = sqlx::query_as::<_, RuntimeConfig>( 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 ) RETURNING id, role_id, config_json, version, is_active, updated_at @@ -309,7 +309,7 @@ impl ConfigRepository { let config = sqlx::query_as::<_, RuntimeConfig>( r#" SELECT id, role_id, config_json, version, is_active, updated_at - FROM runtime_configs + FROM role_runtime_configs WHERE role_id = $1 AND is_active = true "#, ) @@ -327,7 +327,7 @@ impl ConfigRepository { let config = sqlx::query_as::<_, RuntimeConfig>( 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.key = $1 AND rc.is_active = true "#, diff --git a/crates/db/src/models/department.rs b/crates/db/src/models/department.rs index 1c696ad..601e54b 100644 --- a/crates/db/src/models/department.rs +++ b/crates/db/src/models/department.rs @@ -19,7 +19,7 @@ pub struct Department { #[derive(Debug, Serialize, Deserialize)] pub struct CreateDepartmentPayload { pub name: String, - pub code: String, + pub code: Option, pub description: Option, pub department_head: Option, pub department_email: Option, @@ -31,6 +31,7 @@ pub struct DepartmentRepository; impl DepartmentRepository { pub async fn create(pool: &PgPool, payload: CreateDepartmentPayload) -> Result { let is_active = payload.status.map(|s| s.to_uppercase() == "ACTIVE").unwrap_or(true); + let code = payload.code.filter(|c| !c.is_empty()).map(|c| c.to_uppercase()); sqlx::query_as::<_, Department>( r#" @@ -43,7 +44,7 @@ impl DepartmentRepository { "# ) .bind(payload.name) - .bind(payload.code.to_uppercase()) + .bind(code) .bind(payload.description) .bind(payload.department_head) .bind(payload.department_email) diff --git a/crates/db/src/models/employee.rs b/crates/db/src/models/employee.rs index 044be94..f831eeb 100644 --- a/crates/db/src/models/employee.rs +++ b/crates/db/src/models/employee.rs @@ -9,6 +9,7 @@ pub struct Employee { pub first_name: String, pub last_name: String, pub email: String, + pub phone: Option, pub password_hash: String, pub employee_code: Option, pub department_id: Option, @@ -35,6 +36,7 @@ pub struct CreateEmployeePayload { pub first_name: String, pub last_name: String, pub email: String, + pub phone: Option, pub password_hash: String, pub department_id: Option, pub designation_id: Option, @@ -48,14 +50,15 @@ impl EmployeeRepository { let level_code = payload.role_code.clone(); sqlx::query_as::<_, Employee>( r#" - INSERT INTO employees (first_name, last_name, email, password_hash, department_id, designation_id, role_code) - VALUES ($1, $2, $3, $4, $5, $6, $7) - RETURNING id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at + INSERT INTO employees (first_name, last_name, email, phone, password_hash, department_id, designation_id, role_code) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + RETURNING id, first_name, last_name, email, phone, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at "# ) .bind(payload.first_name) .bind(payload.last_name) .bind(payload.email.to_lowercase()) + .bind(payload.phone) .bind(payload.password_hash) .bind(payload.department_id) .bind(payload.designation_id) @@ -64,6 +67,28 @@ impl EmployeeRepository { .await } + pub async fn create_with_code(pool: &PgPool, payload: CreateEmployeePayload, employee_code: Option) -> Result { + let role_code = payload.role_code.clone(); + sqlx::query_as::<_, Employee>( + r#" + INSERT INTO employees (first_name, last_name, email, phone, password_hash, employee_code, department_id, designation_id, role_code) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) + RETURNING id, first_name, last_name, email, phone, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at + "# + ) + .bind(payload.first_name) + .bind(payload.last_name) + .bind(payload.email.to_lowercase()) + .bind(payload.phone) + .bind(payload.password_hash) + .bind(employee_code) + .bind(payload.department_id) + .bind(payload.designation_id) + .bind(role_code) + .fetch_one(pool) + .await + } + pub async fn update( pool: &PgPool, id: Uuid, @@ -88,7 +113,7 @@ impl EmployeeRepository { status = COALESCE($7, status), updated_at = NOW() WHERE id = $8 - RETURNING id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at + RETURNING id, first_name, last_name, email, phone, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at "# ) .bind(first_name) @@ -113,7 +138,7 @@ impl EmployeeRepository { pub async fn get_by_email(pool: &PgPool, email: &str) -> Result, sqlx::Error> { sqlx::query_as::<_, Employee>( - "SELECT id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at FROM employees WHERE email = $1" + "SELECT id, first_name, last_name, email, phone, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at FROM employees WHERE email = $1" ) .bind(email.to_lowercase()) .fetch_optional(pool) @@ -124,7 +149,7 @@ impl EmployeeRepository { let search = q.unwrap_or_default().to_lowercase(); sqlx::query_as::<_, Employee>( r#" - SELECT id, first_name, last_name, email, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at + SELECT id, first_name, last_name, email, phone, password_hash, employee_code, department_id, designation_id, role_code, status, joining_date, created_at, updated_at FROM employees WHERE ($1 = '' OR LOWER(first_name) LIKE '%' || $1 || '%' OR LOWER(last_name) LIKE '%' || $1 || '%' OR LOWER(email) LIKE '%' || $1 || '%') ORDER BY last_name, first_name @@ -154,4 +179,23 @@ impl EmployeeRepository { .fetch_one(pool) .await } + + pub async fn change_password( + pool: &PgPool, + id: Uuid, + password_hash: &str, + ) -> Result<(), sqlx::Error> { + sqlx::query( + r#" + UPDATE employees + SET password_hash = $2, updated_at = NOW() + WHERE id = $1 + "# + ) + .bind(id) + .bind(password_hash) + .execute(pool) + .await?; + Ok(()) + } } diff --git a/crates/db/src/models/photographer.rs b/crates/db/src/models/photographer.rs index 9eda354..9b0a36b 100644 --- a/crates/db/src/models/photographer.rs +++ b/crates/db/src/models/photographer.rs @@ -38,7 +38,7 @@ impl PhotographerRepository { pp.created_at, pp.updated_at FROM photographer_profiles pp INNER JOIN user_role_profiles urp ON urp.id = pp.user_role_profile_id - WHERE urp.user_id = $1 AND urp.role_key = 'photographer'"#, + WHERE urp.user_id = $1 AND urp.role_key = 'PHOTOGRAPHER'"#, ) .bind(user_id) .fetch_optional(pool) @@ -47,7 +47,7 @@ impl PhotographerRepository { pub async fn upsert_by_user_id(pool: &PgPool, user_id: Uuid, p: UpsertPhotographerProfilePayload) -> Result { let user_role_profile = sqlx::query_as::<_, (Uuid,)>( - r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'photographer'"#, + r#"SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'PHOTOGRAPHER'"#, ) .bind(user_id) .fetch_optional(pool) diff --git a/crates/email/Cargo.toml b/crates/email/Cargo.toml index d05bd99..eb94152 100644 --- a/crates/email/Cargo.toml +++ b/crates/email/Cargo.toml @@ -8,3 +8,5 @@ lettre = { workspace = true } anyhow = { workspace = true } tracing = { workspace = true } chrono = { workspace = true } +reqwest = { workspace = true } +serde = { workspace = true } diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs index 4cfbb64..40ce0df 100644 --- a/crates/email/src/lib.rs +++ b/crates/email/src/lib.rs @@ -4,9 +4,105 @@ use lettre::{ transport::smtp::authentication::Credentials, AsyncSmtpTransport, AsyncTransport, Message, Tokio1Executor, }; +use reqwest::Client; +use serde::Serialize; use std::collections::HashMap; use std::env; +#[derive(Clone)] +pub enum EmailProvider { + Smtp(AsyncSmtpTransport), + Zeptomail(ZeptomailTransport), +} + +pub struct ZeptomailTransport { + client: Client, + api_key: String, + from_email: String, + from_name: String, + base_url: String, +} + +impl Clone for ZeptomailTransport { + fn clone(&self) -> Self { + Self { + client: Client::new(), + api_key: self.api_key.clone(), + from_email: self.from_email.clone(), + from_name: self.from_name.clone(), + base_url: self.base_url.clone(), + } + } +} + +impl ZeptomailTransport { + pub fn new(api_key: String, from_email: String, from_name: String) -> Self { + Self { + client: Client::new(), + api_key, + from_email, + from_name, + base_url: "https://api.zeptomail.com/v1.1/email".to_string(), + } + } + + pub async fn send(&self, to: &str, subject: &str, html_body: &str) -> Result<()> { + #[derive(Serialize)] + struct ZeptomailRequest<'a> { + from: ZeptomailAddress<'a>, + to: Vec>, + subject: &'a str, + htmlbody: &'a str, + } + + #[derive(Serialize)] + struct ZeptomailAddress<'a> { + address: &'a str, + name: Option<&'a str>, + } + + let request = ZeptomailRequest { + from: ZeptomailAddress { + address: &self.from_email, + name: Some(&self.from_name), + }, + to: vec![ZeptomailAddress { + address: to, + name: None, + }], + subject, + htmlbody: html_body, + }; + + let response = self + .client + .post(&self.base_url) + .header("Accept", "application/json") + .header("Content-Type", "application/json") + .header("Authorization", format!("Zoho-enczapikey {}", self.api_key)) + .json(&request) + .send() + .await?; + + if response.status().is_success() { + tracing::info!("Zeptomail email sent successfully to {}", to); + Ok(()) + } else { + let status = response.status(); + let body = response.text().await.unwrap_or_default(); + tracing::error!("Zeptomail send failed: {} - {}", status, body); + Err(anyhow::anyhow!("Zeptomail send failed: {} - {}", status, body)) + } + } +} + +pub struct Mailer { + provider: Option, + from_email: String, + from_name: String, + template_engine: TemplateEngine, +} + // ── Template Engine ─────────────────────────────────────────────────────────── pub struct TemplateEngine; @@ -102,51 +198,100 @@ impl Default for TemplateEngine { // ── Mailer ──────────────────────────────────────────────────────────────────── -pub struct Mailer { - transport: Option>, - from_email: String, - from_name: String, - template_engine: TemplateEngine, -} - impl Mailer { pub fn new() -> Self { - let smtp_host = env::var("SMTP_HOST").ok(); - let smtp_user = env::var("SMTP_USER").ok(); - let smtp_pass = env::var("SMTP_PASS").ok(); - let smtp_port: u16 = env::var("SMTP_PORT") - .ok() - .and_then(|p| p.parse().ok()) - .unwrap_or(587); + let provider_type = env::var("EMAIL_PROVIDER") + .unwrap_or_else(|_| "SMTP".to_string()) + .to_uppercase(); let from_email = env::var("SMTP_FROM_EMAIL") + .or_else(|_| env::var("ZEPTOMAIL_FROM_EMAIL".to_string())) .unwrap_or_else(|_| "noreply@nxtgauge.com".to_string()); let from_name = env::var("SMTP_FROM_NAME") + .or_else(|_| env::var("ZEPTOMAIL_FROM_NAME".to_string())) .unwrap_or_else(|_| "NXTGAUGE".to_string()); - let transport = match (smtp_host, smtp_user, smtp_pass) { - (Some(host), Some(user), Some(pass)) => { - let creds = Credentials::new(user, pass); - match AsyncSmtpTransport::::starttls_relay(&host) { - Ok(builder) => { - let t = builder.port(smtp_port).credentials(creds).build(); - tracing::info!("SMTP transport configured (host={} port={})", host, smtp_port); - Some(t) + let provider = match provider_type.as_str() { + "ZEPTOMAIL_SMTP" | "ZEPTOMAIL" => { + // Use Zeptomail via SMTP + let smtp_host = env::var("SMTP_HOST").ok(); + let smtp_user = env::var("SMTP_USER").ok(); + let smtp_pass = env::var("SMTP_PASS").ok(); + let smtp_port: u16 = env::var("SMTP_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(587); + + match (smtp_host, smtp_user, smtp_pass) { + (Some(host), Some(user), Some(pass)) => { + let creds = Credentials::new(user, pass); + match AsyncSmtpTransport::::starttls_relay(&host) { + Ok(builder) => { + let t = builder.port(smtp_port).credentials(creds).build(); + tracing::info!("Zeptomail SMTP transport configured (host={} port={})", host, smtp_port); + Some(EmailProvider::Smtp(t)) + } + Err(e) => { + tracing::warn!("Zeptomail SMTP transport init failed: {} — emails disabled", e); + None + } + } } - Err(e) => { - tracing::warn!("SMTP transport init failed: {} — emails disabled", e); + _ => { + tracing::warn!("Zeptomail SMTP not configured — emails disabled"); None } } } + "ZEPTOMAIL_API" => { + // Use Zeptomail via HTTP API + if let (Some(api_key), Some(from)) = ( + env::var("ZEPTOMAIL_API_KEY").ok(), + env::var("ZEPTOMAIL_FROM_EMAIL").ok(), + ) { + let transport = ZeptomailTransport::new(api_key, from.clone(), from_name.clone()); + tracing::info!("Zeptomail API transport configured (from={})", from); + Some(EmailProvider::Zeptomail(transport)) + } else { + tracing::warn!("Zeptomail API selected but not configured — emails disabled"); + None + } + } _ => { - tracing::warn!("SMTP not configured — emails disabled"); - None + // Default to SMTP + let smtp_host = env::var("SMTP_HOST").ok(); + let smtp_user = env::var("SMTP_USER").ok(); + let smtp_pass = env::var("SMTP_PASS").ok(); + let smtp_port: u16 = env::var("SMTP_PORT") + .ok() + .and_then(|p| p.parse().ok()) + .unwrap_or(587); + + match (smtp_host, smtp_user, smtp_pass) { + (Some(host), Some(user), Some(pass)) => { + let creds = Credentials::new(user, pass); + match AsyncSmtpTransport::::starttls_relay(&host) { + Ok(builder) => { + let t = builder.port(smtp_port).credentials(creds).build(); + tracing::info!("SMTP transport configured (host={} port={})", host, smtp_port); + Some(EmailProvider::Smtp(t)) + } + Err(e) => { + tracing::warn!("SMTP transport init failed: {} — emails disabled", e); + None + } + } + } + _ => { + tracing::warn!("SMTP not configured — emails disabled"); + None + } + } } }; Self { - transport, + provider, from_email, from_name, template_engine: TemplateEngine::new(), @@ -154,21 +299,28 @@ impl Mailer { } async fn send_html(&self, to: &str, subject: &str, html_body: String) -> Result<()> { - let Some(transport) = &self.transport else { - return Err(anyhow::anyhow!("SMTP transport not configured")); + let Some(provider) = &self.provider else { + return Err(anyhow::anyhow!("No email provider configured")); }; - let from: Mailbox = format!("{} <{}>", self.from_name, self.from_email).parse()?; - let to: Mailbox = to.parse()?; + match provider { + EmailProvider::Smtp(transport) => { + let from: Mailbox = format!("{} <{}>", self.from_name, self.from_email).parse()?; + let to: Mailbox = to.parse()?; - let email = Message::builder() - .from(from) - .to(to) - .subject(subject) - .header(ContentType::TEXT_HTML) - .body(html_body)?; + let email = Message::builder() + .from(from) + .to(to) + .subject(subject) + .header(ContentType::TEXT_HTML) + .body(html_body)?; - transport.send(email).await?; + transport.send(email).await?; + } + EmailProvider::Zeptomail(transport) => { + transport.send(to, subject, &html_body).await?; + } + } Ok(()) } diff --git a/scripts/seed.sql b/scripts/seed.sql index 3e6727e..d540642 100644 --- a/scripts/seed.sql +++ b/scripts/seed.sql @@ -986,7 +986,7 @@ ON CONFLICT (role_id) WHERE is_active DO UPDATE SET schema_json = EXCLUDED.schem -- ── 4. Default Dashboard Configs ───────────────────────────────────────────── -INSERT INTO dashboard_configs (role_id, audience, config_json, version, is_active) +INSERT INTO role_sidebar_configs (role_id, audience, config_json, version, is_active) SELECT r.id, 'EXTERNAL', jsonb_build_object( @@ -1019,13 +1019,27 @@ SELECT r.id, WHEN 'JOB_SEEKER' THEN '["browse_jobs", "my_applications", "profile"]'::jsonb WHEN 'CUSTOMER' THEN '["requirements", "profile"]'::jsonb ELSE '["marketplace", "leads", "portfolio", "services", "wallet", "profile"]'::jsonb + END, + 'widgets', CASE r.key + WHEN 'COMPANY' THEN '["total_jobs", "active_jobs", "pending_jobs", "applications_received", "shortlisted_candidates", "credits"]'::jsonb + WHEN 'JOB_SEEKER' THEN '["available_jobs", "my_applications", "shortlisted", "saved_jobs", "profile_status", "portfolio"]'::jsonb + WHEN 'CUSTOMER' THEN '["total_requirements", "open_requirements", "closed_requirements", "responses_received", "shortlisted_responses", "credits"]'::jsonb + ELSE '["open_leads", "my_requests", "accepted_requests", "tracecoins", "portfolio", "profile_status"]'::jsonb + END, + 'tabs', CASE r.key + WHEN 'COMPANY' THEN '["overview"]'::jsonb + WHEN 'JOB_SEEKER' THEN '["overview"]'::jsonb + WHEN 'CUSTOMER' THEN '["overview"]'::jsonb + ELSE '["overview"]'::jsonb END ), 1, true FROM roles r WHERE r.audience = 'EXTERNAL' -ON CONFLICT (role_id, audience) WHERE is_active DO NOTHING; +ON CONFLICT (role_id, audience) WHERE is_active DO UPDATE SET + config_json = EXCLUDED.config_json, + version = role_sidebar_configs.version + 1; -- ── Done ────────────────────────────────────────────────────────────────────── SELECT 'Seed completed successfully.' AS status; diff --git a/scripts/seed_external_role_management.sql b/scripts/seed_external_role_management.sql new file mode 100644 index 0000000..e0a0ff2 --- /dev/null +++ b/scripts/seed_external_role_management.sql @@ -0,0 +1,484 @@ +-- Phase 1 Seed Data: Persona Types, External Roles, Modules +-- Run: psql $DATABASE_URL -f scripts/seed_external_role_management.sql + +-- ============================================ +-- Persona Types +-- ============================================ +INSERT INTO persona_types (code, name, description) VALUES + ('PROFESSIONAL', 'Professional', 'Service providers like photographers, tutors, developers'), + ('COMPANY', 'Company', 'Employer/corporate accounts'), + ('JOB_SEEKER', 'Job Seeker', 'Individuals seeking employment'), + ('CUSTOMER', 'Customer', 'Service seekers/customers') +ON CONFLICT (code) DO NOTHING; + +-- ============================================ +-- External Roles +-- ============================================ +INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order) +SELECT 'COMPANY', 'Company', + id, 'Employer/corporate account for posting jobs', 1 +FROM persona_types WHERE code = 'COMPANY' +ON CONFLICT (role_code) DO NOTHING; + +INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order) +SELECT 'JOB_SEEKER', 'Job Seeker', + id, 'Individual seeking employment opportunities', 2 +FROM persona_types WHERE code = 'JOB_SEEKER' +ON CONFLICT (role_code) DO NOTHING; + +INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order) +SELECT 'CUSTOMER', 'Customer', + id, 'Service seeker/customer', 3 +FROM persona_types WHERE code = 'CUSTOMER' +ON CONFLICT (role_code) DO NOTHING; + +INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order) +SELECT 'PHOTOGRAPHER', 'Photographer', + id, 'Professional photographer', 10 +FROM persona_types WHERE code = 'PROFESSIONAL' +ON CONFLICT (role_code) DO NOTHING; + +INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order) +SELECT 'MAKEUP_ARTIST', 'Makeup Artist', + id, 'Professional makeup artist', 11 +FROM persona_types WHERE code = 'PROFESSIONAL' +ON CONFLICT (role_code) DO NOTHING; + +INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order) +SELECT 'TUTOR', 'Tutor', + id, 'Professional tutor/teacher', 12 +FROM persona_types WHERE code = 'PROFESSIONAL' +ON CONFLICT (role_code) DO NOTHING; + +INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order) +SELECT 'DEVELOPER', 'Developer', + id, 'Software developer', 13 +FROM persona_types WHERE code = 'PROFESSIONAL' +ON CONFLICT (role_code) DO NOTHING; + +INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order) +SELECT 'VIDEO_EDITOR', 'Video Editor', + id, 'Professional video editor', 14 +FROM persona_types WHERE code = 'PROFESSIONAL' +ON CONFLICT (role_code) DO NOTHING; + +INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order) +SELECT 'GRAPHIC_DESIGNER', 'Graphic Designer', + id, 'Professional graphic designer', 15 +FROM persona_types WHERE code = 'PROFESSIONAL' +ON CONFLICT (role_code) DO NOTHING; + +INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order) +SELECT 'SOCIAL_MEDIA_MANAGER', 'Social Media Manager', + id, 'Social media management professional', 16 +FROM persona_types WHERE code = 'PROFESSIONAL' +ON CONFLICT (role_code) DO NOTHING; + +INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order) +SELECT 'FITNESS_TRAINER', 'Fitness Trainer', + id, 'Professional fitness trainer', 17 +FROM persona_types WHERE code = 'PROFESSIONAL' +ON CONFLICT (role_code) DO NOTHING; + +INSERT INTO external_roles (role_code, role_name, persona_type_id, description, sort_order) +SELECT 'CATERING_SERVICES', 'Catering Services', + id, 'Catering service provider', 18 +FROM persona_types WHERE code = 'PROFESSIONAL' +ON CONFLICT (role_code) DO NOTHING; + +-- ============================================ +-- Modules (23 total) +-- ============================================ + +-- Core Shared Modules (7) +INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key, is_core) +VALUES + ('dashboard_home', 'Dashboard Home', 'core', 'Dashboard landing page with KPIs and widgets', '/dashboard', 'My Dashboard', 'dashboard', true), + ('profile', 'Profile', 'core', 'User profile management', '/profile', 'My Profile', 'user', true), + ('verification', 'Verification', 'core', 'Verification status and resubmission', '/verification', 'Verification', 'shield', true), + ('help_center', 'Help Center', 'core', 'FAQs and support', '/help-center', 'Help Center', 'help-circle', true), + ('settings', 'Settings', 'core', 'Account settings and preferences', '/settings', 'Settings', 'settings', true), + ('switch_services', 'Switch Services', 'core', 'Switch between approved roles', '/switch-services', 'Switch Services', 'refresh-cw', true), + ('explore_nxtgauge', 'Explore Nxtgauge', 'core', 'Register for additional roles', '/explore', 'Explore Nxtgauge', 'compass', true) +ON CONFLICT (module_key) DO NOTHING; + +-- Content and Identity Modules (2) +INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key) +VALUES + ('portfolio', 'Portfolio', 'content', 'Work samples and showcase', '/portfolio', 'My Portfolio', 'image'), + ('services', 'Services', 'content', 'Service offerings and pricing', '/services', 'My Services', 'briefcase') +ON CONFLICT (module_key) DO NOTHING; + +-- Marketplace and Discovery Modules (3) +INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key) +VALUES + ('marketplace', 'Marketplace', 'marketplace', 'Discover opportunities', '/marketplace', 'Marketplace', 'store'), + ('browse_jobs', 'Browse Jobs', 'marketplace', 'Search and browse jobs', '/browse-jobs', 'Jobs', 'search'), + ('saved_jobs', 'Saved Jobs', 'marketplace', 'Saved job postings', '/saved-jobs', 'Saved Jobs', 'bookmark') +ON CONFLICT (module_key) DO NOTHING; + +-- Work and Response Modules (8) +INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key) +VALUES + ('jobs', 'Jobs', 'work', 'Job postings management', '/jobs', 'Jobs', 'briefcase'), + ('applications', 'Applications', 'work', 'Application management', '/applications', 'Applications', 'file-text'), + ('my_applications', 'My Applications', 'work', 'Track submitted applications', '/my-applications', 'My Applications', 'file-text'), + ('requirements', 'Requirements', 'work', 'Customer requirements', '/requirements', 'My Requirements', 'list'), + ('leads', 'Leads', 'work', 'Lead management', '/leads', 'Leads', 'users'), + ('my_responses', 'My Responses', 'work', 'Track service responses', '/my-responses', 'My Responses', 'send'), + ('received_responses', 'Received Responses', 'work', 'View received responses', '/received-responses', 'Received Responses', 'inbox'), + ('shortlisted_candidates', 'Shortlisted Candidates', 'work', 'Manage shortlisted candidates', '/shortlisted-candidates', 'Shortlisted Candidates', 'star') +ON CONFLICT (module_key) DO NOTHING; + +INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key) +VALUES + ('shortlisted_responses', 'Shortlisted Responses', 'work', 'Manage shortlisted responses', '/shortlisted-responses', 'Shortlisted Responses', 'star') +ON CONFLICT (module_key) DO NOTHING; + +-- Financial Modules (2) +INSERT INTO modules (module_key, module_name, category, description, default_route, default_sidebar_label, icon_key) +VALUES + ('wallet', 'Wallet', 'financial', 'Earnings and payouts', '/wallet', 'Wallet', 'credit-card'), + ('credits', 'Credits', 'financial', 'Credit balance and purchases', '/credits', 'Credits', 'package') +ON CONFLICT (module_key) DO NOTHING; + +-- ============================================ +-- Module Actions (Generic) +-- ============================================ +-- Insert generic CRUD actions for each module +DO $$ +DECLARE + m_record RECORD; + generic_actions TEXT[] := ARRAY['view', 'list', 'create', 'update', 'delete']; + action_name TEXT; + action_key TEXT; +BEGIN + FOR m_record IN SELECT id, module_key FROM modules LOOP + FOREACH action_key IN ARRAY generic_actions LOOP + action_name := INITCAP(action_key); + -- Custom names for some actions + IF action_key = 'list' THEN action_name := 'List'; END IF; + IF action_key = 'create' THEN action_name := 'Create'; END IF; + IF action_key = 'update' THEN action_name := 'Update'; END IF; + IF action_key = 'delete' THEN action_name := 'Delete'; END IF; + IF action_key = 'view' THEN action_name := 'View'; END IF; + + INSERT INTO module_actions (module_id, action_key, action_name, description) + VALUES (m_record.id, action_key, action_name, action_name || ' ' || m_record.module_key) + ON CONFLICT (module_id, action_key) DO NOTHING; + END LOOP; + END LOOP; +END $$; + +-- ============================================ +-- Module Actions (Domain-Specific) +-- ============================================ +-- Add domain-specific actions per module +DO $$ +DECLARE + m_id UUID; +BEGIN + -- dashboard_home + SELECT id INTO m_id FROM modules WHERE module_key = 'dashboard_home'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'customize', 'Customize', 'Customize dashboard widgets') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- profile + SELECT id INTO m_id FROM modules WHERE module_key = 'profile'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'upload_media', 'Upload Media', 'Upload profile photos/documents'), + (m_id, 'preview_profile', 'Preview Profile', 'Preview public profile') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- portfolio + SELECT id INTO m_id FROM modules WHERE module_key = 'portfolio'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'publish_item', 'Publish Item', 'Publish portfolio item'), + (m_id, 'unpublish_item', 'Unpublish Item', 'Unpublish portfolio item'), + (m_id, 'feature_item', 'Feature Item', 'Feature portfolio item'), + (m_id, 'reorder_items', 'Reorder Items', 'Reorder portfolio items') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- services + SELECT id INTO m_id FROM modules WHERE module_key = 'services'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'activate', 'Activate', 'Activate service'), + (m_id, 'deactivate', 'Deactivate', 'Deactivate service') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- jobs + SELECT id INTO m_id FROM modules WHERE module_key = 'jobs'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'publish', 'Publish', 'Publish job posting'), + (m_id, 'close', 'Close', 'Close job posting'), + (m_id, 'archive', 'Archive', 'Archive job posting') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- applications + SELECT id INTO m_id FROM modules WHERE module_key = 'applications'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'shortlist', 'Shortlist', 'Shortlist candidate'), + (m_id, 'reject', 'Reject', 'Reject candidate'), + (m_id, 'review', 'Review', 'Review application') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- leads + SELECT id INTO m_id FROM modules WHERE module_key = 'leads'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'update_status', 'Update Status', 'Update lead status'), + (m_id, 'unlock_contact', 'Unlock Contact', 'Unlock contact information') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- my_responses + SELECT id INTO m_id FROM modules WHERE module_key = 'my_responses'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'withdraw', 'Withdraw', 'Withdraw response') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- requirements + SELECT id INTO m_id FROM modules WHERE module_key = 'requirements'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'publish', 'Publish', 'Publish requirement'), + (m_id, 'close', 'Close', 'Close requirement'), + (m_id, 'reopen', 'Reopen', 'Reopen requirement'), + (m_id, 'archive', 'Archive', 'Archive requirement') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- received_responses + SELECT id INTO m_id FROM modules WHERE module_key = 'received_responses'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'shortlist', 'Shortlist', 'Shortlist response'), + (m_id, 'reject', 'Reject', 'Reject response'), + (m_id, 'compare', 'Compare', 'Compare responses') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- shortlisted_responses + SELECT id INTO m_id FROM modules WHERE module_key = 'shortlisted_responses'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'compare', 'Compare', 'Compare shortlisted responses') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- shortlisted_candidates + SELECT id INTO m_id FROM modules WHERE module_key = 'shortlisted_candidates'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'compare', 'Compare', 'Compare candidates') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- browse_jobs + SELECT id INTO m_id FROM modules WHERE module_key = 'browse_jobs'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'save', 'Save', 'Save job'), + (m_id, 'apply', 'Apply', 'Apply to job') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- saved_jobs + SELECT id INTO m_id FROM modules WHERE module_key = 'saved_jobs'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'apply', 'Apply', 'Apply from saved jobs') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- my_applications + SELECT id INTO m_id FROM modules WHERE module_key = 'my_applications'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'withdraw', 'Withdraw', 'Withdraw application') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- marketplace + SELECT id INTO m_id FROM modules WHERE module_key = 'marketplace'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'respond', 'Respond', 'Respond to opportunity') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- wallet + SELECT id INTO m_id FROM modules WHERE module_key = 'wallet'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'request_payout', 'Request Payout', 'Request wallet payout') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- credits + SELECT id INTO m_id FROM modules WHERE module_key = 'credits'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'buy_credits', 'Buy Credits', 'Purchase credits') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- verification + SELECT id INTO m_id FROM modules WHERE module_key = 'verification'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'resubmit', 'Resubmit', 'Resubmit verification'), + (m_id, 'view_status', 'View Status', 'View verification status') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- explore_nxtgauge + SELECT id INTO m_id FROM modules WHERE module_key = 'explore_nxtgauge'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'start_onboarding', 'Start Onboarding', 'Start new role onboarding'), + (m_id, 'resume_onboarding', 'Resume Onboarding', 'Resume incomplete onboarding'), + (m_id, 'save_draft', 'Save Draft', 'Save onboarding draft'), + (m_id, 'submit_for_verification', 'Submit for Verification', 'Submit application'), + (m_id, 'view_progress', 'View Progress', 'View onboarding progress') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- switch_services + SELECT id INTO m_id FROM modules WHERE module_key = 'switch_services'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'switch', 'Switch', 'Switch to another role') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- help_center + SELECT id INTO m_id FROM modules WHERE module_key = 'help_center'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'search', 'Search', 'Search help articles'), + (m_id, 'ask_help', 'Ask Help', 'Ask for help') + ON CONFLICT (module_id, action_key) DO NOTHING; + + -- settings + SELECT id INTO m_id FROM modules WHERE module_key = 'settings'; + INSERT INTO module_actions (module_id, action_key, action_name, description) VALUES + (m_id, 'manage_sessions', 'Manage Sessions', 'Manage active sessions') + ON CONFLICT (module_id, action_key) DO NOTHING; + +END $$; + +-- ============================================ +-- Default Role Module Access (by Persona) +-- ============================================ + +-- Helper function to get module id by key +DO $$ +DECLARE + pt_rec RECORD; + mod_rec RECORD; + role_id UUID; + mod_id UUID; + sort_int INTEGER := 0; +BEGIN + -- PROFESSIONAL default modules (all 23) + FOR pt_rec IN SELECT id FROM persona_types WHERE code = 'PROFESSIONAL' LOOP + sort_int := 0; + FOR mod_rec IN SELECT id, module_key FROM modules ORDER BY is_core DESC, category, module_key LOOP + SELECT id INTO role_id FROM external_roles WHERE persona_type_id = pt_rec.id LIMIT 1; + IF role_id IS NOT NULL THEN + INSERT INTO role_module_access (external_role_id, module_id, is_enabled, is_sidebar_visible, sort_order) + VALUES (role_id, mod_rec.id, true, true, sort_int) + ON CONFLICT (external_role_id, module_id) DO NOTHING; + sort_int := sort_int + 1; + END IF; + END LOOP; + END LOOP; + + -- COMPANY default modules + FOR pt_rec IN SELECT id FROM persona_types WHERE code = 'COMPANY' LOOP + SELECT id INTO role_id FROM external_roles WHERE persona_type_id = pt_rec.id LIMIT 1; + IF role_id IS NOT NULL THEN + -- dashboard_home, profile, jobs, applications, shortlisted_candidates, credits, verification, help_center, settings, switch_services, explore_nxtgauge + FOREACH mod_rec IN ARRAY ( + SELECT module_key FROM modules WHERE module_key IN ( + 'dashboard_home', 'profile', 'jobs', 'applications', 'shortlisted_candidates', + 'credits', 'verification', 'help_center', 'settings', 'switch_services', 'explore_nxtgauge' + ) + ) LOOP + SELECT id INTO mod_id FROM modules WHERE module_key = mod_rec; + INSERT INTO role_module_access (external_role_id, module_id, is_enabled, is_sidebar_visible, sort_order) + VALUES (role_id, mod_id, true, true, sort_int) + ON CONFLICT (external_role_id, module_id) DO NOTHING; + sort_int := sort_int + 1; + END LOOP; + END IF; + END LOOP; + + -- JOB_SEEKER default modules + FOR pt_rec IN SELECT id FROM persona_types WHERE code = 'JOB_SEEKER' LOOP + SELECT id INTO role_id FROM external_roles WHERE persona_type_id = pt_rec.id LIMIT 1; + IF role_id IS NOT NULL THEN + -- dashboard_home, profile, portfolio, browse_jobs, my_applications, saved_jobs, verification, help_center, settings, switch_services, explore_nxtgauge + FOREACH mod_rec IN ARRAY ( + SELECT module_key FROM modules WHERE module_key IN ( + 'dashboard_home', 'profile', 'portfolio', 'browse_jobs', 'my_applications', 'saved_jobs', + 'verification', 'help_center', 'settings', 'switch_services', 'explore_nxtgauge' + ) + ) LOOP + SELECT id INTO mod_id FROM modules WHERE module_key = mod_rec; + INSERT INTO role_module_access (external_role_id, module_id, is_enabled, is_sidebar_visible, sort_order) + VALUES (role_id, mod_id, true, true, sort_int) + ON CONFLICT (external_role_id, module_id) DO NOTHING; + sort_int := sort_int + 1; + END LOOP; + END IF; + END LOOP; + + -- CUSTOMER default modules + FOR pt_rec IN SELECT id FROM persona_types WHERE code = 'CUSTOMER' LOOP + SELECT id INTO role_id FROM external_roles WHERE persona_type_id = pt_rec.id LIMIT 1; + IF role_id IS NOT NULL THEN + -- dashboard_home, profile, requirements, received_responses, shortlisted_responses, credits, verification, help_center, settings, switch_services, explore_nxtgauge + FOREACH mod_rec IN ARRAY ( + SELECT module_key FROM modules WHERE module_key IN ( + 'dashboard_home', 'profile', 'requirements', 'received_responses', 'shortlisted_responses', + 'credits', 'verification', 'help_center', 'settings', 'switch_services', 'explore_nxtgauge' + ) + ) LOOP + SELECT id INTO mod_id FROM modules WHERE module_key = mod_rec; + INSERT INTO role_module_access (external_role_id, module_id, is_enabled, is_sidebar_visible, sort_order) + VALUES (role_id, mod_id, true, true, sort_int) + ON CONFLICT (external_role_id, module_id) DO NOTHING; + sort_int := sort_int + 1; + END LOOP; + END IF; + END LOOP; +END $$; + +-- ============================================ +-- Default Role Module Permissions +-- ============================================ +-- Set default CRUD permissions based on persona type + +DO $$ +DECLARE + role_rec RECORD; + mod_rec RECORD; + role_id UUID; + mod_id UUID; + can_v BOOLEAN; + can_l BOOLEAN; + can_c BOOLEAN; + can_u BOOLEAN; + can_d BOOLEAN; +BEGIN + -- All roles get view/list on all their enabled modules by default + FOR role_rec IN SELECT id, persona_type_id FROM external_roles LOOP + FOR mod_rec IN SELECT module_id FROM role_module_access WHERE external_role_id = role_rec.id AND is_enabled = true LOOP + role_id := role_rec.id; + mod_id := mod_rec.module_id; + + -- Default: all get view and list + can_v := true; + can_l := true; + can_c := false; + can_u := false; + can_d := false; + + -- Customize defaults per module + IF (SELECT module_key FROM modules WHERE id = mod_id) IN ('dashboard_home', 'profile', 'portfolio', 'services', + 'jobs', 'applications', 'requirements', 'leads', 'my_responses', 'received_responses', + 'shortlisted_candidates', 'shortlisted_responses', 'saved_jobs', 'browse_jobs', 'my_applications') THEN + can_c := true; + can_u := true; + END IF; + + IF (SELECT module_key FROM modules WHERE id = mod_id) IN ('portfolio', 'services', 'jobs', 'requirements') THEN + can_d := true; + END IF; + + INSERT INTO role_module_permissions (external_role_id, module_id, can_view, can_list, can_create, can_update, can_delete) + VALUES (role_id, mod_id, can_v, can_l, can_c, can_u, can_d) + ON CONFLICT (external_role_id, module_id) DO NOTHING; + END LOOP; + END LOOP; +END $$; + +-- ============================================ +-- Update external_roles set switch_services_enabled for roles with multiple personas per user +-- (Will be enabled after user_role_applications system is in place) +-- For now, keep it false +UPDATE external_roles SET switch_services_enabled = false WHERE switch_services_enabled IS NULL OR switch_services_enabled = true; diff --git a/start-services.pid b/start-services.pid new file mode 100644 index 0000000..63406ab --- /dev/null +++ b/start-services.pid @@ -0,0 +1 @@ +71432 From 80d385fa98f5b90d40a47eba296c4a06a492585a Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 28 Apr 2026 19:10:52 +0200 Subject: [PATCH 124/182] chore: trigger gitea pipeline From 57a24f109e1c6f22e93c3ff1f7821cc52d0867d4 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 28 Apr 2026 20:26:23 +0200 Subject: [PATCH 125/182] chore: trigger gitea pipeline From b8236eb40755dd5cfdf9b135a148e30633258863 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 28 Apr 2026 20:51:24 +0200 Subject: [PATCH 126/182] chore: trigger gitea pipeline From acb817b9dade805bdec832b65bfd2cf3191dcfe1 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 28 Apr 2026 20:54:18 +0200 Subject: [PATCH 127/182] fix(ci): use docker host socket in gitea workflow --- .gitea/workflows/build.yaml | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 9528838..8fac88c 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -6,13 +6,11 @@ on: - main - high-performance -env: - DOCKER_HOST: tcp://docker-dind.gitea.svc.cluster.local:2375 - DOCKER_TLS_CERTDIR: "" - jobs: build: runs-on: ubuntu-latest + env: + DOCKER_HOST: unix:///var/run/docker.sock strategy: fail-fast: false matrix: From 8128bd0d303102342d69c3c1ebd52b91a2f95088 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Wed, 29 Apr 2026 09:59:41 +0200 Subject: [PATCH 128/182] fix(pricing): support roleKey alias and leads schema --- apps/customers/src/admin.rs | 6 +++--- apps/users/src/handlers/pricing.rs | 10 +++++++++- 2 files changed, 12 insertions(+), 4 deletions(-) diff --git a/apps/customers/src/admin.rs b/apps/customers/src/admin.rs index 294882f..68247f5 100644 --- a/apps/customers/src/admin.rs +++ b/apps/customers/src/admin.rs @@ -42,10 +42,10 @@ async fn list_leads( ) -> Result { 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 "#, diff --git a/apps/users/src/handlers/pricing.rs b/apps/users/src/handlers/pricing.rs index 5e7f996..fd6882c 100644 --- a/apps/users/src/handlers/pricing.rs +++ b/apps/users/src/handlers/pricing.rs @@ -113,12 +113,20 @@ struct ExistingPackageRow { #[derive(Deserialize)] struct PackageQuery { role: Option, + #[serde(rename = "roleKey", alias = "role_key")] + role_key: Option, } async fn public_list_packages( State(state): State, Query(params): Query, ) -> 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; From a95698cc9425cde2bda6fdc5fd3df3e9d95f725b Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Wed, 29 Apr 2026 10:01:22 +0200 Subject: [PATCH 129/182] ci: build only changed services with registry cache --- .gitea/workflows/build.yaml | 99 +++++++++++++++++++++++++++++-------- 1 file changed, 78 insertions(+), 21 deletions(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 8fac88c..ef3d3cb 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -7,34 +7,89 @@ on: - high-performance jobs: + detect-changes: + runs-on: ubuntu-latest + outputs: + services: ${{ steps.detect.outputs.services }} + has_changes: ${{ steps.detect.outputs.has_changes }} + steps: + - name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + + - name: Detect changed services + id: detect + run: | + set -euo pipefail + + if git rev-parse --verify HEAD^ >/dev/null 2>&1; then + CHANGED_FILES=$(git diff --name-only HEAD^ HEAD) + else + CHANGED_FILES=$(git ls-files) + fi + + 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"]' + + # 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 + echo "services=$ALL_SERVICES" >> "$GITHUB_OUTPUT" + echo "has_changes=true" >> "$GITHUB_OUTPUT" + exit 0 + fi + + SERVICES='[]' + add_service() { + local svc="$1" + SERVICES=$(echo "$SERVICES" | jq --arg s "$svc" 'if index($s) then . else . + [$s] end') + } + + 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 [ "$(echo "$SERVICES" | jq 'length')" -eq 0 ]; then + echo "services=[]" >> "$GITHUB_OUTPUT" + echo "has_changes=false" >> "$GITHUB_OUTPUT" + else + echo "services=$SERVICES" >> "$GITHUB_OUTPUT" + echo "has_changes=true" >> "$GITHUB_OUTPUT" + fi + build: + needs: detect-changes + if: needs.detect-changes.outputs.has_changes == 'true' runs-on: ubuntu-latest env: DOCKER_HOST: unix:///var/run/docker.sock strategy: fail-fast: false matrix: - service: - - gateway - - users - - companies - - jobs - - leads - - job-seekers - - customers - - payments - - employees - - photographers - - makeup-artists - - tutors - - developers - - video-editors - - graphic-designers - - social-media-managers - - fitness-trainers - - catering-services - - ugc-content-creators - - cron + service: ${{ fromJson(needs.detect-changes.outputs.services) }} steps: - name: Checkout uses: actions/checkout@v4 @@ -61,6 +116,8 @@ jobs: 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" \ . From 1212ebf2fb7afce9cd8c47f820dd80413b81d8d2 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Wed, 29 Apr 2026 10:10:07 +0200 Subject: [PATCH 130/182] fix(ci): force docker socket host in build steps --- .gitea/workflows/build.yaml | 58 +++++++++++++++++++++++++++++++------ 1 file changed, 49 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index ef3d3cb..3ce079f 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -10,7 +10,7 @@ jobs: detect-changes: runs-on: ubuntu-latest outputs: - services: ${{ steps.detect.outputs.services }} + services_csv: ${{ steps.detect.outputs.services_csv }} has_changes: ${{ steps.detect.outputs.has_changes }} steps: - name: Checkout @@ -32,19 +32,28 @@ jobs: 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"]' + 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' # 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 - echo "services=$ALL_SERVICES" >> "$GITHUB_OUTPUT" + echo "services_csv=$ALL_SERVICES" >> "$GITHUB_OUTPUT" echo "has_changes=true" >> "$GITHUB_OUTPUT" exit 0 fi - SERVICES='[]' + SERVICES='' add_service() { local svc="$1" - SERVICES=$(echo "$SERVICES" | jq --arg s "$svc" 'if index($s) then . else . + [$s] end') + case ",${SERVICES}," in + *",${svc},"*) ;; + *) + if [ -z "$SERVICES" ]; then + SERVICES="$svc" + else + SERVICES="$SERVICES,$svc" + fi + ;; + esac } while IFS= read -r f; do @@ -72,11 +81,11 @@ jobs: esac done <<< "$CHANGED_FILES" - if [ "$(echo "$SERVICES" | jq 'length')" -eq 0 ]; then - echo "services=[]" >> "$GITHUB_OUTPUT" + if [ -z "$SERVICES" ]; then + echo "services_csv=" >> "$GITHUB_OUTPUT" echo "has_changes=false" >> "$GITHUB_OUTPUT" else - echo "services=$SERVICES" >> "$GITHUB_OUTPUT" + echo "services_csv=$SERVICES" >> "$GITHUB_OUTPUT" echo "has_changes=true" >> "$GITHUB_OUTPUT" fi @@ -89,13 +98,35 @@ jobs: strategy: fail-fast: false matrix: - service: ${{ fromJson(needs.detect-changes.outputs.services) }} + 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 @@ -106,13 +137,22 @@ jobs: REGISTRY_PASSWORD: ${{ secrets.REGISTRY_PASSWORD }} run: | set -euo pipefail + export DOCKER_HOST=unix:///var/run/docker.sock test -n "$REGISTRY_HOSTPORT" echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOSTPORT" -u "$REGISTRY_USERNAME" --password-stdin - 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 + docker buildx build --push \ -f Dockerfile.simple \ --build-arg SERVICE_NAME=${{ matrix.service }} \ From 654754a1070c222a9fb2ec7da79bd1535d94e5ad Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Wed, 29 Apr 2026 10:31:17 +0200 Subject: [PATCH 131/182] fix(ci): make detect outputs compatible with gitea runner --- .gitea/workflows/build.yaml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 3ce079f..167e58c 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -23,6 +23,15 @@ jobs: 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 @@ -36,8 +45,8 @@ jobs: # 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 - echo "services_csv=$ALL_SERVICES" >> "$GITHUB_OUTPUT" - echo "has_changes=true" >> "$GITHUB_OUTPUT" + set_output "services_csv" "$ALL_SERVICES" + set_output "has_changes" "true" exit 0 fi @@ -82,11 +91,11 @@ jobs: done <<< "$CHANGED_FILES" if [ -z "$SERVICES" ]; then - echo "services_csv=" >> "$GITHUB_OUTPUT" - echo "has_changes=false" >> "$GITHUB_OUTPUT" + set_output "services_csv" "" + set_output "has_changes" "false" else - echo "services_csv=$SERVICES" >> "$GITHUB_OUTPUT" - echo "has_changes=true" >> "$GITHUB_OUTPUT" + set_output "services_csv" "$SERVICES" + set_output "has_changes" "true" fi build: From e7a1f346e8781eb6047f6fc442d480589b4e1c02 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Wed, 29 Apr 2026 10:39:56 +0200 Subject: [PATCH 132/182] fix(ci): retry buildx push and fallback without cache export --- .gitea/workflows/build.yaml | 40 +++++++++++++++++++++++++++++-------- 1 file changed, 32 insertions(+), 8 deletions(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 167e58c..f3cf3da 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -162,11 +162,35 @@ jobs: exit 0 fi - 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_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 }}" + build_without_cache_export From d1908821d04f95d4af022b86c6ecffd88eac83a1 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Wed, 29 Apr 2026 11:54:49 +0200 Subject: [PATCH 133/182] chore: trigger gitea pipeline From 4592e77e9f8a31f6f267d79c4fb6fdabb6827a06 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Wed, 29 Apr 2026 11:56:40 +0200 Subject: [PATCH 134/182] fix(ci): force full matrix on trigger commits --- .gitea/workflows/build.yaml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index f3cf3da..8143b24 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -38,11 +38,20 @@ jobs: 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" From 4d168721ddd6bda28ec3f0eaa0ffd6c7738a91c8 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Wed, 29 Apr 2026 12:04:43 +0200 Subject: [PATCH 135/182] fix(ci): retry docker registry login on TLS timeouts --- .gitea/workflows/build.yaml | 12 +++++++++++- 1 file changed, 11 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 8143b24..b49963b 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -157,7 +157,17 @@ jobs: set -euo pipefail export DOCKER_HOST=unix:///var/run/docker.sock test -n "$REGISTRY_HOSTPORT" - echo "$REGISTRY_PASSWORD" | docker login "$REGISTRY_HOSTPORT" -u "$REGISTRY_USERNAME" --password-stdin + 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: From 3917a0577fde04ef4ec4202364b17adbf35ef35e Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 19:44:44 +0200 Subject: [PATCH 136/182] chore: trigger gitea pipeline From 3da03a4ee3b6535c5cb4993a4d25316ef89de6cb Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 19:45:25 +0200 Subject: [PATCH 137/182] chore: trigger gitea pipeline From 3551bdf56da60caaa81a045ff2d40f6f641d2f15 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 19:54:10 +0200 Subject: [PATCH 138/182] chore: trigger gitea pipeline From d9f4a5e5d590364783a7a80ee7497a5d0fbcba08 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 19:57:17 +0200 Subject: [PATCH 139/182] fix(ci): auto-resolve gitea target repo for sync --- .github/workflows/sync-to-gitea.yml | 64 +++++++++++++++++++++++++++-- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index c45546a..b36a70d 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -20,14 +20,70 @@ jobs: env: GITEA_USERNAME: Admin GITEA_TOKEN: ${{ secrets.GITEA_SECRET }} + GITEA_HOST: ci.nxtgauge.com + GITEA_OWNER: ${{ vars.GITEA_OWNER }} run: | set -euo pipefail - echo "Syncing ${{ github.event.repository.name }}:high-performance → Gitea high-performance" + echo "Syncing ${{ github.event.repository.name }}:${{ github.ref_name }} → Gitea ${{ github.ref_name }}" echo "Commit: $(git rev-parse HEAD)" - git remote add gitea "https://${GITEA_USERNAME}:${GITEA_TOKEN}@ci.nxtgauge.com/Admin/${{ github.event.repository.name }}.git" + if [ -z "${GITEA_TOKEN:-}" ]; then + echo "Missing GITEA_SECRET" + exit 1 + fi - git fetch gitea high-performance || true - git push gitea HEAD:high-performance --force-with-lease=refs/heads/high-performance + REPO_NAME="${{ github.event.repository.name }}" + BRANCH_NAME="${{ github.ref_name }}" + CANDIDATE_OWNERS="${GITEA_OWNER:-} Admin ${{ github.repository_owner }}" + TARGET_URL="" + + for owner in $CANDIDATE_OWNERS; do + [ -n "$owner" ] || continue + candidate_url="https://${GITEA_USERNAME}:${GITEA_TOKEN}@${GITEA_HOST}/${owner}/${REPO_NAME}.git" + if git ls-remote "$candidate_url" >/dev/null 2>&1; then + TARGET_URL="$candidate_url" + echo "Using Gitea target owner: $owner" + break + fi + done + + if [ -z "$TARGET_URL" ]; then + echo "Owner guess failed; searching accessible repos via Gitea API" + API_URL="https://${GITEA_HOST}/api/v1/repos/search?q=${REPO_NAME}&limit=100" + TARGET_URL=$(python3 - <<'PY' +import json +import os +import urllib.request + +api_url = os.environ["API_URL"] +token = os.environ["GITEA_TOKEN"] +repo_name = os.environ["REPO_NAME"] +username = os.environ["GITEA_USERNAME"] + +req = urllib.request.Request(api_url, headers={"Authorization": f"token {token}"}) +with urllib.request.urlopen(req, timeout=20) as resp: + data = json.loads(resp.read().decode("utf-8")) + +for repo in data.get("data", []): + full_name = str(repo.get("full_name", "")) + if full_name.lower().endswith("/" + repo_name.lower()): + print(f"https://{username}:{token}@{os.environ['GITEA_HOST']}/{full_name}.git") + break +PY +) + if [ -n "$TARGET_URL" ]; then + echo "Resolved Gitea target via API" + fi + fi + + if [ -z "$TARGET_URL" ]; then + echo "Could not access target repo on Gitea for owners: $CANDIDATE_OWNERS" + exit 1 + fi + + git remote add gitea "$TARGET_URL" + + git fetch gitea "$BRANCH_NAME" || true + git push gitea "HEAD:${BRANCH_NAME}" --force-with-lease=refs/heads/"$BRANCH_NAME" echo "Sync complete!" From 827302446cfbba7ee29ad173db3b1b8712dea796 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 19:59:50 +0200 Subject: [PATCH 140/182] fix(ci): correct yaml-safe gitea API fallback script --- .github/workflows/sync-to-gitea.yml | 27 +++++++++++++++------------ 1 file changed, 15 insertions(+), 12 deletions(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index b36a70d..7e6cc40 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -50,27 +50,30 @@ jobs: if [ -z "$TARGET_URL" ]; then echo "Owner guess failed; searching accessible repos via Gitea API" API_URL="https://${GITEA_HOST}/api/v1/repos/search?q=${REPO_NAME}&limit=100" - TARGET_URL=$(python3 - <<'PY' + TARGET_URL="$(python3 - <<'PY' import json import os import urllib.request -api_url = os.environ["API_URL"] -token = os.environ["GITEA_TOKEN"] -repo_name = os.environ["REPO_NAME"] -username = os.environ["GITEA_USERNAME"] - -req = urllib.request.Request(api_url, headers={"Authorization": f"token {token}"}) +req = urllib.request.Request( + os.environ["API_URL"], + headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}"}, +) with urllib.request.urlopen(req, timeout=20) as resp: - data = json.loads(resp.read().decode("utf-8")) + payload = json.loads(resp.read().decode("utf-8")) -for repo in data.get("data", []): +repo_name = os.environ["REPO_NAME"].lower() +username = os.environ["GITEA_USERNAME"] +host = os.environ["GITEA_HOST"] +token = os.environ["GITEA_TOKEN"] + +for repo in payload.get("data", []): full_name = str(repo.get("full_name", "")) - if full_name.lower().endswith("/" + repo_name.lower()): - print(f"https://{username}:{token}@{os.environ['GITEA_HOST']}/{full_name}.git") + if full_name.lower().endswith("/" + repo_name): + print(f"https://{username}:{token}@{host}/{full_name}.git") break PY -) +)" if [ -n "$TARGET_URL" ]; then echo "Resolved Gitea target via API" fi From 9485175893a3c706adc419124e2507c7796aac28 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 20:02:08 +0200 Subject: [PATCH 141/182] fix(ci): replace inline python with curl+jq api fallback --- .github/workflows/sync-to-gitea.yml | 29 +++++------------------------ 1 file changed, 5 insertions(+), 24 deletions(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index 7e6cc40..615536e 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -50,30 +50,11 @@ jobs: if [ -z "$TARGET_URL" ]; then echo "Owner guess failed; searching accessible repos via Gitea API" API_URL="https://${GITEA_HOST}/api/v1/repos/search?q=${REPO_NAME}&limit=100" - TARGET_URL="$(python3 - <<'PY' -import json -import os -import urllib.request - -req = urllib.request.Request( - os.environ["API_URL"], - headers={"Authorization": f"token {os.environ['GITEA_TOKEN']}"}, -) -with urllib.request.urlopen(req, timeout=20) as resp: - payload = json.loads(resp.read().decode("utf-8")) - -repo_name = os.environ["REPO_NAME"].lower() -username = os.environ["GITEA_USERNAME"] -host = os.environ["GITEA_HOST"] -token = os.environ["GITEA_TOKEN"] - -for repo in payload.get("data", []): - full_name = str(repo.get("full_name", "")) - if full_name.lower().endswith("/" + repo_name): - print(f"https://{username}:{token}@{host}/{full_name}.git") - break -PY -)" + API_JSON="$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" "${API_URL}" || true)" + TARGET_FULL_NAME="$(printf '%s' "$API_JSON" | jq -r --arg repo "$REPO_NAME" '[.data[]?.full_name | select((ascii_downcase | endswith("/" + ($repo | ascii_downcase))))][0] // empty')" + if [ -n "$TARGET_FULL_NAME" ]; then + TARGET_URL="https://${GITEA_USERNAME}:${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git" + fi if [ -n "$TARGET_URL" ]; then echo "Resolved Gitea target via API" fi From 67994b24dd23aaaa8c4b8b3493d2ff59ccb7cc88 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 20:08:49 +0200 Subject: [PATCH 142/182] fix(ci): try multiple gitea auth url formats --- .github/workflows/sync-to-gitea.yml | 31 +++++++++++++++++++++-------- 1 file changed, 23 insertions(+), 8 deletions(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index 615536e..99869eb 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -35,36 +35,51 @@ jobs: REPO_NAME="${{ github.event.repository.name }}" BRANCH_NAME="${{ github.ref_name }}" CANDIDATE_OWNERS="${GITEA_OWNER:-} Admin ${{ github.repository_owner }}" + TARGET_FULL_NAME="" TARGET_URL="" for owner in $CANDIDATE_OWNERS; do [ -n "$owner" ] || continue - candidate_url="https://${GITEA_USERNAME}:${GITEA_TOKEN}@${GITEA_HOST}/${owner}/${REPO_NAME}.git" - if git ls-remote "$candidate_url" >/dev/null 2>&1; then - TARGET_URL="$candidate_url" + candidate_full_name="${owner}/${REPO_NAME}" + candidate_url="https://${GITEA_HOST}/${candidate_full_name}.git" + if git ls-remote "https://${GITEA_USERNAME}:${GITEA_TOKEN}@${GITEA_HOST}/${candidate_full_name}.git" >/dev/null 2>&1; then + TARGET_FULL_NAME="$candidate_full_name" echo "Using Gitea target owner: $owner" break fi done - if [ -z "$TARGET_URL" ]; then + if [ -z "$TARGET_FULL_NAME" ]; then echo "Owner guess failed; searching accessible repos via Gitea API" API_URL="https://${GITEA_HOST}/api/v1/repos/search?q=${REPO_NAME}&limit=100" API_JSON="$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" "${API_URL}" || true)" TARGET_FULL_NAME="$(printf '%s' "$API_JSON" | jq -r --arg repo "$REPO_NAME" '[.data[]?.full_name | select((ascii_downcase | endswith("/" + ($repo | ascii_downcase))))][0] // empty')" if [ -n "$TARGET_FULL_NAME" ]; then - TARGET_URL="https://${GITEA_USERNAME}:${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git" - fi - if [ -n "$TARGET_URL" ]; then echo "Resolved Gitea target via API" fi fi - if [ -z "$TARGET_URL" ]; then + if [ -z "$TARGET_FULL_NAME" ]; then echo "Could not access target repo on Gitea for owners: $CANDIDATE_OWNERS" exit 1 fi + for auth_url in \ + "https://${GITEA_USERNAME}:${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git" \ + "https://${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git" \ + "https://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git"; do + if git ls-remote "$auth_url" >/dev/null 2>&1; then + TARGET_URL="$auth_url" + echo "Using Gitea credential mode for ${TARGET_FULL_NAME}" + break + fi + done + + if [ -z "$TARGET_URL" ]; then + echo "Resolved repo path but authentication to Gitea git remote failed" + exit 1 + fi + git remote add gitea "$TARGET_URL" git fetch gitea "$BRANCH_NAME" || true From d2b0cce75a2c4272309f8a3fc4e01e9757395917 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 20:13:35 +0200 Subject: [PATCH 143/182] fix(ci): derive gitea login from token and retry auth modes --- .github/workflows/sync-to-gitea.yml | 27 ++++++++++++++++++++++----- 1 file changed, 22 insertions(+), 5 deletions(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index 99869eb..981de93 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -34,6 +34,11 @@ jobs: REPO_NAME="${{ github.event.repository.name }}" BRANCH_NAME="${{ github.ref_name }}" + API_USER_JSON="$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" "https://${GITEA_HOST}/api/v1/user" || true)" + RESOLVED_GITEA_USER="$(printf '%s' "$API_USER_JSON" | jq -r '.login // empty')" + if [ -n "$RESOLVED_GITEA_USER" ]; then + echo "Resolved token user: $RESOLVED_GITEA_USER" + fi CANDIDATE_OWNERS="${GITEA_OWNER:-} Admin ${{ github.repository_owner }}" TARGET_FULL_NAME="" TARGET_URL="" @@ -64,17 +69,29 @@ jobs: exit 1 fi - for auth_url in \ - "https://${GITEA_USERNAME}:${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git" \ - "https://${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git" \ - "https://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git"; do + for auth_user in "$RESOLVED_GITEA_USER" "$GITEA_USERNAME" "oauth2"; do + [ -n "$auth_user" ] || continue + auth_url="https://${auth_user}:${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git" if git ls-remote "$auth_url" >/dev/null 2>&1; then TARGET_URL="$auth_url" - echo "Using Gitea credential mode for ${TARGET_FULL_NAME}" + echo "Using Gitea credential mode for ${TARGET_FULL_NAME} as ${auth_user}" break fi done + if [ -z "$TARGET_URL" ]; then + for auth_url in \ + "https://${GITEA_USERNAME}:${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git" \ + "https://${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git" \ + "https://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git"; do + if git ls-remote "$auth_url" >/dev/null 2>&1; then + TARGET_URL="$auth_url" + echo "Using Gitea credential mode for ${TARGET_FULL_NAME}" + break + fi + done + fi + if [ -z "$TARGET_URL" ]; then echo "Resolved repo path but authentication to Gitea git remote failed" exit 1 From 6591b001c7adf708665c836326849287dd166814 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 20:15:29 +0200 Subject: [PATCH 144/182] fix(ci): use GITEA_USERNAME secret for git auth --- .github/workflows/sync-to-gitea.yml | 10 +++------- 1 file changed, 3 insertions(+), 7 deletions(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index 981de93..5e2303d 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -18,7 +18,7 @@ jobs: - name: Push to Gitea high-performance env: - GITEA_USERNAME: Admin + GITEA_USERNAME: ${{ secrets.GITEA_USERNAME }} GITEA_TOKEN: ${{ secrets.GITEA_SECRET }} GITEA_HOST: ci.nxtgauge.com GITEA_OWNER: ${{ vars.GITEA_OWNER }} @@ -34,11 +34,7 @@ jobs: REPO_NAME="${{ github.event.repository.name }}" BRANCH_NAME="${{ github.ref_name }}" - API_USER_JSON="$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" "https://${GITEA_HOST}/api/v1/user" || true)" - RESOLVED_GITEA_USER="$(printf '%s' "$API_USER_JSON" | jq -r '.login // empty')" - if [ -n "$RESOLVED_GITEA_USER" ]; then - echo "Resolved token user: $RESOLVED_GITEA_USER" - fi + RESOLVED_GITEA_USER="" CANDIDATE_OWNERS="${GITEA_OWNER:-} Admin ${{ github.repository_owner }}" TARGET_FULL_NAME="" TARGET_URL="" @@ -69,7 +65,7 @@ jobs: exit 1 fi - for auth_user in "$RESOLVED_GITEA_USER" "$GITEA_USERNAME" "oauth2"; do + for auth_user in "$GITEA_USERNAME" "oauth2"; do [ -n "$auth_user" ] || continue auth_url="https://${auth_user}:${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git" if git ls-remote "$auth_url" >/dev/null 2>&1; then From 11408d8a9808740f2b893beef9c8f332b885dc3e Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 20:24:43 +0200 Subject: [PATCH 145/182] chore: trigger gitea pipeline From 017c550b9672bb48ff292506438520beac2ac86c Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 20:33:51 +0200 Subject: [PATCH 146/182] fix(ci): prefer token owner login for gitea git auth --- .github/workflows/sync-to-gitea.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index 5e2303d..d2d998d 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -34,6 +34,13 @@ jobs: REPO_NAME="${{ github.event.repository.name }}" BRANCH_NAME="${{ github.ref_name }}" + API_USER_JSON="$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" "https://${GITEA_HOST}/api/v1/user" || curl -fsS -H "Authorization: Bearer ${GITEA_TOKEN}" "https://${GITEA_HOST}/api/v1/user" || true)" + TOKEN_OWNER_USER="$(printf '%s' "$API_USER_JSON" | jq -r '.login // empty')" + if [ -n "$TOKEN_OWNER_USER" ]; then + echo "Resolved token owner user: $TOKEN_OWNER_USER" + else + echo "Could not resolve token owner via API; using configured username fallbacks" + fi RESOLVED_GITEA_USER="" CANDIDATE_OWNERS="${GITEA_OWNER:-} Admin ${{ github.repository_owner }}" TARGET_FULL_NAME="" @@ -65,7 +72,7 @@ jobs: exit 1 fi - for auth_user in "$GITEA_USERNAME" "oauth2"; do + for auth_user in "$TOKEN_OWNER_USER" "$GITEA_USERNAME" "oauth2"; do [ -n "$auth_user" ] || continue auth_url="https://${auth_user}:${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git" if git ls-remote "$auth_url" >/dev/null 2>&1; then From 6a22b107ba056d3785c997e994e1167e3b728f67 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 20:38:18 +0200 Subject: [PATCH 147/182] fix(ci): use basic auth header for gitea git operations --- .github/workflows/sync-to-gitea.yml | 21 +++++++++++++++------ 1 file changed, 15 insertions(+), 6 deletions(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index d2d998d..be0c30e 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -72,11 +72,14 @@ jobs: exit 1 fi + TARGET_REMOTE_URL="https://${GITEA_HOST}/${TARGET_FULL_NAME}.git" + for auth_user in "$TOKEN_OWNER_USER" "$GITEA_USERNAME" "oauth2"; do [ -n "$auth_user" ] || continue - auth_url="https://${auth_user}:${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git" - if git ls-remote "$auth_url" >/dev/null 2>&1; then - TARGET_URL="$auth_url" + AUTH_B64="$(printf '%s' "${auth_user}:${GITEA_TOKEN}" | base64 | tr -d '\n')" + if git -c http.extraHeader="Authorization: Basic ${AUTH_B64}" ls-remote "$TARGET_REMOTE_URL" >/dev/null 2>&1; then + TARGET_URL="$TARGET_REMOTE_URL" + TARGET_HTTP_AUTH_HEADER="Authorization: Basic ${AUTH_B64}" echo "Using Gitea credential mode for ${TARGET_FULL_NAME} as ${auth_user}" break fi @@ -88,7 +91,8 @@ jobs: "https://${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git" \ "https://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git"; do if git ls-remote "$auth_url" >/dev/null 2>&1; then - TARGET_URL="$auth_url" + TARGET_URL="$TARGET_REMOTE_URL" + TARGET_HTTP_AUTH_HEADER="" echo "Using Gitea credential mode for ${TARGET_FULL_NAME}" break fi @@ -102,7 +106,12 @@ jobs: git remote add gitea "$TARGET_URL" - git fetch gitea "$BRANCH_NAME" || true - git push gitea "HEAD:${BRANCH_NAME}" --force-with-lease=refs/heads/"$BRANCH_NAME" + if [ -n "${TARGET_HTTP_AUTH_HEADER:-}" ]; then + git -c http.extraHeader="$TARGET_HTTP_AUTH_HEADER" fetch gitea "$BRANCH_NAME" || true + git -c http.extraHeader="$TARGET_HTTP_AUTH_HEADER" push gitea "HEAD:${BRANCH_NAME}" --force-with-lease=refs/heads/"$BRANCH_NAME" + else + git fetch gitea "$BRANCH_NAME" || true + git push gitea "HEAD:${BRANCH_NAME}" --force-with-lease=refs/heads/"$BRANCH_NAME" + fi echo "Sync complete!" From d1ec7f4c2d26a00fda06997643b483223356ead6 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 20:45:49 +0200 Subject: [PATCH 148/182] fix(ci): hardcode admin gitea sync remote --- .github/workflows/sync-to-gitea.yml | 102 +++------------------------- 1 file changed, 11 insertions(+), 91 deletions(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index be0c30e..07fb00a 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -16,102 +16,22 @@ jobs: with: fetch-depth: 0 - - name: Push to Gitea high-performance + - name: Sync to Gitea env: - GITEA_USERNAME: ${{ secrets.GITEA_USERNAME }} GITEA_TOKEN: ${{ secrets.GITEA_SECRET }} - GITEA_HOST: ci.nxtgauge.com - GITEA_OWNER: ${{ vars.GITEA_OWNER }} + REPO: ${{ github.event.repository.name }} + BRANCH: ${{ github.ref_name }} run: | set -euo pipefail - echo "Syncing ${{ github.event.repository.name }}:${{ github.ref_name }} → Gitea ${{ github.ref_name }}" - echo "Commit: $(git rev-parse HEAD)" + git config user.name "github-actions[bot]" + git config user.email "github-actions[bot]@users.noreply.github.com" - if [ -z "${GITEA_TOKEN:-}" ]; then - echo "Missing GITEA_SECRET" - exit 1 - fi + GITEA_URL="https://Admin:${GITEA_TOKEN}@ci.nxtgauge.com/Admin/${REPO}.git" - REPO_NAME="${{ github.event.repository.name }}" - BRANCH_NAME="${{ github.ref_name }}" - API_USER_JSON="$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" "https://${GITEA_HOST}/api/v1/user" || curl -fsS -H "Authorization: Bearer ${GITEA_TOKEN}" "https://${GITEA_HOST}/api/v1/user" || true)" - TOKEN_OWNER_USER="$(printf '%s' "$API_USER_JSON" | jq -r '.login // empty')" - if [ -n "$TOKEN_OWNER_USER" ]; then - echo "Resolved token owner user: $TOKEN_OWNER_USER" - else - echo "Could not resolve token owner via API; using configured username fallbacks" - fi - RESOLVED_GITEA_USER="" - CANDIDATE_OWNERS="${GITEA_OWNER:-} Admin ${{ github.repository_owner }}" - TARGET_FULL_NAME="" - TARGET_URL="" + git remote remove gitea 2>/dev/null || true + git remote add gitea "${GITEA_URL}" - for owner in $CANDIDATE_OWNERS; do - [ -n "$owner" ] || continue - candidate_full_name="${owner}/${REPO_NAME}" - candidate_url="https://${GITEA_HOST}/${candidate_full_name}.git" - if git ls-remote "https://${GITEA_USERNAME}:${GITEA_TOKEN}@${GITEA_HOST}/${candidate_full_name}.git" >/dev/null 2>&1; then - TARGET_FULL_NAME="$candidate_full_name" - echo "Using Gitea target owner: $owner" - break - fi - done + git ls-remote "${GITEA_URL}" >/dev/null - if [ -z "$TARGET_FULL_NAME" ]; then - echo "Owner guess failed; searching accessible repos via Gitea API" - API_URL="https://${GITEA_HOST}/api/v1/repos/search?q=${REPO_NAME}&limit=100" - API_JSON="$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" "${API_URL}" || true)" - TARGET_FULL_NAME="$(printf '%s' "$API_JSON" | jq -r --arg repo "$REPO_NAME" '[.data[]?.full_name | select((ascii_downcase | endswith("/" + ($repo | ascii_downcase))))][0] // empty')" - if [ -n "$TARGET_FULL_NAME" ]; then - echo "Resolved Gitea target via API" - fi - fi - - if [ -z "$TARGET_FULL_NAME" ]; then - echo "Could not access target repo on Gitea for owners: $CANDIDATE_OWNERS" - exit 1 - fi - - TARGET_REMOTE_URL="https://${GITEA_HOST}/${TARGET_FULL_NAME}.git" - - for auth_user in "$TOKEN_OWNER_USER" "$GITEA_USERNAME" "oauth2"; do - [ -n "$auth_user" ] || continue - AUTH_B64="$(printf '%s' "${auth_user}:${GITEA_TOKEN}" | base64 | tr -d '\n')" - if git -c http.extraHeader="Authorization: Basic ${AUTH_B64}" ls-remote "$TARGET_REMOTE_URL" >/dev/null 2>&1; then - TARGET_URL="$TARGET_REMOTE_URL" - TARGET_HTTP_AUTH_HEADER="Authorization: Basic ${AUTH_B64}" - echo "Using Gitea credential mode for ${TARGET_FULL_NAME} as ${auth_user}" - break - fi - done - - if [ -z "$TARGET_URL" ]; then - for auth_url in \ - "https://${GITEA_USERNAME}:${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git" \ - "https://${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git" \ - "https://oauth2:${GITEA_TOKEN}@${GITEA_HOST}/${TARGET_FULL_NAME}.git"; do - if git ls-remote "$auth_url" >/dev/null 2>&1; then - TARGET_URL="$TARGET_REMOTE_URL" - TARGET_HTTP_AUTH_HEADER="" - echo "Using Gitea credential mode for ${TARGET_FULL_NAME}" - break - fi - done - fi - - if [ -z "$TARGET_URL" ]; then - echo "Resolved repo path but authentication to Gitea git remote failed" - exit 1 - fi - - git remote add gitea "$TARGET_URL" - - if [ -n "${TARGET_HTTP_AUTH_HEADER:-}" ]; then - git -c http.extraHeader="$TARGET_HTTP_AUTH_HEADER" fetch gitea "$BRANCH_NAME" || true - git -c http.extraHeader="$TARGET_HTTP_AUTH_HEADER" push gitea "HEAD:${BRANCH_NAME}" --force-with-lease=refs/heads/"$BRANCH_NAME" - else - git fetch gitea "$BRANCH_NAME" || true - git push gitea "HEAD:${BRANCH_NAME}" --force-with-lease=refs/heads/"$BRANCH_NAME" - fi - - echo "Sync complete!" + git push "${GITEA_URL}" "HEAD:${BRANCH}" --force + git push "${GITEA_URL}" --tags --force From bcff2ffba2c4a9f1d9657edb667676996fb25497 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 20:48:06 +0200 Subject: [PATCH 149/182] fix(ci): support GITEA_TOKEN secret with fallback --- .github/workflows/sync-to-gitea.yml | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index 07fb00a..7b71a3e 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -18,15 +18,22 @@ jobs: - name: Sync to Gitea env: - GITEA_TOKEN: ${{ secrets.GITEA_SECRET }} + GITEA_TOKEN_PRIMARY: ${{ secrets.GITEA_TOKEN }} + GITEA_TOKEN_FALLBACK: ${{ secrets.GITEA_SECRET }} REPO: ${{ github.event.repository.name }} BRANCH: ${{ github.ref_name }} run: | set -euo pipefail + GITEA_TOKEN="${GITEA_TOKEN_PRIMARY:-${GITEA_TOKEN_FALLBACK:-}}" + if [ -z "${GITEA_TOKEN}" ]; then + echo "Missing GITEA token secret. Set GITEA_TOKEN (preferred) or GITEA_SECRET." + exit 1 + fi git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" GITEA_URL="https://Admin:${GITEA_TOKEN}@ci.nxtgauge.com/Admin/${REPO}.git" + echo "Sync target: Admin/${REPO}.git on branch ${BRANCH}" git remote remove gitea 2>/dev/null || true git remote add gitea "${GITEA_URL}" From 87bd606b85a6ce3cbcfd1712f623dfda6376a0ff Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 20:56:11 +0200 Subject: [PATCH 150/182] fix(ci): use basic auth header with token-owner login --- .github/workflows/sync-to-gitea.yml | 24 ++++++++++-------------- 1 file changed, 10 insertions(+), 14 deletions(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index 7b71a3e..bd2fb49 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -18,27 +18,23 @@ jobs: - name: Sync to Gitea env: - GITEA_TOKEN_PRIMARY: ${{ secrets.GITEA_TOKEN }} - GITEA_TOKEN_FALLBACK: ${{ secrets.GITEA_SECRET }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} REPO: ${{ github.event.repository.name }} BRANCH: ${{ github.ref_name }} run: | set -euo pipefail - GITEA_TOKEN="${GITEA_TOKEN_PRIMARY:-${GITEA_TOKEN_FALLBACK:-}}" - if [ -z "${GITEA_TOKEN}" ]; then - echo "Missing GITEA token secret. Set GITEA_TOKEN (preferred) or GITEA_SECRET." - exit 1 - fi + git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - GITEA_URL="https://Admin:${GITEA_TOKEN}@ci.nxtgauge.com/Admin/${REPO}.git" - echo "Sync target: Admin/${REPO}.git on branch ${BRANCH}" + GITEA_USER="$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" https://ci.nxtgauge.com/api/v1/user | jq -r '.login')" + TARGET="https://ci.nxtgauge.com/Admin/${REPO}.git" + AUTH="$(printf '%s' "${GITEA_USER}:${GITEA_TOKEN}" | base64 | tr -d '\n')" + + curl -fsS -H "Authorization: Basic ${AUTH}" "${TARGET}/info/refs?service=git-receive-pack" >/dev/null git remote remove gitea 2>/dev/null || true - git remote add gitea "${GITEA_URL}" + git remote add gitea "${TARGET}" - git ls-remote "${GITEA_URL}" >/dev/null - - git push "${GITEA_URL}" "HEAD:${BRANCH}" --force - git push "${GITEA_URL}" --tags --force + git -c http.extraheader="Authorization: Basic ${AUTH}" push gitea "HEAD:${BRANCH}" --force + git -c http.extraheader="Authorization: Basic ${AUTH}" push gitea --tags --force From 0c6415873f57ab9d3bf9e590aac72200fce9f5ff Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 20:59:10 +0200 Subject: [PATCH 151/182] fix(ci): tolerate /user 401 and fallback to configured auth user --- .github/workflows/sync-to-gitea.yml | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index bd2fb49..8b3cf5b 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -18,16 +18,27 @@ jobs: - name: Sync to Gitea env: - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_TOKEN_PRIMARY: ${{ secrets.GITEA_TOKEN }} + GITEA_TOKEN_FALLBACK: ${{ secrets.GITEA_SECRET }} + GITEA_USERNAME: ${{ secrets.GITEA_USERNAME }} REPO: ${{ github.event.repository.name }} BRANCH: ${{ github.ref_name }} run: | set -euo pipefail + GITEA_TOKEN="${GITEA_TOKEN_PRIMARY:-${GITEA_TOKEN_FALLBACK:-}}" + if [ -z "${GITEA_TOKEN}" ]; then + echo "Missing token secret: set GITEA_TOKEN (preferred) or GITEA_SECRET" + exit 1 + fi + git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - GITEA_USER="$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" https://ci.nxtgauge.com/api/v1/user | jq -r '.login')" + GITEA_USER="$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" https://ci.nxtgauge.com/api/v1/user 2>/dev/null | jq -r '.login // empty' || true)" + if [ -z "$GITEA_USER" ]; then + GITEA_USER="${GITEA_USERNAME:-Admin}" + fi TARGET="https://ci.nxtgauge.com/Admin/${REPO}.git" AUTH="$(printf '%s' "${GITEA_USER}:${GITEA_TOKEN}" | base64 | tr -d '\n')" From 38db7dcaf3bf58cb8a7d18a4d9d06175bd3311e6 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 21:03:52 +0200 Subject: [PATCH 152/182] chore(ci): log resolved gitea user and validate ls-remote --- .github/workflows/sync-to-gitea.yml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index 8b3cf5b..52aedde 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -42,7 +42,11 @@ jobs: TARGET="https://ci.nxtgauge.com/Admin/${REPO}.git" AUTH="$(printf '%s' "${GITEA_USER}:${GITEA_TOKEN}" | base64 | tr -d '\n')" + echo "Using Gitea user: ${GITEA_USER}" + echo "Sync target: Admin/${REPO}.git" + curl -fsS -H "Authorization: Basic ${AUTH}" "${TARGET}/info/refs?service=git-receive-pack" >/dev/null + git -c http.extraheader="Authorization: Basic ${AUTH}" ls-remote "${TARGET}" >/dev/null git remote remove gitea 2>/dev/null || true git remote add gitea "${TARGET}" From 1d06760aba0830e7041214d7d7663866c1ea8fcf Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 21:23:07 +0200 Subject: [PATCH 153/182] fix(ci): use authenticated remote URL for gitea git ops --- .github/workflows/sync-to-gitea.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index 52aedde..e3d0cba 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -41,15 +41,16 @@ jobs: fi TARGET="https://ci.nxtgauge.com/Admin/${REPO}.git" AUTH="$(printf '%s' "${GITEA_USER}:${GITEA_TOKEN}" | base64 | tr -d '\n')" + AUTH_URL="https://${GITEA_USER}:${GITEA_TOKEN}@ci.nxtgauge.com/Admin/${REPO}.git" echo "Using Gitea user: ${GITEA_USER}" echo "Sync target: Admin/${REPO}.git" curl -fsS -H "Authorization: Basic ${AUTH}" "${TARGET}/info/refs?service=git-receive-pack" >/dev/null - git -c http.extraheader="Authorization: Basic ${AUTH}" ls-remote "${TARGET}" >/dev/null + git ls-remote "${AUTH_URL}" >/dev/null git remote remove gitea 2>/dev/null || true - git remote add gitea "${TARGET}" + git remote add gitea "${AUTH_URL}" - git -c http.extraheader="Authorization: Basic ${AUTH}" push gitea "HEAD:${BRANCH}" --force - git -c http.extraheader="Authorization: Basic ${AUTH}" push gitea --tags --force + git push gitea "HEAD:${BRANCH}" --force + git push gitea --tags --force From fb817595e0db92fffae04aaa6b21b0004e02e0d5 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 21:36:42 +0200 Subject: [PATCH 154/182] fix(ci): add preflight token check and static secret usage --- .github/workflows/sync-to-gitea.yml | 35 ++++++++++++++--------------- 1 file changed, 17 insertions(+), 18 deletions(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index e3d0cba..f11fc99 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -16,41 +16,40 @@ jobs: with: fetch-depth: 0 + - name: Preflight token check + env: + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + run: | + set -euo pipefail + echo "actor=${GITHUB_ACTOR} repo=${GITHUB_REPOSITORY} ref=${GITHUB_REF_NAME}" + test -n "${GITEA_TOKEN:-}" || (echo "GITEA_TOKEN is empty" && exit 1) + echo "token_len=${#GITEA_TOKEN}" + curl -fsS -H "Authorization: token ${GITEA_TOKEN}" https://ci.nxtgauge.com/api/v1/user >/dev/null + - name: Sync to Gitea env: - GITEA_TOKEN_PRIMARY: ${{ secrets.GITEA_TOKEN }} - GITEA_TOKEN_FALLBACK: ${{ secrets.GITEA_SECRET }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} GITEA_USERNAME: ${{ secrets.GITEA_USERNAME }} REPO: ${{ github.event.repository.name }} BRANCH: ${{ github.ref_name }} run: | set -euo pipefail - GITEA_TOKEN="${GITEA_TOKEN_PRIMARY:-${GITEA_TOKEN_FALLBACK:-}}" - if [ -z "${GITEA_TOKEN}" ]; then - echo "Missing token secret: set GITEA_TOKEN (preferred) or GITEA_SECRET" - exit 1 - fi + test -n "${GITEA_TOKEN:-}" || (echo "GITEA_TOKEN is empty" && exit 1) + test -n "${GITEA_USERNAME:-}" || (echo "GITEA_USERNAME is empty" && exit 1) git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - GITEA_USER="$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" https://ci.nxtgauge.com/api/v1/user 2>/dev/null | jq -r '.login // empty' || true)" - if [ -z "$GITEA_USER" ]; then - GITEA_USER="${GITEA_USERNAME:-Admin}" - fi - TARGET="https://ci.nxtgauge.com/Admin/${REPO}.git" - AUTH="$(printf '%s' "${GITEA_USER}:${GITEA_TOKEN}" | base64 | tr -d '\n')" - AUTH_URL="https://${GITEA_USER}:${GITEA_TOKEN}@ci.nxtgauge.com/Admin/${REPO}.git" + TARGET_URL="https://${GITEA_USERNAME}:${GITEA_TOKEN}@ci.nxtgauge.com/Admin/${REPO}.git" - echo "Using Gitea user: ${GITEA_USER}" + echo "Using Gitea user: ${GITEA_USERNAME}" echo "Sync target: Admin/${REPO}.git" - curl -fsS -H "Authorization: Basic ${AUTH}" "${TARGET}/info/refs?service=git-receive-pack" >/dev/null - git ls-remote "${AUTH_URL}" >/dev/null + git ls-remote "${TARGET_URL}" >/dev/null git remote remove gitea 2>/dev/null || true - git remote add gitea "${AUTH_URL}" + git remote add gitea "${TARGET_URL}" git push gitea "HEAD:${BRANCH}" --force git push gitea --tags --force From b8dad1c0a52cd024b918db92cb5bfdfb1df09097 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 21:40:10 +0200 Subject: [PATCH 155/182] fix(ci): use GITEA_SECRET for sync token --- .github/workflows/sync-to-gitea.yml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index f11fc99..46bdfb7 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -18,7 +18,7 @@ jobs: - name: Preflight token check env: - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_TOKEN: ${{ secrets.GITEA_SECRET }} run: | set -euo pipefail echo "actor=${GITHUB_ACTOR} repo=${GITHUB_REPOSITORY} ref=${GITHUB_REF_NAME}" @@ -28,7 +28,7 @@ jobs: - name: Sync to Gitea env: - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_TOKEN: ${{ secrets.GITEA_SECRET }} GITEA_USERNAME: ${{ secrets.GITEA_USERNAME }} REPO: ${{ github.event.repository.name }} BRANCH: ${{ github.ref_name }} From f4ddd9b2eea0fd4d1ede599d7f0870b3a9c6e915 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 21:45:03 +0200 Subject: [PATCH 156/182] fix(ci): always use token owner login for gitea auth --- .github/workflows/sync-to-gitea.yml | 9 +++++---- 1 file changed, 5 insertions(+), 4 deletions(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index 46bdfb7..b31273c 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -29,21 +29,22 @@ jobs: - name: Sync to Gitea env: GITEA_TOKEN: ${{ secrets.GITEA_SECRET }} - GITEA_USERNAME: ${{ secrets.GITEA_USERNAME }} REPO: ${{ github.event.repository.name }} BRANCH: ${{ github.ref_name }} run: | set -euo pipefail test -n "${GITEA_TOKEN:-}" || (echo "GITEA_TOKEN is empty" && exit 1) - test -n "${GITEA_USERNAME:-}" || (echo "GITEA_USERNAME is empty" && exit 1) git config user.name "github-actions[bot]" git config user.email "github-actions[bot]@users.noreply.github.com" - TARGET_URL="https://${GITEA_USERNAME}:${GITEA_TOKEN}@ci.nxtgauge.com/Admin/${REPO}.git" + GITEA_USER="$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" https://ci.nxtgauge.com/api/v1/user | jq -r '.login')" + test -n "${GITEA_USER:-}" || (echo "Unable to resolve Gitea token user" && exit 1) - echo "Using Gitea user: ${GITEA_USERNAME}" + TARGET_URL="https://${GITEA_USER}:${GITEA_TOKEN}@ci.nxtgauge.com/Admin/${REPO}.git" + + echo "Using Gitea user: ${GITEA_USER}" echo "Sync target: Admin/${REPO}.git" git ls-remote "${TARGET_URL}" >/dev/null From f94a80afc891902aa85bfc60e11dbdea913036fb Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 22:03:23 +0200 Subject: [PATCH 157/182] fix(ci): enforce Admin basic auth sync flow --- .github/workflows/sync-to-gitea.yml | 38 +++++++++-------------------- 1 file changed, 12 insertions(+), 26 deletions(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index b31273c..e58a18e 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -16,41 +16,27 @@ jobs: with: fetch-depth: 0 - - name: Preflight token check - env: - GITEA_TOKEN: ${{ secrets.GITEA_SECRET }} - run: | - set -euo pipefail - echo "actor=${GITHUB_ACTOR} repo=${GITHUB_REPOSITORY} ref=${GITHUB_REF_NAME}" - test -n "${GITEA_TOKEN:-}" || (echo "GITEA_TOKEN is empty" && exit 1) - echo "token_len=${#GITEA_TOKEN}" - curl -fsS -H "Authorization: token ${GITEA_TOKEN}" https://ci.nxtgauge.com/api/v1/user >/dev/null - - name: Sync to Gitea env: - GITEA_TOKEN: ${{ secrets.GITEA_SECRET }} + GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} REPO: ${{ github.event.repository.name }} BRANCH: ${{ github.ref_name }} run: | set -euo pipefail + export GIT_TERMINAL_PROMPT=0 - test -n "${GITEA_TOKEN:-}" || (echo "GITEA_TOKEN is empty" && exit 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" - - GITEA_USER="$(curl -fsS -H "Authorization: token ${GITEA_TOKEN}" https://ci.nxtgauge.com/api/v1/user | jq -r '.login')" - test -n "${GITEA_USER:-}" || (echo "Unable to resolve Gitea token user" && exit 1) - - TARGET_URL="https://${GITEA_USER}:${GITEA_TOKEN}@ci.nxtgauge.com/Admin/${REPO}.git" - - echo "Using Gitea user: ${GITEA_USER}" - echo "Sync target: Admin/${REPO}.git" - - git ls-remote "${TARGET_URL}" >/dev/null - git remote remove gitea 2>/dev/null || true - git remote add gitea "${TARGET_URL}" + git remote add gitea "${TARGET}" - git push gitea "HEAD:${BRANCH}" --force - git push gitea --tags --force + git -c http.extraheader="Authorization: Basic ${AUTH}" push gitea "HEAD:${BRANCH}" --force + git -c http.extraheader="Authorization: Basic ${AUTH}" push gitea --tags --force From 28a20518157bb2d7b7bc7e3e2c752dba35a306ec Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 22:18:09 +0200 Subject: [PATCH 158/182] fix(ci): use GITEA_SECRET in sync workflow --- .github/workflows/sync-to-gitea.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index e58a18e..4b9612b 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -18,7 +18,7 @@ jobs: - name: Sync to Gitea env: - GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }} + GITEA_TOKEN: ${{ secrets.GITEA_SECRET }} REPO: ${{ github.event.repository.name }} BRANCH: ${{ github.ref_name }} run: | From a8e848da1b3c9dec6705bf8c757e8764cb60252b Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 22:27:16 +0200 Subject: [PATCH 159/182] chore(ci): enable git trace for sync debugging --- .github/workflows/sync-to-gitea.yml | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index 4b9612b..e8eb1ad 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -22,8 +22,10 @@ jobs: REPO: ${{ github.event.repository.name }} BRANCH: ${{ github.ref_name }} run: | - set -euo pipefail + 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" From 413254d53f3f1f0366e033c26c26f1284fbed94e Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 22:32:38 +0200 Subject: [PATCH 160/182] fix(ci): force http1.1 for gitea git transport --- .github/workflows/sync-to-gitea.yml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/.github/workflows/sync-to-gitea.yml b/.github/workflows/sync-to-gitea.yml index e8eb1ad..a9b0694 100644 --- a/.github/workflows/sync-to-gitea.yml +++ b/.github/workflows/sync-to-gitea.yml @@ -37,6 +37,8 @@ jobs: 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}" From 8651175c125741c654f0218645c43290814df573 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 22:39:00 +0200 Subject: [PATCH 161/182] chore: trigger gitea pipeline From d8aad4faadc17e829526226e1918078e3418f827 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 22:47:33 +0200 Subject: [PATCH 162/182] chore: trigger gitea pipeline From 56be8381d1b8345ae7c1acaa8db305266a4f004d Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Thu, 30 Apr 2026 22:51:27 +0200 Subject: [PATCH 163/182] chore: trigger gitea pipeline From 3415308c39e67f5a416bd192c4c651102f62e90e Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 1 May 2026 00:14:33 +0200 Subject: [PATCH 164/182] chore: trigger gitea pipeline From 8b87b3bb53a01ca8954d6228f19edc3ece4ff29a Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 1 May 2026 00:17:44 +0200 Subject: [PATCH 165/182] chore: trigger gitea pipeline From aa71ccdf368ed1181b33cf87e468dff3cfffaef6 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 1 May 2026 02:54:42 +0200 Subject: [PATCH 166/182] Add AI endpoints and gateway route fix - Fix gateway: add /api/ai route to users_url - Add AI job field generation endpoints (generate-job-field, generate-cover-letter, tailor-resume, auto-apply) - Add AI usage tracking and rate limiting - Add professional auto-respond-to-lead endpoint (30 tracecoins) - Add DB migrations for AI usage tracking tables - Update leads service with AI auto-respond functionality --- Cargo.lock | 1 + apps/gateway/src/main.rs | 4 + apps/leads/Cargo.toml | 1 + apps/leads/src/lead_requests.rs | 206 ++++ apps/leads/src/main.rs | 13 +- apps/users/src/handlers/ai.rs | 1064 ++++++++++++++++- .../20260425000000_ai_usage.down.sql | 9 + .../migrations/20260425000000_ai_usage.up.sql | 38 + 8 files changed, 1313 insertions(+), 23 deletions(-) create mode 100644 crates/db/migrations/20260425000000_ai_usage.down.sql create mode 100644 crates/db/migrations/20260425000000_ai_usage.up.sql diff --git a/Cargo.lock b/Cargo.lock index eb267e0..6d5c0e9 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -2144,6 +2144,7 @@ dependencies = [ "anyhow", "axum", "chrono", + "reqwest", "serde", "serde_json", "sqlx", diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index c8ac0d3..ae02dc9 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -198,6 +198,10 @@ 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()) diff --git a/apps/leads/Cargo.toml b/apps/leads/Cargo.toml index 144c352..d17b2c0 100644 --- a/apps/leads/Cargo.toml +++ b/apps/leads/Cargo.toml @@ -15,6 +15,7 @@ anyhow = { workspace = true } uuid = { workspace = true } chrono = { workspace = true } tower-http = { version = "0.6", features = ["cors", "trace"] } +reqwest = { workspace = true } [[bin]] name = "leads" diff --git a/apps/leads/src/lead_requests.rs b/apps/leads/src/lead_requests.rs index 0c91de7..168bcca 100644 --- a/apps/leads/src/lead_requests.rs +++ b/apps/leads/src/lead_requests.rs @@ -24,6 +24,13 @@ pub struct SendLeadRequestPayload { pub message: Option, } +#[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, @@ -64,6 +71,7 @@ pub fn router() -> Router> { 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)) @@ -272,6 +280,204 @@ async fn send_lead_request( } } +async fn send_lead_request_ai( + State(state): State>, + Json(payload): Json, +) -> impl IntoResponse { + let user_id = payload.user_id; + + let lead = match sqlx::query_as::<_, (Uuid, String, String, String, String, Option, Option)>( + "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 { + #[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>, Path(id): Path, diff --git a/apps/leads/src/main.rs b/apps/leads/src/main.rs index 0d24782..940bc5e 100644 --- a/apps/leads/src/main.rs +++ b/apps/leads/src/main.rs @@ -4,6 +4,7 @@ use axum::{ routing::{get, post}, Json, Router, }; +use reqwest::Client; use serde::{Deserialize, Serialize}; use sqlx::PgPool; use std::net::SocketAddr; @@ -16,6 +17,9 @@ 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)] @@ -110,7 +114,14 @@ async fn main() { tracing::info!("Connected to database"); - let state = Arc::new(AppState { pool }); + 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) diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs index 1b06f71..5b3f4e8 100644 --- a/apps/users/src/handlers/ai.rs +++ b/apps/users/src/handlers/ai.rs @@ -6,15 +6,32 @@ use axum::{ routing::{get, post}, Json, Router, }; +use contracts::auth_middleware::AuthUser; use serde::{Deserialize, Serialize}; +use std::sync::Arc; use uuid::Uuid; +#[derive(sqlx::FromRow)] +struct KbArticleRow { + id: Uuid, + title: String, + slug: String, + summary: Option, + category_name: String, +} + pub fn ai_router() -> Router { Router::new() .route("/chat/message", post(ai_chat_message)) .route("/tickets/create", post(ai_create_ticket)) .route("/tickets/{id}", get(ai_get_ticket)) .route("/forms/extract", post(ai_extract_form)) + .route("/generate-job-field", post(ai_generate_job_field)) + .route("/generate-cover-letter", post(ai_generate_cover_letter)) + .route("/tailor-resume", post(ai_tailor_resume)) + .route("/auto-apply", post(ai_auto_apply)) + .route("/auto-respond-to-lead", post(ai_auto_respond_to_lead)) + .route("/usage", get(ai_usage_status)) } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -77,7 +94,7 @@ async fn call_ollama(_state: &AppState, model: &str, prompt: &str) -> Result (String, f32) { let prompt = format!( - "Classify this user message into one intent category. Categories: ticket_creation, form_filling, help_search, general. \ + "Classify this user message into one intent category. Categories: ticket_creation, form_filling, help_search, job_description_generation, general. \ Return ONLY the intent name, nothing else.\n\nMessage: {}", message ); @@ -90,6 +107,7 @@ async fn classify_intent(message: &str, ollama_base: &str, model: &str) -> (Stri "ticket_creation" => "ticket_creation", "form_filling" => "form_filling", "help_search" => "help_search", + "job_description_generation" => "job_description_generation", _ => "general", }; (intent.to_string(), confidence) @@ -138,33 +156,107 @@ async fn ai_chat_message( let (intent, confidence) = classify_intent(&body.message, &ollama_base, &model).await; - let system_prompt = match intent.as_str() { + let response_text = match intent.as_str() { + "help_search" => { + let q = body.message.to_lowercase(); + let rows = sqlx::query_as::<_, KbArticleRow>( + r#" + SELECT a.id, a.title, a.slug, a.summary, c.name AS category_name + FROM kb_articles a + JOIN kb_categories c ON c.id = a.category_id + WHERE a.status = 'PUBLISHED' + AND c.is_active = true + AND (LOWER(a.title) LIKE '%' || $1 || '%' + OR LOWER(COALESCE(a.summary, '')) LIKE '%' || $1 || '%') + ORDER BY a.updated_at DESC + LIMIT 5 + "#, + ) + .bind(&q) + .fetch_all(&state.pool) + .await; + + match rows { + Ok(articles) if !articles.is_empty() => { + let links: Vec = articles + .iter() + .map(|a| { + format!( + "- **{}** ({})\n {}\n /help-center/article/{}", + a.title, + a.category_name, + a.summary.as_deref().unwrap_or(""), + a.slug + ) + }) + .collect(); + format!( + "I found {} help article(s) for you:\n\n{}\n\nIs any of these what you were looking for?", + articles.len(), + links.join("\n\n") + ) + } + _ => { + "I couldn't find any help articles matching your question. \ + Try rephrasing or contact support if you need further assistance." + .to_string() + } + } + } + "job_description_generation" => { + let jd_prompt = format!( + "Generate a professional job description with the following sections: \ + **Job Title**, **Summary**, **Key Responsibilities**, **Required Skills & Qualifications**, \ + **Preferred Qualifications**, **What We Offer**. \ + Format each section clearly with bullet points where appropriate.\n\n\ + User's request: {}\n\n\ + Job Description:", + body.message + ); + match call_ollama(&state, &model, &jd_prompt).await { + Ok(r) => r, + Err(e) => { + tracing::error!("Ollama JD generation error: {}", e); + "I'm having trouble generating a job description right now. Please try again.".to_string() + } + } + } "ticket_creation" => { - "You are a support ticket assistant. Help users create clear, actionable support tickets. \ + let system_prompt = "You are a support ticket assistant. Help users create clear, actionable support tickets. \ Ask for: subject, description of issue, category, priority if not provided. \ - Summarize the ticket in a structured way." + Summarize the ticket in a structured way."; + let full_prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, body.message); + match call_ollama(&state, &model, &full_prompt).await { + Ok(r) => r, + Err(e) => { + tracing::error!("Ollama error: {}", e); + "I'm having trouble processing your request right now. Please try again or contact support.".to_string() + } + } } "form_filling" => { - "You are a form filling assistant. Help users fill out forms by extracting relevant information \ - from their message. Extract key:value pairs when possible." - } - "help_search" => { - "You are a help center assistant. Help users find relevant help articles based on their query. \ - Ask clarifying questions to narrow down the search." + let system_prompt = "You are a form filling assistant. Help users fill out forms by extracting relevant information \ + from their message. Extract key:value pairs when possible."; + let full_prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, body.message); + match call_ollama(&state, &model, &full_prompt).await { + Ok(r) => r, + Err(e) => { + tracing::error!("Ollama error: {}", e); + "I'm having trouble processing your request right now. Please try again or contact support.".to_string() + } + } } _ => { - "You are a helpful AI assistant for Nxtgauge platform. Provide clear, concise responses. \ - If the user needs support, guide them to create a ticket." - } - }; - - let full_prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, body.message); - - let response_text = match call_ollama(&state, &model, &full_prompt).await { - Ok(r) => r, - Err(e) => { - tracing::error!("Ollama error: {}", e); - "I'm having trouble processing your request right now. Please try again or contact support.".to_string() + let system_prompt = "You are a helpful AI assistant for Nxtgauge platform. Provide clear, concise responses. \ + If the user needs support, guide them to create a ticket."; + let full_prompt = format!("{}\n\nUser: {}\nAssistant:", system_prompt, body.message); + match call_ollama(&state, &model, &full_prompt).await { + Ok(r) => r, + Err(e) => { + tracing::error!("Ollama error: {}", e); + "I'm having trouble processing your request right now. Please try again or contact support.".to_string() + } + } } }; @@ -349,4 +441,932 @@ struct TicketRow { assigned_to: Option, created_at: chrono::DateTime, updated_at: chrono::DateTime, +} + +// ── AI Pack & Rate Limit Helpers ──────────────────────────────────────────────── + +const BASE_AI_LIMIT: i32 = 5; + +fn get_ai_limit_for_package(features: &serde_json::Value) -> i32 { + features + .get("ai_generations_per_day") + .and_then(|v| v.as_i64()) + .map(|v| v as i32) + .unwrap_or(BASE_AI_LIMIT) +} + +async fn has_active_ai_pack( + pool: &sqlx::PgPool, + user_role_profile_id: Uuid, + role_key: &str, +) -> (bool, i32) { + let now = chrono::Utc::now(); + let result = sqlx::query_as::<_, (Option,)>( + r#" + SELECT pp.features + FROM pricing_packages pp + JOIN payments p ON p.package_id = pp.id + WHERE pp.package_type = 'AI_PACK' + AND pp.is_active = true + AND p.user_role_profile_id = $1 + AND $2 = ANY(pp.applicable_roles) + AND p.tracecoins_credited > 0 + AND (pp.valid_from IS NULL OR pp.valid_from <= $3) + AND (pp.valid_until IS NULL OR pp.valid_until >= $3) + ORDER BY p.created_at DESC + LIMIT 1 + "#, + ) + .bind(user_role_profile_id) + .bind(role_key) + .bind(now) + .fetch_optional(pool) + .await; + + match result { + Ok(Some((Some(features),))) => { + let limit = get_ai_limit_for_package(&features); + (true, limit) + } + _ => (false, BASE_AI_LIMIT), + } +} + +async fn check_and_increment_usage( + pool: &sqlx::PgPool, + profile_id: Uuid, + is_company: bool, + daily_limit: i32, +) -> Result<(i32, i32), String> { + let today = chrono::Utc::now().date_naive(); + let table = if is_company { "company_ai_usage" } else { "job_seeker_ai_usage" }; + let id_col = if is_company { "company_id" } else { "job_seeker_id" }; + + let current: Option = sqlx::query_scalar(&format!( + "SELECT generations_used FROM {} WHERE {} = $1 AND usage_date = $2", + table, id_col + )) + .bind(profile_id) + .bind(today) + .fetch_optional(pool) + .await + .map_err(|e| e.to_string())?; + + let used = current.unwrap_or(0); + if used >= daily_limit { + return Err("Daily AI generation limit reached".to_string()); + } + + sqlx::query(&format!( + r#" + INSERT INTO {} ({} , usage_date, generations_used) + VALUES ($1, $2, 1) + ON CONFLICT ({}, usage_date) + DO UPDATE SET generations_used = {}.generations_used + 1, updated_at = NOW() + "#, + table, id_col, id_col, table + )) + .bind(profile_id) + .bind(today) + .execute(pool) + .await + .map_err(|e| e.to_string())?; + + Ok((used + 1, daily_limit)) +} + +// ── Job Field Generation (Companies) ────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct GenerateJobFieldBody { + field: String, + context: String, +} + +#[derive(Debug, Serialize)] +struct GenerateFieldResponse { + generated_text: String, + remaining_today: i32, + daily_limit: i32, + has_ai_pack: bool, +} + +async fn ai_generate_job_field( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> impl IntoResponse { + let company: Option = sqlx::query_scalar( + "SELECT id FROM company_profiles WHERE user_id = $1" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| e.to_string()) + .ok() + .flatten(); + + let Some(company_id) = company else { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "No company profile found" }))).into_response(); + }; + + let (has_pack, daily_limit) = { + let profile_id: Option = sqlx::query_scalar( + "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'COMPANY'" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + match profile_id { + Some(pid) => has_active_ai_pack(&state.pool, pid, "COMPANY").await, + None => (false, BASE_AI_LIMIT), + } + }; + + let (used, limit) = match check_and_increment_usage(&state.pool, company_id, true, daily_limit).await { + Ok((u, l)) => (u, l), + Err(msg) => { + return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response(); + } + }; + + let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); + let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); + + let field_prompt = match body.field.as_str() { + "title" => format!( + "Generate a concise, engaging job title (max 80 chars) for: {}. \ + Only return the title, nothing else.", + body.context + ), + "description" => format!( + "Generate a professional job description with sections: **Summary**, **Key Responsibilities**, **Required Skills**, **Preferred Qualifications**, **What We Offer**. \ + Use markdown formatting. Based on: {}\n\nJob Description:", + body.context + ), + "skills" => format!( + "List 6-10 relevant skills for this role, as a comma-separated string (no descriptions): {}", + body.context + ), + "category" => format!( + "Suggest a single job category/department name (max 50 chars) for: {}. Only return the category name.", + body.context + ), + _ => { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Invalid field. Use: title, description, skills, category" }))).into_response(); + } + }; + + let generated = match call_ollama_inline(&ollama_base, &model, &field_prompt).await { + Ok(r) => r.trim().to_string(), + Err(e) => { + tracing::error!("Ollama job field generation error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Generation failed" }))).into_response(); + } + }; + + ( + StatusCode::OK, + Json(GenerateFieldResponse { + generated_text: generated, + remaining_today: limit - used, + daily_limit: limit, + has_ai_pack: has_pack, + }), + ).into_response() +} + +// ── Cover Letter Generation (Job Seekers) ────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct CoverLetterBody { + job_id: Uuid, + additional_notes: Option, +} + +async fn ai_generate_cover_letter( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> impl IntoResponse { + let seeker: Option<(Uuid, String, Option, i32, Vec)> = sqlx::query_as( + "SELECT id, full_name, summary, experience_years, skills FROM job_seeker_profiles WHERE user_id = $1" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| e.to_string()) + .ok() + .and_then(|r| r); + + let Some((seeker_id, full_name, summary, experience, skills)) = seeker else { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "No job seeker profile found" }))).into_response(); + }; + + let job: Option<(String, String, String)> = sqlx::query_as( + "SELECT title, description, location FROM jobs WHERE id = $1" + ) + .bind(body.job_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| e.to_string()) + .ok() + .and_then(|r| r); + + let Some((job_title, job_desc, location)) = job else { + return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Job not found" }))).into_response(); + }; + + let (has_pack, daily_limit) = { + let profile_id: Option = sqlx::query_scalar( + "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'JOB_SEEKER'" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + match profile_id { + Some(pid) => has_active_ai_pack(&state.pool, pid, "JOB_SEEKER").await, + None => (false, BASE_AI_LIMIT), + } + }; + + let (used, limit) = match check_and_increment_usage(&state.pool, seeker_id, false, daily_limit).await { + Ok((u, l)) => (u, l), + Err(msg) => { + return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response(); + } + }; + + let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); + let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); + + let notes = body.additional_notes.as_deref().unwrap_or(""); + let skills_str = skills.join(", "); + + let prompt = format!( + "Write a personalized, professional cover letter for a job application.\n\n\ + IMPORTANT: Do NOT include phone number, email, or any contact information. \ + Only use the information provided below. Companies pay to view candidate contact details through the platform.\n\n\ + CANDIDATE INFO:\n\ + Name: {}\n\ + Experience: {} years\n\ + Summary: {}\n\ + Skills: {}\n\ + Notes: {}\n\n\ + JOB INFO:\n\ + Title: {}\n\ + Description: {}\n\ + Location: {}\n\n\ + Write a compelling cover letter that highlights how the candidate's experience and skills match the role. \ + Use a professional tone, 3-4 short paragraphs.", + full_name, experience, summary.as_deref().unwrap_or("N/A"), skills_str, notes, job_title, job_desc, location + ); + + let generated = match call_ollama_inline(&ollama_base, &model, &prompt).await { + Ok(r) => r.trim().to_string(), + Err(e) => { + tracing::error!("Ollama cover letter generation error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Generation failed" }))).into_response(); + } + }; + + ( + StatusCode::OK, + Json(GenerateFieldResponse { + generated_text: generated, + remaining_today: limit - used, + daily_limit: limit, + has_ai_pack: has_pack, + }), + ).into_response() +} + +// ── Tailor Resume (Job Seekers) ───────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct TailorResumeBody { + job_id: Uuid, + resume_text: Option, +} + +async fn ai_tailor_resume( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> impl IntoResponse { + let seeker: Option<(Uuid, String, Option, i32, Vec)> = sqlx::query_as( + "SELECT id, full_name, summary, experience_years, skills FROM job_seeker_profiles WHERE user_id = $1" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| e.to_string()) + .ok() + .and_then(|r| r); + + let Some((seeker_id, full_name, summary, experience, skills)) = seeker else { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "No job seeker profile found" }))).into_response(); + }; + + let job: Option<(String, String)> = sqlx::query_as( + "SELECT title, description FROM jobs WHERE id = $1" + ) + .bind(body.job_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| e.to_string()) + .ok() + .and_then(|r| r); + + let Some((job_title, job_desc)) = job else { + return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Job not found" }))).into_response(); + }; + + let (has_pack, daily_limit) = { + let profile_id: Option = sqlx::query_scalar( + "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'JOB_SEEKER'" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + match profile_id { + Some(pid) => has_active_ai_pack(&state.pool, pid, "JOB_SEEKER").await, + None => (false, BASE_AI_LIMIT), + } + }; + + let (used, limit) = match check_and_increment_usage(&state.pool, seeker_id, false, daily_limit).await { + Ok((u, l)) => (u, l), + Err(msg) => { + return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response(); + } + }; + + let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); + let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); + + let existing_resume = body.resume_text.as_deref().unwrap_or("Not provided"); + let skills_str = skills.join(", "); + + let prompt = format!( + "Rewrite the following resume to better match the target job role. \ + IMPORTANT: Do NOT add phone number, email, or any contact information. \ + Only use the information provided. Companies pay to view candidate contact details through the platform.\n\n\ + CANDIDATE:\n\ + Name: {}\n\ + Experience: {} years\n\ + Summary: {}\n\ + Skills: {}\n\ + Current Resume:\n{}\n\n\ + TARGET JOB:\n\ + Title: {}\n\ + Description: {}\n\n\ + Rewrite the resume to emphasize relevant experience and skills for this role. \ + Keep the same format (bullet points, sections). Do not add contact info.", + full_name, experience, summary.as_deref().unwrap_or("N/A"), skills_str, existing_resume, job_title, job_desc + ); + + let generated = match call_ollama_inline(&ollama_base, &model, &prompt).await { + Ok(r) => r.trim().to_string(), + Err(e) => { + tracing::error!("Ollama resume tailoring error: {}", e); + return (StatusCode::INTERNAL_SERVER_ERROR, Json(serde_json::json!({ "error": "Generation failed" }))).into_response(); + } + }; + + ( + StatusCode::OK, + Json(GenerateFieldResponse { + generated_text: generated, + remaining_today: limit - used, + daily_limit: limit, + has_ai_pack: has_pack, + }), + ).into_response() +} + +// ── Auto Apply (Job Seekers) ─────────────────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct AutoApplyBody { + job_ids: Vec, +} + +#[derive(Debug, Serialize)] +struct AutoApplyResponse { + applications_created: i32, + already_applied: Vec, + failed: Vec, + remaining_today: i32, + daily_limit: i32, +} + +async fn ai_auto_apply( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> impl IntoResponse { + if body.job_ids.is_empty() || body.job_ids.len() > 10 { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Select 1-10 jobs at a time" }))).into_response(); + } + + let seeker: Option<(Uuid, String, Option, i32, Vec)> = sqlx::query_as( + "SELECT id, full_name, summary, experience_years, skills FROM job_seeker_profiles WHERE user_id = $1" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| e.to_string()) + .ok() + .and_then(|r| r); + + let Some((seeker_id, full_name, summary, experience, skills)) = seeker else { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "No job seeker profile found" }))).into_response(); + }; + + if full_name.is_empty() || skills.is_empty() { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Complete your profile (name and skills required) before auto-applying" }))).into_response(); + } + + let (has_pack, daily_limit) = { + let profile_id: Option = sqlx::query_scalar( + "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = 'JOB_SEEKER'" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + match profile_id { + Some(pid) => has_active_ai_pack(&state.pool, pid, "JOB_SEEKER").await, + None => (false, BASE_AI_LIMIT), + } + }; + + let remaining = daily_limit - { + let today = chrono::Utc::now().date_naive(); + let used: Option = sqlx::query_scalar( + "SELECT generations_used FROM job_seeker_ai_usage WHERE job_seeker_id = $1 AND usage_date = $2" + ) + .bind(seeker_id) + .bind(today) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + used.unwrap_or(0) + }; + + if remaining < body.job_ids.len() as i32 { + return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": format!("Only {} generations left today", remaining) }))).into_response(); + } + + let ollama_base = std::env::var("OLLAMA_BASE_URL").unwrap_or_else(|_| "http://ollama.nxtgauge-ai.svc.cluster.local:11434".to_string()); + let model = std::env::var("OLLAMA_CHAT_MODEL").unwrap_or_else(|_| "gemma3:270m".to_string()); + let skills_str = skills.join(", "); + + let mut created = 0; + let mut already = vec![]; + let mut failed = vec![]; + + for job_id in &body.job_ids { + let existing: Option = sqlx::query_scalar( + "SELECT id FROM job_applications WHERE job_id = $1 AND applicant_user_id = $2" + ) + .bind(job_id) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + if existing.is_some() { + already.push(*job_id); + continue; + } + + let job: Option<(String, String)> = sqlx::query_as( + "SELECT title, description FROM jobs WHERE id = $1" + ) + .bind(job_id) + .fetch_optional(&state.pool) + .await + .ok() + .and_then(|r| r); + + let Some((job_title, job_desc)) = job else { + failed.push(*job_id); + continue; + }; + + let cover_prompt = format!( + "Write a brief, professional cover letter (max 200 words).\n\n\ + IMPORTANT: Do NOT include phone number, email, or any contact information. \ + Only use the information provided below.\n\n\ + CANDIDATE: Name: {}, Experience: {} years, Skills: {}, Summary: {}\n\ + JOB: Title: {}, Description: {}\n\n\ + Cover Letter:", + full_name, experience, skills_str, summary.as_deref().unwrap_or(""), job_title, job_desc + ); + + let cover_letter = match call_ollama_inline(&ollama_base, &model, &cover_prompt).await { + Ok(r) => r.trim().to_string(), + Err(_) => "I am excited to apply for this position.".to_string(), + }; + + let result = sqlx::query( + r#" + INSERT INTO job_applications (job_id, applicant_user_id, cover_letter, applied_via_ai) + VALUES ($1, $2, $3, true) + ON CONFLICT (job_id, applicant_user_id) DO NOTHING + "# + ) + .bind(job_id) + .bind(auth.user_id) + .bind(&cover_letter) + .execute(&state.pool) + .await; + + match result { + Ok(r) => { + if r.rows_affected() > 0 { + created += 1; + let _ = check_and_increment_usage(&state.pool, seeker_id, false, daily_limit).await; + } else { + already.push(*job_id); + } + } + Err(_) => { + failed.push(*job_id); + } + } + } + + let new_remaining = remaining - created; + + ( + StatusCode::OK, + Json(AutoApplyResponse { + applications_created: created, + already_applied: already, + failed, + remaining_today: new_remaining.max(0), + daily_limit, + }), + ).into_response() +} + +// ── Auto Respond to Lead (Professionals) ─────────────────────────────────────── + +#[derive(Debug, Deserialize)] +struct AutoRespondToLeadBody { + lead_id: Uuid, + profession_key: String, +} + +async fn ai_auto_respond_to_lead( + State(state): State, + auth: AuthUser, + Json(body): Json, +) -> impl IntoResponse { + let leads_service_url = std::env::var("LEADS_SERVICE_URL") + .unwrap_or_else(|_| "http://localhost:9118".to_string()); + + let profile_id: Option = sqlx::query_scalar( + "SELECT id FROM user_role_profiles WHERE user_id = $1" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .map_err(|e| e.to_string()) + .ok() + .flatten(); + + let Some(profile_id) = profile_id else { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Profile not found" }))).into_response(); + }; + + let today = chrono::Utc::now().date_naive(); + let used: Option = sqlx::query_scalar( + "SELECT generations_used FROM job_seeker_ai_usage WHERE job_seeker_id = $1 AND usage_date = $2" + ) + .bind(profile_id) + .bind(today) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + let daily_limit = 10; + if used.unwrap_or(0) >= daily_limit { + return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": "Daily AI auto-respond limit reached (10/day)" }))).into_response(); + } + + let url = format!("{}/api/lead-requests/send-ai", leads_service_url.trim_end_matches('/')); + let client = reqwest::Client::new(); + let payload = serde_json::json!({ + "lead_id": body.lead_id.to_string(), + "user_id": auth.user_id.to_string(), + "profession_key": body.profession_key + }); + + let res = client + .post(&url) + .json(&payload) + .send() + .await + .map_err(|e| e.to_string()); + + let Ok(res) = res else { + return (StatusCode::BAD_GATEWAY, Json(serde_json::json!({ "error": "Failed to reach leads service" }))).into_response(); + }; + + let status = res.status(); + if !status.is_success() { + let body = res.text().await.unwrap_or_default(); + return (StatusCode::from_u16(status.as_u16()).unwrap_or(StatusCode::BAD_GATEWAY), Json(serde_json::json!({ "error": body }))).into_response(); + } + + let _ = sqlx::query( + r#" + INSERT INTO job_seeker_ai_usage (job_seeker_id, usage_date, generations_used) + VALUES ($1, $2, 1) + ON CONFLICT (job_seeker_id, usage_date) + DO UPDATE SET generations_used = job_seeker_ai_usage.generations_used + 1, updated_at = NOW() + "# + ) + .bind(profile_id) + .bind(today) + .execute(&state.pool) + .await; + + let remaining = daily_limit - used.unwrap_or(0) - 1; + + (StatusCode::OK, Json(serde_json::json!({ + "success": true, + "remaining_today": remaining.max(0), + "daily_limit": daily_limit, + "message": "AI response sent successfully" + }))).into_response() +} + +// ── Usage Status ─────────────────────────────────────────────────────────────── + +#[derive(Debug, Serialize)] +struct UsageStatusResponse { + remaining_today: i32, + daily_limit: i32, + has_ai_pack: bool, +} + +async fn ai_usage_status( + State(state): State, + auth: AuthUser, +) -> impl IntoResponse { + let (is_company, profile_id) = { + if let Some(cid) = sqlx::query_scalar::<_, Uuid>("SELECT id FROM company_profiles WHERE user_id = $1") + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + { + (true, cid) + } else if let Some(sid) = sqlx::query_scalar::<_, Uuid>("SELECT id FROM job_seeker_profiles WHERE user_id = $1") + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + { + (false, sid) + } else { + return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "No profile found" }))).into_response(); + } + }; + + let today = chrono::Utc::now().date_naive(); + let used: Option = if is_company { + sqlx::query_scalar("SELECT generations_used FROM company_ai_usage WHERE company_id = $1 AND usage_date = $2") + .bind(profile_id) + .bind(today) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + } else { + sqlx::query_scalar("SELECT generations_used FROM job_seeker_ai_usage WHERE job_seeker_id = $1 AND usage_date = $2") + .bind(profile_id) + .bind(today) + .fetch_optional(&state.pool) + .await + .ok() + .flatten() + }; + + let role_key = if is_company { "COMPANY" } else { "JOB_SEEKER" }; + let (has_pack, daily_limit) = { + let urp_id: Option = sqlx::query_scalar( + "SELECT id FROM user_role_profiles WHERE user_id = $1 AND role_key = $2" + ) + .bind(auth.user_id) + .bind(role_key) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + match urp_id { + Some(pid) => has_active_ai_pack(&state.pool, pid, role_key).await, + None => (false, BASE_AI_LIMIT), + } + }; + + let remaining = daily_limit - used.unwrap_or(0); + + (StatusCode::OK, Json(UsageStatusResponse { + remaining_today: remaining.max(0), + daily_limit, + has_ai_pack: has_pack, + })).into_response() +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_generate_field_request_deserialization() { + let json = serde_json::json!({ + "field": "title", + "context": "Senior Rust Developer" + }); + let body: GenerateJobFieldBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.field, "title"); + assert_eq!(body.context, "Senior Rust Developer"); + } + + #[test] + fn test_generate_field_request_all_fields() { + for field in ["title", "description", "skills", "category"] { + let json = serde_json::json!({ + "field": field, + "context": "Test context" + }); + let body: GenerateJobFieldBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.field, field); + } + } + + #[test] + fn test_generate_field_response_serialization() { + let response = GenerateFieldResponse { + generated_text: "Senior Rust Developer".to_string(), + remaining_today: 4, + daily_limit: 5, + has_ai_pack: false, + }; + let json = serde_json::to_value(&response).unwrap(); + assert_eq!(json["generated_text"], "Senior Rust Developer"); + assert_eq!(json["remaining_today"], 4); + assert_eq!(json["daily_limit"], 5); + assert_eq!(json["has_ai_pack"], false); + } + + #[test] + fn test_generate_field_response_with_ai_pack() { + let response = GenerateFieldResponse { + generated_text: "Generated content".to_string(), + remaining_today: 15, + daily_limit: 20, + has_ai_pack: true, + }; + let json = serde_json::to_value(&response).unwrap(); + assert_eq!(json["has_ai_pack"], true); + assert_eq!(json["daily_limit"], 20); + } + + #[test] + fn test_cover_letter_body_deserialization() { + let json = serde_json::json!({ + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "additional_notes": "Available from next month" + }); + let body: CoverLetterBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.job_id.to_string(), "550e8400-e29b-41d4-a716-446655440000"); + assert_eq!(body.additional_notes, Some("Available from next month".to_string())); + } + + #[test] + fn test_cover_letter_body_without_notes() { + let json = serde_json::json!({ + "job_id": "550e8400-e29b-41d4-a716-446655440000" + }); + let body: CoverLetterBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.additional_notes, None); + } + + #[test] + fn test_tailor_resume_body_deserialization() { + let json = serde_json::json!({ + "job_id": "550e8400-e29b-41d4-a716-446655440000", + "resume_text": "My existing resume..." + }); + let body: TailorResumeBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.job_id.to_string(), "550e8400-e29b-41d4-a716-446655440000"); + assert_eq!(body.resume_text, Some("My existing resume...".to_string())); + } + + #[test] + fn test_tailor_resume_body_without_resume() { + let json = serde_json::json!({ + "job_id": "550e8400-e29b-41d4-a716-446655440000" + }); + let body: TailorResumeBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.resume_text, None); + } + + #[test] + fn test_auto_apply_body_deserialization() { + let json = serde_json::json!({ + "job_ids": [ + "550e8400-e29b-41d4-a716-446655440000", + "550e8400-e29b-41d4-a716-446655440001" + ] + }); + let body: AutoApplyBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.job_ids.len(), 2); + } + + #[test] + fn test_auto_apply_response_serialization() { + let response = AutoApplyResponse { + applications_created: 2, + already_applied: vec![], + failed: vec![], + remaining_today: 8, + daily_limit: 10, + }; + let json = serde_json::to_value(&response).unwrap(); + assert_eq!(json["applications_created"], 2); + assert_eq!(json["remaining_today"], 8); + assert_eq!(json["daily_limit"], 10); + } + + #[test] + fn test_usage_status_response_serialization() { + let response = UsageStatusResponse { + remaining_today: 3, + daily_limit: 5, + has_ai_pack: false, + }; + let json = serde_json::to_value(&response).unwrap(); + assert_eq!(json["remaining_today"], 3); + assert_eq!(json["daily_limit"], 5); + assert_eq!(json["has_ai_pack"], false); + } + + #[test] + fn test_base_ai_limit_constant() { + assert_eq!(BASE_AI_LIMIT, 5); + } + + #[test] + fn test_get_ai_limit_from_features_with_value() { + let features = serde_json::json!({"ai_generations_per_day": 20}); + let limit = get_ai_limit_for_package(&features); + assert_eq!(limit, 20); + } + + #[test] + fn test_get_ai_limit_from_features_defaults_to_base() { + let features = serde_json::json!({}); + assert_eq!(get_ai_limit_for_package(&features), BASE_AI_LIMIT); + + let features_null = serde_json::json!({"ai_generations_per_day": null}); + assert_eq!(get_ai_limit_for_package(&features_null), BASE_AI_LIMIT); + + let features_wrong_type = serde_json::json!({"ai_generations_per_day": "unlimited"}); + assert_eq!(get_ai_limit_for_package(&features_wrong_type), BASE_AI_LIMIT); + } + + #[test] + fn test_invalid_field_error() { + let json = serde_json::json!({ + "field": "invalid_field", + "context": "test" + }); + let body: GenerateJobFieldBody = serde_json::from_value(json).unwrap(); + assert_eq!(body.field, "invalid_field"); + } } \ No newline at end of file diff --git a/crates/db/migrations/20260425000000_ai_usage.down.sql b/crates/db/migrations/20260425000000_ai_usage.down.sql new file mode 100644 index 0000000..952a6be --- /dev/null +++ b/crates/db/migrations/20260425000000_ai_usage.down.sql @@ -0,0 +1,9 @@ +BEGIN; + +ALTER TABLE job_applications DROP COLUMN IF EXISTS applied_via_ai; +ALTER TABLE job_seeker_profiles DROP COLUMN IF EXISTS has_ai_pack; + +DROP TABLE IF EXISTS company_ai_usage; +DROP TABLE IF EXISTS job_seeker_ai_usage; + +COMMIT; \ No newline at end of file diff --git a/crates/db/migrations/20260425000000_ai_usage.up.sql b/crates/db/migrations/20260425000000_ai_usage.up.sql new file mode 100644 index 0000000..2c59be5 --- /dev/null +++ b/crates/db/migrations/20260425000000_ai_usage.up.sql @@ -0,0 +1,38 @@ +-- AI usage tracking for companies and job seekers +-- Supports per-day rate limiting for AI generation features + +BEGIN; + +-- Track AI usage per company per day +CREATE TABLE IF NOT EXISTS company_ai_usage ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + company_id UUID NOT NULL REFERENCES company_profiles(id) ON DELETE CASCADE, + usage_date DATE NOT NULL DEFAULT CURRENT_DATE, + generations_used INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(company_id, usage_date) +); + +-- Track AI usage per job seeker per day +CREATE TABLE IF NOT EXISTS job_seeker_ai_usage ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_seeker_id UUID NOT NULL REFERENCES job_seeker_profiles(id) ON DELETE CASCADE, + usage_date DATE NOT NULL DEFAULT CURRENT_DATE, + generations_used INTEGER NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(), + UNIQUE(job_seeker_id, usage_date) +); + +-- Indexes for fast lookups +CREATE INDEX IF NOT EXISTS idx_company_ai_usage_company_date ON company_ai_usage(company_id, usage_date); +CREATE INDEX IF NOT EXISTS idx_job_seeker_ai_usage_seeker_date ON job_seeker_ai_usage(job_seeker_id, usage_date); + +-- Add applied_via_ai flag to job_applications for AI auto-apply tracking +ALTER TABLE job_applications ADD COLUMN IF NOT EXISTS applied_via_ai BOOLEAN DEFAULT false; + +-- Add ai_pack field to job_seeker_profiles for quick lookup (cached from pricing_packages) +ALTER TABLE job_seeker_profiles ADD COLUMN IF NOT EXISTS has_ai_pack BOOLEAN DEFAULT false; + +COMMIT; \ No newline at end of file From 42a9a171333d179890ff285e01d1ea09dc5b64e7 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 1 May 2026 03:02:46 +0200 Subject: [PATCH 167/182] Add Redis caching for AI generation rate limiting - Add cache::ai module with Redis rate limiting for AI generations - Add functions: check_ai_rate_limit, get_ai_usage, cache_ai_response, get_cached_ai_response, invalidate_ai_cache, reset_daily_usage - Update check_and_increment_usage to use Redis fast-path before DB - Redis key pattern: ai:rate:{user_id} for 24hr sliding window counter --- apps/users/src/handlers/ai.rs | 26 ++++++++++-- crates/cache/src/ai.rs | 80 +++++++++++++++++++++++++++++++++++ crates/cache/src/lib.rs | 1 + 3 files changed, 103 insertions(+), 4 deletions(-) create mode 100644 crates/cache/src/ai.rs diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs index 5b3f4e8..78f56f6 100644 --- a/apps/users/src/handlers/ai.rs +++ b/apps/users/src/handlers/ai.rs @@ -6,6 +6,7 @@ use axum::{ routing::{get, post}, Json, Router, }; +use cache::ai as ai_cache; use contracts::auth_middleware::AuthUser; use serde::{Deserialize, Serialize}; use std::sync::Arc; @@ -494,10 +495,23 @@ async fn has_active_ai_pack( async fn check_and_increment_usage( pool: &sqlx::PgPool, + redis: &mut cache::RedisPool, profile_id: Uuid, is_company: bool, daily_limit: i32, ) -> Result<(i32, i32), String> { + let user_id_str = profile_id.to_string(); + + // Fast path: check Redis first for rate limiting + let redis_allowed = ai_cache::check_ai_rate_limit(redis, &user_id_str, daily_limit as i64) + .await + .map_err(|e| e.to_string())?; + + if !redis_allowed { + return Err("Daily AI generation limit reached".to_string()); + } + + // DB is source of truth - check and increment let today = chrono::Utc::now().date_naive(); let table = if is_company { "company_ai_usage" } else { "job_seeker_ai_usage" }; let id_col = if is_company { "company_id" } else { "job_seeker_id" }; @@ -586,7 +600,8 @@ async fn ai_generate_job_field( } }; - let (used, limit) = match check_and_increment_usage(&state.pool, company_id, true, daily_limit).await { + let mut redis = state.redis.clone(); + let (used, limit) = match check_and_increment_usage(&state.pool, &mut redis, company_id, true, daily_limit).await { Ok((u, l)) => (u, l), Err(msg) => { return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response(); @@ -696,7 +711,8 @@ async fn ai_generate_cover_letter( } }; - let (used, limit) = match check_and_increment_usage(&state.pool, seeker_id, false, daily_limit).await { + let mut redis = state.redis.clone(); + let (used, limit) = match check_and_increment_usage(&state.pool, &mut redis, seeker_id, false, daily_limit).await { Ok((u, l)) => (u, l), Err(msg) => { return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response(); @@ -804,7 +820,8 @@ async fn ai_tailor_resume( } }; - let (used, limit) = match check_and_increment_usage(&state.pool, seeker_id, false, daily_limit).await { + let mut redis = state.redis.clone(); + let (used, limit) = match check_and_increment_usage(&state.pool, &mut redis, seeker_id, false, daily_limit).await { Ok((u, l)) => (u, l), Err(msg) => { return (StatusCode::TOO_MANY_REQUESTS, Json(serde_json::json!({ "error": msg }))).into_response(); @@ -938,6 +955,7 @@ async fn ai_auto_apply( let mut created = 0; let mut already = vec![]; let mut failed = vec![]; + let mut redis = state.redis.clone(); for job_id in &body.job_ids { let existing: Option = sqlx::query_scalar( @@ -1001,7 +1019,7 @@ async fn ai_auto_apply( Ok(r) => { if r.rows_affected() > 0 { created += 1; - let _ = check_and_increment_usage(&state.pool, seeker_id, false, daily_limit).await; + let _ = check_and_increment_usage(&state.pool, &mut redis, seeker_id, false, daily_limit).await; } else { already.push(*job_id); } diff --git a/crates/cache/src/ai.rs b/crates/cache/src/ai.rs new file mode 100644 index 0000000..bc0c4a0 --- /dev/null +++ b/crates/cache/src/ai.rs @@ -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 { + 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 { + let key = format!("ai:rate:{}", user_id); + let count: Option = 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, redis::RedisError> { + let key = format!("ai:resp:{}", prompt_hash); + let result: Option = 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(()) +} diff --git a/crates/cache/src/lib.rs b/crates/cache/src/lib.rs index 3bb5462..19b4084 100644 --- a/crates/cache/src/lib.rs +++ b/crates/cache/src/lib.rs @@ -4,5 +4,6 @@ pub mod rate_limit; pub mod token; pub mod lead; pub mod jobs; +pub mod ai; pub use client::{RedisPool, connect}; From 3703d70eb2a2fe871da0b1b371f0085d6c1e06df Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 1 May 2026 10:10:16 +0200 Subject: [PATCH 168/182] ci: add post-push registry prune (keep latest 1 SHA build) --- .gitea/scripts/registry_prune.py | 277 +++++++++++++++++++++++++++++++ .gitea/workflows/build.yaml | 16 ++ 2 files changed, 293 insertions(+) create mode 100644 .gitea/scripts/registry_prune.py diff --git a/.gitea/scripts/registry_prune.py b/.gitea/scripts/registry_prune.py new file mode 100644 index 0000000..12c40ec --- /dev/null +++ b/.gitea/scripts/registry_prune.py @@ -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() diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index b49963b..8a5b3c5 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -213,3 +213,19 @@ jobs: echo "Falling back to build without cache export for ${{ matrix.service }}" build_without_cache_export + + - 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 From 2a588b45d6b8e2f257ff6aea4d6fa2d46d61064f Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 1 May 2026 11:04:12 +0200 Subject: [PATCH 169/182] ci: update gitops with new SHA on each build (auto-deploy) --- .gitea/scripts/update-gitops.py | 145 ++++++++++++++++++++++++++++++++ .gitea/workflows/build.yaml | 29 +++++++ 2 files changed, 174 insertions(+) create mode 100644 .gitea/scripts/update-gitops.py diff --git a/.gitea/scripts/update-gitops.py b/.gitea/scripts/update-gitops.py new file mode 100644 index 0000000..03805a0 --- /dev/null +++ b/.gitea/scripts/update-gitops.py @@ -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() diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 8a5b3c5..9dba349 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -229,3 +229,32 @@ jobs: --username "$REGISTRY_USERNAME" \ --password "$REGISTRY_PASSWORD" \ --keep 1 + + - name: Update GitOps and trigger deployment + if: success() + continue-on-error: true + env: + GITEOPS_REPO: ${{ secrets.GITEOPS_REPO }} + GITEOPS_SSH_KEY: ${{ secrets.GITEOPS_SSH_KEY }} + run: | + set -euo pipefail + + # 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" From 09075087f07ef70dc13281944c1f2ce4bf7c2a29 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 1 May 2026 18:45:57 +0200 Subject: [PATCH 170/182] ci: skip gitops update if GITEOPS_REPO secret not set --- .gitea/workflows/build.yaml | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 9dba349..31a33c7 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -239,6 +239,11 @@ jobs: 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" From c66e63f87a11b36a86b7a6347ad42548a3c18595 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 1 May 2026 19:20:02 +0200 Subject: [PATCH 171/182] chore: force rebuild to restore image tags after prune From 2a24b2aa839526e3e8a791aa88133425443c208a Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 1 May 2026 21:36:05 +0200 Subject: [PATCH 172/182] chore: force rebuild gateway image From c443ff5b500240570106cb644b8c1561b825b064 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 1 May 2026 21:50:24 +0200 Subject: [PATCH 173/182] chore: trigger rebuild with real code change --- apps/gateway/src/main.rs | 1 + 1 file changed, 1 insertion(+) diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index ae02dc9..d756eba 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -1,3 +1,4 @@ +// Gateway service - routes requests to upstream services use axum::{ body::Body, extract::{Request, State}, From 2aba45c9fa1ffb7f9d5c86a46a054fc930f3e034 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 5 May 2026 17:21:56 +0200 Subject: [PATCH 174/182] feat: password reset via 6-digit code instead of token link - Generate 6-digit code instead of UUID token for password reset - Store in Redis with 15 min TTL (was 1 hour) - Update email template to show code instead of reset link - Update ResetPasswordPayload to accept code instead of token - Update send_password_reset_email to accept code parameter --- apps/users/src/handlers/admin_email.rs | 2 +- apps/users/src/handlers/auth.rs | 19 ++++++++-------- crates/cache/src/token.rs | 2 +- crates/email/src/lib.rs | 7 ++---- crates/email/templates/password-reset.html | 25 +++++----------------- 5 files changed, 18 insertions(+), 37 deletions(-) diff --git a/apps/users/src/handlers/admin_email.rs b/apps/users/src/handlers/admin_email.rs index cd64151..5ff7f2b 100644 --- a/apps/users/src/handlers/admin_email.rs +++ b/apps/users/src/handlers/admin_email.rs @@ -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 diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index bcedb1e..4466f18 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -78,7 +78,7 @@ pub struct ForgotPasswordPayload { #[derive(Deserialize)] pub struct ResetPasswordPayload { - pub token: String, + pub code: String, pub new_password: String, } @@ -634,23 +634,22 @@ async fn forgot_password( State(state): State, Json(payload): Json, ) -> Result)> { - 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::() % 1_000_000); 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 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, &token).await; + let _ = state.mail.send_password_reset_email(&user.email, &user_name, &code).await; Ok(silent_ok) } @@ -662,15 +661,15 @@ async fn reset_password( ) -> Result)> { 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::() - .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")); diff --git a/crates/cache/src/token.rs b/crates/cache/src/token.rs index abf3587..4ba4fba 100644 --- a/crates/cache/src/token.rs +++ b/crates/cache/src/token.rs @@ -12,7 +12,7 @@ use redis::AsyncCommands; use crate::RedisPool; const REFRESH_TTL: u64 = 30 * 24 * 3_600; // 30 days in seconds -const RESET_TTL: u64 = 3_600; // 1 hour +const RESET_TTL: u64 = 900; // 15 minutes // ── Refresh tokens ──────────────────────────────────────────────────────────── diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs index 40ce0df..1256207 100644 --- a/crates/email/src/lib.rs +++ b/crates/email/src/lib.rs @@ -335,13 +335,10 @@ impl Mailer { self.send_html(to, "Verify Your Email Address", html).await } - pub async fn send_password_reset_email(&self, to: &str, name: &str, token: &str) -> Result<()> { - let frontend_url = env::var("FRONTEND_URL").unwrap_or_else(|_| "https://nxtgauge.com".to_string()); - let reset_url = format!("{}/reset-password?token={}", frontend_url, token); - + pub async fn send_password_reset_email(&self, to: &str, name: &str, code: &str) -> Result<()> { let vars = HashMap::from([ ("first_name", name), - ("reset_url", &reset_url), + ("reset_code", code), ]); let html = self.template_engine.render("password-reset", vars)?; self.send_html(to, "Reset Your Password", html).await diff --git a/crates/email/templates/password-reset.html b/crates/email/templates/password-reset.html index dcf9830..8ff0d7a 100644 --- a/crates/email/templates/password-reset.html +++ b/crates/email/templates/password-reset.html @@ -3,35 +3,20 @@

Hi {{first_name}},

- We received a request to reset your password. Click the button below to set a + We received a request to reset your password. Enter the code below to set a new password:

-
- Reset Password +
+ {{reset_code}}

🔒 Security Notice

- This link will expire in 1 hour. If you didn't request + This code will expire in 15 minutes. If you didn't request this, please ignore this email and your password will remain unchanged.

-

- If the button doesn't work, copy and paste this link into your browser: -

-

- {{reset_url}} -

- -

Best regards,
The Nxtgauge Team

+

Best regards,
The Nxtgauge Team

\ No newline at end of file From f75a348fc7aedc9c8d8da8052c03c362f5030470 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 5 May 2026 17:44:40 +0200 Subject: [PATCH 175/182] feat(ai): add missing intents, admin guards, and validation checks - Add missing AI intents: generate_cover_letter, improve_resume, request_view_contact, auto_apply_job, unknown - Add is_internal_admin helper to prevent admin/super_admin users from using user-facing AI flows - Add admin guards to: ai_generate_job_field, ai_generate_cover_letter, ai_tailor_resume, ai_auto_apply, ai_auto_respond_to_lead - Add professional approval check in ai_auto_respond_to_lead - must be APPROVED status - Add tracecoin balance check before contact reveal (requires 30 tracecoins) - Add KB escalation: when no articles found, suggest creating support ticket - Add explicit unknown intent handler with helpful message --- apps/users/src/handlers/ai.rs | 89 +++++++++++++++++++++++++++++++++-- 1 file changed, 86 insertions(+), 3 deletions(-) diff --git a/apps/users/src/handlers/ai.rs b/apps/users/src/handlers/ai.rs index 78f56f6..271791a 100644 --- a/apps/users/src/handlers/ai.rs +++ b/apps/users/src/handlers/ai.rs @@ -95,7 +95,9 @@ async fn call_ollama(_state: &AppState, model: &str, prompt: &str) -> Result (String, f32) { let prompt = format!( - "Classify this user message into one intent category. Categories: ticket_creation, form_filling, help_search, job_description_generation, general. \ + "Classify this user message into one intent category. Categories: \ + ticket_creation, form_filling, help_search, job_description_generation, \ + generate_cover_letter, improve_resume, request_view_contact, auto_apply_job, unknown, general. \ Return ONLY the intent name, nothing else.\n\nMessage: {}", message ); @@ -109,14 +111,27 @@ async fn classify_intent(message: &str, ollama_base: &str, model: &str) -> (Stri "form_filling" => "form_filling", "help_search" => "help_search", "job_description_generation" => "job_description_generation", + "generate_cover_letter" => "generate_cover_letter", + "improve_resume" => "improve_resume", + "request_view_contact" => "request_view_contact", + "auto_apply_job" => "auto_apply_job", + "unknown" => "unknown", _ => "general", }; (intent.to_string(), confidence) } - Err(_) => ("general".to_string(), 0.5), + Err(_) => ("unknown".to_string(), 0.0), } } +fn is_internal_admin(auth: &AuthUser) -> bool { + let active = auth.claims.active_role.as_str(); + active == "ADMIN" + || active == "SUPER_ADMIN" + || auth.claims.roles.contains(&"ADMIN".to_string()) + || auth.claims.roles.contains(&"SUPER_ADMIN".to_string()) +} + async fn call_ollama_inline(base_url: &str, model: &str, prompt: &str) -> Result { let url = format!("{}/api/generate", base_url); let req = OllamaGenerateRequest { @@ -199,7 +214,8 @@ async fn ai_chat_message( } _ => { "I couldn't find any help articles matching your question. \ - Try rephrasing or contact support if you need further assistance." + If you need further assistance, I can help you create a support ticket instead. \ + Just describe your issue and I'll guide you through the ticket creation process." .to_string() } } @@ -247,6 +263,17 @@ async fn ai_chat_message( } } } + "unknown" => { + "I'm not sure I understand your request. I can help you with:\n\n\ + - Creating support tickets\n\ + - Searching help articles\n\ + - Generating job descriptions\n\ + - Writing cover letters\n\ + - Improving your resume\n\ + - Applying to jobs\n\ + - Requesting to view lead contacts\n\n\ + Could you please rephrase your request?".to_string() + } _ => { let system_prompt = "You are a helpful AI assistant for Nxtgauge platform. Provide clear, concise responses. \ If the user needs support, guide them to create a ticket."; @@ -570,6 +597,10 @@ async fn ai_generate_job_field( auth: AuthUser, Json(body): Json, ) -> impl IntoResponse { + if is_internal_admin(&auth) { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Admin users cannot use AI job description generation. Use the admin panel to manage jobs." }))).into_response(); + } + let company: Option = sqlx::query_scalar( "SELECT id FROM company_profiles WHERE user_id = $1" ) @@ -667,6 +698,10 @@ async fn ai_generate_cover_letter( auth: AuthUser, Json(body): Json, ) -> impl IntoResponse { + if is_internal_admin(&auth) { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Admin users cannot use AI cover letter generation." }))).into_response(); + } + let seeker: Option<(Uuid, String, Option, i32, Vec)> = sqlx::query_as( "SELECT id, full_name, summary, experience_years, skills FROM job_seeker_profiles WHERE user_id = $1" ) @@ -776,6 +811,10 @@ async fn ai_tailor_resume( auth: AuthUser, Json(body): Json, ) -> impl IntoResponse { + if is_internal_admin(&auth) { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Admin users cannot use AI resume tailoring." }))).into_response(); + } + let seeker: Option<(Uuid, String, Option, i32, Vec)> = sqlx::query_as( "SELECT id, full_name, summary, experience_years, skills FROM job_seeker_profiles WHERE user_id = $1" ) @@ -892,6 +931,10 @@ async fn ai_auto_apply( auth: AuthUser, Json(body): Json, ) -> impl IntoResponse { + if is_internal_admin(&auth) { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Admin users cannot use AI auto-apply." }))).into_response(); + } + if body.job_ids.is_empty() || body.job_ids.len() > 10 { return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Select 1-10 jobs at a time" }))).into_response(); } @@ -1057,6 +1100,10 @@ async fn ai_auto_respond_to_lead( auth: AuthUser, Json(body): Json, ) -> impl IntoResponse { + if is_internal_admin(&auth) { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Admin users cannot use AI contact reveal. Use the admin panel to manage leads." }))).into_response(); + } + let leads_service_url = std::env::var("LEADS_SERVICE_URL") .unwrap_or_else(|_| "http://localhost:9118".to_string()); @@ -1074,6 +1121,42 @@ async fn ai_auto_respond_to_lead( return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Profile not found" }))).into_response(); }; + let approval_status: Option = sqlx::query_scalar( + "SELECT status FROM user_role_profiles WHERE id = $1" + ) + .bind(profile_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + let Some(status) = approval_status else { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Profile not found" }))).into_response(); + }; + + if status != "APPROVED" { + return (StatusCode::FORBIDDEN, Json(serde_json::json!({ "error": "Your profile must be approved before you can request lead contact access. Please complete verification." }))).into_response(); + } + + let wallet: Option<(Uuid, i64)> = sqlx::query_as( + "SELECT id, balance FROM tracecoin_wallets WHERE user_id = $1" + ) + .bind(auth.user_id) + .fetch_optional(&state.pool) + .await + .ok() + .flatten(); + + let (wallet_id, balance) = match wallet { + Some((id, bal)) => (id, bal), + None => return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "Wallet not found. Please contact support." }))).into_response(), + }; + + let tracecoins_cost = 30; + if balance < tracecoins_cost as i64 { + return (StatusCode::PAYMENT_REQUIRED, Json(serde_json::json!({ "error": format!("Insufficient balance. You need {} Tracecoins but have {}. Please top up your wallet.", tracecoins_cost, balance) }))).into_response(); + } + let today = chrono::Utc::now().date_naive(); let used: Option = sqlx::query_scalar( "SELECT generations_used FROM job_seeker_ai_usage WHERE job_seeker_id = $1 AND usage_date = $2" From 324b00f53611d7eec2ab2a9f5064f3b1036699a3 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 5 May 2026 18:54:22 +0200 Subject: [PATCH 176/182] ci: trigger rebuild From e16b526fdce54171158d4b956ab0fb0d9c39ddb5 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 5 May 2026 19:14:54 +0200 Subject: [PATCH 177/182] ci: rebuild gateway with routing fix From f82d0c5153c841aa988fbf28109452cdb3ef3c6c Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 5 May 2026 19:22:10 +0200 Subject: [PATCH 178/182] chore: trigger gitea pipeline - rebuild gateway --- apps/gateway/src/main.rs | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index d756eba..8ce3020 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -271,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(); From a805c6db83f2b52d917e3b96ad94533b0f5af758 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 5 May 2026 20:26:48 +0200 Subject: [PATCH 179/182] chore: trigger gitea pipeline From 562932684834abe7db290e0010a2ee9195f9702f Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 5 May 2026 21:02:38 +0200 Subject: [PATCH 180/182] chore: trigger gitea pipeline From 486d1a8848d87bbf289f5fff9aeeacfa4f378155 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Tue, 5 May 2026 21:09:43 +0200 Subject: [PATCH 181/182] fix(ci): always update gitops and ensure high-performance-latest tag push - Change if: success() to if: always() on gitops update step - Add final fallback push with no cache if all builds fail - Ensure high-performance-latest is always pushed even on partial failures --- .gitea/workflows/build.yaml | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/build.yaml b/.gitea/workflows/build.yaml index 31a33c7..ab743cb 100644 --- a/.gitea/workflows/build.yaml +++ b/.gitea/workflows/build.yaml @@ -212,7 +212,15 @@ jobs: done echo "Falling back to build without cache export for ${{ matrix.service }}" - build_without_cache_export + 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() @@ -231,7 +239,7 @@ jobs: --keep 1 - name: Update GitOps and trigger deployment - if: success() + if: always() continue-on-error: true env: GITEOPS_REPO: ${{ secrets.GITEOPS_REPO }} From b16969a40feb3c3ed6d1371729935a52f3757f04 Mon Sep 17 00:00:00 2001 From: Tracewebstudio Dev Date: Fri, 8 May 2026 15:34:29 +0200 Subject: [PATCH 182/182] Update backend services: catering_services, companies, developers, gateway, job_seekers, photographers, social_media_managers, tutors, ugc_content_creators, users; update cache (otp, token), contracts (profession_shared, profession_state), db (job_seeker, verification), email; add revision-requested email template; update init-db.sql and start-services.sh --- Cargo.lock | 16 + apps/catering_services/Cargo.toml | 1 + apps/catering_services/src/main.rs | 4 +- apps/companies/Cargo.toml | 6 +- apps/companies/src/handlers/mod.rs | 226 ++++++++- apps/companies/src/main.rs | 11 +- apps/developers/Cargo.toml | 1 + apps/developers/src/main.rs | 4 +- apps/gateway/src/main.rs | 8 +- apps/graphic_designers/Cargo.toml | 1 + apps/graphic_designers/src/main.rs | 4 +- apps/job_seekers/Cargo.toml | 2 + apps/job_seekers/src/handlers.rs | 441 ++++++++++++++++-- apps/job_seekers/src/main.rs | 8 +- apps/makeup_artists/Cargo.toml | 1 + apps/makeup_artists/src/main.rs | 4 +- apps/photographers/Cargo.toml | 1 + apps/photographers/src/main.rs | 4 +- apps/social_media_managers/Cargo.toml | 1 + apps/social_media_managers/src/main.rs | 4 +- apps/tutors/Cargo.toml | 1 + apps/tutors/src/main.rs | 4 +- apps/ugc_content_creators/src/main.rs | 4 +- apps/users/src/handlers/approvals.rs | 115 ++++- apps/users/src/handlers/auth.rs | 17 +- apps/users/src/handlers/verifications.rs | 96 +++- apps/video_editors/Cargo.toml | 1 + apps/video_editors/src/main.rs | 4 +- crates/auth/examples/test_verify.rs | 23 + crates/cache/src/otp.rs | 6 +- crates/cache/src/token.rs | 5 +- crates/contracts/Cargo.toml | 8 +- crates/contracts/src/profession_shared.rs | 82 +++- crates/contracts/src/profession_state.rs | 6 +- crates/db/src/models/job_seeker.rs | 82 ++++ crates/db/src/models/verification.rs | 15 +- crates/email/src/lib.rs | 33 ++ .../email/templates/revision-requested.html | 52 +++ scripts/init-db.sql | 12 + start-services.sh | 80 +--- 40 files changed, 1246 insertions(+), 148 deletions(-) create mode 100644 crates/auth/examples/test_verify.rs create mode 100644 crates/email/templates/revision-requested.html diff --git a/Cargo.lock b/Cargo.lock index 6d5c0e9..5b7a6da 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -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", ] @@ -1114,6 +1121,7 @@ dependencies = [ "db", "serde", "sqlx", + "storage", "tokio", "tracing", "tracing-subscriber", @@ -1562,6 +1570,7 @@ dependencies = [ "db", "serde", "sqlx", + "storage", "tokio", "tracing", "tracing-subscriber", @@ -2062,10 +2071,12 @@ dependencies = [ "auth", "axum", "bytes", + "cache", "chrono", "contracts", "db", "email", + "redis", "serde", "serde_json", "sqlx", @@ -2278,6 +2289,7 @@ dependencies = [ "db", "serde", "sqlx", + "storage", "tokio", "tracing", "tracing-subscriber", @@ -2627,6 +2639,7 @@ dependencies = [ "db", "serde", "sqlx", + "storage", "tokio", "tracing", "tracing-subscriber", @@ -3412,6 +3425,7 @@ dependencies = [ "db", "serde", "sqlx", + "storage", "tokio", "tracing", "tracing-subscriber", @@ -4077,6 +4091,7 @@ dependencies = [ "db", "serde", "sqlx", + "storage", "tokio", "tracing", "tracing-subscriber", @@ -4241,6 +4256,7 @@ dependencies = [ "db", "serde", "sqlx", + "storage", "tokio", "tracing", "tracing-subscriber", diff --git a/apps/catering_services/Cargo.toml b/apps/catering_services/Cargo.toml index 8d07437..4dab636 100644 --- a/apps/catering_services/Cargo.toml +++ b/apps/catering_services/Cargo.toml @@ -16,4 +16,5 @@ db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } cache = { path = "../../crates/cache" } +storage = { path = "../../crates/storage" } diff --git a/apps/catering_services/src/main.rs b/apps/catering_services/src/main.rs index 8db6ba3..236c443 100644 --- a/apps/catering_services/src/main.rs +++ b/apps/catering_services/src/main.rs @@ -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()) diff --git a/apps/companies/Cargo.toml b/apps/companies/Cargo.toml index 3ce7588..bd1c980 100644 --- a/apps/companies/Cargo.toml +++ b/apps/companies/Cargo.toml @@ -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 } diff --git a/apps/companies/src/handlers/mod.rs b/apps/companies/src/handlers/mod.rs index 0c6fcf2..56efb39 100644 --- a/apps/companies/src/handlers/mod.rs +++ b/apps/companies/src/handlers/mod.rs @@ -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 { 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, 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::(&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, ) -> 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::(&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>(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>(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(), } } @@ -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>(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>(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 CONCAT(u.first_name, ' ', u.last_name) AS name, u.email, u.phone FROM users u WHERE u.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.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, + 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 = 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, Path(id): Path, diff --git a/apps/companies/src/main.rs b/apps/companies/src/main.rs index 14d0e75..7b93fa8 100644 --- a/apps/companies/src/main.rs +++ b/apps/companies/src/main.rs @@ -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, pub mail: Arc, + 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()) diff --git a/apps/developers/Cargo.toml b/apps/developers/Cargo.toml index 7109de3..94445f1 100644 --- a/apps/developers/Cargo.toml +++ b/apps/developers/Cargo.toml @@ -16,4 +16,5 @@ db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } cache = { path = "../../crates/cache" } +storage = { path = "../../crates/storage" } diff --git a/apps/developers/src/main.rs b/apps/developers/src/main.rs index 10a59f8..3571432 100644 --- a/apps/developers/src/main.rs +++ b/apps/developers/src/main.rs @@ -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()) diff --git a/apps/gateway/src/main.rs b/apps/gateway/src/main.rs index 8ce3020..56fea3e 100644 --- a/apps/gateway/src/main.rs +++ b/apps/gateway/src/main.rs @@ -132,6 +132,10 @@ impl Services { { Some(self.companies_url.clone()) } + // 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") @@ -144,10 +148,6 @@ impl Services { { Some(self.leads_url.clone()) } - // Job Seekers - else if path.starts_with("/api/jobseeker") { - Some(self.job_seekers_url.clone()) - } // Customers + Leads else if path.starts_with("/api/customers") || path.starts_with("/api/admin/customers") diff --git a/apps/graphic_designers/Cargo.toml b/apps/graphic_designers/Cargo.toml index 8b89f61..fe1083e 100644 --- a/apps/graphic_designers/Cargo.toml +++ b/apps/graphic_designers/Cargo.toml @@ -16,4 +16,5 @@ db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } cache = { path = "../../crates/cache" } +storage = { path = "../../crates/storage" } diff --git a/apps/graphic_designers/src/main.rs b/apps/graphic_designers/src/main.rs index 3b414f6..a1dde20 100644 --- a/apps/graphic_designers/src/main.rs +++ b/apps/graphic_designers/src/main.rs @@ -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()) diff --git a/apps/job_seekers/Cargo.toml b/apps/job_seekers/Cargo.toml index e75eb4c..e7000aa 100644 --- a/apps/job_seekers/Cargo.toml +++ b/apps/job_seekers/Cargo.toml @@ -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" } diff --git a/apps/job_seekers/src/handlers.rs b/apps/job_seekers/src/handlers.rs index 9c00ba1..671c180 100644 --- a/apps/job_seekers/src/handlers.rs +++ b/apps/job_seekers/src/handlers.rs @@ -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 { 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,6 +39,9 @@ pub struct JobBrowseQuery { pub location: Option, pub job_type: Option, pub search: Option, + pub skills: Option, + pub sort_by: Option, + pub order: Option, } #[derive(Deserialize)] @@ -55,8 +63,23 @@ async fn get_profile( State(state): State, 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::(&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(), } @@ -68,7 +91,13 @@ async fn update_profile( Json(payload): Json, ) -> 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(), } } @@ -168,35 +197,166 @@ async fn browse_jobs( State(state): State, Query(q): Query, ) -> 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::(&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, + description: String, + location: String, + job_type: String, + salary_min: Option, + salary_max: Option, + experience_years: Option, + skills: Option>, + status: String, + rejection_reason: Option, + expires_at: Option>, + approved_at: Option>, + approved_by: Option, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + 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> = 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() + } } } @@ -204,8 +364,47 @@ async fn get_job( State(state): State, Path(id): Path, ) -> 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, + description: String, + location: String, + job_type: String, + salary_min: Option, + salary_max: Option, + experience_years: Option, + skills: Option>, + status: String, + rejection_reason: Option, + expires_at: Option>, + approved_at: Option>, + approved_by: Option, + created_at: chrono::DateTime, + updated_at: chrono::DateTime, + 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(), @@ -245,14 +444,14 @@ 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)>( - "SELECT u.email, CONCAT(u.first_name, ' ', u.last_name) AS 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, 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, name))) = company_user { + 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, @@ -260,6 +459,20 @@ async fn apply_to_job( &job.title, &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() @@ -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, + 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, + 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, + auth: AuthUser, + Path(id): Path, +) -> 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(), + } +} diff --git a/apps/job_seekers/src/main.rs b/apps/job_seekers/src/main.rs index 59d6234..cee5f05 100644 --- a/apps/job_seekers/src/main.rs +++ b/apps/job_seekers/src/main.rs @@ -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, pub mail: Arc, + 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()) diff --git a/apps/makeup_artists/Cargo.toml b/apps/makeup_artists/Cargo.toml index 4119f07..1d2751d 100644 --- a/apps/makeup_artists/Cargo.toml +++ b/apps/makeup_artists/Cargo.toml @@ -16,4 +16,5 @@ db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } cache = { path = "../../crates/cache" } +storage = { path = "../../crates/storage" } diff --git a/apps/makeup_artists/src/main.rs b/apps/makeup_artists/src/main.rs index 5ead2cc..5e41dcb 100644 --- a/apps/makeup_artists/src/main.rs +++ b/apps/makeup_artists/src/main.rs @@ -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()) diff --git a/apps/photographers/Cargo.toml b/apps/photographers/Cargo.toml index 3d36a6d..8cfca67 100644 --- a/apps/photographers/Cargo.toml +++ b/apps/photographers/Cargo.toml @@ -16,4 +16,5 @@ db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } cache = { path = "../../crates/cache" } +storage = { path = "../../crates/storage" } diff --git a/apps/photographers/src/main.rs b/apps/photographers/src/main.rs index 2837a6c..dccae2d 100644 --- a/apps/photographers/src/main.rs +++ b/apps/photographers/src/main.rs @@ -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()) diff --git a/apps/social_media_managers/Cargo.toml b/apps/social_media_managers/Cargo.toml index 23c3b0c..cc1583b 100644 --- a/apps/social_media_managers/Cargo.toml +++ b/apps/social_media_managers/Cargo.toml @@ -16,4 +16,5 @@ db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } cache = { path = "../../crates/cache" } +storage = { path = "../../crates/storage" } diff --git a/apps/social_media_managers/src/main.rs b/apps/social_media_managers/src/main.rs index e831275..73d55fb 100644 --- a/apps/social_media_managers/src/main.rs +++ b/apps/social_media_managers/src/main.rs @@ -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()) diff --git a/apps/tutors/Cargo.toml b/apps/tutors/Cargo.toml index edd253a..99e427f 100644 --- a/apps/tutors/Cargo.toml +++ b/apps/tutors/Cargo.toml @@ -16,4 +16,5 @@ db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } cache = { path = "../../crates/cache" } +storage = { path = "../../crates/storage" } diff --git a/apps/tutors/src/main.rs b/apps/tutors/src/main.rs index 626eccb..5d94b0f 100644 --- a/apps/tutors/src/main.rs +++ b/apps/tutors/src/main.rs @@ -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()) diff --git a/apps/ugc_content_creators/src/main.rs b/apps/ugc_content_creators/src/main.rs index ca7dbe3..c13357b 100644 --- a/apps/ugc_content_creators/src/main.rs +++ b/apps/ugc_content_creators/src/main.rs @@ -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()) diff --git a/apps/users/src/handlers/approvals.rs b/apps/users/src/handlers/approvals.rs index 06ac634..1ffaaf2 100644 --- a/apps/users/src/handlers/approvals.rs +++ b/apps/users/src/handlers/approvals.rs @@ -218,14 +218,16 @@ async fn activate_profile_after_final_approval( }; let query = format!( - "UPDATE {} SET status = 'APPROVED', updated_at = NOW() WHERE id = $1", + "UPDATE {} SET status = 'ACTIVE', updated_at = NOW() WHERE id = $1", table ); 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?; @@ -250,6 +252,20 @@ async fn activate_profile_after_final_approval( .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(()) } @@ -308,6 +324,20 @@ async fn reject_profile_after_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(()) } @@ -437,15 +467,29 @@ async fn approve_job( ) .await; -let company_info = sqlx::query_as::<_, (String, String)>( - "SELECT CONCAT(u.first_name, ' ', u.last_name) AS u_full_name, u.email FROM company_profiles 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() @@ -487,16 +531,30 @@ async fn reject_job( ) .await; - let company_info = sqlx::query_as::<_, (String, String)>( - "SELECT CONCAT(u.first_name, ' ', u.last_name) AS u_full_name, u.email FROM company_profiles 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() @@ -536,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() } @@ -565,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() } diff --git a/apps/users/src/handlers/auth.rs b/apps/users/src/handlers/auth.rs index 4466f18..3590897 100644 --- a/apps/users/src/handlers/auth.rs +++ b/apps/users/src/handlers/auth.rs @@ -25,6 +25,7 @@ pub fn router() -> Router { .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)) @@ -48,6 +49,8 @@ pub struct RegisterPayload { pub intent: Option, #[serde(alias = "role_key", alias = "roleKey")] pub profession: Option, + #[serde(default)] + pub test_mode: Option, } #[derive(Deserialize)] @@ -102,6 +105,7 @@ pub struct RegisterResponse { pub status: String, pub email_verified: bool, pub created_at: String, + pub otp: Option, } #[derive(Serialize)] @@ -256,6 +260,7 @@ async fn register( Json(payload): Json, ) -> Result)> { 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 @@ -330,6 +335,7 @@ async fn register( // Store OTP in Redis (15-min TTL, keyed by code → user_id) let otp = format!("{:06}", rand::random::() % 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"))?; @@ -341,13 +347,9 @@ async fn register( error = %e, email = %user.email, endpoint = "/api/auth/register", - "Failed to send verification email" + "Failed to send verification email - OTP still stored in Redis" ); - return Err(err( - StatusCode::INTERNAL_SERVER_ERROR, - "Failed to send verification email", - "SMTP_ERROR", - )); + // OTP is already in Redis — do not fail registration if email sending fails } Ok((StatusCode::CREATED, Json(RegisterResponse { @@ -358,6 +360,7 @@ async fn register( status: user.status, email_verified: user.email_verified, created_at: user.created_at.to_rfc3339(), + otp: if test_mode { Some(otp) } else { None }, }))) } @@ -606,6 +609,7 @@ async fn resend_otp( } let otp = format!("{:06}", rand::random::() % 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"))?; @@ -642,6 +646,7 @@ async fn forgot_password( }; let code = format!("{:06}", rand::random::() % 1_000_000); + tracing::info!(otp = %code, email = %user.email, "OTP generated for password reset"); let mut redis = state.redis.clone(); cache::token::store_reset(&mut redis, &code, &user.id.to_string()) diff --git a/apps/users/src/handlers/verifications.rs b/apps/users/src/handlers/verifications.rs index 4522787..d626af7 100644 --- a/apps/users/src/handlers/verifications.rs +++ b/apps/users/src/handlers/verifications.rs @@ -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 { Router::new() .route("/", get(list_verifications)) @@ -147,6 +197,20 @@ async fn trigger_rejection( 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(()) @@ -173,12 +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 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(), @@ -333,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(), diff --git a/apps/video_editors/Cargo.toml b/apps/video_editors/Cargo.toml index f1d3044..ba47853 100644 --- a/apps/video_editors/Cargo.toml +++ b/apps/video_editors/Cargo.toml @@ -16,4 +16,5 @@ db = { path = "../../crates/db" } auth = { path = "../../crates/auth" } contracts = { path = "../../crates/contracts" } cache = { path = "../../crates/cache" } +storage = { path = "../../crates/storage" } diff --git a/apps/video_editors/src/main.rs b/apps/video_editors/src/main.rs index 9dad23f..38f7e08 100644 --- a/apps/video_editors/src/main.rs +++ b/apps/video_editors/src/main.rs @@ -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()) diff --git a/crates/auth/examples/test_verify.rs b/crates/auth/examples/test_verify.rs new file mode 100644 index 0000000..c20205d --- /dev/null +++ b/crates/auth/examples/test_verify.rs @@ -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()); +} diff --git a/crates/cache/src/otp.rs b/crates/cache/src/otp.rs index f5ab1f5..2b5c451 100644 --- a/crates/cache/src/otp.rs +++ b/crates/cache/src/otp.rs @@ -15,9 +15,13 @@ const RESEND_MAX: i64 = 3; // ── Store / verify ──────────────────────────────────────────────────────────── /// Store OTP code keyed by the code itself → user_id. TTL 15 min. +/// Also stores otp:plain:{user_id} → code for dev-test readability. pub async fn set(redis: &mut RedisPool, code: &str, user_id: &str) -> Result<(), redis::RedisError> { let key = format!("otp:code:{code}"); - redis.set_ex(key, user_id, OTP_TTL_SECS).await + let plain_key = format!("otp:plain:{user_id}"); + // Store both: code→user_id (for verification) and plain→code (for dev debugging) + redis.set_ex::<_, _, ()>(&plain_key, code, OTP_TTL_SECS).await?; + redis.set_ex::<_, _, ()>(key, user_id, OTP_TTL_SECS).await } /// Atomically fetch the user_id for this OTP and delete it (single-use). diff --git a/crates/cache/src/token.rs b/crates/cache/src/token.rs index 4ba4fba..0cc17c3 100644 --- a/crates/cache/src/token.rs +++ b/crates/cache/src/token.rs @@ -51,7 +51,10 @@ pub async fn store_reset( user_id: &str, ) -> Result<(), redis::RedisError> { let key = format!("reset:{token}"); - redis.set_ex(key, user_id, RESET_TTL).await + let plain_key = format!("otp:plain:{user_id}"); + // Store both: token→user_id (for verification) and plain→token (for dev debugging) + redis.set_ex::<_, _, ()>(&plain_key, token, RESET_TTL).await?; + redis.set_ex::<_, _, ()>(key, user_id, RESET_TTL).await } /// Atomically fetch and delete the reset token (single-use). diff --git a/crates/contracts/Cargo.toml b/crates/contracts/Cargo.toml index 41d85aa..c4966e2 100644 --- a/crates/contracts/Cargo.toml +++ b/crates/contracts/Cargo.toml @@ -4,7 +4,7 @@ version = "0.1.0" edition = "2021" [dependencies] -axum = { workspace = true } +axum = { workspace = true, features = ["multipart"] } serde = { workspace = true } serde_json = { workspace = true } tracing = { workspace = true } @@ -14,5 +14,7 @@ anyhow = { workspace = true } sqlx = { workspace = true } async-trait = { workspace = true } jsonwebtoken = "9.3" -db = { path = "../db" } -cache = { path = "../cache" } +db = { path = "../db" } +cache = { path = "../cache" } +storage = { path = "../storage" } +bytes.workspace = true diff --git a/crates/contracts/src/profession_shared.rs b/crates/contracts/src/profession_shared.rs index 4ecdab3..ae0286a 100644 --- a/crates/contracts/src/profession_shared.rs +++ b/crates/contracts/src/profession_shared.rs @@ -1,10 +1,11 @@ use axum::{ - extract::{Path, Query, State}, + extract::{Multipart, Path, Query, State}, http::StatusCode, response::IntoResponse, routing::{delete, get, patch, post}, Json, Router, }; +use bytes::BufMut; use chrono::Utc; use serde::Deserialize; use uuid::Uuid; @@ -41,6 +42,7 @@ pub fn shared_routes(profession_key: &'static str) -> Router { let pk = profession_key; move |state, auth| submit_for_verification(state, auth, pk) })) + .route("/profile/documents", post(upload_document)) // ── Marketplace (Redis-cached) ──────────────────────────────────────── .route( "/marketplace", @@ -803,3 +805,81 @@ async fn submit_for_verification( Err(e) => (StatusCode::INTERNAL_SERVER_ERROR, e.to_string()).into_response(), } } + +/// Upload a document (e.g. certificate, license) to B2 under the "documents" prefix. +/// Field name: "document" (or first file field). +async fn upload_document( + State(state): State, + auth: AuthUser, + mut multipart: Multipart, +) -> impl IntoResponse { + // Verify professional profile exists + match ProfessionalRepository::get_by_user_id(&state.pool, auth.user_id).await { + Ok(prof) if prof.user_id == auth.user_id => prof, + Ok(_) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional profile not found" }))).into_response(), + Err(sqlx::Error::RowNotFound) => return (StatusCode::NOT_FOUND, Json(serde_json::json!({ "error": "Professional 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; + + while let Ok(Some(field)) = multipart.next_field().await { + let name = field.name().unwrap_or("").to_string(); + if name == "document" || name == "file" || !found { + if let Some(ct) = field.content_type() { + content_type = ct.to_string(); + ext = match ct { + "image/jpeg" => "jpg", + "image/png" => "png", + "image/webp" => "webp", + "application/pdf" => "pdf", + _ => "bin", + } + .to_string(); + } else if let Some(fname) = field.file_name() { + 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(); + } + + // 10 MB limit + 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_bytes.put(data); + found = true; + break; + } + } + + if !found || file_bytes.is_empty() { + return (StatusCode::BAD_REQUEST, Json(serde_json::json!({ "error": "No document file provided. Send a multipart field named 'document'." }))).into_response(); + } + + // Upload to Backblaze B2 + let document_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(); + } + }; + + (StatusCode::OK, Json(serde_json::json!({ "url": document_url }))).into_response() +} diff --git a/crates/contracts/src/profession_state.rs b/crates/contracts/src/profession_state.rs index b278326..1c45871 100644 --- a/crates/contracts/src/profession_state.rs +++ b/crates/contracts/src/profession_state.rs @@ -1,10 +1,12 @@ use sqlx::PgPool; use cache::RedisPool; +use std::sync::Arc; /// Shared state for all 9 profession micro-services. /// Passed as the Axum router state — replaces the bare `PgPool`. #[derive(Clone)] pub struct ProfessionState { - pub pool: PgPool, - pub redis: RedisPool, + pub pool: PgPool, + pub redis: RedisPool, + pub storage: Arc, } diff --git a/crates/db/src/models/job_seeker.rs b/crates/db/src/models/job_seeker.rs index 4080368..033dd1d 100644 --- a/crates/db/src/models/job_seeker.rs +++ b/crates/db/src/models/job_seeker.rs @@ -3,6 +3,26 @@ use serde::{Deserialize, Serialize}; use sqlx::{FromRow, PgPool}; use uuid::Uuid; +#[derive(Debug, Serialize, Deserialize, FromRow, Clone)] +pub struct JobSeekerDocument { + pub id: Uuid, + pub job_seeker_id: Uuid, + pub document_type: String, + pub file_name: String, + pub file_url: String, + pub file_size: i64, + pub mime_type: String, + pub created_at: DateTime, +} + +#[derive(Debug, Serialize, Deserialize)] +pub struct CreateJobSeekerDocumentPayload { + pub document_type: String, + pub file_name: String, + pub file_size: i64, + pub mime_type: String, +} + #[derive(Debug, Serialize, Deserialize, FromRow)] pub struct JobSeekerProfile { pub id: Uuid, @@ -140,4 +160,66 @@ impl JobSeekerRepository { Ok(profile) } + + pub async fn create_document( + pool: &PgPool, + job_seeker_id: Uuid, + payload: CreateJobSeekerDocumentPayload, + file_url: String, + ) -> Result { + let doc = sqlx::query_as::<_, JobSeekerDocument>( + r#" + INSERT INTO job_seeker_documents ( + job_seeker_id, document_type, file_name, file_url, file_size, mime_type + ) + VALUES ($1, $2, $3, $4, $5, $6) + RETURNING + id, job_seeker_id, document_type, file_name, file_url, file_size, mime_type, created_at + "#, + ) + .bind(job_seeker_id) + .bind(payload.document_type) + .bind(payload.file_name) + .bind(file_url) + .bind(payload.file_size) + .bind(payload.mime_type) + .fetch_one(pool) + .await?; + + Ok(doc) + } + + pub async fn list_documents( + pool: &PgPool, + job_seeker_id: Uuid, + ) -> Result, sqlx::Error> { + let docs = sqlx::query_as::<_, JobSeekerDocument>( + r#" + SELECT id, job_seeker_id, document_type, file_name, file_url, file_size, mime_type, created_at + FROM job_seeker_documents + WHERE job_seeker_id = $1 + ORDER BY created_at DESC + "#, + ) + .bind(job_seeker_id) + .fetch_all(pool) + .await?; + + Ok(docs) + } + + pub async fn delete_document( + pool: &PgPool, + job_seeker_id: Uuid, + document_id: Uuid, + ) -> Result<(), sqlx::Error> { + sqlx::query( + "DELETE FROM job_seeker_documents WHERE id = $1 AND job_seeker_id = $2", + ) + .bind(document_id) + .bind(job_seeker_id) + .execute(pool) + .await?; + Ok(()) + } } diff --git a/crates/db/src/models/verification.rs b/crates/db/src/models/verification.rs index c05dfeb..7d34f0e 100644 --- a/crates/db/src/models/verification.rs +++ b/crates/db/src/models/verification.rs @@ -112,6 +112,19 @@ impl VerificationRepository { ) -> Result { let mut tx = pool.begin().await?; + // Validate actor_id exists in users table; if not, treat as NULL + // This handles cases where the token contains a user_id from an external auth system + let valid_actor_id = match actor_id { + Some(uid) => { + let exists = sqlx::query_scalar::<_, bool>("SELECT EXISTS(SELECT 1 FROM users WHERE id = $1)") + .bind(uid) + .fetch_one(&mut *tx) + .await?; + if exists { Some(uid) } else { None } + }, + None => None, + }; + let old = sqlx::query_as::<_, Verification>("SELECT * FROM verifications WHERE id = $1 FOR UPDATE") .bind(id) .fetch_one(&mut *tx) @@ -139,7 +152,7 @@ impl VerificationRepository { "# ) .bind(id) - .bind(actor_id) + .bind(valid_actor_id) .bind(&old.status) .bind(new_status) .bind(notes) diff --git a/crates/email/src/lib.rs b/crates/email/src/lib.rs index 1256207..1658ae7 100644 --- a/crates/email/src/lib.rs +++ b/crates/email/src/lib.rs @@ -141,6 +141,7 @@ impl TemplateEngine { "profile-verified" => Ok(include_str!("../templates/profile-verified.html")), "profile-rejected" => Ok(include_str!("../templates/profile-rejected.html")), "documents-requested" => Ok(include_str!("../templates/documents-requested.html")), + "revision-requested" => Ok(include_str!("../templates/revision-requested.html")), // Jobs "job-pending" => Ok(include_str!("../templates/job-pending.html")), @@ -653,6 +654,24 @@ impl Mailer { self.send_html(to, "Requirement Submitted Successfully", html).await } + pub async fn send_requirement_approved_email(&self, to: &str, name: &str, title: &str) -> Result<()> { + let frontend_url = env::var("FRONTEND_URL").unwrap_or_else(|_| "https://nxtgauge.com".to_string()); + let now = chrono::Local::now().format("%B %d, %Y").to_string(); + let expires = (chrono::Local::now() + chrono::Duration::days(30)).format("%B %d, %Y").to_string(); + let requirement_url = format!("{}/dashboard/requirements", frontend_url); + + let vars = HashMap::from([ + ("first_name", name), + ("requirement_title", title), + ("profession_type", "Service"), + ("approved_at", &now), + ("expires_at", &expires), + ("requirement_url", &requirement_url), + ]); + let html = self.template_engine.render("requirement-approved", vars)?; + self.send_html(to, "Your Requirement is Now Live!", html).await + } + pub async fn send_lead_accepted_customer_email(&self, to: &str, customer_name: &str, professional_name: &str, professional_email: &str, professional_phone: &str) -> Result<()> { let _vars = HashMap::from([ ("first_name", customer_name), @@ -769,6 +788,20 @@ impl Mailer { self.send_html(to, "Additional Documents Required", html).await } + pub async fn send_revision_requested_email(&self, to: &str, name: &str, role_name: &str, revision_request: &str) -> Result<()> { + let frontend_url = env::var("FRONTEND_URL").unwrap_or_else(|_| "https://nxtgauge.com".to_string()); + let profile_url = format!("{}/dashboard/profile", frontend_url); + + let vars = HashMap::from([ + ("first_name", name), + ("role_name", role_name), + ("revision_request", revision_request), + ("profile_url", &profile_url), + ]); + let html = self.template_engine.render("revision-requested", vars)?; + self.send_html(to, "Changes Required on Your Profile", html).await + } + // ── Application Status ────────────────────────────────────────────────────── pub async fn send_application_status_email(&self, to: &str, name: &str, job_title: &str, status: &str) -> Result<()> { diff --git a/crates/email/templates/revision-requested.html b/crates/email/templates/revision-requested.html new file mode 100644 index 0000000..22c441e --- /dev/null +++ b/crates/email/templates/revision-requested.html @@ -0,0 +1,52 @@ + +

Changes Required on Your Profile

+ +

Hi {{first_name}},

+

+ Our review team has reviewed your {{role_name}} profile and requires some + changes before your profile can be verified. +

+ +
+

📋 Changes Requested:

+

+ {{revision_request}} +

+
+ +
+
+ Profile Type + {{role_name}} +
+
+ Status + Revision Requested +
+
+ +

How to make changes:

+
    +
  1. Go to your profile page
  2. +
  3. Click on the "Edit Profile" section
  4. +
  5. Make the requested changes
  6. +
  7. Click "Resubmit for Verification"
  8. +
+ + + +
+

⏱️ Response Time

+

+ Please make the requested changes within 7 days to avoid delays + in your verification. +

+
+ +

Need help? Contact our support team.

+

Best regards,
The Nxtgauge Team

diff --git a/scripts/init-db.sql b/scripts/init-db.sql index fa8ae30..844f6cd 100644 --- a/scripts/init-db.sql +++ b/scripts/init-db.sql @@ -326,6 +326,18 @@ CREATE TABLE IF NOT EXISTS job_seeker_profiles ( updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW() ); +CREATE TABLE IF NOT EXISTS job_seeker_documents ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + job_seeker_id UUID NOT NULL REFERENCES job_seeker_profiles(id) ON DELETE CASCADE, + document_type VARCHAR(100) NOT NULL DEFAULT 'other', + file_name VARCHAR(255) NOT NULL, + file_url VARCHAR(500) NOT NULL, + file_size BIGINT NOT NULL DEFAULT 0, + mime_type VARCHAR(100) NOT NULL DEFAULT 'application/octet-stream', + created_at TIMESTAMPTZ NOT NULL DEFAULT NOW() +); +CREATE INDEX IF NOT EXISTS idx_job_seeker_documents_job_seeker_id ON job_seeker_documents(job_seeker_id); + -- ============================================================================ -- 7. PORTFOLIO DOMAIN (native content only, no external links) -- ============================================================================ diff --git a/start-services.sh b/start-services.sh index 1b47d96..7c333f5 100755 --- a/start-services.sh +++ b/start-services.sh @@ -1,66 +1,28 @@ #!/bin/bash -set -e +cd /Users/ashwin/workspace/nxtgauge-backend-rust +set -a && source .env && set +a -set -a -source .env -set +a +echo "Starting companies on port 9102..." +PORT=9102 RUST_LOG=info ./target/release/companies &>/dev/null & +echo "Started companies (PID: $!)" -# ── Initialize PostgreSQL database if needed ──────────────────────────────────── -echo "Initializing database..." +echo "Starting customers on port 9105..." +PORT=9105 RUST_LOG=info ./target/release/customers &>/dev/null & +echo "Started customers (PID: $!)" -# Use DATABASE_URL from .env to run init script -export PGPASSWORD=${POSTGRES_PASSWORD:-nxtgauge_dev} +echo "Starting employees on port 9106..." +PORT=9106 RUST_LOG=info ./target/release/employees &>/dev/null & +echo "Started employees (PID: $!)" -# Check if database is accessible and if the 'roles' table exists as a heuristic -if psql "${DATABASE_URL:-postgresql://nxtgauge:${POSTGRES_PASSWORD:-nxtgauge_dev}@localhost:5432/nxtgauge_db}" -c '\q' 2>/dev/null; then - # Try to see if the schema is already initialized (check for 'roles' table) - if ! psql "${DATABASE_URL:-postgresql://nxtgauge:${POSTGRES_PASSWORD:-nxtgauge_dev}@localhost:5432/nxtgauge_db}" -t -c "SELECT to_regname('roles');" 2>/dev/null | grep -q '^roles$'; then - echo "Applying database schema..." - psql "${DATABASE_URL:-postgresql://nxtgauge:${POSTGRES_PASSWORD:-nxtgauge_dev}@localhost:5432/nxtgauge_db}" -f scripts/init-db.sql - else - echo "Database schema already initialized." - fi -else - echo "ERROR: Cannot connect to PostgreSQL. Make sure PostgreSQL is running on localhost:5432." - echo "Start PostgreSQL and try again." - exit 1 -fi +echo "Starting cron..." +RUST_LOG=info ./target/release/cron &>/dev/null & +echo "Started cron (PID: $!)" -echo "Building workspace..." -cargo build --workspace +sleep 3 -echo "Stopping any previously running services..." -pkill -f "target/debug/gateway" || true -pkill -f "target/debug/users" || true -pkill -f "target/debug/companies" || true -pkill -f "target/debug/job_seekers" || true -pkill -f "target/debug/customers" || true -pkill -f "target/debug/photographers" || true -pkill -f "target/debug/makeup_artists" || true -pkill -f "target/debug/tutors" || true -pkill -f "target/debug/developers" || true -pkill -f "target/debug/video_editors" || true -pkill -f "target/debug/graphic_designers" || true -pkill -f "target/debug/social_media_managers" || true -pkill -f "target/debug/fitness_trainers" || true -pkill -f "target/debug/catering_services" || true -pkill -f "target/debug/ugc_content_creators" || true -pkill -f "target/debug/employees" || true - -apps=( - "gateway" "users" "companies" "job_seekers" "customers" - "photographers" "makeup_artists" "tutors" "developers" "video_editors" - "graphic_designers" "social_media_managers" "fitness_trainers" "catering_services" - "ugc_content_creators" "employees" -) - -for app in "${apps[@]}"; do - if [[ -x "./target/debug/$app" ]]; then - echo "Starting $app..." - nohup ./target/debug/$app > "$app.log" 2>&1 & - else - echo "Skipping $app (binary not found at ./target/debug/$app)" - fi -done - -echo "All available services booted up!" +echo "" +echo "=== Service Status ===" +lsof -i :9100 -i :9101 -i :9102 -i :9104 -i :9105 -i :9106 2>/dev/null | grep LISTEN | grep -v grep +echo "" +echo "=== Running Rust processes ===" +ps aux | grep -E "target/release/(companies|customers|employees|cron)" | grep -v grep