From b832395a4a5ffe60f46007dc02b2ffebaba4b74e Mon Sep 17 00:00:00 2001 From: Utsav joshi Date: Wed, 25 Mar 2026 23:27:47 +0530 Subject: [PATCH 01/15] feat: Implement AI rate limiting, enhance JobCard UI for chat, and add deployment and backup scripts. --- .github/SECRETS.md | 119 ++--- DEPLOYMENT.md | 182 +++++++ README.md | 182 +++++++ apps/api/package.json | 5 +- apps/api/src/controllers/auth.controller.ts | 2 +- apps/api/src/controllers/user.controller.ts | 15 +- apps/api/src/lib/redis.ts | 20 + apps/api/src/middleware/ai-rate-limit.ts | 107 ++++ apps/api/src/middleware/strict-rate-limit.ts | 31 +- .../src/middleware/token-bucket-rate-limit.ts | 154 ++++++ apps/api/src/server.ts | 112 +++-- apps/api/src/services/cache.service.ts | 116 +++++ apps/api/src/services/chat.service.ts | 116 ++++- apps/web/index.html | 9 +- apps/web/src/App.tsx | 2 - apps/web/src/components/chat/JobCarousel.tsx | 70 ++- apps/web/src/components/jobs/JobCard.tsx | 427 +++++++++++----- apps/web/src/hooks/useSSEChat.ts | 1 - apps/web/src/lib/api-client.ts | 3 +- docker-compose.prod.yml | 74 +-- package-lock.json | 471 +++++++++++++++--- packages/database/src/schema.ts | 2 + packages/logger/src/index.ts | 58 ++- scripts/add-hnsw-indexes.sql | 29 ++ scripts/backup.sh | 68 +++ 25 files changed, 1963 insertions(+), 412 deletions(-) create mode 100644 DEPLOYMENT.md create mode 100644 apps/api/src/lib/redis.ts create mode 100644 apps/api/src/middleware/ai-rate-limit.ts create mode 100644 apps/api/src/middleware/token-bucket-rate-limit.ts create mode 100644 apps/api/src/services/cache.service.ts create mode 100644 scripts/add-hnsw-indexes.sql create mode 100644 scripts/backup.sh diff --git a/.github/SECRETS.md b/.github/SECRETS.md index 4934d9c..8c3f960 100644 --- a/.github/SECRETS.md +++ b/.github/SECRETS.md @@ -1,113 +1,58 @@ # GitHub Secrets Configuration -This document lists all the secrets that need to be configured in GitHub repository settings for CI/CD pipelines to work properly. +All secrets needed for CI/CD pipelines. Configure in **Settings → Secrets and variables → Actions**. ## Required Secrets -### Docker Hub (for docker-build job) +### VPS Deployment -- **DOCKER_USERNAME** - Your Docker Hub username -- **DOCKER_PASSWORD** - Your Docker Hub password or access token +| Secret | Description | Example | +| ------------- | ----------------------------------- | ------------- | +| `VPS_HOST` | VPS IP address or hostname | `203.0.113.1` | +| `VPS_USER` | SSH username on VPS | `deploy` | +| `VPS_PORT` | SSH port | `22` | +| `VPS_SSH_KEY` | Private SSH key for the deploy user | Full PEM key | -### Code Coverage (optional) +### Container Registry (GHCR) -- **CODECOV_TOKEN** - Codecov token for uploading coverage reports - - Get from: https://codecov.io/ - -### Deployment Secrets - -#### Staging Environment - -- **STAGING_DATABASE_URL** - PostgreSQL connection string for staging - - Format: `postgresql://user:password@host:port/database` - -#### Production Environment - -- **PRODUCTION_DATABASE_URL** - PostgreSQL connection string for production - - Format: `postgresql://user:password@host:port/database` - -### Notifications (optional) - -- **SLACK_WEBHOOK_URL** - Slack webhook URL for deployment notifications - - Create at: https://api.slack.com/messaging/webhooks +> [!NOTE] +> GHCR uses `GITHUB_TOKEN` automatically — no additional secrets needed for pushing images. ## How to Add Secrets 1. Go to your GitHub repository -2. Click on **Settings** → **Secrets and variables** → **Actions** +2. Click **Settings** → **Secrets and variables** → **Actions** 3. Click **New repository secret** -4. Add each secret with its corresponding value +4. Add each secret listed above -## Environment-Specific Secrets +## VPS Setup Checklist -### Staging +Before the deploy workflow can succeed, ensure the VPS has: -Go to **Settings** → **Environments** → **staging** → **Add secret** +1. Docker and Docker Compose installed +2. The `deploy` user with docker group access +3. Project directory at `/opt/postly` with `.env` file (`chmod 600`) +4. GHCR login configured: `docker login ghcr.io -u -p ` +5. SSH key added to `~/.ssh/authorized_keys` for the deploy user -### Production - -Go to **Settings** → **Environments** → **production** → **Add secret** - -## Security Best Practices - -1. **Never commit secrets to the repository** -2. **Rotate secrets regularly** (every 3-6 months) -3. **Use environment-specific secrets** for staging vs production -4. **Limit secret access** to specific workflows/environments -5. **Enable branch protection** for main and develop branches -6. **Require approval** for production deployments - -## Additional Configuration - -### Branch Protection Rules (Recommended) +## Branch Protection Rules (Recommended) For `main` branch: - ✅ Require pull request reviews before merging -- ✅ Require status checks to pass before merging - - lint - - type-check - - test - - build +- ✅ Require status checks to pass (lint, type-check, test, build) - ✅ Require branches to be up to date before merging -- ✅ Require conversation resolution before merging - ✅ Do not allow bypassing the above settings -For `develop` branch: - -- ✅ Require status checks to pass before merging -- ✅ Require branches to be up to date before merging - -### Environment Protection Rules - -For `production` environment: - -- ✅ Required reviewers (at least 1) -- ✅ Wait timer: 5 minutes -- ✅ Deployment branches: Only `main` and tags matching `v*` - -For `staging` environment: - -- ✅ Deployment branches: Only `main` and `develop` - -## Verifying Configuration - -After adding secrets, you can verify they're working by: - -1. Pushing to a feature branch -2. Creating a pull request to `develop` or `main` -3. Check that CI workflow runs successfully -4. For deployment, merge to `main` and verify deployment workflow - -## Troubleshooting - -If workflows fail due to missing secrets: - -1. Check workflow logs for specific error messages -2. Verify secret names match exactly (case-sensitive) -3. Ensure secrets are set in the correct environment -4. Check that workflow has permission to access the secret +## Rollback -## Contact +Every deploy tags images with the Git SHA. To rollback: -For questions about secrets configuration, contact your DevOps team or repository administrator. +```bash +ssh deploy@ +cd /opt/postly +export API_IMAGE=ghcr.io//api: +export SCRAPER_IMAGE=ghcr.io//scraper: +export BOT_IMAGE=ghcr.io//bot: +docker compose -f docker-compose.prod.yml up -d --no-deps api bot scraper +``` diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..85e1d0e --- /dev/null +++ b/DEPLOYMENT.md @@ -0,0 +1,182 @@ +# Postly — Production Deployment Guide + +Complete runbook for deploying Postly to a VPS. + +--- + +## Architecture Overview + +``` +VPS Proxy (Nginx/Traefik) → API (Express) + → Static Web (Vite build) + +Internal Docker Network: + API ←→ PostgreSQL (pgvector) + API ←→ Redis (BullMQ + Caching) + Scraper ←→ PostgreSQL + Bot ←→ PostgreSQL + Redis +``` + +**Stack:** Node.js API · Python Scraper · Python Discord Bot · PostgreSQL 16 + pgvector · Redis 7 + +--- + +## Quick-Start Checklist + +``` +□ 1. Clone repo to /opt/postly, create .env +□ 2. docker compose -f docker-compose.prod.yml up -d +□ 3. Verify all services healthy +□ 4. Configure GitHub Actions secrets +□ 5. Push to main → verify pipeline runs +□ 6. Set up cron backup job +□ 7. Run a backup restore drill +``` + +--- + +## Step-by-Step Deployment + +### 1. Clone and Configure + +Log into your VPS and run: + +```bash +cd /opt/postly +git clone https://github.com/.git . + +# Create production .env from template +cp .env.production.example .env +chmod 600 .env + +# Edit .env — fill in all CHANGE_ME values +nano .env +``` + +**Critical .env values to set:** + +- `DB_PASSWORD` — Strong random password (`openssl rand -hex 16`) +- `JWT_SECRET` / `JWT_REFRESH_SECRET` — (`openssl rand -hex 32`) +- `DISCORD_BOT_TOKEN` — From Discord Developer Portal +- `WEB_URL` — Your production domain (e.g., `https://postly.io`) + +### 2. Login to GHCR + +```bash +# Login to pull pre-built images from GitHub Container Registry +echo "" | docker login ghcr.io -u --password-stdin +``` + +### 3. Start the Stack + +```bash +docker compose -f docker-compose.prod.yml up -d +``` + +Verify all services are healthy: + +```bash +docker compose -f docker-compose.prod.yml ps +curl -s http://localhost:3000/health | jq +``` + +Expected health response: + +```json +{ + "status": "ok", + "checks": { "db": "ok", "redis": "ok" }, + "uptime": 12.345 +} +``` + +### 4. Run HNSW Index Migration (One-time) + +```bash +docker exec -i postly-postgres psql -U postly -d postly < scripts/add-hnsw-indexes.sql +``` + +### 5. Setup Backups + +```bash +# Make backup script executable +chmod +x scripts/backup.sh + +# Test it manually first +bash scripts/backup.sh + +# Add to cron (runs daily at 2 AM) +(crontab -l 2>/dev/null; echo "0 2 * * * /opt/postly/scripts/backup.sh >> /var/log/postly-backup.log 2>&1") | crontab - +``` + +### 6. Configure GitHub Actions + +Add these secrets in **Settings → Secrets → Actions**: + +| Secret | Value | +| ------------- | -------------------------- | +| `VPS_HOST` | Your VPS IP address | +| `VPS_USER` | `deploy` | +| `VPS_PORT` | `22` | +| `VPS_SSH_KEY` | Full private SSH key (PEM) | + +Push to `main` and verify the pipeline deploys successfully. + +--- + +## Rollback + +Every deploy tags images with the Git SHA. To rollback: + +```bash +cd /opt/postly +export API_IMAGE=ghcr.io//api: +export SCRAPER_IMAGE=ghcr.io//scraper: +export BOT_IMAGE=ghcr.io//bot: +docker compose -f docker-compose.prod.yml up -d --no-deps api bot scraper +``` + +--- + +## Backup Restore Drill + +Run this monthly to verify backups work: + +```bash +# Start a throwaway Postgres container +docker run -d --name pg-restore-test -e POSTGRES_PASSWORD=test pgvector/pgvector:pg16 + +# Restore latest backup into it +docker exec -i pg-restore-test pg_restore -U postgres -d postgres --create < backups/local/$(ls -t backups/local/ | head -1) + +# Verify data +docker exec pg-restore-test psql -U postgres -d postly -c "SELECT count(*) FROM users;" + +# Clean up +docker rm -f pg-restore-test +``` + +--- + +## Scaling Roadmap + +| Users | Action | Cost Impact | +| ------- | ----------------------------------------------- | ----------- | +| 0–1K | Current setup, no changes | — | +| 1K–10K | Add Postgres read replica (second VPS) | +€4.5/mo | +| 10K–50K | Extract scraper to own VPS, add pgBouncer | +€4.5/mo | +| 50K+ | Consider managed DB, split into domain services | Variable | + +--- + +## File Reference + +| File | Purpose | +| ------------------------------ | -------------------------------- | +| `docker-compose.prod.yml` | Main production stack | +| `scripts/backup.sh` | Daily PostgreSQL backup | +| `scripts/add-hnsw-indexes.sql` | pgvector HNSW indexes (run once) | +| `.env.production.example` | Production env template | +| `.github/workflows/deploy.yml` | CI/CD pipeline | +| `.github/workflows/ci.yml` | PR checks | +| `.github/SECRETS.md` | GitHub secrets reference | diff --git a/README.md b/README.md index e69de29..85e1d0e 100644 --- a/README.md +++ b/README.md @@ -0,0 +1,182 @@ +# Postly — Production Deployment Guide + +Complete runbook for deploying Postly to a VPS. + +--- + +## Architecture Overview + +``` +VPS Proxy (Nginx/Traefik) → API (Express) + → Static Web (Vite build) + +Internal Docker Network: + API ←→ PostgreSQL (pgvector) + API ←→ Redis (BullMQ + Caching) + Scraper ←→ PostgreSQL + Bot ←→ PostgreSQL + Redis +``` + +**Stack:** Node.js API · Python Scraper · Python Discord Bot · PostgreSQL 16 + pgvector · Redis 7 + +--- + +## Quick-Start Checklist + +``` +□ 1. Clone repo to /opt/postly, create .env +□ 2. docker compose -f docker-compose.prod.yml up -d +□ 3. Verify all services healthy +□ 4. Configure GitHub Actions secrets +□ 5. Push to main → verify pipeline runs +□ 6. Set up cron backup job +□ 7. Run a backup restore drill +``` + +--- + +## Step-by-Step Deployment + +### 1. Clone and Configure + +Log into your VPS and run: + +```bash +cd /opt/postly +git clone https://github.com/.git . + +# Create production .env from template +cp .env.production.example .env +chmod 600 .env + +# Edit .env — fill in all CHANGE_ME values +nano .env +``` + +**Critical .env values to set:** + +- `DB_PASSWORD` — Strong random password (`openssl rand -hex 16`) +- `JWT_SECRET` / `JWT_REFRESH_SECRET` — (`openssl rand -hex 32`) +- `DISCORD_BOT_TOKEN` — From Discord Developer Portal +- `WEB_URL` — Your production domain (e.g., `https://postly.io`) + +### 2. Login to GHCR + +```bash +# Login to pull pre-built images from GitHub Container Registry +echo "" | docker login ghcr.io -u --password-stdin +``` + +### 3. Start the Stack + +```bash +docker compose -f docker-compose.prod.yml up -d +``` + +Verify all services are healthy: + +```bash +docker compose -f docker-compose.prod.yml ps +curl -s http://localhost:3000/health | jq +``` + +Expected health response: + +```json +{ + "status": "ok", + "checks": { "db": "ok", "redis": "ok" }, + "uptime": 12.345 +} +``` + +### 4. Run HNSW Index Migration (One-time) + +```bash +docker exec -i postly-postgres psql -U postly -d postly < scripts/add-hnsw-indexes.sql +``` + +### 5. Setup Backups + +```bash +# Make backup script executable +chmod +x scripts/backup.sh + +# Test it manually first +bash scripts/backup.sh + +# Add to cron (runs daily at 2 AM) +(crontab -l 2>/dev/null; echo "0 2 * * * /opt/postly/scripts/backup.sh >> /var/log/postly-backup.log 2>&1") | crontab - +``` + +### 6. Configure GitHub Actions + +Add these secrets in **Settings → Secrets → Actions**: + +| Secret | Value | +| ------------- | -------------------------- | +| `VPS_HOST` | Your VPS IP address | +| `VPS_USER` | `deploy` | +| `VPS_PORT` | `22` | +| `VPS_SSH_KEY` | Full private SSH key (PEM) | + +Push to `main` and verify the pipeline deploys successfully. + +--- + +## Rollback + +Every deploy tags images with the Git SHA. To rollback: + +```bash +cd /opt/postly +export API_IMAGE=ghcr.io//api: +export SCRAPER_IMAGE=ghcr.io//scraper: +export BOT_IMAGE=ghcr.io//bot: +docker compose -f docker-compose.prod.yml up -d --no-deps api bot scraper +``` + +--- + +## Backup Restore Drill + +Run this monthly to verify backups work: + +```bash +# Start a throwaway Postgres container +docker run -d --name pg-restore-test -e POSTGRES_PASSWORD=test pgvector/pgvector:pg16 + +# Restore latest backup into it +docker exec -i pg-restore-test pg_restore -U postgres -d postgres --create < backups/local/$(ls -t backups/local/ | head -1) + +# Verify data +docker exec pg-restore-test psql -U postgres -d postly -c "SELECT count(*) FROM users;" + +# Clean up +docker rm -f pg-restore-test +``` + +--- + +## Scaling Roadmap + +| Users | Action | Cost Impact | +| ------- | ----------------------------------------------- | ----------- | +| 0–1K | Current setup, no changes | — | +| 1K–10K | Add Postgres read replica (second VPS) | +€4.5/mo | +| 10K–50K | Extract scraper to own VPS, add pgBouncer | +€4.5/mo | +| 50K+ | Consider managed DB, split into domain services | Variable | + +--- + +## File Reference + +| File | Purpose | +| ------------------------------ | -------------------------------- | +| `docker-compose.prod.yml` | Main production stack | +| `scripts/backup.sh` | Daily PostgreSQL backup | +| `scripts/add-hnsw-indexes.sql` | pgvector HNSW indexes (run once) | +| `.env.production.example` | Production env template | +| `.github/workflows/deploy.yml` | CI/CD pipeline | +| `.github/workflows/ci.yml` | PR checks | +| `.github/SECRETS.md` | GitHub secrets reference | diff --git a/apps/api/package.json b/apps/api/package.json index 6dba678..70fe208 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -16,8 +16,9 @@ "@dodopayments/express": "^0.2.6", "@postly/config": "*", "@postly/database": "*", + "@postly/logger": "*", "@postly/shared-types": "*", - "bcryptjs": "^2.4.3", + "bcrypt": "^5.1.1", "bullmq": "^5.31.3", "cors": "^2.8.6", "dotenv": "^16.4.7", @@ -34,7 +35,7 @@ "devDependencies": { "@postly/eslint-config": "*", "@postly/typescript-config": "*", - "@types/bcryptjs": "^2.4.6", + "@types/bcrypt": "^5.0.2", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/jsonwebtoken": "^9.0.7", diff --git a/apps/api/src/controllers/auth.controller.ts b/apps/api/src/controllers/auth.controller.ts index 4c990dd..40ec620 100644 --- a/apps/api/src/controllers/auth.controller.ts +++ b/apps/api/src/controllers/auth.controller.ts @@ -1,5 +1,5 @@ import { Request, Response, NextFunction } from "express"; -import bcrypt from "bcryptjs"; +import bcrypt from "bcrypt"; import jwt, { type SignOptions } from "jsonwebtoken"; import crypto from "crypto"; import { z } from "zod"; diff --git a/apps/api/src/controllers/user.controller.ts b/apps/api/src/controllers/user.controller.ts index 8d2043b..45b5458 100644 --- a/apps/api/src/controllers/user.controller.ts +++ b/apps/api/src/controllers/user.controller.ts @@ -7,6 +7,7 @@ import { subscriptionQueries, } from "@postly/database"; import type { JwtPayload } from "../middleware/auth.js"; +import { CacheService } from "../services/cache.service.js"; const updateProfileSchema = z.object({ full_name: z.string().min(1).max(100).optional(), @@ -50,7 +51,14 @@ export class UserController { ): Promise => { try { const payload = req.user as JwtPayload; - const user = await userQueries.findById(payload.id); + const cacheKey = CacheService.generateKey("user:profile", payload.id); + + const user = await CacheService.getOrSet( + cacheKey, + 300, // 5 minutes TTL + async () => await userQueries.findById(payload.id), + ); + if (!user) { res .status(404) @@ -97,6 +105,11 @@ export class UserController { .json({ success: false, error: { message: "User not found" } }); return; } + + // Invalidate profile cache + const cacheKey = CacheService.generateKey("user:profile", payload.id); + await CacheService.invalidate(cacheKey); + res.json({ success: true, data: updated }); } catch (error) { next(error); diff --git a/apps/api/src/lib/redis.ts b/apps/api/src/lib/redis.ts new file mode 100644 index 0000000..e2248d1 --- /dev/null +++ b/apps/api/src/lib/redis.ts @@ -0,0 +1,20 @@ +import { Redis } from "ioredis"; +import { REDIS_URL } from "../config/secrets.js"; + +/** + * Shared Redis client for the API. + * + * Reuses the same connection pool for multiple features (rate limiting, health checks, etc.) + * to keep the connection count low and stable. + */ +export const redis = new Redis(REDIS_URL || "redis://localhost:6379", { + maxRetriesPerRequest: 1, + connectTimeout: 5000, +}); + +redis.on("error", (err) => { + // We log but don't crash — features should "fail open" if Redis is down + console.error("Shared Redis connection error:", err); +}); + +export default redis; diff --git a/apps/api/src/middleware/ai-rate-limit.ts b/apps/api/src/middleware/ai-rate-limit.ts new file mode 100644 index 0000000..c81eb2c --- /dev/null +++ b/apps/api/src/middleware/ai-rate-limit.ts @@ -0,0 +1,107 @@ +import { Request, Response, NextFunction } from "express"; +import { NODE_ENV } from "../config/secrets.js"; +import { redis } from "../lib/redis.js"; +import type { JwtPayload } from "./auth.js"; + +interface AIRateLimitConfig { + /** Max AI calls per window */ + maxCalls: number; + /** Window duration in seconds (default: 86400 = 24 hours) */ + windowSeconds: number; + /** Key prefix for Redis */ + keyPrefix: string; +} + +const DEFAULT_CONFIG: AIRateLimitConfig = { + maxCalls: 50, + windowSeconds: 86400, // 24 hours + keyPrefix: "ai:ratelimit", +}; + +/** + * Check if a user has exceeded their AI call rate limit. + * Returns true if the user is within limits, false if exceeded. + */ +export async function checkAIRateLimit( + userId: string, + config: Partial = {}, +): Promise<{ allowed: boolean; remaining: number; resetAt: Date }> { + const { maxCalls, windowSeconds, keyPrefix } = { + ...DEFAULT_CONFIG, + ...config, + }; + + const key = `${keyPrefix}:${userId}`; + const count = await redis.incr(key); + + // Set TTL on first increment + if (count === 1) { + await redis.expire(key, windowSeconds); + } + + const ttl = await redis.ttl(key); + const resetAt = new Date(Date.now() + ttl * 1000); + + return { + allowed: count <= maxCalls, + remaining: Math.max(0, maxCalls - count), + resetAt, + }; +} + +/** + * Express middleware that enforces per-user AI rate limiting. + * + * Usage: + * router.post("/ai/analyze", authenticate, aiRateLimitMiddleware(), handler); + * router.post("/ai/embed", authenticate, aiRateLimitMiddleware({ maxCalls: 100 }), handler); + */ +export function aiRateLimitMiddleware(config: Partial = {}) { + return async (req: Request, res: Response, next: NextFunction) => { + // Skip in development + if (NODE_ENV !== "production") { + next(); + return; + } + + const user = req.user as JwtPayload | undefined; + if (!user?.id) { + res.status(401).json({ + success: false, + error: { message: "Authentication required" }, + }); + return; + } + + try { + const { allowed, remaining, resetAt } = await checkAIRateLimit( + user.id, + config, + ); + + // Set AI specific rate limit headers to prevent collision with global API limits + res.setHeader("X-AI-RateLimit-Limit", config.maxCalls || 50); + res.setHeader("X-AI-RateLimit-Remaining", remaining); + res.setHeader("X-AI-RateLimit-Reset", resetAt.toISOString()); + + if (!allowed) { + res.status(429).json({ + success: false, + error: { + message: + "Daily AI usage limit reached. Upgrade your plan for more.", + resetAt: resetAt.toISOString(), + remaining: 0, + }, + }); + return; + } + + next(); + } catch (error) { + // Fail open — don't block users if Redis is down + console.error("AI rate limiting error:", error); + next(); + } + }; +} diff --git a/apps/api/src/middleware/strict-rate-limit.ts b/apps/api/src/middleware/strict-rate-limit.ts index 70616de..b322ad7 100644 --- a/apps/api/src/middleware/strict-rate-limit.ts +++ b/apps/api/src/middleware/strict-rate-limit.ts @@ -1,14 +1,8 @@ import { Request, Response, NextFunction } from "express"; -import { Redis } from "ioredis"; -import { REDIS_URL, NODE_ENV } from "../config/secrets.js"; +import { NODE_ENV } from "../config/secrets.js"; +import { redis } from "../lib/redis.js"; import type { JwtPayload } from "./auth.js"; -// Initialize Redis client -const redis = new Redis(REDIS_URL || "redis://localhost:6379"); -redis.on("error", (err) => { - console.error("Redis (strict-rate-limit) connection error:", err); -}); - interface RateLimitConfig { windowMs: number; max: number | ((req: Request) => number | Promise); @@ -53,11 +47,28 @@ export const createStrictRateLimiter = (config: RateLimitConfig) => { // Get current count const currentCount = await redis.get(key); const count = currentCount ? parseInt(currentCount, 10) : 0; + const remaining = Math.max(0, maxLimit - count); - if (count >= maxLimit && maxLimit !== Infinity) { + let resetDate: Date; + if (count > 0) { const ttl = await redis.ttl(key); - const resetDate = new Date(Date.now() + ttl * 1000); + resetDate = new Date(Date.now() + ttl * 1000); + } else { + resetDate = new Date(Date.now() + config.windowMs); + } + + // Set AI specific rate limit headers + res.setHeader( + "X-AI-RateLimit-Limit", + maxLimit === Infinity ? "Infinity" : maxLimit.toString(), + ); + res.setHeader( + "X-AI-RateLimit-Remaining", + maxLimit === Infinity ? "Infinity" : remaining.toString(), + ); + res.setHeader("X-AI-RateLimit-Reset", resetDate.toISOString()); + if (count >= maxLimit && maxLimit !== Infinity) { res.status(429).json({ success: false, error: { diff --git a/apps/api/src/middleware/token-bucket-rate-limit.ts b/apps/api/src/middleware/token-bucket-rate-limit.ts new file mode 100644 index 0000000..44a8f78 --- /dev/null +++ b/apps/api/src/middleware/token-bucket-rate-limit.ts @@ -0,0 +1,154 @@ +import { Request, Response, NextFunction } from "express"; +import { redis } from "../lib/redis.js"; +import jwt from "jsonwebtoken"; + +interface RateLimitConfig { + maxTokens: number; + refillRateSec: number; + keyPrefix?: string; +} + +// Token Bucket algorithm using Redis Lua script to prevent race conditions. +// KEYS[1] = bucket key +// ARGV[1] = max capacity +// ARGV[2] = refill rate per second +// ARGV[3] = current time in ms +// ARGV[4] = requested tokens +const tokenBucketScript = ` + local key = KEYS[1] + local capacity = tonumber(ARGV[1]) + local refill_rate_per_sec = tonumber(ARGV[2]) + local now_ms = tonumber(ARGV[3]) + local requested = tonumber(ARGV[4]) + + local bucket = redis.call("HMGET", key, "tokens", "last_refill") + local tokens = tonumber(bucket[1]) + local last_refill = tonumber(bucket[2]) + + if not tokens then + tokens = capacity + last_refill = now_ms + else + local time_passed_ms = math.max(0, now_ms - last_refill) + local accrued = (time_passed_ms / 1000) * refill_rate_per_sec + tokens = math.min(capacity, tokens + accrued) + end + + local granted = 0 + if tokens >= requested then + tokens = tokens - requested + granted = 1 + end + + redis.call("HMSET", key, "tokens", tostring(tokens), "last_refill", tostring(now_ms)) + -- TTL is enough time for bucket to refill completely + local ttl = math.ceil(capacity / refill_rate_per_sec) + 1 + redis.call("EXPIRE", key, ttl) + + return { granted, tostring(tokens) } +`; + +// Register the Lua script. +// ioredis adds it to the client instance as 'consumeTokenBucket'. +redis.defineCommand("consumeTokenBucket", { + numberOfKeys: 1, + lua: tokenBucketScript, +}); + +/** + * Token Bucket API Rate Limiting Middleware + * + * - Algorithm: Token Bucket mapping directly to tokens refilled per second + * - Identifier: Decoded JWT User ID for authenticated users, falling back to IP Address. + * - Concurrency: Handled atomically via Redis Lua script. + * - Fail-Safe: If Redis is down, it fails open (allows traffic). + * + * @param config { RateLimitConfig } + */ +export const tokenBucketRateLimiter = (config: RateLimitConfig) => { + const { maxTokens, refillRateSec, keyPrefix = "rl:tb" } = config; + + return async (req: Request, res: Response, next: NextFunction) => { + try { + if (redis.status !== "ready") { + // Fail Open behavior if Redis is not connected + return next(); + } + + // Identifier: Start with IP Address + let identifier = req.ip || "unknown-ip"; + + // Attempt to decode the JWT to use User ID as identifier + const authHeader = req.headers["authorization"]; + if (authHeader && authHeader.startsWith("Bearer ")) { + const token = authHeader.split(" ")[1]; + try { + // We decode instead of verifying here because + // this middleware might run before the auth middleware + // and decoding is faster for identifying rate limits per user + const decoded = jwt.decode(token) as { id?: string }; + if (decoded && decoded.id) { + identifier = decoded.id; + } + } catch { + // ignore invalid tokens, fallback to IP + } + } + + const key = `${keyPrefix}:${identifier}`; + const nowMs = Date.now(); + const requested = 1; + + // Executing the predefined Lua Script + const [grantedResult, currentTokensResult] = (await ( + redis as any + ).consumeTokenBucket( + key, + maxTokens, + refillRateSec, + nowMs, + requested, + )) as [number, string]; + + const granted = grantedResult === 1; + const currentTokens = parseFloat(currentTokensResult); + const remaining = Math.max(0, Math.floor(currentTokens)); + + // Calculate when the user will have at least 1 token again + let resetMs = nowMs; + if (!granted && currentTokens < 1) { + const tokensNeeded = 1 - currentTokens; + resetMs = nowMs + (tokensNeeded / refillRateSec) * 1000; + } + + // Inject standard HTTP headers + res.setHeader("X-RateLimit-Limit", maxTokens.toString()); + res.setHeader("X-RateLimit-Remaining", remaining.toString()); + // X-RateLimit-Reset is typically epoch timestamp in seconds + res.setHeader("X-RateLimit-Reset", Math.ceil(resetMs / 1000).toString()); + + if (granted) { + return next(); + } + + // Bucket is empty, return HTTP 429 Too Many Requests + return res.status(429).json({ + success: false, + error: { + code: "too_many_requests", + message: "Too many requests. Please try again later.", + limit: maxTokens, + remaining: remaining, + reset_at: Math.ceil(resetMs / 1000), + }, + }); + } catch (err) { + // Fail Open logic: Allow traffic if Lua script fails or Redis throws an error + console.error("Token Bucket Rate Limiter Error:", err); + // Ensure we haven't already sent headers + if (!res.headersSent) { + return next(); + } + } + }; +}; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 157b477..ed49e31 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -1,7 +1,9 @@ import express from "express"; import cors from "cors"; import helmet from "helmet"; -import rateLimit from "express-rate-limit"; +import { tokenBucketRateLimiter } from "./middleware/token-bucket-rate-limit.js"; +import { pool } from "@postly/database"; +import { logger } from "@postly/logger"; import { API_PORT, WEB_URL, NODE_ENV } from "./config/secrets.js"; import { errorHandler } from "./middleware/error-handler.js"; import { notFoundHandler } from "./middleware/not-found.js"; @@ -17,6 +19,8 @@ import { queueService } from "./services/queue.service.js"; const app = express(); +import { redis as healthRedis } from "./lib/redis.js"; + // Security middleware app.use( helmet({ @@ -58,28 +62,45 @@ app.use( }), ); -const globalLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 100, - standardHeaders: true, - legacyHeaders: false, - message: { - success: false, - error: { message: "Too many requests, please try again later" }, - }, +const globalLimiter = tokenBucketRateLimiter({ + maxTokens: 100, + refillRateSec: 10, // 10 tokens per second refill + keyPrefix: "rl:global", }); -const authLimiter = rateLimit({ - windowMs: 15 * 60 * 1000, - max: 50, - standardHeaders: true, - legacyHeaders: false, - message: { - success: false, - error: { - message: "Too many authentication attempts, please try again later", - }, - }, +const authLimiter = tokenBucketRateLimiter({ + maxTokens: 50, + refillRateSec: 5, // 5 tokens per second refill + keyPrefix: "rl:auth", +}); + +// Health check — registered BEFORE rate limiter so it's never throttled +app.get("/health", async (_req, res) => { + const checks: Record = {}; + + // Check Postgres + try { + await pool.query("SELECT 1"); + checks.db = "ok"; + } catch { + checks.db = "failed"; + } + + // Check Redis + try { + await healthRedis.ping(); + checks.redis = "ok"; + } catch { + checks.redis = "failed"; + } + + const allHealthy = checks.db === "ok" && checks.redis === "ok"; + res.status(allHealthy ? 200 : 503).json({ + status: allHealthy ? "ok" : "degraded", + checks, + uptime: process.uptime(), + timestamp: new Date().toISOString(), + }); }); app.use(globalLimiter); @@ -87,8 +108,23 @@ app.use(globalLimiter); app.use(express.json({ limit: "10mb" })); app.use(express.urlencoded({ extended: true, limit: "10mb" })); -app.get("/health", (_req, res) => { - res.json({ status: "ok", timestamp: new Date().toISOString() }); +// Request logging middleware (structured for production log aggregation) +app.use((req, res, next) => { + const start = Date.now(); + res.on("finish", () => { + const duration = Date.now() - start; + // Only log in production or for slow requests + if (NODE_ENV === "production" || duration > 1000) { + logger.info("request", { + method: req.method, + url: req.url, + status: res.statusCode, + duration_ms: duration, + user_id: (req as any).user?.id || null, + }); + } + }); + next(); }); // API routes @@ -113,34 +149,20 @@ app.listen(API_PORT, "0.0.0.0", async () => { // Initialize Discord Job Queue try { await queueService.initDailyCron(); - // Schedule daily dispatch at 9:00 AM UTC - const now = new Date(); - const target = new Date(now); - target.setUTCHours(9, 0, 0, 0); - if (target <= now) target.setDate(target.getDate() + 1); - const msUntilFirst = target.getTime() - now.getTime(); - const DAY_MS = 24 * 60 * 60 * 1000; - - setTimeout(() => { - queueService.dispatchAll(); - setInterval(() => queueService.dispatchAll(), DAY_MS); - }, msUntilFirst); - - console.log(`📅 Discord daily job dispatch cron initialized (9:00 AM)`); } catch (err) { console.error("Failed to initialize Discord Queue:", err); } }); -// Graceful shutdown -process.on("SIGTERM", () => { - console.log("SIGTERM received, shutting down gracefully..."); +// Graceful shutdown — close all connections before exiting +const shutdown = async (signal: string) => { + logger.info(`${signal} received, shutting down gracefully...`); + healthRedis.disconnect(); + await pool.end(); process.exit(0); -}); +}; -process.on("SIGINT", () => { - console.log("SIGINT received, shutting down gracefully..."); - process.exit(0); -}); +process.on("SIGTERM", () => shutdown("SIGTERM")); +process.on("SIGINT", () => shutdown("SIGINT")); export default app; diff --git a/apps/api/src/services/cache.service.ts b/apps/api/src/services/cache.service.ts new file mode 100644 index 0000000..ac052fa --- /dev/null +++ b/apps/api/src/services/cache.service.ts @@ -0,0 +1,116 @@ +import { redis } from "../lib/redis.js"; + +/** + * Standardized Cache Service + * Provides generic cache-aside wrapping with graceful degradation. + */ +export class CacheService { + private static readonly APP_NAME = "postly"; + private static readonly VERSION = "v1"; + + /** + * Generates a namespaced, standardized key to prevent collisions. + * Format: app_name:version:entity:id + * + * @example + * CacheService.generateKey('user', '123') // => "postly:v1:user:123" + */ + public static generateKey(entity: string, id: string | number): string { + return `${this.APP_NAME}:${this.VERSION}:${entity}:${id}`; + } + + /** + * Generic Cache-Aside Wrapper (Fail-Open) + * + * Attempts to fetch data from the KV store. On miss or redis error, + * falls back to the provided `fetchFunction`, caches the result, + * and returns the data. + * + * @param key Fully qualified cache key (use `generateKey` for consistency). + * @param ttlSeconds Time-to-live in seconds. + * @param fetchFunction Async function to execute on cache miss (e.g. DB query). + * @returns The cached or freshly fetched data. + */ + public static async getOrSet( + key: string, + ttlSeconds: number, + fetchFunction: () => Promise, + ): Promise { + try { + // 1. Check Redis for existing data + const cachedData = await redis.get(key); + if (cachedData) { + return JSON.parse(cachedData) as T; + } + } catch (error) { + // Log warning but DO NOT crash (Graceful Degradation) + console.warn(`[CacheService] Redis GET failed for key "${key}":`, error); + } + + // 2. On miss (or Redis failure), execute the source DB query + const freshData = await fetchFunction(); + + // 3. Attempt to save the fresh data to the cache + try { + // Only cache non-null/non-undefined results + if (freshData !== undefined && freshData !== null) { + // Run SET asynchronously to not block returning the response + redis.setex(key, ttlSeconds, JSON.stringify(freshData)).catch((err) => { + console.warn( + `[CacheService] Background Redis SETEX failed for key "${key}":`, + err, + ); + }); + } + } catch (error) { + console.warn( + `[CacheService] Redis SETEX synchronous error for key "${key}":`, + error, + ); + } + + // 4. Return data immediately + return freshData; + } + + /** + * Invalidates a specific key. + */ + public static async invalidate(key: string): Promise { + try { + await redis.del(key); + } catch (error) { + console.warn(`[CacheService] Redis DEL failed for key "${key}":`, error); + } + } + + /** + * Invalidates all keys matching a pattern using a non-blocking SCAN operation. + * + * @example CacheService.invalidatePattern('postly:v1:user:*') + */ + public static async invalidatePattern(pattern: string): Promise { + try { + let cursor = "0"; + do { + // Scan in chunks of 100 to avoid blocking the Redis event loop + const [nextCursor, matchingKeys] = await redis.scan( + cursor, + "MATCH", + pattern, + "COUNT", + "100", + ); + cursor = nextCursor; + if (matchingKeys.length > 0) { + await redis.del(...matchingKeys); + } + } while (cursor !== "0"); + } catch (error) { + console.warn( + `[CacheService] Redis pattern invalidation failed for "${pattern}":`, + error, + ); + } + } +} diff --git a/apps/api/src/services/chat.service.ts b/apps/api/src/services/chat.service.ts index a05bf1b..584e6e3 100644 --- a/apps/api/src/services/chat.service.ts +++ b/apps/api/src/services/chat.service.ts @@ -3,6 +3,7 @@ import { conversationQueries, resumeQueries, jobQueries, + userQueries, } from "@postly/database"; import { matchingService } from "./matching.service.js"; import type { @@ -18,6 +19,47 @@ interface MatchedJob extends Job { ai_explanation?: string; } +interface JobIntent { + isRelated: boolean; + isSpecific: boolean; + techKeywords: string[]; + allKeywords: string[]; +} + +/** + * More precise intent detection for job-related queries + */ +function getJobIntent(message: string): JobIntent { + const techKeywords = [ + "react", "frontend", "backend", "fullstack", "node", "python", "java", "typescript", + "javascript", "html", "css", "vue", "angular", "aws", "cloud", "devops", + "structural", "environmental", "civil", "mechanical", "electrical" + ]; + const levelKeywords = [ + "software", "engineer", "developer", "designer", "architect", "manager", "lead", + "senior", "junior", "intern", "graduate", "entry", "level", "remote", "hybrid" + ]; + const generalKeywords = [ + "job", "career", "hiring", "opportunity", "opening", "position", + "vacancy", "work", "stack", "hire", "recruiting", "talent", + "apply", "application", "resume", "cv", "salary", "role", "brief", + "looking for", "hunting", "find", "search" + ]; + const lowercaseMsg = message.toLowerCase(); + + const foundTech = techKeywords.filter(kw => lowercaseMsg.includes(kw)); + const foundLevel = levelKeywords.filter(kw => lowercaseMsg.includes(kw)); + const hasGeneral = generalKeywords.some(kw => lowercaseMsg.includes(kw)); + + return { + isRelated: foundTech.length > 0 || foundLevel.length > 0 || hasGeneral || message.length > 50, + isSpecific: foundTech.length > 0 || foundLevel.length > 0, + techKeywords: foundTech, + allKeywords: [...foundTech, ...foundLevel] + }; +} +// ... (omitting helper for brevity in diff) + // Helper to transform raw job data to UI-ready format function toOptimizedJobMatch(job: MatchedJob): OptimizedJobMatch { const formatSalary = (min?: number, max?: number): string | undefined => { @@ -69,14 +111,16 @@ export class ChatService { userMessage, ); - // 2. Get conversation context - const conversation = await conversationQueries.findById( - conversationId, - userId, - ); + // 2. Get conversation context and user context + const [conversation, user] = await Promise.all([ + conversationQueries.findById(conversationId, userId), + userQueries.findById(userId) + ]); if (!conversation) { throw new Error("Conversation not found"); } + + const userRole = user?.role || "job_seeker"; const messages = await conversationQueries.getMessages(conversationId); @@ -86,21 +130,24 @@ export class ChatService { // 4. Load resume context and job matches if available let resumeContext = ""; let jobMatches: MatchedJob[] = []; + const intent = getJobIntent(userMessage); if (effectiveResumeId) { const resume = await resumeQueries.findById(effectiveResumeId); if (resume?.parsed_text) { resumeContext = `\n\nUser's Resume Summary:\n- Skills: ${resume.skills?.join(", ") || "Not specified"}\n- Experience: ${resume.experience_years || 0} years\n- Summary: ${resume.parsed_text.substring(0, 1000)}`; - // Find matching jobs based on resume - try { - jobMatches = await matchingService.findMatchingJobs( - effectiveResumeId, - userId, - 5, // Limit to top 5 - ); - } catch (err) { - console.error("Failed to fetch job matches:", err); + // Find matching jobs based on resume ONLY if not employer AND intent is related + if (userRole !== "employer" && userRole !== "admin" && intent.isRelated) { + try { + jobMatches = await matchingService.findMatchingJobs( + effectiveResumeId, + userId, + 5, // Limit to top 5 + ); + } catch (err) { + console.error("Failed to fetch job matches:", err); + } } } @@ -111,7 +158,8 @@ export class ChatService { } // 4b. FALLBACK: If no resume or no matches, fetch recent active jobs - if (jobMatches.length === 0) { + // Only do this if the user is not an employer AND intent is related + if (jobMatches.length === 0 && userRole !== "employer" && userRole !== "admin" && intent.isRelated) { try { const recentJobs = await jobQueries.findActive(undefined, 5, 0); jobMatches = recentJobs.map((job: Job) => ({ @@ -123,6 +171,24 @@ export class ChatService { } } + // 4c. FILTER: If intent is specific, ensure matches are actually relevant. + // If user specified tech keywords, at least one must match. + // Otherwise, at least one level/general keyword must match. + if (intent.isSpecific && jobMatches.length > 0) { + jobMatches = jobMatches.filter(job => { + const searchSpace = ( + (job.title || "") + " " + + (job.description || "") + " " + + (job.skills_required?.join(" ") || "") + ).toLowerCase(); + + if (intent.techKeywords.length > 0) { + return intent.techKeywords.some((kw: string) => searchSpace.includes(kw)); + } + return intent.allKeywords.some((kw: string) => searchSpace.includes(kw)); + }); + } + // 5. Build system prompt with job context let jobContext = ""; if (jobMatches.length > 0) { @@ -135,21 +201,27 @@ export class ChatService { .join("\n")}`; } - const systemPrompt = `You are an AI career assistant helping with resume analysis and job search. + let roleSpecificInstructions = ""; + if (userRole === "employer") { + roleSpecificInstructions = "You are an AI assistant helping an employer looking to hire candidates. Your ONLY function is to help with hiring, evaluating candidates, and posting jobs."; + } else { + roleSpecificInstructions = "You are an AI career assistant helping with resume analysis and job search."; + } + const systemPrompt = `${roleSpecificInstructions} +${userRole !== "employer" ? ` Your capabilities: - Analyze resumes and provide constructive feedback - Suggest relevant job opportunities from our database - Offer career advice and interview tips - Help with job applications - +` : ""} IMPORTANT INSTRUCTIONS: -1. When the user asks for jobs, ALWAYS reference the jobs listed below if any are available. These are REAL jobs from our database. -2. Summarize the available jobs briefly and let the user know they can see the full details in the job cards. -3. DO NOT invent or hallucinate job listings. Only mention jobs that are explicitly listed in "Available job opportunities" or "Matching job opportunities" section below. -4. If no jobs are listed below, inform the user that no jobs are currently available in our database. +1. ${userRole === "employer" ? "Focus STRICTLY on helping the employer with hiring. UNDER NO CIRCUMSTANCES should you suggest, mention, or offer job listings, career advice, or job search help to an employer. If asked for jobs, politely clarify your role." : "When the user explicitly asks for jobs or career opportunities, reference the jobs listed below. If they just say 'hi' or make small talk, respond conversationally without bringing up jobs."} +2. DO NOT invent or hallucinate facts. +${userRole !== "employer" ? "3. DO NOT hallucinate job listings. Only mention jobs explicitly listed in the context below.\n4. If the user asks for jobs and none are listed, inform the user that no jobs are currently available." : ""} -Be professional, encouraging, and concise.${resumeContext}${jobContext}`; +Be professional, encouraging, and concise.${resumeContext}${userRole !== "employer" ? jobContext : ""}`; // 6. Prepare conversation history const conversationHistory = messages diff --git a/apps/web/index.html b/apps/web/index.html index e49b108..ea8376d 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -5,9 +5,16 @@ - Postly - AI Job Matching + Postly - Search Jobs + + + import("@pages/TransmissionLanding").then((m) => ({ default: m.TransmissionLanding, @@ -41,7 +40,6 @@ const TransmissionNotFound = lazy(() => })), ); -// ─── Auth utilities ───────────────────────────────────────────────── const ForgotPasswordPage = lazy(() => import("./pages/ForgotPasswordPage").then((m) => ({ default: m.ForgotPasswordPage, diff --git a/apps/web/src/components/chat/JobCarousel.tsx b/apps/web/src/components/chat/JobCarousel.tsx index 65d1478..88699c6 100644 --- a/apps/web/src/components/chat/JobCarousel.tsx +++ b/apps/web/src/components/chat/JobCarousel.tsx @@ -15,13 +15,44 @@ export function JobCarousel({ onApply, }: JobCarouselProps) { return ( -
- {message &&

{message}

} +
+ {message && ( +

+ {message} +

+ )} {/* Scroll Container */} -
+
{data.map((job) => ( -
+
))} @@ -29,14 +60,39 @@ export function JobCarousel({ {/* Actions */} {suggested_actions && suggested_actions.length > 0 && ( -
+
{suggested_actions.map((action, i) => ( ))}
diff --git a/apps/web/src/components/jobs/JobCard.tsx b/apps/web/src/components/jobs/JobCard.tsx index e733958..68725a9 100644 --- a/apps/web/src/components/jobs/JobCard.tsx +++ b/apps/web/src/components/jobs/JobCard.tsx @@ -57,26 +57,275 @@ export function JobCard({ const { display_info, matching_data, meta } = job; const isChat = variant === "chat"; + if (isChat) { + return ( +
{ + e.currentTarget.style.transform = "translate(-2px, -2px)"; + e.currentTarget.style.boxShadow = "6px 6px 0 var(--tx-border)"; + }} + onMouseLeave={(e) => { + e.currentTarget.style.transform = "translate(0, 0)"; + e.currentTarget.style.boxShadow = "4px 4px 0 var(--tx-border)"; + }} + > + {/* Match Score */} + {matching_data.match_score > 0 && ( +
+
+ {matching_data.match_score}% MATCH +
+
+ )} + + {/* Content */} +
+

+ {display_info.title} +

+ +
+ + + {display_info.company} + +
+ +
+ {display_info.location && ( + + + + {display_info.location} + + + )} + {meta.salary_range && ( + + {meta.salary_range} + + )} +
+ +
+ {matching_data.key_skills?.slice(0, 3).map((skill) => ( + + {skill} + + ))} + {matching_data.key_skills && + matching_data.key_skills.length > 3 && ( + + +{matching_data.key_skills.length - 3} + + )} +
+
+ + {/* Footer actions */} +
+ +
+
+ ); + } + return (
-
+
{/* Match Score */} {matching_data.match_score > 0 && ( -
+
-
+
+
-

+

{display_info.title}

@@ -109,34 +348,27 @@ export function JobCard({
{/* Save button */} - {!isChat && ( - - )} +
{/* Location & Job Type */} -
+
{display_info.location && ( @@ -145,7 +377,7 @@ export function JobCard({ )} - {!isChat && meta.remote && ( + {meta.remote && ( Remote @@ -160,99 +392,50 @@ export function JobCard({ {/* Skills */} {matching_data.key_skills && matching_data.key_skills.length > 0 && ( -
- {matching_data.key_skills - .slice(0, isChat ? 3 : 4) - .map((skill) => ( - - {skill} - - ))} - {matching_data.key_skills.length > (isChat ? 3 : 4) && ( +
+ {matching_data.key_skills.slice(0, 4).map((skill) => ( - +{matching_data.key_skills.length - (isChat ? 3 : 4)} + {skill} + + ))} + {matching_data.key_skills.length > 4 && ( + + +{matching_data.key_skills.length - 4} )}
)} {/* AI Explanation */} - {!isChat && matching_data.ai_explanation && ( + {matching_data.ai_explanation && (

{matching_data.ai_explanation}

)} {/* Actions */} -
- {isChat ? ( +
+ {meta.apply_url && ( - ) : ( - <> - {meta.apply_url && ( - - )} - - - {meta.posted_at - ? new Date(meta.posted_at).toLocaleDateString() - : "Recently posted"} - - )} + + + {meta.posted_at + ? new Date(meta.posted_at).toLocaleDateString() + : "Recently posted"} +
diff --git a/apps/web/src/hooks/useSSEChat.ts b/apps/web/src/hooks/useSSEChat.ts index 9ccb6e8..67c0d85 100644 --- a/apps/web/src/hooks/useSSEChat.ts +++ b/apps/web/src/hooks/useSSEChat.ts @@ -65,7 +65,6 @@ export function useSSEChat() { try { const newConv = await chatService.createConversation( undefined, - message, ); currentConversationId = newConv.id; addConversation(newConv); diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts index 3ca1326..2a911b3 100644 --- a/apps/web/src/lib/api-client.ts +++ b/apps/web/src/lib/api-client.ts @@ -24,9 +24,8 @@ apiClient.interceptors.response.use( (response) => response, async (error) => { if (error.response?.status === 401) { - // Token expired, try to refresh localStorage.removeItem("access_token"); - // window.location.href = "/login"; // Temporarily disabled for debugging + window.location.href = "/login"; console.warn("401 Unauthorized - Token removed"); } return Promise.reject(error); diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index b461035..1e0c3a4 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -1,5 +1,6 @@ services: - # PostgreSQL + + # ─── PostgreSQL with pgvector ────────────────────────────── postgres: image: pgvector/pgvector:pg16 container_name: postly-postgres @@ -8,69 +9,77 @@ services: POSTGRES_USER: ${DB_USER:-postly} POSTGRES_PASSWORD: ${DB_PASSWORD:?Database password required} POSTGRES_DB: ${DB_NAME:-postly} - DATABASE_URL: postgresql://${DB_USER:-postly}:${DB_PASSWORD}@postgres:5432/${DB_NAME:-postly} - ports: - - "5432:5432" volumes: - postgres_data:/var/lib/postgresql/data - ./packages/database/migrations:/docker-entrypoint-initdb.d:ro command: > postgres - -c shared_buffers=64MB - -c effective_cache_size=128MB - -c work_mem=4MB - -c maintenance_work_mem=32MB + -c shared_buffers=256MB + -c effective_cache_size=512MB + -c work_mem=16MB + -c maintenance_work_mem=64MB -c max_connections=30 -c random_page_cost=1.1 + -c wal_compression=on deploy: resources: limits: - cpus: '0.25' - memory: 180M + cpus: '0.5' + memory: 512M healthcheck: test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postly}"] interval: 10s timeout: 5s retries: 5 + logging: + driver: "json-file" + options: + max-size: "10m" + max-file: "3" networks: - postly-network - # Redis + # ─── Redis (Job Queue & Caching) ────────────────────────── redis: image: redis:7-alpine container_name: postly-redis restart: unless-stopped command: > redis-server - --maxmemory 40mb - --maxmemory-policy noeviction + --maxmemory 64mb + --maxmemory-policy allkeys-lru --appendonly yes --save 60 1 + --loglevel warning volumes: - redis_data:/data deploy: resources: limits: cpus: '0.1' - memory: 50M + memory: 80M healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 3s retries: 5 - ports: - - "6379:6379" + logging: + driver: "json-file" + options: + max-size: "5m" + max-file: "2" networks: - postly-network + # NOTE: No ports exposed — only accessible via internal Docker network - # API Server + # ─── API Server ──────────────────────────────────────────── api: - image: utsavjoshi/postly-api:latest + image: ${API_IMAGE:-utsavjoshi/postly-api:latest} container_name: postly-api restart: unless-stopped environment: NODE_ENV: production - NODE_OPTIONS: "--max-old-space-size=200" + NODE_OPTIONS: "--max-old-space-size=384" DB_HOST: postgres DB_PORT: 5432 DB_NAME: ${DB_NAME:-postly} @@ -96,26 +105,28 @@ services: deploy: resources: limits: - cpus: '0.3' - memory: 220M + cpus: '0.5' + memory: 512M healthcheck: test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/health', (res) => { if (res.statusCode !== 200) process.exit(1); }).on('error', () => process.exit(1));"] interval: 30s timeout: 10s retries: 3 + start_period: 15s logging: driver: "json-file" options: max-size: "10m" max-file: "3" - ports: - - "3000:3000" networks: - postly-network + ports: + - "3000:3000" + # NOTE: No ports exposed — traffic flows through Nginx only - # Scraper + # ─── Scraper (Python + Playwright) ──────────────────────── scraper: - image: utsavjoshi/postly-scraper:latest + image: ${SCRAPER_IMAGE:-utsavjoshi/postly-scraper:latest} container_name: postly-scraper restart: unless-stopped environment: @@ -131,13 +142,11 @@ services: depends_on: postgres: condition: service_healthy - ports: - - "8080:8080" - shm_size: '512mb' + shm_size: '512mb' deploy: resources: limits: - cpus: '0.3' + cpus: '0.5' memory: 400M healthcheck: test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8080/health')"] @@ -153,9 +162,9 @@ services: networks: - postly-network - # Discord Bot + # ─── Discord Bot ─────────────────────────────────────────── bot: - image: utsavjoshi/postly-bot:latest + image: ${BOT_IMAGE:-utsavjoshi/postly-bot:latest} container_name: postly-bot restart: unless-stopped environment: @@ -179,7 +188,7 @@ services: resources: limits: cpus: '0.2' - memory: 150M + memory: 200M logging: driver: "json-file" options: @@ -188,7 +197,6 @@ services: networks: - postly-network - volumes: postgres_data: redis_data: diff --git a/package-lock.json b/package-lock.json index 869788c..b92b595 100644 --- a/package-lock.json +++ b/package-lock.json @@ -28,8 +28,9 @@ "@dodopayments/express": "^0.2.6", "@postly/config": "*", "@postly/database": "*", + "@postly/logger": "*", "@postly/shared-types": "*", - "bcryptjs": "^2.4.3", + "bcrypt": "^5.1.1", "bullmq": "^5.31.3", "cors": "^2.8.6", "dotenv": "^16.4.7", @@ -46,7 +47,7 @@ "devDependencies": { "@postly/eslint-config": "*", "@postly/typescript-config": "*", - "@types/bcryptjs": "^2.4.6", + "@types/bcrypt": "^5.0.2", "@types/cors": "^2.8.17", "@types/express": "^5.0.0", "@types/jsonwebtoken": "^9.0.7", @@ -2695,6 +2696,26 @@ "@langchain/core": "^1.0.0" } }, + "node_modules/@mapbox/node-pre-gyp": { + "version": "1.0.11", + "resolved": "https://registry.npmjs.org/@mapbox/node-pre-gyp/-/node-pre-gyp-1.0.11.tgz", + "integrity": "sha512-Yhlar6v9WQgUp/He7BdgzOz8lqMQ8sU+jkCq7Wx8Myc5YFJLbEe7lgui/V7G1qB1DJykHSGwreceSaD60Y0PUQ==", + "license": "BSD-3-Clause", + "dependencies": { + "detect-libc": "^2.0.0", + "https-proxy-agent": "^5.0.0", + "make-dir": "^3.1.0", + "node-fetch": "^2.6.7", + "nopt": "^5.0.0", + "npmlog": "^5.0.1", + "rimraf": "^3.0.2", + "semver": "^7.3.5", + "tar": "^6.1.11" + }, + "bin": { + "node-pre-gyp": "bin/node-pre-gyp" + } + }, "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz", @@ -4565,12 +4586,15 @@ "@babel/types": "^7.28.2" } }, - "node_modules/@types/bcryptjs": { - "version": "2.4.6", - "resolved": "https://registry.npmjs.org/@types/bcryptjs/-/bcryptjs-2.4.6.tgz", - "integrity": "sha512-9xlo6R2qDs5uixm0bcIqCeMCE6HiQsIyel9KQySStiyqNl2tnj2mP3DX1Nf56MD6KMenNNlBBsy3LJ7gUEQPXQ==", + "node_modules/@types/bcrypt": { + "version": "5.0.2", + "resolved": "https://registry.npmjs.org/@types/bcrypt/-/bcrypt-5.0.2.tgz", + "integrity": "sha512-6atioO8Y75fNcbmj0G7UjI9lXN2pQ/IGJ2FWT4a/btd0Lk9lQalHLKhkgKVZ3r+spnmWUKfbMi1GEe9wyHQfNQ==", "dev": true, - "license": "MIT" + "license": "MIT", + "dependencies": { + "@types/node": "*" + } }, "node_modules/@types/body-parser": { "version": "1.19.6", @@ -5377,6 +5401,12 @@ "node": ">=10.0.0" } }, + "node_modules/abbrev": { + "version": "1.1.1", + "resolved": "https://registry.npmjs.org/abbrev/-/abbrev-1.1.1.tgz", + "integrity": "sha512-nne9/IiQ/hzIhY6pdDnbBtz7DjPTKrY00P/zvPSm5pOFkl6xuGrGnXn/VtTNNfNtAfZ9/1RtehkszU9qcTii0Q==", + "license": "ISC" + }, "node_modules/accepts": { "version": "1.3.8", "resolved": "https://registry.npmjs.org/accepts/-/accepts-1.3.8.tgz", @@ -5411,6 +5441,18 @@ "acorn": "^6.0.0 || ^7.0.0 || ^8.0.0" } }, + "node_modules/agent-base": { + "version": "6.0.2", + "resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz", + "integrity": "sha512-RZNwNclF7+MS/8bDg70amg32dyeZGZxiDuQmZxKLAlQjr3jGyLx+4Kkk58UO7D2QdgFIQCovuSuZESne6RG6XQ==", + "license": "MIT", + "dependencies": { + "debug": "4" + }, + "engines": { + "node": ">= 6.0.0" + } + }, "node_modules/ajv": { "version": "6.14.0", "resolved": "https://registry.npmjs.org/ajv/-/ajv-6.14.0.tgz", @@ -5458,6 +5500,40 @@ "integrity": "sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw==", "license": "MIT" }, + "node_modules/aproba": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/aproba/-/aproba-2.1.0.tgz", + "integrity": "sha512-tLIEcj5GuR2RSTnxNKdkK0dJ/GrC7P38sUkiDmDuHfsHmbagTFAxDVIBltoklXEVIQ/f14IL8IMJ5pn9Hez1Ew==", + "license": "ISC" + }, + "node_modules/are-we-there-yet": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/are-we-there-yet/-/are-we-there-yet-2.0.0.tgz", + "integrity": "sha512-Ci/qENmwHnsYo9xKIcUJN5LeDKdJ6R1Z1j9V/J5wyq8nh/mYPEpIKJbBZXtZjG04HiK7zV/p6Vs9952MrMeUIw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "delegates": "^1.0.0", + "readable-stream": "^3.6.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/are-we-there-yet/node_modules/readable-stream": { + "version": "3.6.2", + "resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-3.6.2.tgz", + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "license": "MIT", + "dependencies": { + "inherits": "^2.0.3", + "string_decoder": "^1.1.1", + "util-deprecate": "^1.0.1" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/argparse": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/argparse/-/argparse-2.0.1.tgz", @@ -5762,11 +5838,19 @@ "baseline-browser-mapping": "dist/cli.js" } }, - "node_modules/bcryptjs": { - "version": "2.4.3", - "resolved": "https://registry.npmjs.org/bcryptjs/-/bcryptjs-2.4.3.tgz", - "integrity": "sha512-V/Hy/X9Vt7f3BbPJEi8BdVFMByHi+jNXrYkW3huaybV/kQ0KJg0Y6PkEMbn+zeT+i+SiKZ/HMqJGIIt4LZDqNQ==", - "license": "MIT" + "node_modules/bcrypt": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/bcrypt/-/bcrypt-5.1.1.tgz", + "integrity": "sha512-AGBHOG5hPYZ5Xl9KXzU5iKq9516yEmvCKDg3ecP5kX2aB6UqTeXZxk2ELnDgDm6BQSMlLt9rDB4LoSMx0rYwww==", + "hasInstallScript": true, + "license": "MIT", + "dependencies": { + "@mapbox/node-pre-gyp": "^1.0.11", + "node-addon-api": "^5.0.0" + }, + "engines": { + "node": ">= 10.0.0" + } }, "node_modules/bluebird": { "version": "3.4.7", @@ -6153,6 +6237,15 @@ "node": ">= 16" } }, + "node_modules/chownr": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/chownr/-/chownr-2.0.0.tgz", + "integrity": "sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==", + "license": "ISC", + "engines": { + "node": ">=10" + } + }, "node_modules/class-variance-authority": { "version": "0.7.1", "resolved": "https://registry.npmjs.org/class-variance-authority/-/class-variance-authority-0.7.1.tgz", @@ -6216,28 +6309,6 @@ "dev": true, "license": "MIT" }, - "node_modules/cliui/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/cliui/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/cliui/node_modules/wrap-ansi": { "version": "7.0.0", "resolved": "https://registry.npmjs.org/wrap-ansi/-/wrap-ansi-7.0.0.tgz", @@ -6320,6 +6391,15 @@ "node": ">=18" } }, + "node_modules/color-support": { + "version": "1.1.3", + "resolved": "https://registry.npmjs.org/color-support/-/color-support-1.1.3.tgz", + "integrity": "sha512-qiBjkpbMLO/HL68y+lh4q0/O1MZFj2RX6X/KmMa3+gJD3z+WwI1ZzDHysvqHGS3mP6mznPckpXmw1nI9cJjyRg==", + "license": "ISC", + "bin": { + "color-support": "bin.js" + } + }, "node_modules/combined-stream": { "version": "1.0.8", "resolved": "https://registry.npmjs.org/combined-stream/-/combined-stream-1.0.8.tgz", @@ -6377,6 +6457,12 @@ "node": ">= 6" } }, + "node_modules/console-control-strings": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/console-control-strings/-/console-control-strings-1.1.0.tgz", + "integrity": "sha512-ty/fTekppD2fIwRvnZAVdeOiGd1c7YXEixbgJTNzqcxJWKQnjJ/V1bNEEE6hygpM3WjwHFUVK6HTjWSzV4a8sQ==", + "license": "ISC" + }, "node_modules/console-table-printer": { "version": "2.15.0", "resolved": "https://registry.npmjs.org/console-table-printer/-/console-table-printer-2.15.0.tgz", @@ -6643,6 +6729,12 @@ "node": ">=0.4.0" } }, + "node_modules/delegates": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/delegates/-/delegates-1.0.0.tgz", + "integrity": "sha512-bd2L678uiWATM6m5Z1VzNCErI3jiGzt6HGY8OVICs40JQq/HALfbyNJmp0UDakEY4pMMaN0Ly5om/B1VI/+xfQ==", + "license": "MIT" + }, "node_modules/denque": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/denque/-/denque-2.1.0.tgz", @@ -6684,7 +6776,6 @@ "version": "2.1.2", "resolved": "https://registry.npmjs.org/detect-libc/-/detect-libc-2.1.2.tgz", "integrity": "sha512-Btj2BOOO83o3WyH59e8MgXsxEQVcarkUOpEYrubB0urwnN10yQ364rsiByU11nZlqWYZm05i/of7io4mzihBtQ==", - "devOptional": true, "license": "Apache-2.0", "engines": { "node": ">=8" @@ -7421,6 +7512,12 @@ "dev": true, "license": "ISC" }, + "node_modules/emoji-regex": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", + "license": "MIT" + }, "node_modules/enabled": { "version": "2.0.0", "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", @@ -8395,6 +8492,36 @@ "node": ">= 0.6" } }, + "node_modules/fs-minipass": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/fs-minipass/-/fs-minipass-2.1.0.tgz", + "integrity": "sha512-V/JgOLFCS+R6Vcq0slCuaeWEdNC3ouDlJMNIsacH2VtALiu9mV4LPrHc5cDl8k5aw6J8jwgWWpiTo5RYhmIzvg==", + "license": "ISC", + "dependencies": { + "minipass": "^3.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/fs-minipass/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/fs-minipass/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/fs.realpath": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/fs.realpath/-/fs.realpath-1.0.0.tgz", @@ -8454,6 +8581,27 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/gauge": { + "version": "3.0.2", + "resolved": "https://registry.npmjs.org/gauge/-/gauge-3.0.2.tgz", + "integrity": "sha512-+5J6MS/5XksCuXq++uFRsnUd7Ovu1XenbeuIuNRJxYWjgQbPuFhT14lAvsWfqfAmnwluf1OwMjz39HjfLPci0Q==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "aproba": "^1.0.3 || ^2.0.0", + "color-support": "^1.1.2", + "console-control-strings": "^1.0.0", + "has-unicode": "^2.0.1", + "object-assign": "^4.1.1", + "signal-exit": "^3.0.0", + "string-width": "^4.2.3", + "strip-ansi": "^6.0.1", + "wide-align": "^1.1.2" + }, + "engines": { + "node": ">=10" + } + }, "node_modules/generator-function": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/generator-function/-/generator-function-2.0.1.tgz", @@ -8754,6 +8902,12 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/has-unicode": { + "version": "2.0.1", + "resolved": "https://registry.npmjs.org/has-unicode/-/has-unicode-2.0.1.tgz", + "integrity": "sha512-8Rf9Y83NBReMnx0gFzA8JImQACstCYWUplepDa9xprwwtmgEZUF0h/i5xSA625zB/I37EtrswSST6OXxwaaIJQ==", + "license": "ISC" + }, "node_modules/hasown": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz", @@ -8862,6 +9016,19 @@ "url": "https://opencollective.com/express" } }, + "node_modules/https-proxy-agent": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/https-proxy-agent/-/https-proxy-agent-5.0.1.tgz", + "integrity": "sha512-dFcAjpTQFgoLMzC2VwU+C/CbS7uRL0lWmxDITmqm7C+7F0Odmj6s9l6alZc6AELXhrnggM2CeWSXHGOdX2YtwA==", + "license": "MIT", + "dependencies": { + "agent-base": "6", + "debug": "4" + }, + "engines": { + "node": ">= 6" + } + }, "node_modules/husky": { "version": "9.1.7", "resolved": "https://registry.npmjs.org/husky/-/husky-9.1.7.tgz", @@ -9239,7 +9406,6 @@ "version": "3.0.0", "resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz", "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==", - "dev": true, "license": "MIT", "engines": { "node": ">=8" @@ -10227,6 +10393,30 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/make-dir": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/make-dir/-/make-dir-3.1.0.tgz", + "integrity": "sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==", + "license": "MIT", + "dependencies": { + "semver": "^6.0.0" + }, + "engines": { + "node": ">=8" + }, + "funding": { + "url": "https://github.com/sponsors/sindresorhus" + } + }, + "node_modules/make-dir/node_modules/semver": { + "version": "6.3.1", + "resolved": "https://registry.npmjs.org/semver/-/semver-6.3.1.tgz", + "integrity": "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==", + "license": "ISC", + "bin": { + "semver": "bin/semver.js" + } + }, "node_modules/mammoth": { "version": "1.11.0", "resolved": "https://registry.npmjs.org/mammoth/-/mammoth-1.11.0.tgz", @@ -11208,6 +11398,46 @@ "url": "https://github.com/sponsors/ljharb" } }, + "node_modules/minipass": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-5.0.0.tgz", + "integrity": "sha512-3FnjYuehv9k6ovOEbyOswadCDPX1piCfhV8ncmYtHOjuPwylVWsghTLo7rabjC3Rx5xD4HDx8Wm1xnMF7S5qFQ==", + "license": "ISC", + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib": { + "version": "2.1.2", + "resolved": "https://registry.npmjs.org/minizlib/-/minizlib-2.1.2.tgz", + "integrity": "sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==", + "license": "MIT", + "dependencies": { + "minipass": "^3.0.0", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">= 8" + } + }, + "node_modules/minizlib/node_modules/minipass": { + "version": "3.3.6", + "resolved": "https://registry.npmjs.org/minipass/-/minipass-3.3.6.tgz", + "integrity": "sha512-DxiNidxSEK+tHG6zOIklvNOwm3hvCrbUrdtzY74U6HKTJxvIDfOUL5W5P2Ghd3DTkhhKPYGqeNUIh5qcM4YBfw==", + "license": "ISC", + "dependencies": { + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=8" + } + }, + "node_modules/minizlib/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/mkdirp": { "version": "0.5.6", "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-0.5.6.tgz", @@ -11324,6 +11554,12 @@ "integrity": "sha512-AGK2yQKIjRuqnc6VkX2Xj5d+QW8xZ87pa1UK6yA6ouUyuxfHuMP6umE5QK7UmTeOAymo+Zx1Fxiuw9rVx8taHQ==", "license": "MIT" }, + "node_modules/node-addon-api": { + "version": "5.1.0", + "resolved": "https://registry.npmjs.org/node-addon-api/-/node-addon-api-5.1.0.tgz", + "integrity": "sha512-eh0GgfEkpnoWDq+VY8OyvYhFEzBk6jIYbRKdIlyTiAXIVJ8PyBaKb0rp7oDtoddbdoHWhq8wwr+XZ81F1rpNdA==", + "license": "MIT" + }, "node_modules/node-exports-info": { "version": "1.6.0", "resolved": "https://registry.npmjs.org/node-exports-info/-/node-exports-info-1.6.0.tgz", @@ -11351,6 +11587,26 @@ "semver": "bin/semver.js" } }, + "node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "license": "MIT", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/node-gyp-build-optional-packages": { "version": "5.2.2", "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz", @@ -11373,6 +11629,34 @@ "dev": true, "license": "MIT" }, + "node_modules/nopt": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/nopt/-/nopt-5.0.0.tgz", + "integrity": "sha512-Tbj67rffqceeLpcRXrT7vKAN8CwfPeIBgM7E6iBkmKLV7bEMwpGgYLGv0jACUsECaa/vuxP0IjEont6umdMgtQ==", + "license": "ISC", + "dependencies": { + "abbrev": "1" + }, + "bin": { + "nopt": "bin/nopt.js" + }, + "engines": { + "node": ">=6" + } + }, + "node_modules/npmlog": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/npmlog/-/npmlog-5.0.1.tgz", + "integrity": "sha512-AqZtDUWOMKs1G/8lwylVjrdYgqA4d9nu8hc+0gzRxlDb1I10+FHBGMXs6aiQHFdCUUlqH99MUMuLfzWDNDtfxw==", + "deprecated": "This package is no longer supported.", + "license": "ISC", + "dependencies": { + "are-we-there-yet": "^2.0.0", + "console-control-strings": "^1.1.0", + "gauge": "^3.0.0", + "set-blocking": "^2.0.0" + } + }, "node_modules/object-assign": { "version": "4.1.1", "resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz", @@ -12893,6 +13177,12 @@ "node": ">= 0.8.0" } }, + "node_modules/set-blocking": { + "version": "2.0.0", + "resolved": "https://registry.npmjs.org/set-blocking/-/set-blocking-2.0.0.tgz", + "integrity": "sha512-KiKBS8AnWGEyLzofFfmvKwpdPzqiy16LvQfK3yv/fVH7Bj13/wl3JSR1J+rfgRE9q7xUJK4qvgS8raSOeLUehw==", + "license": "ISC" + }, "node_modules/set-cookie-parser": { "version": "2.7.2", "resolved": "https://registry.npmjs.org/set-cookie-parser/-/set-cookie-parser-2.7.2.tgz", @@ -13057,6 +13347,12 @@ "dev": true, "license": "ISC" }, + "node_modules/signal-exit": { + "version": "3.0.7", + "resolved": "https://registry.npmjs.org/signal-exit/-/signal-exit-3.0.7.tgz", + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==", + "license": "ISC" + }, "node_modules/simple-wcswidth": { "version": "1.1.2", "resolved": "https://registry.npmjs.org/simple-wcswidth/-/simple-wcswidth-1.1.2.tgz", @@ -13213,6 +13509,20 @@ "integrity": "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g==", "license": "MIT" }, + "node_modules/string-width": { + "version": "4.2.3", + "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "license": "MIT", + "dependencies": { + "emoji-regex": "^8.0.0", + "is-fullwidth-code-point": "^3.0.0", + "strip-ansi": "^6.0.1" + }, + "engines": { + "node": ">=8" + } + }, "node_modules/string.prototype.matchall": { "version": "4.0.12", "resolved": "https://registry.npmjs.org/string.prototype.matchall/-/string.prototype.matchall-4.0.12.tgz", @@ -13437,6 +13747,42 @@ "url": "https://opencollective.com/webpack" } }, + "node_modules/tar": { + "version": "6.2.1", + "resolved": "https://registry.npmjs.org/tar/-/tar-6.2.1.tgz", + "integrity": "sha512-DZ4yORTwrbTj/7MZYq2w+/ZFdI6OZ/f9SFHR+71gIVUZhOQPHzVCLpvRnPgyaMpfWxxk/4ONva3GQSyNIKRv6A==", + "deprecated": "Old versions of tar are not supported, and contain widely publicized security vulnerabilities, which have been fixed in the current version. Please update. Support for old versions may be purchased (at exorbitant rates) by contacting i@izs.me", + "license": "ISC", + "dependencies": { + "chownr": "^2.0.0", + "fs-minipass": "^2.0.0", + "minipass": "^5.0.0", + "minizlib": "^2.1.1", + "mkdirp": "^1.0.3", + "yallist": "^4.0.0" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/mkdirp": { + "version": "1.0.4", + "resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-1.0.4.tgz", + "integrity": "sha512-vVqVZQyf3WLx2Shd0qJ9xuvqgAyKPLAiqITEtqW0oIUjzo3PePDd6fW9iFz30ef7Ysp/oiWqbhszeGWW2T6Gzw==", + "license": "MIT", + "bin": { + "mkdirp": "bin/cmd.js" + }, + "engines": { + "node": ">=10" + } + }, + "node_modules/tar/node_modules/yallist": { + "version": "4.0.0", + "resolved": "https://registry.npmjs.org/yallist/-/yallist-4.0.0.tgz", + "integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==", + "license": "ISC" + }, "node_modules/text-hex": { "version": "1.0.0", "resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz", @@ -13528,6 +13874,12 @@ "node": ">=0.6" } }, + "node_modules/tr46": { + "version": "0.0.3", + "resolved": "https://registry.npmjs.org/tr46/-/tr46-0.0.3.tgz", + "integrity": "sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==", + "license": "MIT" + }, "node_modules/trim-lines": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/trim-lines/-/trim-lines-3.0.1.tgz", @@ -14789,6 +15141,22 @@ "resolved": "apps/web", "link": true }, + "node_modules/webidl-conversions": { + "version": "3.0.1", + "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", + "integrity": "sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==", + "license": "BSD-2-Clause" + }, + "node_modules/whatwg-url": { + "version": "5.0.0", + "resolved": "https://registry.npmjs.org/whatwg-url/-/whatwg-url-5.0.0.tgz", + "integrity": "sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==", + "license": "MIT", + "dependencies": { + "tr46": "~0.0.3", + "webidl-conversions": "^3.0.0" + } + }, "node_modules/which": { "version": "2.0.2", "resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz", @@ -14912,6 +15280,15 @@ "node": ">=8" } }, + "node_modules/wide-align": { + "version": "1.1.5", + "resolved": "https://registry.npmjs.org/wide-align/-/wide-align-1.1.5.tgz", + "integrity": "sha512-eDMORYaPNZ4sQIuuYPDHdQvf4gyCF9rEEV/yPxGfwPkRodwEgiMUUXTx/dex+Me0wxx53S+NgUHaP7y3MGlDmg==", + "license": "ISC", + "dependencies": { + "string-width": "^1.0.2 || 2 || 3 || 4" + } + }, "node_modules/winston": { "version": "3.19.0", "resolved": "https://registry.npmjs.org/winston/-/winston-3.19.0.tgz", @@ -15078,28 +15455,6 @@ "node": ">=12" } }, - "node_modules/yargs/node_modules/emoji-regex": { - "version": "8.0.0", - "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", - "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==", - "dev": true, - "license": "MIT" - }, - "node_modules/yargs/node_modules/string-width": { - "version": "4.2.3", - "resolved": "https://registry.npmjs.org/string-width/-/string-width-4.2.3.tgz", - "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", - "dev": true, - "license": "MIT", - "dependencies": { - "emoji-regex": "^8.0.0", - "is-fullwidth-code-point": "^3.0.0", - "strip-ansi": "^6.0.1" - }, - "engines": { - "node": ">=8" - } - }, "node_modules/yocto-queue": { "version": "0.1.0", "resolved": "https://registry.npmjs.org/yocto-queue/-/yocto-queue-0.1.0.tgz", diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index 4ba2f67..3789f51 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -21,6 +21,7 @@ export const userRoleEnum = pgEnum("user_role", [ "job_seeker", "employer", "admin", + "discord_owner", ]); export const applicationStatusEnum = pgEnum("application_status", [ @@ -183,6 +184,7 @@ export const jobs = pgTable( locationIdx: index("idx_jobs_location").on(table.location), remoteIdx: index("idx_jobs_remote").on(table.remote), jobTypeIdx: index("idx_jobs_type").on(table.job_type), + activeIdx: index("idx_jobs_active").on(table.is_active), }), ); diff --git a/packages/logger/src/index.ts b/packages/logger/src/index.ts index b673129..e822c43 100644 --- a/packages/logger/src/index.ts +++ b/packages/logger/src/index.ts @@ -2,43 +2,65 @@ import winston from "winston"; const { combine, timestamp, printf, colorize, errors } = winston.format; -// Custom log format -const logFormat = printf(({ level, message, timestamp, stack }) => { +// Custom log format for development +const devFormat = printf(({ level, message, timestamp, stack }) => { return `${timestamp} [${level}]: ${stack || message}`; }); +// Redact sensitive fields from logs +const redactSensitive = winston.format((info) => { + const data = info as Record; + if (data.req?.headers?.authorization) { + data.req.headers.authorization = "[REDACTED]"; + } + if (data.body?.password) { + data.body.password = "[REDACTED]"; + } + if (data.body?.token) { + data.body.token = "[REDACTED]"; + } + return info; +}); + // Create logger instance export const logger = winston.createLogger({ level: process.env.LOG_LEVEL || "info", + defaultMeta: { + service: process.env.SERVICE_NAME || "unknown-service", + env: process.env.NODE_ENV || "development", + }, format: combine( errors({ stack: true }), + redactSensitive(), timestamp({ format: "YYYY-MM-DD HH:mm:ss" }), - logFormat, ), - transports: [ - // Console transport with colors + transports: [], +}); + +// Production: structured JSON to stdout (for Loki/Promtail to pick up) +if (process.env.NODE_ENV === "production") { + logger.add( + new winston.transports.Console({ + format: combine(timestamp(), winston.format.json()), + }), + ); +} else { + // Development: colorized human-readable output + file logs + logger.add( new winston.transports.Console({ - format: combine(colorize(), logFormat), + format: combine(colorize(), devFormat), }), - // File transport for errors + ); + logger.add( new winston.transports.File({ filename: "logs/error.log", level: "error", }), - // File transport for all logs + ); + logger.add( new winston.transports.File({ filename: "logs/combined.log", }), - ], -}); - -// Production environment: disable console colors, enable JSON format -if (process.env.NODE_ENV === "production") { - logger.clear(); - logger.add( - new winston.transports.Console({ - format: combine(timestamp(), winston.format.json()), - }), ); } diff --git a/scripts/add-hnsw-indexes.sql b/scripts/add-hnsw-indexes.sql new file mode 100644 index 0000000..d94c7bd --- /dev/null +++ b/scripts/add-hnsw-indexes.sql @@ -0,0 +1,29 @@ +-- ────────────────────────────────────────────────────────── +-- HNSW Vector Indexes for pgvector +-- ────────────────────────────────────────────────────────── +-- Uses HNSW (Hierarchical Navigable Small World) instead of +-- IVFFlat for better recall and no training step required. +-- +-- m=16, ef_construction=64 is a good balance for <100k rows. +-- All embedding columns are 1024 dimensions (Voyage AI). +-- ────────────────────────────────────────────────────────── + +-- Resumes: used for resume-to-job matching +CREATE INDEX CONCURRENTLY IF NOT EXISTS resumes_embedding_hnsw +ON resumes USING hnsw (embedding vector_cosine_ops) +WITH (m = 16, ef_construction = 64); + +-- Jobs: used for job-to-resume matching and job search +CREATE INDEX CONCURRENTLY IF NOT EXISTS jobs_embedding_hnsw +ON jobs USING hnsw (embedding vector_cosine_ops) +WITH (m = 16, ef_construction = 64); + +-- Employer Profiles: used for company similarity search +CREATE INDEX CONCURRENTLY IF NOT EXISTS employer_profiles_embedding_hnsw +ON employer_profiles USING hnsw (embedding vector_cosine_ops) +WITH (m = 16, ef_construction = 64); + +-- Seeker Profiles: used for candidate matching +CREATE INDEX CONCURRENTLY IF NOT EXISTS seeker_profiles_embedding_hnsw +ON seeker_profiles USING hnsw (embedding vector_cosine_ops) +WITH (m = 16, ef_construction = 64); diff --git a/scripts/backup.sh b/scripts/backup.sh new file mode 100644 index 0000000..1a3b47f --- /dev/null +++ b/scripts/backup.sh @@ -0,0 +1,68 @@ +#!/bin/bash +# ────────────────────────────────────────────────────────────── +# Postly — Daily PostgreSQL Backup +# ────────────────────────────────────────────────────────────── +# Cron entry: 0 2 * * * /opt/postly/scripts/backup.sh >> /var/log/postly-backup.log 2>&1 +# +# Prerequisites: +# - rclone configured with a remote named "b2" (Backblaze B2) +# - Or comment out the rclone section to use local backups only +# ────────────────────────────────────────────────────────────── +set -euo pipefail + +DATE=$(date +%Y%m%d_%H%M%S) +BACKUP_DIR="/opt/postly/backups/local" +BACKUP_FILE="/tmp/postly_${DATE}.dump" +RETENTION_DAYS=7 + +# Optional: Discord/Slack webhook for failure alerts +ALERT_WEBHOOK="${ALERT_WEBHOOK:-}" + +echo "[${DATE}] Starting Postly backup..." + +# Create local backup directory +mkdir -p "${BACKUP_DIR}" + +# ─── Step 1: Dump PostgreSQL ────────────────────────────────── +echo "→ Creating compressed PostgreSQL dump..." +docker exec postly-postgres pg_dump \ + -U "${DB_USER:-postly}" \ + -d "${DB_NAME:-postly}" \ + -Fc --compress=9 \ + > "${BACKUP_FILE}" + +BACKUP_SIZE=$(du -sh "${BACKUP_FILE}" | cut -f1) +echo " Dump created: ${BACKUP_SIZE}" + +# ─── Step 2: Upload to remote storage (Backblaze B2) ──────── +# Uncomment the following when rclone is configured: +# +# echo "→ Uploading to Backblaze B2..." +# rclone copy "${BACKUP_FILE}" b2:postly-backups/daily/ +# +# # Verify upload +# if rclone ls b2:postly-backups/daily/ | grep -q "postly_${DATE}"; then +# echo " ✅ Upload verified" +# else +# echo " ❌ Upload verification failed!" +# if [ -n "${ALERT_WEBHOOK}" ]; then +# curl -s -X POST "${ALERT_WEBHOOK}" \ +# -H "Content-Type: application/json" \ +# -d "{\"content\": \"⚠️ Postly backup upload failed for ${DATE}\"}" +# fi +# fi + +# ─── Step 3: Local rolling retention ──────────────────────── +echo "→ Copying to local backup directory..." +cp "${BACKUP_FILE}" "${BACKUP_DIR}/" + +echo "→ Cleaning backups older than ${RETENTION_DAYS} days..." +find "${BACKUP_DIR}" -name "postly_*.dump" -mtime "+${RETENTION_DAYS}" -delete + +# ─── Step 4: Clean up temp file ────────────────────────────── +rm -f "${BACKUP_FILE}" + +# ─── Done ──────────────────────────────────────────────────── +KEPT=$(ls -1 "${BACKUP_DIR}"/postly_*.dump 2>/dev/null | wc -l) +echo "✅ Backup complete. ${KEPT} backups retained locally." +echo "" From c2f0112a9acc097cbd64838f774446279eebd968 Mon Sep 17 00:00:00 2001 From: Utsav joshi Date: Sun, 29 Mar 2026 23:54:18 +0530 Subject: [PATCH 02/15] feat: implement bot automation, scraping infrastructure, and enhanced usage tracking with database schema expansion --- apps/api/package.json | 1 + apps/api/src/config/secrets.ts | 2 + apps/api/src/controllers/auth.controller.ts | 274 +- apps/api/src/controllers/user.controller.ts | 2 +- apps/api/src/lib/resend.ts | 4 + apps/api/src/middleware/auth.ts | 2 +- apps/api/src/middleware/strict-rate-limit.ts | 4 +- .../src/middleware/token-bucket-rate-limit.ts | 23 +- apps/api/src/routes/auth.routes.ts | 2 + apps/api/src/server.ts | 4 +- apps/api/src/services/chat.service.ts | 137 +- apps/api/src/services/resume.service.ts | 10 +- apps/web/index.html | 1 + apps/web/src/App.tsx | 8 + apps/web/src/hooks/useSSEChat.ts | 4 +- apps/web/src/pages/TransmissionChat.tsx | 16 +- apps/web/src/pages/TransmissionLanding.tsx | 9 +- apps/web/src/pages/TransmissionLogin.tsx | 6 +- apps/web/src/pages/TransmissionRegister.tsx | 5 +- apps/web/src/pages/VerifyOtpPage.tsx | 259 + apps/web/src/services/auth.service.ts | 22 +- apps/web/src/stores/auth.store.ts | 39 +- apps/web/src/styles/transmission.css | 32 + apps/web/src/types/auth.ts | 10 + package-lock.json | 38 + packages/config/app-config/src/index.ts | 6 + .../migrations/0004_loose_madame_web.sql | 253 + .../database/migrations/0005_brown_viper.sql | 12 + .../migrations/meta/0004_snapshot.json | 4184 +++++++++++++++ .../migrations/meta/0005_snapshot.json | 4509 +++++++++++++++++ .../database/migrations/meta/_journal.json | 16 +- packages/database/src/queries/applications.ts | 107 +- .../database/src/queries/conversations.ts | 15 +- .../database/src/queries/employer_profiles.ts | 13 +- packages/database/src/queries/jobs.ts | 23 +- packages/database/src/queries/matches.ts | 12 +- .../database/src/queries/notifications.ts | 16 +- packages/database/src/queries/payments.ts | 1 + .../database/src/queries/seeker_profiles.ts | 9 +- .../database/src/queries/subscriptions.ts | 7 + packages/database/src/queries/users.ts | 196 +- packages/database/src/schema.ts | 633 ++- packages/logger/src/index.ts | 21 +- packages/shared-types/src/domain.ts | 8 +- packages/shared-types/src/index.ts | 17 +- 45 files changed, 10763 insertions(+), 209 deletions(-) create mode 100644 apps/api/src/lib/resend.ts create mode 100644 apps/web/src/pages/VerifyOtpPage.tsx create mode 100644 packages/database/migrations/0004_loose_madame_web.sql create mode 100644 packages/database/migrations/0005_brown_viper.sql create mode 100644 packages/database/migrations/meta/0004_snapshot.json create mode 100644 packages/database/migrations/meta/0005_snapshot.json diff --git a/apps/api/package.json b/apps/api/package.json index 70fe208..1867f2b 100644 --- a/apps/api/package.json +++ b/apps/api/package.json @@ -30,6 +30,7 @@ "mammoth": "^1.11.0", "multer": "^2.0.2", "pdf-parse": "^2.4.5", + "resend": "^6.9.4", "zod": "^3.24.1" }, "devDependencies": { diff --git a/apps/api/src/config/secrets.ts b/apps/api/src/config/secrets.ts index e506fa4..391634e 100644 --- a/apps/api/src/config/secrets.ts +++ b/apps/api/src/config/secrets.ts @@ -34,4 +34,6 @@ export { DODO_PAYMENTS_WEBHOOK_KEY, DODO_PAYMENTS_ENVIRONMENT, DODO_PAYMENTS_RETURN_URL, + RESEND_API_KEY, + RESEND_FROM_EMAIL, } from "@postly/config"; diff --git a/apps/api/src/controllers/auth.controller.ts b/apps/api/src/controllers/auth.controller.ts index 40ec620..8855d02 100644 --- a/apps/api/src/controllers/auth.controller.ts +++ b/apps/api/src/controllers/auth.controller.ts @@ -3,15 +3,18 @@ import bcrypt from "bcrypt"; import jwt, { type SignOptions } from "jsonwebtoken"; import crypto from "crypto"; import { z } from "zod"; -import { userQueries } from "@postly/database"; -import type { AuthResponse } from "@postly/shared-types"; +import { userQueries, otpQueries } from "@postly/database"; +import type { AuthResponse, UserRole } from "@postly/shared-types"; import type { JwtPayload } from "../middleware/auth.js"; import { JWT_SECRET, JWT_REFRESH_SECRET, JWT_EXPIRES_IN, JWT_REFRESH_EXPIRES_IN, + RESEND_FROM_EMAIL, } from "../config/secrets.js"; +import { resend } from "../lib/resend.js"; + // ─── Validation Schemas ────────────────────────────────────────────────────── @@ -39,15 +42,29 @@ const resetPasswordSchema = z.object({ password: z.string().min(8, "Password must be at least 8 characters"), }); +const verifyOtpSchema = z.object({ + email: z.string().email("Invalid email address"), + code: z.string().length(6, "OTP must be 6 digits"), +}); + +const resendOtpSchema = z.object({ + email: z.string().email("Invalid email address"), +}); + + // ─── Controller ────────────────────────────────────────────────────────────── export class AuthController { /** * Generate access + refresh token pair for a user. */ - private generateTokens(user: { id: string; email: string; role: string }) { + private generateTokens(user: { + id: string; + email: string; + roles: UserRole[]; + }) { const access_token = jwt.sign( - { id: user.id, email: user.email, role: user.role }, + { id: user.id, email: user.email, roles: user.roles }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN as SignOptions["expiresIn"] }, ); @@ -97,22 +114,44 @@ export class AuthController { password_hash, full_name, }); - const tokens = this.generateTokens(user); - const response: AuthResponse = { - user: { - id: user.id, + // Generate 6-digit OTP + const otpCode = Math.floor(100000 + Math.random() * 900000).toString(); + const otpHash = await bcrypt.hash(otpCode, 10); + const otpExpiry = new Date(Date.now() + 10 * 60 * 1000); // 10 minutes + + await otpQueries.upsertOtp(user.id, otpHash, otpExpiry); + + // Send OTP via Resend + try { + await resend.emails.send({ + from: RESEND_FROM_EMAIL, + to: email, + subject: "Verify your Postly account", + html: ` +
+

Welcome to Postly!

+

Your verification code is:

+
+ ${otpCode} +
+

This code will expire in 10 minutes.

+

If you didn't create an account, you can safely ignore this email.

+
+ `, + }); + } catch (emailError) { + console.error("Failed to send verification email:", emailError); + // We still created the user, they can request a resend later + } + + res.status(201).json({ + success: true, + data: { + message: "Registration successful. Please check your email for the verification code.", email: user.email, - full_name: user.full_name, - role: user.role, - is_verified: user.is_verified, - created_at: user.created_at, - updated_at: user.updated_at, }, - ...tokens, - }; - - res.status(201).json({ success: true, data: response }); + }); } catch (error) { next(error); } @@ -158,6 +197,19 @@ export class AuthController { return; } + // Check if email is verified + if (!user.is_verified) { + res.status(403).json({ + success: false, + error: { + message: "Email not verified", + code: "EMAIL_NOT_VERIFIED", + }, + }); + return; + } + + // Track login timestamp await userQueries.updateLastLogin(user.id); @@ -168,7 +220,7 @@ export class AuthController { id: user.id, email: user.email, full_name: user.full_name, - role: user.role, + roles: user.roles, is_verified: user.is_verified, created_at: user.created_at, updated_at: user.updated_at, @@ -233,7 +285,7 @@ export class AuthController { } const access_token = jwt.sign( - { id: user.id, email: user.email, role: user.role }, + { id: user.id, email: user.email, roles: user.roles }, JWT_SECRET, { expiresIn: JWT_EXPIRES_IN as SignOptions["expiresIn"] }, ); @@ -268,7 +320,7 @@ export class AuthController { id: user.id, email: user.email, full_name: user.full_name, - role: user.role, + roles: user.roles, is_verified: user.is_verified, last_login_at: user.last_login_at, created_at: user.created_at, @@ -361,4 +413,186 @@ export class AuthController { next(error); } }; + + // ─── POST /verify-otp ──────────────────────────────────────────────────── + + verifyOtp = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + try { + const validation = verifyOtpSchema.safeParse(req.body); + if (!validation.success) { + res.status(400).json({ + success: false, + error: { message: validation.error.errors[0].message }, + }); + return; + } + + const { email, code } = validation.data; + const user = await userQueries.findByEmail(email); + + if (!user) { + res.status(404).json({ + success: false, + error: { message: "User not found" }, + }); + return; + } + + if (user.is_verified) { + res.status(400).json({ + success: false, + error: { message: "User is already verified" }, + }); + return; + } + + const otp = await otpQueries.findOtpByUserId(user.id); + if (!otp) { + res.status(400).json({ + success: false, + error: { message: "No verification code found. Please request a new one." }, + }); + return; + } + + // Check expiry + if (new Date() > new Date(otp.expires_at)) { + await otpQueries.deleteOtp(otp.id); + res.status(400).json({ + success: false, + error: { message: "Verification code expired. Please request a new one." }, + }); + return; + } + + // Check attempts + if (otp.attempts >= 3) { + res.status(429).json({ + success: false, + error: { message: "Too many failed attempts. Please request a new code." }, + }); + return; + } + + // Verify code + const isValid = await bcrypt.compare(code, otp.code_hash); + if (!isValid) { + await otpQueries.incrementOtpAttempts(otp.id); + res.status(400).json({ + success: false, + error: { message: "Invalid verification code" }, + }); + return; + } + + // Success + await otpQueries.verifyUser(user.id); + await otpQueries.deleteOtp(otp.id); + + + const tokens = this.generateTokens(user); + const response: AuthResponse = { + user: { + id: user.id, + email: user.email, + full_name: user.full_name, + roles: user.roles, + is_verified: true, + created_at: user.created_at, + updated_at: new Date(), + }, + ...tokens, + }; + + res.json({ success: true, data: response }); + } catch (error) { + next(error); + } + }; + + // ─── POST /resend-otp ──────────────────────────────────────────────────── + + resendOtp = async ( + req: Request, + res: Response, + next: NextFunction, + ): Promise => { + try { + const validation = resendOtpSchema.safeParse(req.body); + if (!validation.success) { + res.status(400).json({ + success: false, + error: { message: validation.error.errors[0].message }, + }); + return; + } + + const { email } = validation.data; + const user = await userQueries.findByEmail(email); + + if (!user) { + res.status(404).json({ + success: false, + error: { message: "User not found" }, + }); + return; + } + + if (user.is_verified) { + res.status(400).json({ + success: false, + error: { message: "User is already verified" }, + }); + return; + } + + const existingOtp = await otpQueries.findOtpByUserId(user.id); + if (existingOtp) { + const timeSinceCreation = Date.now() - new Date(existingOtp.created_at || 0).getTime(); + if (timeSinceCreation < 60 * 1000) { + const waitTime = Math.ceil((60 * 1000 - timeSinceCreation) / 1000); + res.status(429).json({ + success: false, + error: { message: `Please wait ${waitTime} seconds before requesting a new code.` }, + }); + return; + } + } + + // Generate new OTP + const otpCode = Math.floor(100000 + Math.random() * 900000).toString(); + const otpHash = await bcrypt.hash(otpCode, 10); + const otpExpiry = new Date(Date.now() + 10 * 60 * 1000); + + await otpQueries.upsertOtp(user.id, otpHash, otpExpiry); + + + await resend.emails.send({ + from: RESEND_FROM_EMAIL, + to: email, + subject: "Your new Postly verification code", + html: ` +
+

Verification Code

+

Your new verification code is:

+
+ ${otpCode} +
+

This code will expire in 10 minutes.

+
+ `, + }); + + res.json({ + success: true, + data: { message: "Verification code resent successfully." }, + }); + } catch (error) { + next(error); + } + }; } diff --git a/apps/api/src/controllers/user.controller.ts b/apps/api/src/controllers/user.controller.ts index 45b5458..b5eb92b 100644 --- a/apps/api/src/controllers/user.controller.ts +++ b/apps/api/src/controllers/user.controller.ts @@ -71,7 +71,7 @@ export class UserController { id: user.id, email: user.email, full_name: user.full_name, - role: user.role, + roles: user.roles, is_verified: user.is_verified, last_login_at: user.last_login_at, created_at: user.created_at, diff --git a/apps/api/src/lib/resend.ts b/apps/api/src/lib/resend.ts new file mode 100644 index 0000000..8d4d430 --- /dev/null +++ b/apps/api/src/lib/resend.ts @@ -0,0 +1,4 @@ +import { Resend } from "resend"; +import { RESEND_API_KEY } from "../config/secrets.js"; + +export const resend = new Resend(RESEND_API_KEY); diff --git a/apps/api/src/middleware/auth.ts b/apps/api/src/middleware/auth.ts index f01334d..122ef3f 100644 --- a/apps/api/src/middleware/auth.ts +++ b/apps/api/src/middleware/auth.ts @@ -13,7 +13,7 @@ import type { UserRole } from "@postly/shared-types"; export interface JwtPayload { id: string; email: string; - role: UserRole; + roles: UserRole[]; iat?: number; exp?: number; } diff --git a/apps/api/src/middleware/strict-rate-limit.ts b/apps/api/src/middleware/strict-rate-limit.ts index b322ad7..5ae9a7a 100644 --- a/apps/api/src/middleware/strict-rate-limit.ts +++ b/apps/api/src/middleware/strict-rate-limit.ts @@ -105,8 +105,8 @@ export const chatRateLimiter = createStrictRateLimiter({ const user = req.user as JwtPayload | undefined; if (!user) return 3; - if (user.role === "admin") return Infinity; - if (user.role === "employer") return 50; + if (user.roles.includes("admin")) return Infinity; + if (user.roles.includes("employer")) return 50; return 3; }, diff --git a/apps/api/src/middleware/token-bucket-rate-limit.ts b/apps/api/src/middleware/token-bucket-rate-limit.ts index 44a8f78..bfd451c 100644 --- a/apps/api/src/middleware/token-bucket-rate-limit.ts +++ b/apps/api/src/middleware/token-bucket-rate-limit.ts @@ -1,6 +1,7 @@ import { Request, Response, NextFunction } from "express"; import { redis } from "../lib/redis.js"; import jwt from "jsonwebtoken"; +import { Redis } from "ioredis"; interface RateLimitConfig { maxTokens: number; @@ -8,6 +9,16 @@ interface RateLimitConfig { keyPrefix?: string; } +interface RedisWithTokenBucket extends Redis { + consumeTokenBucket( + key: string, + maxTokens: number, + refillRateSec: number, + nowMs: number, + requested: number, + ): Promise<[number, string]>; +} + // Token Bucket algorithm using Redis Lua script to prevent race conditions. // KEYS[1] = bucket key // ARGV[1] = max capacity @@ -100,15 +111,9 @@ export const tokenBucketRateLimiter = (config: RateLimitConfig) => { const requested = 1; // Executing the predefined Lua Script - const [grantedResult, currentTokensResult] = (await ( - redis as any - ).consumeTokenBucket( - key, - maxTokens, - refillRateSec, - nowMs, - requested, - )) as [number, string]; + const [grantedResult, currentTokensResult] = await ( + redis as RedisWithTokenBucket + ).consumeTokenBucket(key, maxTokens, refillRateSec, nowMs, requested); const granted = grantedResult === 1; const currentTokens = parseFloat(currentTokensResult); diff --git a/apps/api/src/routes/auth.routes.ts b/apps/api/src/routes/auth.routes.ts index 103b0fd..52cc3bc 100644 --- a/apps/api/src/routes/auth.routes.ts +++ b/apps/api/src/routes/auth.routes.ts @@ -11,6 +11,8 @@ router.post("/login", authController.login); router.post("/refresh", authController.refresh); router.post("/forgot-password", authController.forgotPassword); router.post("/reset-password", authController.resetPassword); +router.post("/verify-otp", authController.verifyOtp); +router.post("/resend-otp", authController.resendOtp); // Protected router.get("/me", authenticateToken, authController.me); diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index ed49e31..f0252de 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -120,7 +120,9 @@ app.use((req, res, next) => { url: req.url, status: res.statusCode, duration_ms: duration, - user_id: (req as any).user?.id || null, + user_id: + (req as unknown as Request & { user?: { id: string } }).user?.id || + null, }); } }); diff --git a/apps/api/src/services/chat.service.ts b/apps/api/src/services/chat.service.ts index 584e6e3..efc532c 100644 --- a/apps/api/src/services/chat.service.ts +++ b/apps/api/src/services/chat.service.ts @@ -31,31 +31,85 @@ interface JobIntent { */ function getJobIntent(message: string): JobIntent { const techKeywords = [ - "react", "frontend", "backend", "fullstack", "node", "python", "java", "typescript", - "javascript", "html", "css", "vue", "angular", "aws", "cloud", "devops", - "structural", "environmental", "civil", "mechanical", "electrical" + "react", + "frontend", + "backend", + "fullstack", + "node", + "python", + "java", + "typescript", + "javascript", + "html", + "css", + "vue", + "angular", + "aws", + "cloud", + "devops", + "structural", + "environmental", + "civil", + "mechanical", + "electrical", ]; const levelKeywords = [ - "software", "engineer", "developer", "designer", "architect", "manager", "lead", - "senior", "junior", "intern", "graduate", "entry", "level", "remote", "hybrid" + "software", + "engineer", + "developer", + "designer", + "architect", + "manager", + "lead", + "senior", + "junior", + "intern", + "graduate", + "entry", + "level", + "remote", + "hybrid", ]; const generalKeywords = [ - "job", "career", "hiring", "opportunity", "opening", "position", - "vacancy", "work", "stack", "hire", "recruiting", "talent", - "apply", "application", "resume", "cv", "salary", "role", "brief", - "looking for", "hunting", "find", "search" + "job", + "career", + "hiring", + "opportunity", + "opening", + "position", + "vacancy", + "work", + "stack", + "hire", + "recruiting", + "talent", + "apply", + "application", + "resume", + "cv", + "salary", + "role", + "brief", + "looking for", + "hunting", + "find", + "search", ]; const lowercaseMsg = message.toLowerCase(); - - const foundTech = techKeywords.filter(kw => lowercaseMsg.includes(kw)); - const foundLevel = levelKeywords.filter(kw => lowercaseMsg.includes(kw)); - const hasGeneral = generalKeywords.some(kw => lowercaseMsg.includes(kw)); - + + const foundTech = techKeywords.filter((kw) => lowercaseMsg.includes(kw)); + const foundLevel = levelKeywords.filter((kw) => lowercaseMsg.includes(kw)); + const hasGeneral = generalKeywords.some((kw) => lowercaseMsg.includes(kw)); + return { - isRelated: foundTech.length > 0 || foundLevel.length > 0 || hasGeneral || message.length > 50, + isRelated: + foundTech.length > 0 || + foundLevel.length > 0 || + hasGeneral || + message.length > 50, isSpecific: foundTech.length > 0 || foundLevel.length > 0, techKeywords: foundTech, - allKeywords: [...foundTech, ...foundLevel] + allKeywords: [...foundTech, ...foundLevel], }; } // ... (omitting helper for brevity in diff) @@ -114,13 +168,13 @@ export class ChatService { // 2. Get conversation context and user context const [conversation, user] = await Promise.all([ conversationQueries.findById(conversationId, userId), - userQueries.findById(userId) + userQueries.findById(userId), ]); if (!conversation) { throw new Error("Conversation not found"); } - - const userRole = user?.role || "job_seeker"; + + const userRole = user?.roles[0] || "job_seeker"; const messages = await conversationQueries.getMessages(conversationId); @@ -138,7 +192,11 @@ export class ChatService { resumeContext = `\n\nUser's Resume Summary:\n- Skills: ${resume.skills?.join(", ") || "Not specified"}\n- Experience: ${resume.experience_years || 0} years\n- Summary: ${resume.parsed_text.substring(0, 1000)}`; // Find matching jobs based on resume ONLY if not employer AND intent is related - if (userRole !== "employer" && userRole !== "admin" && intent.isRelated) { + if ( + userRole !== "employer" && + userRole !== "admin" && + intent.isRelated + ) { try { jobMatches = await matchingService.findMatchingJobs( effectiveResumeId, @@ -159,7 +217,12 @@ export class ChatService { // 4b. FALLBACK: If no resume or no matches, fetch recent active jobs // Only do this if the user is not an employer AND intent is related - if (jobMatches.length === 0 && userRole !== "employer" && userRole !== "admin" && intent.isRelated) { + if ( + jobMatches.length === 0 && + userRole !== "employer" && + userRole !== "admin" && + intent.isRelated + ) { try { const recentJobs = await jobQueries.findActive(undefined, 5, 0); jobMatches = recentJobs.map((job: Job) => ({ @@ -175,17 +238,23 @@ export class ChatService { // If user specified tech keywords, at least one must match. // Otherwise, at least one level/general keyword must match. if (intent.isSpecific && jobMatches.length > 0) { - jobMatches = jobMatches.filter(job => { + jobMatches = jobMatches.filter((job) => { const searchSpace = ( - (job.title || "") + " " + - (job.description || "") + " " + + (job.title || "") + + " " + + (job.description || "") + + " " + (job.skills_required?.join(" ") || "") ).toLowerCase(); - + if (intent.techKeywords.length > 0) { - return intent.techKeywords.some((kw: string) => searchSpace.includes(kw)); + return intent.techKeywords.some((kw: string) => + searchSpace.includes(kw), + ); } - return intent.allKeywords.some((kw: string) => searchSpace.includes(kw)); + return intent.allKeywords.some((kw: string) => + searchSpace.includes(kw), + ); }); } @@ -203,19 +272,25 @@ export class ChatService { let roleSpecificInstructions = ""; if (userRole === "employer") { - roleSpecificInstructions = "You are an AI assistant helping an employer looking to hire candidates. Your ONLY function is to help with hiring, evaluating candidates, and posting jobs."; + roleSpecificInstructions = + "You are an AI assistant helping an employer looking to hire candidates. Your ONLY function is to help with hiring, evaluating candidates, and posting jobs."; } else { - roleSpecificInstructions = "You are an AI career assistant helping with resume analysis and job search."; + roleSpecificInstructions = + "You are an AI career assistant helping with resume analysis and job search."; } const systemPrompt = `${roleSpecificInstructions} -${userRole !== "employer" ? ` +${ + userRole !== "employer" + ? ` Your capabilities: - Analyze resumes and provide constructive feedback - Suggest relevant job opportunities from our database - Offer career advice and interview tips - Help with job applications -` : ""} +` + : "" +} IMPORTANT INSTRUCTIONS: 1. ${userRole === "employer" ? "Focus STRICTLY on helping the employer with hiring. UNDER NO CIRCUMSTANCES should you suggest, mention, or offer job listings, career advice, or job search help to an employer. If asked for jobs, politely clarify your role." : "When the user explicitly asks for jobs or career opportunities, reference the jobs listed below. If they just say 'hi' or make small talk, respond conversationally without bringing up jobs."} 2. DO NOT invent or hallucinate facts. diff --git a/apps/api/src/services/resume.service.ts b/apps/api/src/services/resume.service.ts index e767e75..b950241 100644 --- a/apps/api/src/services/resume.service.ts +++ b/apps/api/src/services/resume.service.ts @@ -5,9 +5,7 @@ import type { ResumeAnalysis, EducationEntry, } from "@postly/shared-types"; -import { createRequire } from "module"; -const require = createRequire(import.meta.url); -const pdfParse = require("pdf-parse"); +import { PDFParse } from "pdf-parse"; import mammoth from "mammoth"; export class ResumeService { @@ -40,8 +38,10 @@ export class ResumeService { * Parse PDF file */ private async parsePDF(buffer: Buffer): Promise { - const data = await pdfParse(buffer); - return data.text.trim(); + // Use the class-based API for pdf-parse v2 + const parser = new PDFParse({ data: buffer }); + const result = await parser.getText(); + return result.text.trim(); } /** diff --git a/apps/web/index.html b/apps/web/index.html index ea8376d..eb78b71 100644 --- a/apps/web/index.html +++ b/apps/web/index.html @@ -8,6 +8,7 @@ Postly - Search Jobs + ', html, re.DOTALL) - if match: - try: - data = json.loads(match.group(1)) - return data.get("props", {}) - except json.JSONDecodeError: - return None + try: + data = await resp.json() + return data.get("pageProps", data) + except Exception: + return None + + if status == 404: + logger.debug(f"Detail 404 for {requisition_id} — buildId may be stale") return None - if response and response.status == 429: + if status == 429: await asyncio.sleep(30) raise PlaywrightError("Rate limited on detail") - logger.debug(f"Detail {response.status if response else 'None'} for {requisition_id}") + if status == 403: + logger.warning("Detail 403 — blocked on individual fetch") + await asyncio.sleep(30) + raise PlaywrightError("Rate limited / blocked on detail") + + logger.debug(f"Detail {status} for {requisition_id}") return None # ─── Parsing ────────────────────────────────────────────────── @@ -359,20 +567,19 @@ def _extract_requisition_id(card: Dict[str, Any]) -> Optional[str]: return card.get("requisition_id") or card.get("objectID") def _parse_job(self, raw: Dict[str, Any]) -> Optional[ScrapedJob]: - """Parse raw job detail JSON into ScrapedJob.""" + """ + Parse raw job data into ScrapedJob. + Handles both search result cards and detail page JSON. + """ try: if not raw: return None - - # Start with the main props - data = raw.get("pageProps", raw) - - # Merge nested job info if present, but keep parent fields + + data = raw.get("pageProps", raw) if "pageProps" in raw else raw + for key in ["job", "job_information"]: nested = data.get(key) if isinstance(nested, dict): - # Shallow merge: nested fields overwrite parent but we keep what's unique - # This ensures we get 'requisition_id' from parent and 'title' from child merged = data.copy() merged.update(nested) data = merged @@ -395,7 +602,6 @@ def _parse_job(self, raw: Dict[str, Any]) -> Optional[ScrapedJob]: logger.debug(f"Parsing failed: missing title or id. keys: {list(data.keys())}") return None - # Company company_data = ( data.get("enriched_company_data") or data.get("company_data") @@ -408,7 +614,6 @@ def _parse_job(self, raw: Dict[str, Any]) -> Optional[ScrapedJob]: or "Unknown" ) - # Description: HTML → plain text description_html = ( data.get("description") or data.get("job_description_html") @@ -416,22 +621,18 @@ def _parse_job(self, raw: Dict[str, Any]) -> Optional[ScrapedJob]: ) description = _html_to_text(description_html) - # Fall back to alternate field if len(description) < 10: description = data.get("description_clean") or data.get("job_description_text") or description - + if len(description) < 10: logger.debug(f"Description too short for {requisition_id}. Content: {description[:50]}") return None - # Processed data v5 = data.get("v5_processed_job_data") or data.get("processed_data") or {} - # Salary salary_min = _safe_decimal(v5.get("yearly_min_compensation") or data.get("yearly_min_compensation")) salary_max = _safe_decimal(v5.get("yearly_max_compensation") or data.get("yearly_max_compensation")) - # Location & remote workplace_type = (v5.get("workplace_type") or "").lower() is_remote = workplace_type == "remote" location = ( @@ -440,18 +641,14 @@ def _parse_job(self, raw: Dict[str, Any]) -> Optional[ScrapedJob]: or ("Remote" if is_remote else None) ) - # Skills raw_tools = v5.get("technical_tools") or data.get("skills_required") or [] skills = [str(t) for t in raw_tools if t] if isinstance(raw_tools, list) else [] - # Experience min_yoe = v5.get("min_industry_and_role_yoe") experience = f"{min_yoe}+ years" if min_yoe else None - # Job type job_type = data.get("employment_type") or v5.get("employment_type") - # Apply URL apply_url = ( data.get("apply_url") or f"{self.BASE}/viewjob/{requisition_id}" @@ -479,7 +676,6 @@ def _parse_job(self, raw: Dict[str, Any]) -> Optional[ScrapedJob]: "nb_employees": company_data.get("nb_employees"), }, ) - logger.debug(f"Parsed job successfully: {job.title} | {job.company_name}") return job except Exception as exc: @@ -491,29 +687,58 @@ def _parse_job(self, raw: Dict[str, Any]) -> Optional[ScrapedJob]: async def scrape( self, - known_ids: Optional[set] = None, + known_ids: Optional[Set[str]] = None, ) -> AsyncIterator[ScrapedJob]: """ - Full scrape cycle: discover IDs via search → fetch details → yield jobs. + Full scrape cycle using Playwright to bypass protections. - Args: - known_ids: Set of requisition_ids already in DB (skip these). + IMPORTANT: This is a best-effort spider. If Cloudflare blocks us, + we log a warning and yield nothing — we never crash the pipeline. + The other API-based spiders (Remotive, Arbeitnow, Greenhouse) will + still provide jobs even if hiring.cafe is fully blocked. """ known = known_ids or set() - - logger.info({"event": "scrape_start", "source": "hiring_cafe"}) start_time = datetime.now(timezone.utc) + logger.info({"event": "scrape_start", "source": "hiring_cafe"}) - # Step 1: Discover build ID - await self._ensure_build_id() + try: + async with async_playwright() as pw: + try: + browser_obj, context, self._page, ua = await self._get_clearance(pw) + except Exception as e: + logger.warning( + f"[hiring_cafe] Cloudflare clearance failed — skipping this source. " + f"Other sources will still run. Error: {e}" + ) + self.errors += 1 + return + + try: + async for job in self._run_scrape_loop(known, start_time, total=None): + yield job + finally: + if browser_obj: + await browser_obj.close() + elif context: + await context.close() + self._page = None + except Exception as e: + logger.warning( + f"[hiring_cafe] Spider crashed — skipping this source. Error: {e}" + ) + self.errors += 1 - # Step 2: Get total count for progress logging + async def _run_scrape_loop(self, known: Set[str], start_time: datetime, total: Optional[int]) -> AsyncIterator[ScrapedJob]: + """Isolates the central loop iteration.""" + + await self._ensure_build_id() total = await self._get_total_count() - # Step 3: Paginate search results to collect requisition IDs offset = 0 page_num = 0 - all_req_ids: List[str] = [] + + seen_ids: Set[str] = set() + duplicate_streak: int = 0 while page_num < self._max_pages: try: @@ -532,36 +757,53 @@ async def scrape( if not hits: logger.info({"event": "pagination_complete", "pages": page_num}) break + + page_ids = {self._extract_requisition_id(c) for c in hits if self._extract_requisition_id(c)} + + if page_ids and page_ids.issubset(seen_ids): + duplicate_streak += 1 + if duplicate_streak >= 2: + logger.warning(f"Pagination loop detected, stopping early at offset {offset}") + break + else: + duplicate_streak = 0 + + seen_ids.update(page_ids) - page_req_ids = [] + new_on_page = 0 for card in hits: req_id = self._extract_requisition_id(card) - if req_id and req_id not in known: - page_req_ids.append(req_id) - all_req_ids.append(req_id) # keep for final metrics - - # Immediately fetch details for this page's new IDs - if page_req_ids: - logger.info(f"Discovered {len(page_req_ids)} new jobs on page {page_num + 1}. Fetching details...") - for rid in page_req_ids: + if not req_id or req_id in known: + continue + + known.add(req_id) + + job = self._parse_job(card) + + if not job and self._build_id: try: - result = await self._fetch_job_detail(rid) - if result: - job = self._parse_job(result) - if job: - self.jobs_found += 1 - yield job + detail = await self._fetch_job_detail(req_id) + if detail: + job = self._parse_job(detail) except Exception as exc: - logger.error(f"Detail fetch failed for {rid}: {exc}") + logger.debug(f"Detail fetch failed for {req_id}: {exc}") self.errors += 1 + if job: + self.jobs_found += 1 + new_on_page += 1 + yield job + + if new_on_page > 0: + logger.info(f"Page {page_num + 1}: found {new_on_page} new jobs") + self.pages_scraped += 1 page_num += 1 offset += self._page_size - + if page_num % 5 == 0: - logger.info(f"Progress: {page_num}/{self._max_pages} search pages processed. Total found: {self.jobs_found}") - + logger.info(f"Progress: {page_num}/{self._max_pages} pages. Total found: {self.jobs_found}") + if total and offset >= total: logger.info(f"Reached total {total} jobs") break @@ -579,7 +821,7 @@ async def scrape( async def scrape_all( self, - known_ids: Optional[set] = None, + known_ids: Optional[Set[str]] = None, ) -> List[ScrapedJob]: """Scrape all jobs and return as a list.""" jobs: List[ScrapedJob] = [] diff --git a/apps/scraper/src/spiders/remotive.py b/apps/scraper/src/spiders/remotive.py new file mode 100644 index 0000000..ec9552a --- /dev/null +++ b/apps/scraper/src/spiders/remotive.py @@ -0,0 +1,226 @@ +#!/usr/bin/env python3 +""" +remotive.py +Spider for Remotive's public REST API. + +Endpoint: https://remotive.com/api/remote-jobs +- Free, no auth, no Cloudflare +- Returns remote-only jobs with salary, category, tags +- Rate limit: max 2 requests/minute (TOS) +""" + +import logging +import re +from datetime import datetime, timezone +from typing import AsyncIterator, Optional, Set + +import sys +from pathlib import Path +sys.path.insert(0, str(Path(__file__).parent.parent)) + +from models import ScrapedJob +from spiders.base import ( + BaseSpider, + html_to_text, + extract_yoe, + extract_salary, + detect_job_type, + safe_decimal, +) + +logger = logging.getLogger(__name__) + +# Categories to scrape — covers tech, design, data, devops, marketing +CATEGORIES = [ + "software-dev", + "design", + "data", + "devops-sysadmin", + "product", + "customer-support", + "marketing", + "qa", + "writing", + "hr", + "finance-legal", + "business", + "all-others", +] + + +class RemotiveSpider(BaseSpider): + """ + Production spider for Remotive.com remote job listings. + + Features: + - Pure aiohttp — no browser, no Playwright, no Cloudflare issues + - Category-based iteration for broad coverage + - Rich field extraction: salary, YOE, job_type + - All results are remote by definition + """ + + SOURCE_NAME = "remotive" + BASE_URL = "https://remotive.com/api/remote-jobs" + + def __init__(self, requests_per_minute: int = 2): + # Remotive TOS: max 2 req/min, max 4 fetches/day + super().__init__(requests_per_minute=requests_per_minute) + + def _parse_job(self, raw: dict) -> Optional[ScrapedJob]: + """Parse a Remotive API job object into a ScrapedJob.""" + try: + job_id = raw.get("id") + title = raw.get("title", "").strip() + company = raw.get("company_name", "").strip() + + if not title or not company: + return None + + # Description + desc_html = raw.get("description", "") + description = html_to_text(desc_html) + if len(description) < 10: + return None + + # URL + source_url = raw.get("url", "") + if not source_url: + return None + + # Location — Remotive provides candidate_required_location + location = raw.get("candidate_required_location", "Worldwide") + + # Salary — Remotive provides salary field as text + salary_text = raw.get("salary", "") + salary_min, salary_max = None, None + if salary_text: + salary_min, salary_max = extract_salary(salary_text) + + # Also try extracting from description if no salary found + if not salary_min and not salary_max: + salary_min, salary_max = extract_salary(description[:2000]) + + # Job type + raw_job_type = raw.get("job_type", "") + job_type = self._normalize_job_type(raw_job_type) + if not job_type: + job_type = detect_job_type(description[:1000]) + + # YOE — extract from description + yoe = extract_yoe(description) + + # Category/tags as skills + category = raw.get("category", "") + tags = raw.get("tags", []) or [] + skills = [t for t in tags if t] if isinstance(tags, list) else [] + if category and category not in skills: + skills.insert(0, category) + + # Posted date + posted_at = None + pub_date = raw.get("publication_date") + if pub_date: + try: + posted_at = datetime.fromisoformat(pub_date.replace("Z", "+00:00")) + except (ValueError, AttributeError): + pass + + return ScrapedJob( + title=title, + company_name=company, + description=description, + location=location, + salary_min=salary_min, + salary_max=salary_max, + job_type=job_type, + remote=True, # All Remotive jobs are remote + source=self.SOURCE_NAME, + source_url=source_url, + skills_required=skills, + experience_required=yoe, + posted_at=posted_at, + is_active=True, + requisition_id=str(job_id) if job_id else source_url, + ) + + except Exception as e: + logger.error(f"[remotive] Parse error: {e}", exc_info=True) + self.errors += 1 + return None + + @staticmethod + def _normalize_job_type(raw: str) -> Optional[str]: + """Normalize Remotive job_type strings.""" + if not raw: + return None + lower = raw.lower().replace("_", " ").replace("-", " ") + if "full" in lower: + return "full_time" + if "part" in lower: + return "part_time" + if "contract" in lower or "freelance" in lower: + return "contract" + if "intern" in lower: + return "internship" + return raw.lower().replace(" ", "_") + + async def scrape( + self, known_ids: Optional[Set[str]] = None + ) -> AsyncIterator[ScrapedJob]: + """ + Scrape all Remotive jobs across categories. + """ + known = known_ids or set() + logger.info({"event": "scrape_start", "source": self.SOURCE_NAME}) + + try: + for category in CATEGORIES: + data = await self._get_json( + self.BASE_URL, + params={"category": category, "limit": 100}, + ) + + if not data: + continue + + jobs_list = data.get("jobs", []) + if not jobs_list: + logger.debug(f"[remotive] No jobs in category: {category}") + continue + + self.pages_scraped += 1 + new_count = 0 + + for raw_job in jobs_list: + job_id = str(raw_job.get("id", "")) + url = raw_job.get("url", "") + + # Dedup: check by source URL or job ID + if url in known or job_id in known: + continue + + known.add(url) + known.add(job_id) + + job = self._parse_job(raw_job) + if job: + self.jobs_found += 1 + new_count += 1 + yield job + + if new_count > 0: + logger.info(f"[remotive] Category '{category}': {new_count} new jobs") + + except Exception as e: + logger.error(f"[remotive] Scrape failed: {e}", exc_info=True) + self.errors += 1 + finally: + await self.close() + + logger.info({ + "event": "scrape_complete", + "source": self.SOURCE_NAME, + "jobs_found": self.jobs_found, + "pages_scraped": self.pages_scraped, + "errors": self.errors, + }) diff --git a/apps/scraper/src/test_hiring.py b/apps/scraper/src/test_hiring.py new file mode 100644 index 0000000..f17b828 --- /dev/null +++ b/apps/scraper/src/test_hiring.py @@ -0,0 +1,22 @@ +import asyncio +import logging +from spiders.hiring_cafe import HiringCafeSpider +from playwright.async_api import async_playwright + +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s [%(levelname)s] %(name)s: %(message)s' +) + +async def test_clearance(): + spider = HiringCafeSpider() + async with async_playwright() as pw: + try: + cookies, ua = await spider._get_clearance(pw) + has_clearance = any(c['name'] == 'cf_clearance' for c in cookies) + print(f">>> TEST RESULT: cf_clearance obtained: {has_clearance}") + except Exception as e: + print(f">>> TEST ERROR: {e}") + +if __name__ == "__main__": + asyncio.run(test_clearance()) diff --git a/apps/scraper/src/utils.py b/apps/scraper/src/utils.py new file mode 100644 index 0000000..e71cdd6 --- /dev/null +++ b/apps/scraper/src/utils.py @@ -0,0 +1,15 @@ +#!/usr/bin/env python3 +""" +utils.py +Shared utilities for the scraper application. +""" +from typing import List, Optional + +def format_vector(embedding: Optional[List[float]]) -> Optional[str]: + """ + Safely formats a float list into pgvector-compatible string. + Use this everywhere — never use str(embedding) directly. + """ + if not embedding: + return None + return '[' + ','.join(str(v) for v in embedding) + ']' \ No newline at end of file diff --git a/apps/scraper/test_args.py b/apps/scraper/test_args.py new file mode 100644 index 0000000..8029cfe --- /dev/null +++ b/apps/scraper/test_args.py @@ -0,0 +1,52 @@ +import asyncio +from playwright.async_api import async_playwright + +async def test_args(args_list, tag): + try: + async with async_playwright() as pw: + browser = await pw.chromium.launch( + headless=True, + args=args_list + ) + context = await browser.new_context( + user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + viewport={"width": 1280, "height": 800}, + locale="en-US", + ) + page = await context.new_page() + await page.add_init_script(""" + Object.defineProperty(navigator, 'plugins', { get: () => Object.freeze([{name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format', length: 1}]) }); + window.chrome = { runtime: {} }; + """) + print(f"[{tag}] going to hiring.cafe...") + await page.goto("https://hiring.cafe", wait_until="domcontentloaded", timeout=15000) + await page.wait_for_timeout(3000) + print(f"[{tag}] SUCCESS") + await browser.close() + except Exception as e: + print(f"[{tag}] CRASH:", e) + +async def main(): + print("Testing config 1: default + swiftshader") + await test_args([ + "--disable-blink-features=AutomationControlled", + "--disable-dev-shm-usage", + "--use-gl=swiftshader" + ], "swiftshader") + + print("Testing config 2: default WITHOUT swiftshader (no disable-gpu)") + await test_args([ + "--disable-blink-features=AutomationControlled", + "--disable-dev-shm-usage" + ], "no-gpu-flags") + + print("Testing config 3: angle swiftshader") + await test_args([ + "--disable-blink-features=AutomationControlled", + "--disable-dev-shm-usage", + "--use-gl=angle", + "--use-angle=swiftshader" + ], "angle-swiftshader") + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/apps/scraper/test_no_sandbox.py b/apps/scraper/test_no_sandbox.py new file mode 100644 index 0000000..a8d8f9c --- /dev/null +++ b/apps/scraper/test_no_sandbox.py @@ -0,0 +1,42 @@ +import asyncio +import os +from playwright.async_api import async_playwright + +async def test_no_sandbox(with_js_inject, tag): + try: + async with async_playwright() as pw: + # specifically ensuring --no-sandbox is absent! + browser = await pw.chromium.launch( + headless=True, + args=["--disable-blink-features=AutomationControlled"] + ) + context = await browser.new_context( + user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + viewport={"width": 1280, "height": 800}, + ) + page = await context.new_page() + + if with_js_inject: + await page.add_init_script(""" + Object.defineProperty(navigator, 'plugins', { get: () => Object.freeze([{name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format', length: 1}]) }); + window.chrome = { runtime: {} }; + """) + + print(f"[{tag}] going to hiring.cafe...") + await page.goto("https://hiring.cafe", wait_until="domcontentloaded", timeout=15000) + await page.wait_for_timeout(3000) + html = await page.evaluate("() => document.title") + print(f"[{tag}] SUCCESS, title: {html}") + await browser.close() + except Exception as e: + print(f"[{tag}] CRASH:", e) + +async def main(): + print("Testing 1: NO NO-SANDBOX, WITH JS") + await test_no_sandbox(True, "with_js") + + print("Testing 2: NO NO-SANDBOX, NO JS") + await test_no_sandbox(False, "no_js") + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/apps/scraper/test_plugins.py b/apps/scraper/test_plugins.py new file mode 100644 index 0000000..db7712b --- /dev/null +++ b/apps/scraper/test_plugins.py @@ -0,0 +1,41 @@ +import asyncio +from playwright.async_api import async_playwright + +async def test_plugins(with_plugins_hack_enabled, tag): + try: + async with async_playwright() as pw: + browser = await pw.chromium.launch( + headless=True, + args=[ + "--no-sandbox", + "--disable-blink-features=AutomationControlled", + ] + ) + context = await browser.new_context( + user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + ) + page = await context.new_page() + + if with_plugins_hack_enabled: + await page.add_init_script(""" + Object.defineProperty(navigator, 'plugins', { get: () => Object.freeze([{name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format', length: 1}]) }); + """) + + print(f"[{tag}] going to hiring.cafe...") + await page.goto("https://hiring.cafe", wait_until="domcontentloaded", timeout=15000) + await page.wait_for_timeout(3000) + title = await page.title() + print(f"[{tag}] SUCCESS, title: {title}") + await browser.close() + except Exception as e: + print(f"[{tag}] CRASH:", e) + +async def main(): + print("Testing 1: WITH JS inject") + await test_plugins(True, "with_js") + + print("Testing 2: WITHOUT JS inject") + await test_plugins(False, "no_js") + +if __name__ == '__main__': + asyncio.run(main()) diff --git a/apps/scraper/test_single_process.py b/apps/scraper/test_single_process.py new file mode 100644 index 0000000..15c2a03 --- /dev/null +++ b/apps/scraper/test_single_process.py @@ -0,0 +1,39 @@ +import asyncio +from playwright.async_api import async_playwright + +async def test_single_process(): + try: + async with async_playwright() as pw: + print("launching single process chromium...") + browser = await pw.chromium.launch( + headless=True, + args=[ + "--disable-blink-features=AutomationControlled", + "--disable-dev-shm-usage", + "--single-process", # Stops subprocess spawning (avoids mach_port failures) + ] + ) + print("context...") + context = await browser.new_context( + user_agent="Mozilla/5.0 (Macintosh; Intel Mac OS X 10_15_7) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/131.0.0.0 Safari/537.36", + viewport={"width": 1280, "height": 800}, + ) + print("page...") + page = await context.new_page() + + await page.add_init_script(""" + Object.defineProperty(navigator, 'plugins', { get: () => Object.freeze([{name: 'Chrome PDF Plugin', filename: 'internal-pdf-viewer', description: 'Portable Document Format', length: 1}]) }); + window.chrome = { runtime: {} }; + """) + + print("going to hiring.cafe...") + await page.goto("https://hiring.cafe", wait_until="domcontentloaded", timeout=15000) + await page.wait_for_timeout(3000) + html = await page.evaluate("() => document.title") + print(f"SUCCESS, title: {html}") + await browser.close() + except Exception as e: + print("CRASH:", e) + +if __name__ == '__main__': + asyncio.run(test_single_process()) From 8c7726511b2f5cea292fcd30f1c32612bb98fc67 Mon Sep 17 00:00:00 2001 From: Utsav joshi Date: Tue, 28 Apr 2026 22:26:49 +0530 Subject: [PATCH 11/15] feat: implement bot configuration system with new database schema and API controllers --- apps/api/src/controllers/bot.controller.ts | 179 ++ apps/api/src/routes/bot.routes.ts | 21 + apps/api/src/server.ts | 10 +- apps/api/src/services/chat.service.ts | 92 +- apps/api/src/services/queue.service.ts | 57 +- apps/web/src/App.tsx | 1 + .../components/chat/TransmissionSidebar.tsx | 2 +- apps/web/src/components/ui/PageLoader.tsx | 174 +- apps/web/src/components/ui/Toast.tsx | 110 +- apps/web/src/pages/TransmissionHome.tsx | 40 +- .../database/migrations/0006_loving_forge.sql | 151 ++ .../migrations/0007_tan_the_order.sql | 9 + .../migrations/meta/0006_snapshot.json | 2230 ++++++++++++++++ .../migrations/meta/0007_snapshot.json | 2284 +++++++++++++++++ .../database/migrations/meta/_journal.json | 16 +- packages/database/src/queries/applications.ts | 28 +- packages/database/src/queries/bots.ts | 115 + .../database/src/queries/conversations.ts | 224 +- packages/database/src/queries/index.ts | 1 + packages/database/src/queries/jobs.ts | 3 - .../database/src/queries/notifications.ts | 113 +- .../database/src/queries/subscriptions.ts | 27 +- packages/database/src/queries/users.ts | 2 +- packages/database/src/schema.ts | 1161 ++------- packages/shared-types/src/domain.ts | 50 +- packages/shared-types/src/index.ts | 134 +- 26 files changed, 5698 insertions(+), 1536 deletions(-) create mode 100644 apps/api/src/controllers/bot.controller.ts create mode 100644 apps/api/src/routes/bot.routes.ts create mode 100644 packages/database/migrations/0006_loving_forge.sql create mode 100644 packages/database/migrations/0007_tan_the_order.sql create mode 100644 packages/database/migrations/meta/0006_snapshot.json create mode 100644 packages/database/migrations/meta/0007_snapshot.json create mode 100644 packages/database/src/queries/bots.ts diff --git a/apps/api/src/controllers/bot.controller.ts b/apps/api/src/controllers/bot.controller.ts new file mode 100644 index 0000000..8820aeb --- /dev/null +++ b/apps/api/src/controllers/bot.controller.ts @@ -0,0 +1,179 @@ +import { Request, Response, NextFunction } from "express"; +import { db, bot_configs, eq, and, botQueries } from "@postly/database"; +import { queueService } from "../services/queue.service.js"; +import type { JwtPayload } from "../middleware/auth.js"; +import { WEB_URL } from "../config/secrets.js"; +import type { BotPlatform } from "@postly/shared-types"; + +export class BotController { + /** + * GET /api/v1/bots/callback + * Handles the redirect from OAuth providers (e.g. Discord). + */ + handleDiscordCallback = async ( + req: Request, + res: Response, + _next: NextFunction, + ): Promise => { + try { + const { guild_id } = req.query; + const user = req.user as JwtPayload; + + if (!guild_id) { + res.redirect(`${WEB_URL}/dashboard?discord_error=missing_guild`); + return; + } + + await botQueries.upsertConfig({ + user_id: user.id, + platform: "discord", + target_id: guild_id as string, + }); + + res.redirect( + `${WEB_URL}/dashboard?discord_success=true&guild_id=${guild_id}`, + ); + } catch (error) { + console.error("Discord callback error:", error); + res.redirect(`${WEB_URL}/dashboard?discord_error=true`); + } + }; + + /** + * GET /api/v1/bots/configs + * Returns all bot configurations for the current user. + */ + getConfigs = async ( + req: Request, + res: Response, + _next: NextFunction, + ): Promise => { + try { + const user = req.user as JwtPayload; + const configs = await db + .select() + .from(bot_configs) + .where(eq(bot_configs.user_id, user.id)); + + res.json({ + success: true, + data: configs, + }); + } catch (error) { + _next(error); + } + }; + + /** + * POST /api/v1/bots/configs + * Manually create or update a bot config (e.g. for Webhooks, Twitter, Reddit). + */ + upsertConfig = async ( + req: Request, + res: Response, + _next: NextFunction, + ): Promise => { + try { + const user = req.user as JwtPayload; + const { platform, target_id, target_name, webhook_url, credentials, filters } = req.body; + + const result = await botQueries.upsertConfig({ + user_id: user.id, + platform: platform as BotPlatform, + target_id, + target_name, + webhook_url, + credentials, + ...filters, + }); + + res.json({ + success: true, + data: result, + }); + } catch (error) { + _next(error); + } + }; + + /** + * PATCH /api/v1/bots/configs/:id + * Updates an existing bot configuration. + */ + updateConfig = async ( + req: Request, + res: Response, + _next: NextFunction, + ): Promise => { + try { + const { id } = req.params; + const user = req.user as JwtPayload; + const updateData = req.body; + + const [existing] = await db + .select() + .from(bot_configs) + .where(and(eq(bot_configs.id, id as string), eq(bot_configs.user_id, user.id))) + .limit(1); + + if (!existing) { + res.status(404).json({ success: false, message: "Config not found" }); + return; + } + + const [updated] = await db + .update(bot_configs) + .set({ + ...updateData, + updated_at: new Date(), + }) + .where(eq(bot_configs.id, id as string)) + .returning(); + + res.json({ + success: true, + data: updated, + }); + } catch (error) { + _next(error); + } + }; + + /** + * POST /api/v1/bots/configs/:id/test + * Manually trigger a test notification for a specific bot config. + */ + triggerTestNotification = async ( + req: Request, + res: Response, + _next: NextFunction, + ): Promise => { + try { + const { id } = req.params; + const user = req.user as JwtPayload; + + const [config] = await db + .select() + .from(bot_configs) + .where(and(eq(bot_configs.id, id as string), eq(bot_configs.user_id, user.id))) + .limit(1); + + if (!config) { + res.status(404).json({ + success: false, + message: "Bot configuration not found.", + }); + return; + } + + await queueService.dispatchForPlatform(config.id); + + res.json({ + success: true, + message: `Test notification queued for ${config.platform}!`, + }); + } catch (error) { + _next(error); + } + }; +} diff --git a/apps/api/src/routes/bot.routes.ts b/apps/api/src/routes/bot.routes.ts new file mode 100644 index 0000000..8472745 --- /dev/null +++ b/apps/api/src/routes/bot.routes.ts @@ -0,0 +1,21 @@ +import { Router } from "express"; +import { authenticateToken } from "../middleware/auth.js"; +import { BotController } from "../controllers/bot.controller.js"; + +const router = Router(); +const botController = new BotController(); + +// Some bot platforms might use callbacks (OAuth) +// We keep them separate and authenticate if possible, but usually these are handled via state/session. +// Here we assume authenticateToken works for our flow. +router.get("/discord/callback", authenticateToken, botController.handleDiscordCallback); + +// Protected management routes +router.use(authenticateToken); + +router.get("/configs", botController.getConfigs); +router.post("/configs", botController.upsertConfig); +router.patch("/configs/:id", botController.updateConfig); +router.post("/configs/:id/test", botController.triggerTestNotification); + +export default router; diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 4152ee6..8ab5ad7 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -13,7 +13,7 @@ import userRoutes from "./routes/user.routes.js"; import jobRoutes from "./routes/job.routes.js"; import resumeRoutes from "./routes/resume.routes.js"; import chatRoutes from "./routes/chat.routes.js"; -import discordRoutes from "./routes/discord.routes.js"; +import botRoutes from "./routes/bot.routes.js"; import dodoRoutes from "./routes/dodo.routes.js"; import applicationRoutes from "./routes/application.routes.js"; import { queueService } from "./services/queue.service.js"; @@ -142,12 +142,12 @@ app.use((req, res, next) => { }); // API routes -app.use("/api/v1/auth", authRoutes); // apiRateLimiter is global +app.use("/api/v1/auth", authRoutes); app.use("/api/v1/users", userRoutes); app.use("/api/v1/jobs", jobRoutes); app.use("/api/v1/resumes", aiRateLimiter, resumeRoutes); app.use("/api/v1/chat", aiRateLimiter, chatRoutes); -app.use("/api/v1/discord", discordRoutes); +app.use("/api/v1/bots", botRoutes); app.use("/api/v1/payments", dodoRoutes); app.use("/api/v1/applications", applicationRoutes); @@ -163,11 +163,11 @@ app.listen(API_PORT, "0.0.0.0", async () => { console.log(`🚀 API server running on http://0.0.0.0:${API_PORT}`); console.log(`📝 Environment: ${NODE_ENV}`); - // Initialize Discord Job Queue + // Initialize Bot Job Queue try { await queueService.initDailyCron(); } catch (err) { - console.error("Failed to initialize Discord Queue:", err); + console.error("Failed to initialize Bot Queue:", err); } }); diff --git a/apps/api/src/services/chat.service.ts b/apps/api/src/services/chat.service.ts index efc532c..d72f0bb 100644 --- a/apps/api/src/services/chat.service.ts +++ b/apps/api/src/services/chat.service.ts @@ -30,47 +30,10 @@ interface JobIntent { * More precise intent detection for job-related queries */ function getJobIntent(message: string): JobIntent { - const techKeywords = [ - "react", - "frontend", - "backend", - "fullstack", - "node", - "python", - "java", - "typescript", - "javascript", - "html", - "css", - "vue", - "angular", - "aws", - "cloud", - "devops", - "structural", - "environmental", - "civil", - "mechanical", - "electrical", - ]; - const levelKeywords = [ - "software", - "engineer", - "developer", - "designer", - "architect", - "manager", - "lead", - "senior", - "junior", - "intern", - "graduate", - "entry", - "level", - "remote", - "hybrid", - ]; - const generalKeywords = [ + const lowercaseMsg = message.toLowerCase(); + + // Universal job related terms (not just tech) + const jobKeywords = [ "job", "career", "hiring", @@ -79,7 +42,6 @@ function getJobIntent(message: string): JobIntent { "position", "vacancy", "work", - "stack", "hire", "recruiting", "talent", @@ -89,27 +51,44 @@ function getJobIntent(message: string): JobIntent { "cv", "salary", "role", - "brief", "looking for", "hunting", "find", "search", + "offer", + "interview", + "employer", + "company", + "staff", + "manager", + "engineer", + "designer", + "architect", + "developer", + "sales", + "marketing", + "doctor", + "nurse", + "teacher", + "driver", + "chef", + "accounting", + "legal", + "retail", + "remote", + "hybrid", + "fullstack", + "frontend", + "backend", ]; - const lowercaseMsg = message.toLowerCase(); - const foundTech = techKeywords.filter((kw) => lowercaseMsg.includes(kw)); - const foundLevel = levelKeywords.filter((kw) => lowercaseMsg.includes(kw)); - const hasGeneral = generalKeywords.some((kw) => lowercaseMsg.includes(kw)); + const foundKeywords = jobKeywords.filter((kw) => lowercaseMsg.includes(kw)); return { - isRelated: - foundTech.length > 0 || - foundLevel.length > 0 || - hasGeneral || - message.length > 50, - isSpecific: foundTech.length > 0 || foundLevel.length > 0, - techKeywords: foundTech, - allKeywords: [...foundTech, ...foundLevel], + isRelated: foundKeywords.length > 0 || message.length > 50, + isSpecific: foundKeywords.length > 2, // A heuristic for specific queries + techKeywords: [], // Deprecated: keep for type compatibility + allKeywords: foundKeywords, }; } // ... (omitting helper for brevity in diff) @@ -334,7 +313,8 @@ Be professional, encouraging, and concise.${resumeContext}${userRole !== "employ conversationId, "assistant", fullResponse, - metadata as Record, + metadata.usage?.total_tokens, + metadata, ); // 9. Auto-generate conversation title if this is the first message diff --git a/apps/api/src/services/queue.service.ts b/apps/api/src/services/queue.service.ts index afdc1eb..318fce5 100644 --- a/apps/api/src/services/queue.service.ts +++ b/apps/api/src/services/queue.service.ts @@ -1,14 +1,14 @@ import { Queue } from "bullmq"; import { REDIS_URL } from "../config/secrets.js"; -import { db, discord_configs, eq } from "@postly/database"; +import { db, bot_configs, eq } from "@postly/database"; -const DISCORD_QUEUE_NAME = "discord_notifications"; +const BOT_QUEUE_NAME = "bot_notifications"; export class QueueService { - private discordQueue: Queue; + private botQueue: Queue; constructor() { - this.discordQueue = new Queue(DISCORD_QUEUE_NAME, { + this.botQueue = new Queue(BOT_QUEUE_NAME, { connection: { url: REDIS_URL || "redis://localhost:6379", }, @@ -16,17 +16,12 @@ export class QueueService { } /** - * Initializes the daily cron that dispatches job alerts to all active Discord servers. + * Initializes the daily cron that dispatches job alerts to all active bot integrations. * Runs every day at 9:00 AM UTC. - * - * Instead of using an intermediate "daily_job_dispatch" job that requires - * a Node.js Worker to process, this directly queries active configs and - * enqueues one `send_discord_message` job per server — which the Python - * bot worker picks up via Redis. */ initDailyCron = async () => { // Use a repeatable job that fires at 9 AM daily - await this.discordQueue.add( + await this.botQueue.add( "daily_job_dispatch", { trigger: "cron" }, { @@ -37,41 +32,49 @@ export class QueueService { removeOnFail: 5, }, ); - console.log("📅 Discord daily job dispatch cron initialized (9:00 AM)"); + console.log("📅 Bot daily job dispatch cron initialized (9:00 AM)"); }; /** - * Dispatch job alerts for all active guilds. + * Dispatch job alerts for all active bot configurations. * Called by the cron handler or manually. */ dispatchAll = async () => { const activeConfigs = await db .select() - .from(discord_configs) - .where(eq(discord_configs.is_active, true)); + .from(bot_configs) + .where(eq(bot_configs.is_active, true)); let queued = 0; for (const config of activeConfigs) { - if (config.channel_id) { - await this.dispatchForGuild(config.guild_id, config.channel_id); - queued++; - } + await this.dispatchForPlatform(config.id); + queued++; } console.log( - `✅ Queued job alerts for ${queued}/${activeConfigs.length} servers.`, + `✅ Queued job alerts for ${queued}/${activeConfigs.length} bot integrations.`, ); return queued; }; /** - * Manually trigger a dispatch for a single server (e.g. for testing). + * Manually trigger a dispatch for a single bot config (e.g. for testing). */ - dispatchForGuild = async (guildId: string, channelId: string) => { - await this.discordQueue.add( - "send_discord_message", + dispatchForPlatform = async (configId: string) => { + const [config] = await db + .select() + .from(bot_configs) + .where(eq(bot_configs.id, configId)) + .limit(1); + + if (!config) return; + + await this.botQueue.add( + "send_bot_message", { - guild_id: guildId, - channel_id: channelId, + config_id: config.id, + platform: config.platform, + target_id: config.target_id, + webhook_url: config.webhook_url, timestamp: new Date().toISOString(), }, { @@ -79,7 +82,7 @@ export class QueueService { removeOnFail: 3, }, ); - console.log(`✅ Job dispatched for guild: ${guildId}`); + console.log(`✅ Job dispatched for ${config.platform} config: ${configId}`); }; } diff --git a/apps/web/src/App.tsx b/apps/web/src/App.tsx index af0167f..d7f37d9 100644 --- a/apps/web/src/App.tsx +++ b/apps/web/src/App.tsx @@ -3,6 +3,7 @@ import { BrowserRouter, Routes, Route } from "react-router-dom"; import { ProtectedRoute } from "./components/ProtectedRoute"; import { PageLoader } from "./components/ui/PageLoader"; import { ToastContainer } from "./components/ui/Toast"; +import "./styles/transmission.css"; const TransmissionHome = lazy(() => import("@pages/TransmissionHome").then((m) => ({ diff --git a/apps/web/src/components/chat/TransmissionSidebar.tsx b/apps/web/src/components/chat/TransmissionSidebar.tsx index 3b61e76..e6b9c87 100644 --- a/apps/web/src/components/chat/TransmissionSidebar.tsx +++ b/apps/web/src/components/chat/TransmissionSidebar.tsx @@ -370,7 +370,7 @@ export function TransmissionSidebar({ }} > {[ - { label: "Pricing", path: "/pricing" }, + // { label: "Pricing", path: "/pricing" }, { label: "Account Settings", path: "/settings" }, { label: "Integration", path: "/integrations" }, { label: "Logout", action: logout, danger: true }, diff --git a/apps/web/src/components/ui/PageLoader.tsx b/apps/web/src/components/ui/PageLoader.tsx index 788e885..9efaf7c 100644 --- a/apps/web/src/components/ui/PageLoader.tsx +++ b/apps/web/src/components/ui/PageLoader.tsx @@ -1,15 +1,175 @@ import React from "react"; +import "../../styles/transmission.css"; export const PageLoader: React.FC = () => { return ( -
-
-
-
+
+ + + {/* ─── Animated Runner SVG ────────────────────────────────────── */} +
+ {/* Speed Lines */} +
+
+ + + {/* Head */} + + {/* Body */} + + {/* Arms */} + + + {/* Legs */} + + + + + + + +
+ +
+

+ TRANSMITTING SIGNAL +

+
+

+ Running to your destination... +

-

- Loading your experience... -

); }; diff --git a/apps/web/src/components/ui/Toast.tsx b/apps/web/src/components/ui/Toast.tsx index 674d048..ef5b775 100644 --- a/apps/web/src/components/ui/Toast.tsx +++ b/apps/web/src/components/ui/Toast.tsx @@ -1,7 +1,8 @@ import { useEffect, useState } from "react"; import { useToastStore } from "../../stores/toast.store"; -import { X, CheckCircle, AlertCircle, Info, AlertTriangle } from "lucide-react"; +import { X } from "lucide-react"; import { createPortal } from "react-dom"; +import "../../styles/transmission.css"; export function ToastContainer() { const { toasts, removeToast } = useToastStore(); @@ -15,38 +16,99 @@ export function ToastContainer() { if (!mounted) return null; return createPortal( -
+
{toasts.map((toast) => (
- {toast.type === "success" && ( - - )} - {toast.type === "error" && ( - - )} - {toast.type === "warning" && ( - - )} - {toast.type === "info" && } +
-

{toast.message}

+
+ + {toast.type || "SIGNAL"} + +

+ {toast.message} +

+
))} diff --git a/apps/web/src/pages/TransmissionHome.tsx b/apps/web/src/pages/TransmissionHome.tsx index 01f0e54..4316a7b 100644 --- a/apps/web/src/pages/TransmissionHome.tsx +++ b/apps/web/src/pages/TransmissionHome.tsx @@ -75,6 +75,7 @@ export function TransmissionHome() { ) : ( -
- [ TRANSMISSION ACTIVE ] -

- {/* ─── Features ────────────────────────────────────────────────── */} -
- - - -
- {/* ─── Footer ──────────────────────────────────────────────────── */}
- © 2026 POSTLY TERMINAL // ALL RIGHTS RESERVED // BROADCASTING WORLDWIDE + © {new Date().getFullYear()} POSTLY

); diff --git a/packages/database/migrations/0006_loving_forge.sql b/packages/database/migrations/0006_loving_forge.sql new file mode 100644 index 0000000..1dceeef --- /dev/null +++ b/packages/database/migrations/0006_loving_forge.sql @@ -0,0 +1,151 @@ +CREATE TABLE "notifications" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "user_id" uuid NOT NULL, + "type" varchar(100), + "subject" varchar(500) NOT NULL, + "content" text NOT NULL, + "to_email" varchar(255) NOT NULL, + "status" "notification_status" DEFAULT 'pending' NOT NULL, + "sent_at" timestamp with time zone, + "error_message" text, + "created_at" timestamp with time zone DEFAULT now() +); +--> statement-breakpoint +ALTER TABLE "career_site_integrations" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "discord_configs" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "discord_posted_jobs" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "email_notifications" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "job_fingerprints" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "message_attachments" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "notification_templates" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "plan_quotas" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "promo_codes" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "rate_limit_overrides" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "scrape_runs" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +ALTER TABLE "scrape_sources" DISABLE ROW LEVEL SECURITY;--> statement-breakpoint +DROP TABLE "career_site_integrations" CASCADE;--> statement-breakpoint +DROP TABLE "discord_configs" CASCADE;--> statement-breakpoint +DROP TABLE "discord_posted_jobs" CASCADE;--> statement-breakpoint +DROP TABLE "email_notifications" CASCADE;--> statement-breakpoint +DROP TABLE "job_fingerprints" CASCADE;--> statement-breakpoint +DROP TABLE "message_attachments" CASCADE;--> statement-breakpoint +DROP TABLE "notification_templates" CASCADE;--> statement-breakpoint +DROP TABLE "plan_quotas" CASCADE;--> statement-breakpoint +DROP TABLE "promo_codes" CASCADE;--> statement-breakpoint +DROP TABLE "rate_limit_overrides" CASCADE;--> statement-breakpoint +DROP TABLE "scrape_runs" CASCADE;--> statement-breakpoint +DROP TABLE "scrape_sources" CASCADE;--> statement-breakpoint +ALTER TABLE "bot_posts" DROP CONSTRAINT "bot_posts_bot_config_id_job_id_unique";--> statement-breakpoint +ALTER TABLE "payments" DROP CONSTRAINT "payments_dodo_payment_id_unique";--> statement-breakpoint +ALTER TABLE "payments" DROP CONSTRAINT "payments_idempotency_key_unique";--> statement-breakpoint +ALTER TABLE "token_usage" DROP CONSTRAINT "token_usage_user_id_window_type_window_start_unique";--> statement-breakpoint +ALTER TABLE "jobs" DROP CONSTRAINT "jobs_scrape_source_id_scrape_sources_id_fk"; +--> statement-breakpoint +ALTER TABLE "subscriptions" DROP CONSTRAINT "subscriptions_promo_code_id_promo_codes_id_fk"; +--> statement-breakpoint +ALTER TABLE "system_prompts" DROP CONSTRAINT "system_prompts_created_by_users_id_fk"; +--> statement-breakpoint +ALTER TABLE "bot_configs" ALTER COLUMN "platform" SET DATA TYPE text;--> statement-breakpoint +DROP TYPE "public"."bot_platform";--> statement-breakpoint +CREATE TYPE "public"."bot_platform" AS ENUM('discord', 'reddit', 'twitter');--> statement-breakpoint +ALTER TABLE "bot_configs" ALTER COLUMN "platform" SET DATA TYPE "public"."bot_platform" USING "platform"::"public"."bot_platform";--> statement-breakpoint +ALTER TABLE "subscriptions" ALTER COLUMN "plan" SET DATA TYPE text;--> statement-breakpoint +DROP TYPE "public"."subscription_plan";--> statement-breakpoint +CREATE TYPE "public"."subscription_plan" AS ENUM('seeker', 'employer', 'discord_owner');--> statement-breakpoint +ALTER TABLE "subscriptions" ALTER COLUMN "plan" SET DATA TYPE "public"."subscription_plan" USING "plan"::"public"."subscription_plan";--> statement-breakpoint +DROP INDEX "idx_applications_seeker_status";--> statement-breakpoint +DROP INDEX "idx_applications_job_pipeline";--> statement-breakpoint +DROP INDEX "idx_bot_configs_active";--> statement-breakpoint +DROP INDEX "idx_bot_posts_config";--> statement-breakpoint +DROP INDEX "idx_conversations_user";--> statement-breakpoint +DROP INDEX "idx_conversations_archived";--> statement-breakpoint +DROP INDEX "idx_employer_profiles_company";--> statement-breakpoint +DROP INDEX "idx_employer_profiles_industry";--> statement-breakpoint +DROP INDEX "idx_job_matches_user";--> statement-breakpoint +DROP INDEX "idx_job_matches_job";--> statement-breakpoint +DROP INDEX "idx_jobs_location";--> statement-breakpoint +DROP INDEX "idx_jobs_remote";--> statement-breakpoint +DROP INDEX "idx_jobs_type";--> statement-breakpoint +DROP INDEX "idx_messages_conversation";--> statement-breakpoint +DROP INDEX "idx_messages_parent";--> statement-breakpoint +DROP INDEX "idx_messages_active";--> statement-breakpoint +DROP INDEX "idx_payments_user";--> statement-breakpoint +DROP INDEX "idx_payments_dodo_payment";--> statement-breakpoint +DROP INDEX "idx_payments_status";--> statement-breakpoint +DROP INDEX "idx_seeker_profiles_level";--> statement-breakpoint +DROP INDEX "idx_seeker_profiles_job_type";--> statement-breakpoint +DROP INDEX "idx_subscriptions_user";--> statement-breakpoint +DROP INDEX "idx_subscriptions_status";--> statement-breakpoint +DROP INDEX "idx_subscriptions_dodo_sub";--> statement-breakpoint +DROP INDEX "idx_subscriptions_period_end";--> statement-breakpoint +DROP INDEX "idx_system_prompts_active";--> statement-breakpoint +DROP INDEX "idx_token_usage_user_window";--> statement-breakpoint +DROP INDEX "idx_users_reset_token";--> statement-breakpoint +ALTER TABLE "subscriptions" ALTER COLUMN "plan" DROP DEFAULT;--> statement-breakpoint +ALTER TABLE "bot_configs" ADD COLUMN "target_id" varchar(255);--> statement-breakpoint +ALTER TABLE "bot_configs" ADD COLUMN "target_name" varchar(255);--> statement-breakpoint +ALTER TABLE "bot_configs" ADD COLUMN "webhook_url" text;--> statement-breakpoint +ALTER TABLE "bot_configs" ADD COLUMN "filter_keywords" varchar(500);--> statement-breakpoint +ALTER TABLE "bot_configs" ADD COLUMN "filter_locations" varchar(500);--> statement-breakpoint +ALTER TABLE "bot_configs" ADD COLUMN "filter_min_salary" numeric(10, 2);--> statement-breakpoint +ALTER TABLE "bot_configs" ADD COLUMN "filter_job_types" varchar(255)[];--> statement-breakpoint +ALTER TABLE "notifications" ADD CONSTRAINT "notifications_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +ALTER TABLE "applications" DROP COLUMN "notes";--> statement-breakpoint +ALTER TABLE "applications" DROP COLUMN "external_url";--> statement-breakpoint +ALTER TABLE "applications" DROP COLUMN "contact_info";--> statement-breakpoint +ALTER TABLE "applications" DROP COLUMN "next_interview_at";--> statement-breakpoint +ALTER TABLE "applications" DROP COLUMN "offer_details";--> statement-breakpoint +ALTER TABLE "applications" DROP COLUMN "match_score";--> statement-breakpoint +ALTER TABLE "applications" DROP COLUMN "ai_explanation";--> statement-breakpoint +ALTER TABLE "applications" DROP COLUMN "deleted_at";--> statement-breakpoint +ALTER TABLE "audit_log" DROP COLUMN "diff";--> statement-breakpoint +ALTER TABLE "audit_log" DROP COLUMN "ip_address";--> statement-breakpoint +ALTER TABLE "bot_configs" DROP COLUMN "platform_config";--> statement-breakpoint +ALTER TABLE "bot_configs" DROP COLUMN "token_expires_at";--> statement-breakpoint +ALTER TABLE "bot_configs" DROP COLUMN "job_filters";--> statement-breakpoint +ALTER TABLE "bot_configs" DROP COLUMN "post_format";--> statement-breakpoint +ALTER TABLE "bot_configs" DROP COLUMN "max_posts_per_day";--> statement-breakpoint +ALTER TABLE "bot_configs" DROP COLUMN "post_schedule";--> statement-breakpoint +ALTER TABLE "bot_posts" DROP COLUMN "platform";--> statement-breakpoint +ALTER TABLE "bot_posts" DROP COLUMN "content_snapshot";--> statement-breakpoint +ALTER TABLE "conversations" DROP COLUMN "model";--> statement-breakpoint +ALTER TABLE "conversations" DROP COLUMN "is_archived";--> statement-breakpoint +ALTER TABLE "conversations" DROP COLUMN "system_prompt_version";--> statement-breakpoint +ALTER TABLE "conversations" DROP COLUMN "total_tokens_used";--> statement-breakpoint +ALTER TABLE "conversations" DROP COLUMN "context_window_used";--> statement-breakpoint +ALTER TABLE "conversations" DROP COLUMN "deleted_at";--> statement-breakpoint +ALTER TABLE "jobs" DROP COLUMN "scrape_source_id";--> statement-breakpoint +ALTER TABLE "messages" DROP COLUMN "metadata";--> statement-breakpoint +ALTER TABLE "messages" DROP COLUMN "parent_message_id";--> statement-breakpoint +ALTER TABLE "messages" DROP COLUMN "version";--> statement-breakpoint +ALTER TABLE "messages" DROP COLUMN "is_active";--> statement-breakpoint +ALTER TABLE "messages" DROP COLUMN "status";--> statement-breakpoint +ALTER TABLE "messages" DROP COLUMN "model_used";--> statement-breakpoint +ALTER TABLE "messages" DROP COLUMN "latency_ms";--> statement-breakpoint +ALTER TABLE "messages" DROP COLUMN "finish_reason";--> statement-breakpoint +ALTER TABLE "messages" DROP COLUMN "cost_usd";--> statement-breakpoint +ALTER TABLE "messages" DROP COLUMN "deleted_at";--> statement-breakpoint +ALTER TABLE "payments" DROP COLUMN "dodo_payment_id";--> statement-breakpoint +ALTER TABLE "payments" DROP COLUMN "dodo_customer_id";--> statement-breakpoint +ALTER TABLE "payments" DROP COLUMN "event_type";--> statement-breakpoint +ALTER TABLE "payments" DROP COLUMN "idempotency_key";--> statement-breakpoint +ALTER TABLE "payments" DROP COLUMN "raw_payload";--> statement-breakpoint +ALTER TABLE "payments" DROP COLUMN "paid_at";--> statement-breakpoint +ALTER TABLE "subscriptions" DROP COLUMN "dodo_customer_id";--> statement-breakpoint +ALTER TABLE "subscriptions" DROP COLUMN "dodo_product_id";--> statement-breakpoint +ALTER TABLE "subscriptions" DROP COLUMN "current_period_start";--> statement-breakpoint +ALTER TABLE "subscriptions" DROP COLUMN "trial_ends_at";--> statement-breakpoint +ALTER TABLE "subscriptions" DROP COLUMN "cancelled_at";--> statement-breakpoint +ALTER TABLE "subscriptions" DROP COLUMN "access_until";--> statement-breakpoint +ALTER TABLE "subscriptions" DROP COLUMN "promo_code_id";--> statement-breakpoint +ALTER TABLE "subscriptions" DROP COLUMN "raw_data";--> statement-breakpoint +ALTER TABLE "system_prompts" DROP COLUMN "created_by";--> statement-breakpoint +ALTER TABLE "token_usage" DROP COLUMN "window_type";--> statement-breakpoint +ALTER TABLE "token_usage" DROP COLUMN "api_calls";--> statement-breakpoint +ALTER TABLE "token_usage" DROP COLUMN "job_matches";--> statement-breakpoint +ALTER TABLE "token_usage" DROP COLUMN "created_at";--> statement-breakpoint +ALTER TABLE "token_usage" DROP COLUMN "updated_at";--> statement-breakpoint +ALTER TABLE "otp_codes" ADD CONSTRAINT "otp_codes_user_id_unique" UNIQUE("user_id");--> statement-breakpoint +ALTER TABLE "token_usage" ADD CONSTRAINT "token_usage_user_id_window_start_unique" UNIQUE("user_id","window_start");--> statement-breakpoint +DROP TYPE "public"."integration_provider";--> statement-breakpoint +DROP TYPE "public"."scrape_source_status"; \ No newline at end of file diff --git a/packages/database/migrations/0007_tan_the_order.sql b/packages/database/migrations/0007_tan_the_order.sql new file mode 100644 index 0000000..19af4c4 --- /dev/null +++ b/packages/database/migrations/0007_tan_the_order.sql @@ -0,0 +1,9 @@ +ALTER TABLE "messages" ADD COLUMN "metadata" jsonb;--> statement-breakpoint +ALTER TABLE "payments" ADD COLUMN "dodo_payment_id" varchar(255);--> statement-breakpoint +ALTER TABLE "payments" ADD COLUMN "dodo_customer_id" varchar(255);--> statement-breakpoint +ALTER TABLE "payments" ADD COLUMN "event_type" varchar(100);--> statement-breakpoint +ALTER TABLE "payments" ADD COLUMN "paid_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "payments" ADD COLUMN "raw_payload" jsonb;--> statement-breakpoint +ALTER TABLE "payments" ADD COLUMN "idempotency_key" varchar(255);--> statement-breakpoint +ALTER TABLE "subscriptions" ADD COLUMN "dodo_customer_id" varchar(255);--> statement-breakpoint +ALTER TABLE "subscriptions" ADD COLUMN "raw_data" jsonb; \ No newline at end of file diff --git a/packages/database/migrations/meta/0006_snapshot.json b/packages/database/migrations/meta/0006_snapshot.json new file mode 100644 index 0000000..6c87c4e --- /dev/null +++ b/packages/database/migrations/meta/0006_snapshot.json @@ -0,0 +1,2230 @@ +{ + "id": "cdd04111-fd50-40fc-a796-70fcc5572658", + "prevId": "d1e6f70c-0f27-40e6-9966-f3a471418b1c", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.application_status_history": { + "name": "application_status_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "from_status": { + "name": "from_status", + "type": "application_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "to_status": { + "name": "to_status", + "type": "application_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "changed_by": { + "name": "changed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "application_status_history_application_id_applications_id_fk": { + "name": "application_status_history_application_id_applications_id_fk", + "tableFrom": "application_status_history", + "tableTo": "applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "application_status_history_changed_by_users_id_fk": { + "name": "application_status_history_changed_by_users_id_fk", + "tableFrom": "application_status_history", + "tableTo": "users", + "columnsFrom": [ + "changed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.applications": { + "name": "applications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "seeker_id": { + "name": "seeker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resume_id": { + "name": "resume_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "application_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'applied'" + }, + "cover_letter": { + "name": "cover_letter", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "applications_seeker_id_users_id_fk": { + "name": "applications_seeker_id_users_id_fk", + "tableFrom": "applications", + "tableTo": "users", + "columnsFrom": [ + "seeker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "applications_job_id_jobs_id_fk": { + "name": "applications_job_id_jobs_id_fk", + "tableFrom": "applications", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "applications_resume_id_resumes_id_fk": { + "name": "applications_resume_id_resumes_id_fk", + "tableFrom": "applications", + "tableTo": "resumes", + "columnsFrom": [ + "resume_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "applications_seeker_id_job_id_unique": { + "name": "applications_seeker_id_job_id_unique", + "nullsNotDistinct": false, + "columns": [ + "seeker_id", + "job_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_configs": { + "name": "bot_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "bot_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "credentials": { + "name": "credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filter_keywords": { + "name": "filter_keywords", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "filter_locations": { + "name": "filter_locations", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "filter_min_salary": { + "name": "filter_min_salary", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "filter_job_types": { + "name": "filter_job_types", + "type": "varchar(255)[]", + "primaryKey": false, + "notNull": false + }, + "last_post_at": { + "name": "last_post_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_configs_user_id_users_id_fk": { + "name": "bot_configs_user_id_users_id_fk", + "tableFrom": "bot_configs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "bot_configs_user_id_platform_unique": { + "name": "bot_configs_user_id_platform_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "platform" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_posts": { + "name": "bot_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "bot_config_id": { + "name": "bot_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "external_post_id": { + "name": "external_post_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "default": "'sent'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_posts_bot_config_id_bot_configs_id_fk": { + "name": "bot_posts_bot_config_id_bot_configs_id_fk", + "tableFrom": "bot_posts", + "tableTo": "bot_configs", + "columnsFrom": [ + "bot_config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_posts_job_id_jobs_id_fk": { + "name": "bot_posts_job_id_jobs_id_fk", + "tableFrom": "bot_posts", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "resume_id": { + "name": "resume_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "conversations_user_id_users_id_fk": { + "name": "conversations_user_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_resume_id_resumes_id_fk": { + "name": "conversations_resume_id_resumes_id_fk", + "tableFrom": "conversations", + "tableTo": "resumes", + "columnsFrom": [ + "resume_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.employer_profiles": { + "name": "employer_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "company_website": { + "name": "company_website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_logo_url": { + "name": "company_logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_description": { + "name": "company_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_size": { + "name": "company_size", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "industry": { + "name": "industry", + "type": "varchar(150)", + "primaryKey": false, + "notNull": false + }, + "headquarters_location": { + "name": "headquarters_location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "social_links": { + "name": "social_links", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "active_job_count": { + "name": "active_job_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "is_verified": { + "name": "is_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_employer_profiles_user": { + "name": "idx_employer_profiles_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "employer_profiles_user_id_users_id_fk": { + "name": "employer_profiles_user_id_users_id_fk", + "tableFrom": "employer_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "employer_profiles_user_id_unique": { + "name": "employer_profiles_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_matches": { + "name": "job_matches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resume_id": { + "name": "resume_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "match_score": { + "name": "match_score", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "ai_explanation": { + "name": "ai_explanation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_saved": { + "name": "is_saved", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "job_matches_user_id_users_id_fk": { + "name": "job_matches_user_id_users_id_fk", + "tableFrom": "job_matches", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "job_matches_resume_id_resumes_id_fk": { + "name": "job_matches_resume_id_resumes_id_fk", + "tableFrom": "job_matches", + "tableTo": "resumes", + "columnsFrom": [ + "resume_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "job_matches_job_id_jobs_id_fk": { + "name": "job_matches_job_id_jobs_id_fk", + "tableFrom": "job_matches", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "job_matches_user_id_job_id_unique": { + "name": "job_matches_user_id_job_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "job_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "salary_min": { + "name": "salary_min", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "salary_max": { + "name": "salary_max", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "job_type": { + "name": "job_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "remote": { + "name": "remote", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "source": { + "name": "source", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "skills_required": { + "name": "skills_required", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "experience_required": { + "name": "experience_required", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "employer_id": { + "name": "employer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "external_job_id": { + "name": "external_job_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_jobs_company": { + "name": "idx_jobs_company", + "columns": [ + { + "expression": "company_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_jobs_active": { + "name": "idx_jobs_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_employer_id_users_id_fk": { + "name": "jobs_employer_id_users_id_fk", + "tableFrom": "jobs", + "tableTo": "users", + "columnsFrom": [ + "employer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_email": { + "name": "to_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_codes": { + "name": "otp_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_otp_user": { + "name": "idx_otp_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "otp_codes_user_id_users_id_fk": { + "name": "otp_codes_user_id_users_id_fk", + "tableFrom": "otp_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "otp_codes_user_id_unique": { + "name": "otp_codes_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "status": { + "name": "status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_user_id_users_id_fk": { + "name": "payments_user_id_users_id_fk", + "tableFrom": "payments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payments_subscription_id_subscriptions_id_fk": { + "name": "payments_subscription_id_subscriptions_id_fk", + "tableFrom": "payments", + "tableTo": "subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resumes": { + "name": "resumes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parsed_text": { + "name": "parsed_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "experience_years": { + "name": "experience_years", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "education": { + "name": "education", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_resumes_user": { + "name": "idx_resumes_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resumes_user_id_users_id_fk": { + "name": "resumes_user_id_users_id_fk", + "tableFrom": "resumes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seeker_profiles": { + "name": "seeker_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "headline": { + "name": "headline", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "experience_years": { + "name": "experience_years", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "experience_level": { + "name": "experience_level", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "education": { + "name": "education", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "certifications": { + "name": "certifications", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "languages": { + "name": "languages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "work_history": { + "name": "work_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "desired_job_titles": { + "name": "desired_job_titles", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "desired_locations": { + "name": "desired_locations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "desired_salary_min": { + "name": "desired_salary_min", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "desired_salary_max": { + "name": "desired_salary_max", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "desired_job_type": { + "name": "desired_job_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "open_to_remote": { + "name": "open_to_remote", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "open_to_relocation": { + "name": "open_to_relocation", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "prompt_history_summary": { + "name": "prompt_history_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_parsed_at": { + "name": "last_parsed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_seeker_profiles_user": { + "name": "idx_seeker_profiles_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "seeker_profiles_user_id_users_id_fk": { + "name": "seeker_profiles_user_id_users_id_fk", + "tableFrom": "seeker_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "seeker_profiles_user_id_unique": { + "name": "seeker_profiles_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "subscription_plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "dodo_subscription_id": { + "name": "dodo_subscription_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_user_id_users_id_fk": { + "name": "subscriptions_user_id_users_id_fk", + "tableFrom": "subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_user_id_unique": { + "name": "subscriptions_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "subscriptions_dodo_subscription_id_unique": { + "name": "subscriptions_dodo_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "dodo_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_prompts": { + "name": "system_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_usage": { + "name": "token_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "token_usage_user_id_users_id_fk": { + "name": "token_usage_user_id_users_id_fk", + "tableFrom": "token_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "token_usage_user_id_window_start_unique": { + "name": "token_usage_user_id_window_start_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "window_start" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "full_name": { + "name": "full_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "roles": { + "name": "roles", + "type": "user_role[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'{\"job_seeker\"}'" + }, + "is_verified": { + "name": "is_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "password_reset_token": { + "name": "password_reset_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_reset_expires_at": { + "name": "password_reset_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_role": { + "name": "idx_users_role", + "columns": [ + { + "expression": "roles", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.application_status": { + "name": "application_status", + "schema": "public", + "values": [ + "applied", + "under_review", + "phone_screen", + "interviewed", + "offer_extended", + "accepted", + "rejected", + "withdrawn" + ] + }, + "public.bot_platform": { + "name": "bot_platform", + "schema": "public", + "values": [ + "discord", + "reddit", + "twitter" + ] + }, + "public.notification_status": { + "name": "notification_status", + "schema": "public", + "values": [ + "pending", + "sent", + "failed", + "bounced", + "opened" + ] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": [ + "pending", + "succeeded", + "failed", + "refunded", + "disputed" + ] + }, + "public.subscription_plan": { + "name": "subscription_plan", + "schema": "public", + "values": [ + "seeker", + "employer", + "discord_owner" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "active", + "cancelled", + "past_due", + "trialing", + "paused", + "expired" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "job_seeker", + "employer", + "admin", + "discord_owner" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/migrations/meta/0007_snapshot.json b/packages/database/migrations/meta/0007_snapshot.json new file mode 100644 index 0000000..bd10ef6 --- /dev/null +++ b/packages/database/migrations/meta/0007_snapshot.json @@ -0,0 +1,2284 @@ +{ + "id": "294ad33b-0987-400c-8f79-09b31806134d", + "prevId": "cdd04111-fd50-40fc-a796-70fcc5572658", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.application_status_history": { + "name": "application_status_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "from_status": { + "name": "from_status", + "type": "application_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "to_status": { + "name": "to_status", + "type": "application_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "changed_by": { + "name": "changed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "application_status_history_application_id_applications_id_fk": { + "name": "application_status_history_application_id_applications_id_fk", + "tableFrom": "application_status_history", + "tableTo": "applications", + "columnsFrom": [ + "application_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "application_status_history_changed_by_users_id_fk": { + "name": "application_status_history_changed_by_users_id_fk", + "tableFrom": "application_status_history", + "tableTo": "users", + "columnsFrom": [ + "changed_by" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.applications": { + "name": "applications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "seeker_id": { + "name": "seeker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resume_id": { + "name": "resume_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "application_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'applied'" + }, + "cover_letter": { + "name": "cover_letter", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "applications_seeker_id_users_id_fk": { + "name": "applications_seeker_id_users_id_fk", + "tableFrom": "applications", + "tableTo": "users", + "columnsFrom": [ + "seeker_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "applications_job_id_jobs_id_fk": { + "name": "applications_job_id_jobs_id_fk", + "tableFrom": "applications", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "applications_resume_id_resumes_id_fk": { + "name": "applications_resume_id_resumes_id_fk", + "tableFrom": "applications", + "tableTo": "resumes", + "columnsFrom": [ + "resume_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "applications_seeker_id_job_id_unique": { + "name": "applications_seeker_id_job_id_unique", + "nullsNotDistinct": false, + "columns": [ + "seeker_id", + "job_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": [ + "actor_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_configs": { + "name": "bot_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "bot_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "credentials": { + "name": "credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filter_keywords": { + "name": "filter_keywords", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "filter_locations": { + "name": "filter_locations", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "filter_min_salary": { + "name": "filter_min_salary", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "filter_job_types": { + "name": "filter_job_types", + "type": "varchar(255)[]", + "primaryKey": false, + "notNull": false + }, + "last_post_at": { + "name": "last_post_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_configs_user_id_users_id_fk": { + "name": "bot_configs_user_id_users_id_fk", + "tableFrom": "bot_configs", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "bot_configs_user_id_platform_unique": { + "name": "bot_configs_user_id_platform_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "platform" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_posts": { + "name": "bot_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "bot_config_id": { + "name": "bot_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "external_post_id": { + "name": "external_post_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "default": "'sent'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_posts_bot_config_id_bot_configs_id_fk": { + "name": "bot_posts_bot_config_id_bot_configs_id_fk", + "tableFrom": "bot_posts", + "tableTo": "bot_configs", + "columnsFrom": [ + "bot_config_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_posts_job_id_jobs_id_fk": { + "name": "bot_posts_job_id_jobs_id_fk", + "tableFrom": "bot_posts", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "resume_id": { + "name": "resume_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "conversations_user_id_users_id_fk": { + "name": "conversations_user_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_resume_id_resumes_id_fk": { + "name": "conversations_resume_id_resumes_id_fk", + "tableFrom": "conversations", + "tableTo": "resumes", + "columnsFrom": [ + "resume_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.employer_profiles": { + "name": "employer_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "company_website": { + "name": "company_website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_logo_url": { + "name": "company_logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_description": { + "name": "company_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_size": { + "name": "company_size", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "industry": { + "name": "industry", + "type": "varchar(150)", + "primaryKey": false, + "notNull": false + }, + "headquarters_location": { + "name": "headquarters_location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "social_links": { + "name": "social_links", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "active_job_count": { + "name": "active_job_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "is_verified": { + "name": "is_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_employer_profiles_user": { + "name": "idx_employer_profiles_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "employer_profiles_user_id_users_id_fk": { + "name": "employer_profiles_user_id_users_id_fk", + "tableFrom": "employer_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "employer_profiles_user_id_unique": { + "name": "employer_profiles_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_matches": { + "name": "job_matches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resume_id": { + "name": "resume_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "match_score": { + "name": "match_score", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "ai_explanation": { + "name": "ai_explanation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_saved": { + "name": "is_saved", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "job_matches_user_id_users_id_fk": { + "name": "job_matches_user_id_users_id_fk", + "tableFrom": "job_matches", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "job_matches_resume_id_resumes_id_fk": { + "name": "job_matches_resume_id_resumes_id_fk", + "tableFrom": "job_matches", + "tableTo": "resumes", + "columnsFrom": [ + "resume_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "job_matches_job_id_jobs_id_fk": { + "name": "job_matches_job_id_jobs_id_fk", + "tableFrom": "job_matches", + "tableTo": "jobs", + "columnsFrom": [ + "job_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "job_matches_user_id_job_id_unique": { + "name": "job_matches_user_id_job_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "job_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "salary_min": { + "name": "salary_min", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "salary_max": { + "name": "salary_max", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "job_type": { + "name": "job_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "remote": { + "name": "remote", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "source": { + "name": "source", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "skills_required": { + "name": "skills_required", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "experience_required": { + "name": "experience_required", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "employer_id": { + "name": "employer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "external_job_id": { + "name": "external_job_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_jobs_company": { + "name": "idx_jobs_company", + "columns": [ + { + "expression": "company_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_jobs_active": { + "name": "idx_jobs_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_employer_id_users_id_fk": { + "name": "jobs_employer_id_users_id_fk", + "tableFrom": "jobs", + "tableTo": "users", + "columnsFrom": [ + "employer_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": [ + "conversation_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_email": { + "name": "to_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_codes": { + "name": "otp_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_otp_user": { + "name": "idx_otp_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "otp_codes_user_id_users_id_fk": { + "name": "otp_codes_user_id_users_id_fk", + "tableFrom": "otp_codes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "otp_codes_user_id_unique": { + "name": "otp_codes_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dodo_payment_id": { + "name": "dodo_payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "dodo_customer_id": { + "name": "dodo_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "status": { + "name": "status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_user_id_users_id_fk": { + "name": "payments_user_id_users_id_fk", + "tableFrom": "payments", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payments_subscription_id_subscriptions_id_fk": { + "name": "payments_subscription_id_subscriptions_id_fk", + "tableFrom": "payments", + "tableTo": "subscriptions", + "columnsFrom": [ + "subscription_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resumes": { + "name": "resumes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parsed_text": { + "name": "parsed_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "experience_years": { + "name": "experience_years", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "education": { + "name": "education", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_resumes_user": { + "name": "idx_resumes_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resumes_user_id_users_id_fk": { + "name": "resumes_user_id_users_id_fk", + "tableFrom": "resumes", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seeker_profiles": { + "name": "seeker_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "headline": { + "name": "headline", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "experience_years": { + "name": "experience_years", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "experience_level": { + "name": "experience_level", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "education": { + "name": "education", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "certifications": { + "name": "certifications", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "languages": { + "name": "languages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "work_history": { + "name": "work_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "desired_job_titles": { + "name": "desired_job_titles", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "desired_locations": { + "name": "desired_locations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "desired_salary_min": { + "name": "desired_salary_min", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "desired_salary_max": { + "name": "desired_salary_max", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "desired_job_type": { + "name": "desired_job_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "open_to_remote": { + "name": "open_to_remote", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "open_to_relocation": { + "name": "open_to_relocation", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "prompt_history_summary": { + "name": "prompt_history_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_parsed_at": { + "name": "last_parsed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_seeker_profiles_user": { + "name": "idx_seeker_profiles_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "seeker_profiles_user_id_users_id_fk": { + "name": "seeker_profiles_user_id_users_id_fk", + "tableFrom": "seeker_profiles", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "seeker_profiles_user_id_unique": { + "name": "seeker_profiles_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "nullsNotDistinct": false, + "columns": [ + "token_hash" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "subscription_plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "dodo_subscription_id": { + "name": "dodo_subscription_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "dodo_customer_id": { + "name": "dodo_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw_data": { + "name": "raw_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_user_id_users_id_fk": { + "name": "subscriptions_user_id_users_id_fk", + "tableFrom": "subscriptions", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_user_id_unique": { + "name": "subscriptions_user_id_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id" + ] + }, + "subscriptions_dodo_subscription_id_unique": { + "name": "subscriptions_dodo_subscription_id_unique", + "nullsNotDistinct": false, + "columns": [ + "dodo_subscription_id" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_prompts": { + "name": "system_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_usage": { + "name": "token_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "token_usage_user_id_users_id_fk": { + "name": "token_usage_user_id_users_id_fk", + "tableFrom": "token_usage", + "tableTo": "users", + "columnsFrom": [ + "user_id" + ], + "columnsTo": [ + "id" + ], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "token_usage_user_id_window_start_unique": { + "name": "token_usage_user_id_window_start_unique", + "nullsNotDistinct": false, + "columns": [ + "user_id", + "window_start" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "full_name": { + "name": "full_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "roles": { + "name": "roles", + "type": "user_role[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'{\"job_seeker\"}'" + }, + "is_verified": { + "name": "is_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "password_reset_token": { + "name": "password_reset_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_reset_expires_at": { + "name": "password_reset_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_role": { + "name": "idx_users_role", + "columns": [ + { + "expression": "roles", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": [ + "email" + ] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.application_status": { + "name": "application_status", + "schema": "public", + "values": [ + "applied", + "under_review", + "phone_screen", + "interviewed", + "offer_extended", + "accepted", + "rejected", + "withdrawn" + ] + }, + "public.bot_platform": { + "name": "bot_platform", + "schema": "public", + "values": [ + "discord", + "reddit", + "twitter" + ] + }, + "public.notification_status": { + "name": "notification_status", + "schema": "public", + "values": [ + "pending", + "sent", + "failed", + "bounced", + "opened" + ] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": [ + "pending", + "succeeded", + "failed", + "refunded", + "disputed" + ] + }, + "public.subscription_plan": { + "name": "subscription_plan", + "schema": "public", + "values": [ + "seeker", + "employer", + "discord_owner" + ] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "active", + "cancelled", + "past_due", + "trialing", + "paused", + "expired" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": [ + "job_seeker", + "employer", + "admin", + "discord_owner" + ] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} \ No newline at end of file diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index eb0a3dd..f16a074 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -43,6 +43,20 @@ "when": 1774806193977, "tag": "0005_brown_viper", "breakpoints": true + }, + { + "idx": 6, + "version": "7", + "when": 1777393089539, + "tag": "0006_loving_forge", + "breakpoints": true + }, + { + "idx": 7, + "version": "7", + "when": 1777394234644, + "tag": "0007_tan_the_order", + "breakpoints": true } ] -} +} \ No newline at end of file diff --git a/packages/database/src/queries/applications.ts b/packages/database/src/queries/applications.ts index ae2ec03..03da84e 100644 --- a/packages/database/src/queries/applications.ts +++ b/packages/database/src/queries/applications.ts @@ -1,4 +1,4 @@ -import { eq, desc, and, ilike, isNull } from "drizzle-orm"; +import { eq, desc, and, ilike } from "drizzle-orm"; import { db } from "../index"; import { applications, jobs, application_status_history } from "../schema"; import type { ApplicationStatus } from "@postly/shared-types"; @@ -38,7 +38,7 @@ export const applicationQueries = { const [result] = await db .select() .from(applications) - .where(and(eq(applications.id, id), isNull(applications.deleted_at))); + .where(eq(applications.id, id)); return result ?? null; }, @@ -60,7 +60,6 @@ export const applicationQueries = { .where( and( eq(applications.seeker_id, seekerId), - isNull(applications.deleted_at), ), ) .orderBy(desc(applications.applied_at)) @@ -133,30 +132,9 @@ export const applicationQueries = { return result ?? null; }, - async updateNotes(id: string, seekerId: string, notes: string) { - const [result] = await db - .update(applications) - .set({ notes, updated_at: new Date() }) - .where(and(eq(applications.id, id), eq(applications.seeker_id, seekerId))) - .returning(); - return result ?? null; - }, - async updateMatchScore( - id: string, - matchScore: number, - aiExplanation?: string, - ) { - await db - .update(applications) - .set({ - match_score: matchScore.toString(), - ai_explanation: aiExplanation, - updated_at: new Date(), - }) - .where(eq(applications.id, id)); - }, + async delete(id: string, seekerId: string) { const [result] = await db diff --git a/packages/database/src/queries/bots.ts b/packages/database/src/queries/bots.ts new file mode 100644 index 0000000..5604424 --- /dev/null +++ b/packages/database/src/queries/bots.ts @@ -0,0 +1,115 @@ +import { eq, and, desc } from "drizzle-orm"; +import { db } from "../index"; +import { bot_configs, bot_posts } from "../schema"; +import type { BotPlatform } from "@postly/shared-types"; + +export interface CreateBotConfigInput { + user_id: string; + platform: BotPlatform; + target_id?: string; + target_name?: string; + webhook_url?: string; + credentials?: any; + filter_keywords?: string; + filter_locations?: string; + filter_min_salary?: string; + filter_job_types?: string[]; +} + +export const botQueries = { + /** + * Create or update bot configuration for a platform + */ + async upsertConfig(input: CreateBotConfigInput) { + const [result] = await db + .insert(bot_configs) + .values({ + ...input, + updated_at: new Date(), + }) + .onConflictDoUpdate({ + target: [bot_configs.user_id, bot_configs.platform], + set: { + ...input, + updated_at: new Date(), + }, + }) + .returning(); + + return result; + }, + + /** + * Find bot config for a user and platform + */ + async findConfig(userId: string, platform: BotPlatform) { + const [result] = await db + .select() + .from(bot_configs) + .where( + and(eq(bot_configs.user_id, userId), eq(bot_configs.platform, platform)), + ); + + return result ?? null; + }, + + /** + * Get all active bot configs for a platform (for the worker) + */ + async findActiveConfigsByPlatform(platform: BotPlatform) { + return db + .select() + .from(bot_configs) + .where(and(eq(bot_configs.platform, platform), eq(bot_configs.is_active, true))); + }, + + /** + * Create a record of a job posted by a bot + */ + async createPost(botConfigId: string, jobId: string, externalPostId?: string) { + const [result] = await db + .insert(bot_posts) + .values({ + bot_config_id: botConfigId, + job_id: jobId, + external_post_id: externalPostId, + status: "sent", + }) + .returning(); + + // Update last post timestamp + await db + .update(bot_configs) + .set({ last_post_at: new Date() }) + .where(eq(bot_configs.id, botConfigId)); + + return result; + }, + + /** + * Log a failed post attempt + */ + async logPostError(botConfigId: string, jobId: string, errorMessage: string) { + return db + .insert(bot_posts) + .values({ + bot_config_id: botConfigId, + job_id: jobId, + status: "failed", + error_message: errorMessage, + }) + .returning(); + }, + + /** + * Get recent posts for a bot config + */ + async findRecentPosts(botConfigId: string, limit = 50) { + return db + .select() + .from(bot_posts) + .where(eq(bot_posts.bot_config_id, botConfigId)) + .orderBy(desc(bot_posts.posted_at)) + .limit(limit); + }, +}; diff --git a/packages/database/src/queries/conversations.ts b/packages/database/src/queries/conversations.ts index 0788653..d438800 100644 --- a/packages/database/src/queries/conversations.ts +++ b/packages/database/src/queries/conversations.ts @@ -1,69 +1,43 @@ -import { eq, asc, and, isNull } from "drizzle-orm"; +import { eq, asc, and, desc } from "drizzle-orm"; import { db } from "../index"; import { conversations, messages } from "../schema"; -import type { - Conversation, - Message, - MessageStreamStatus, -} from "@postly/shared-types"; +import type { Conversation, Message } from "@postly/shared-types"; export const conversationQueries = { /** * Create a new conversation */ - async create( - userId: string, - resumeId?: string, - model?: string, - ): Promise { + async create(userId: string, resumeId?: string): Promise { const [result] = await db .insert(conversations) - .values({ user_id: userId, resume_id: resumeId, model }) + .values({ user_id: userId, resume_id: resumeId }) .returning(); return result as unknown as Conversation; }, /** - * Get all conversations for a user (most recent first, excludes archived by default) + * Get all conversations for a user */ - async findByUser( - userId: string, - limit = 50, - includeArchived = false, - ): Promise { - const conditions = [ - eq(conversations.user_id, userId), - isNull(conversations.deleted_at), - ]; - if (!includeArchived) { - conditions.push(eq(conversations.is_archived, false)); - } - + async findByUser(userId: string, limit = 50): Promise { const result = await db .select() .from(conversations) - .where(and(...conditions)) - .orderBy(asc(conversations.updated_at)) + .where(eq(conversations.user_id, userId)) + .orderBy(desc(conversations.updated_at)) .limit(limit); return result as unknown as Conversation[]; }, /** - * Get a single conversation by ID (scoped to user) + * Get a single conversation by ID */ async findById(id: string, userId: string): Promise { const [result] = await db .select() .from(conversations) - .where( - and( - eq(conversations.id, id), - eq(conversations.user_id, userId), - isNull(conversations.deleted_at), - ), - ); + .where(and(eq(conversations.id, id), eq(conversations.user_id, userId))); return (result as unknown as Conversation) || null; }, @@ -79,7 +53,7 @@ export const conversationQueries = { }, /** - * Update conversation resume_id + * Update the resume associated with a conversation */ async updateResumeId(id: string, resumeId: string): Promise { await db @@ -89,17 +63,7 @@ export const conversationQueries = { }, /** - * Archive / unarchive a conversation - */ - async setArchived(id: string, isArchived: boolean): Promise { - await db - .update(conversations) - .set({ is_archived: isArchived, updated_at: new Date() }) - .where(eq(conversations.id, id)); - }, - - /** - * Delete a conversation (scoped to user) + * Delete a conversation */ async delete(id: string, userId: string): Promise { const [result] = await db @@ -117,11 +81,10 @@ export const conversationQueries = { */ async createMessage( conversationId: string, - role: Message["role"], + role: string, content: string, - metadata?: Record, - parentMessageId?: string, - status: MessageStreamStatus = "completed", + tokensUsed?: number, + metadata?: any, ): Promise { const [result] = await db .insert(messages) @@ -129,9 +92,8 @@ export const conversationQueries = { conversation_id: conversationId, role, content, + tokens_used: tokensUsed, metadata, - parent_message_id: parentMessageId, - status, }) .returning(); @@ -145,29 +107,7 @@ export const conversationQueries = { }, /** - * Get only the active-branch messages (what the user sees) - */ - async getActiveThread( - conversationId: string, - limit = 100, - ): Promise { - const result = await db - .select() - .from(messages) - .where( - and( - eq(messages.conversation_id, conversationId), - eq(messages.is_active, true), - ), - ) - .orderBy(asc(messages.created_at)) - .limit(limit); - - return result as unknown as Message[]; - }, - - /** - * Get ALL messages (including inactive branches) for tree rendering + * Get messages for a conversation */ async getMessages(conversationId: string, limit = 100): Promise { const result = await db @@ -179,134 +119,4 @@ export const conversationQueries = { return result as unknown as Message[]; }, - - /** - * Edit a user message → creates a new sibling version, - * deactivates the old message and its entire subtree. - */ - async editMessage( - messageId: string, - newContent: string, - conversationId: string, - ): Promise { - // 1. Get the original message - const [original] = await db - .select() - .from(messages) - .where(eq(messages.id, messageId)); - - if (!original) throw new Error("Message not found"); - - // 2. Deactivate ALL active messages in this conversation - await db - .update(messages) - .set({ is_active: false }) - .where( - and( - eq(messages.conversation_id, conversationId), - eq(messages.is_active, true), - ), - ); - - // 3. Re-activate ancestors of the edited message (walk the parent chain) - const allMessages = await db - .select() - .from(messages) - .where(eq(messages.conversation_id, conversationId)) - .orderBy(asc(messages.created_at)); - - const messageMap = new Map(allMessages.map((m) => [m.id, m])); - const ancestorIds = new Set(); - let currentParentId = original.parent_message_id; - - while (currentParentId) { - ancestorIds.add(currentParentId); - const parent = messageMap.get(currentParentId); - currentParentId = parent?.parent_message_id || null; - } - - for (const ancestorId of ancestorIds) { - await db - .update(messages) - .set({ is_active: true }) - .where(eq(messages.id, ancestorId)); - } - - // 4. Create new version as a sibling (same parent, higher version) - const [newMessage] = await db - .insert(messages) - .values({ - conversation_id: conversationId, - role: original.role, - content: newContent, - parent_message_id: original.parent_message_id, - version: original.version + 1, - is_active: true, - status: "completed", - }) - .returning(); - - // Touch conversation timestamp - await db - .update(conversations) - .set({ updated_at: new Date() }) - .where(eq(conversations.id, conversationId)); - - return newMessage as unknown as Message; - }, - - /** - * Cancel a streaming message (user pressed "Stop generating") - */ - async cancelMessage(messageId: string): Promise { - await db - .update(messages) - .set({ status: "cancelled" }) - .where(eq(messages.id, messageId)); - }, - - /** - * Update message status (streaming lifecycle) - */ - async updateMessageStatus( - messageId: string, - status: MessageStreamStatus, - ): Promise { - await db.update(messages).set({ status }).where(eq(messages.id, messageId)); - }, - - /** - * Update message content (for streamed content accumulation) - */ - async updateMessageContent( - messageId: string, - content: string, - metadata?: Record, - ): Promise { - const updateData: Record = { content }; - if (metadata) updateData.metadata = metadata; - - await db.update(messages).set(updateData).where(eq(messages.id, messageId)); - }, - - /** - * Get all edit versions for a parent (for "edited 2/3" navigation) - */ - async getMessageVersions( - parentMessageId: string, - role: string, - ): Promise { - const result = await db - .select() - .from(messages) - .where( - and( - eq(messages.parent_message_id, parentMessageId), - eq(messages.role, role), - ), - ) - .orderBy(asc(messages.version)); - - return result as unknown as Message[]; - }, }; diff --git a/packages/database/src/queries/index.ts b/packages/database/src/queries/index.ts index 8015092..3f2f7c2 100644 --- a/packages/database/src/queries/index.ts +++ b/packages/database/src/queries/index.ts @@ -9,3 +9,4 @@ export * from "./seeker_profiles"; export * from "./subscriptions"; export * from "./payments"; export * from "./notifications"; +export * from "./bots"; diff --git a/packages/database/src/queries/jobs.ts b/packages/database/src/queries/jobs.ts index 4915a60..8217475 100644 --- a/packages/database/src/queries/jobs.ts +++ b/packages/database/src/queries/jobs.ts @@ -30,7 +30,6 @@ export const jobQueries = { experience_required: input.experience_required, expires_at: input.expires_at, employer_id: employerId, - scrape_source_id: input.scrape_source_id, external_job_id: input.external_job_id, fingerprint: input.fingerprint, }) @@ -77,7 +76,6 @@ export const jobQueries = { updated_at: new Date(), expires_at: expiresAt, is_active: true, - scrape_source_id: input.scrape_source_id, external_job_id: input.external_job_id, fingerprint: input.fingerprint, }) @@ -106,7 +104,6 @@ export const jobQueries = { embedding: input.embedding, expires_at: expiresAt, is_active: true, - scrape_source_id: input.scrape_source_id, external_job_id: input.external_job_id, fingerprint: input.fingerprint, }) diff --git a/packages/database/src/queries/notifications.ts b/packages/database/src/queries/notifications.ts index 3e800c2..4451714 100644 --- a/packages/database/src/queries/notifications.ts +++ b/packages/database/src/queries/notifications.ts @@ -1,110 +1,69 @@ -import { eq, desc, and, isNull } from "drizzle-orm"; +import { eq, desc } from "drizzle-orm"; import { db } from "../index"; -import { email_notifications, notification_templates } from "../schema"; -import type { - NotificationStatus, - CreateNotificationTemplateInput, - CreateNotificationInput, -} from "@postly/shared-types"; +import { notifications } from "../schema"; +import type { NotificationStatus } from "@postly/shared-types"; -export const notificationQueries = { - // Templates - async findTemplateBySlug(slug: string) { - const [result] = await db - .select() - .from(notification_templates) - .where( - and( - eq(notification_templates.slug, slug), - isNull(notification_templates.deleted_at), - ), - ); - - return result ?? null; - }, +export interface CreateNotificationInput { + user_id: string; + type?: string; + subject: string; + content: string; + to_email: string; + status?: NotificationStatus; +} - async createTemplate(input: CreateNotificationTemplateInput) { - const [result] = await db - .insert(notification_templates) - .values(input) - .returning(); - - return result; - }, - - // Notifications - async createNotification(input: CreateNotificationInput) { +export const notificationQueries = { + /** + * Create a notification record (log) + */ + async create(input: CreateNotificationInput) { const [result] = await db - .insert(email_notifications) - .values({ ...input, status: "pending" }) + .insert(notifications) + .values({ ...input, status: input.status || "pending" }) .returning(); return result; }, - // Worker queue: fetch all pending notifications due for dispatch. + /** + * Worker queue: fetch all pending notifications. + */ async findPending(limit = 100) { return db - .select({ - notification: email_notifications, - template: { - html_body: notification_templates.html_body, - text_body: notification_templates.text_body, - }, - }) - .from(email_notifications) - .leftJoin( - notification_templates, - eq(email_notifications.template_id, notification_templates.id), - ) - .where(and(eq(email_notifications.status, "pending"))) - .orderBy(email_notifications.scheduled_at) + .select() + .from(notifications) + .where(eq(notifications.status, "pending")) + .orderBy(notifications.created_at) .limit(limit); }, + /** + * Update notification status after sending. + */ async updateStatus( id: string, status: NotificationStatus, - providerMessageId?: string, errorMessage?: string, ) { await db - .update(email_notifications) + .update(notifications) .set({ status, - ...(providerMessageId && { provider_message_id: providerMessageId }), ...(errorMessage && { error_message: errorMessage }), ...(status === "sent" && { sent_at: new Date() }), - ...(status === "opened" && { opened_at: new Date() }), }) - .where(eq(email_notifications.id, id)); - }, - - async incrementRetryCount(id: string) { - const [existing] = await db - .select({ retry_count: email_notifications.retry_count }) - .from(email_notifications) - .where(eq(email_notifications.id, id)); - - if (!existing) return; - - await db - .update(email_notifications) - .set({ retry_count: (existing.retry_count ?? 0) + 1 }) - .where(eq(email_notifications.id, id)); + .where(eq(notifications.id, id)); }, + /** + * Fetch notification history for a user. + */ async findByUser(userId: string, limit = 50) { return db .select() - .from(email_notifications) - .where( - and( - eq(email_notifications.user_id, userId), - isNull(email_notifications.deleted_at), - ), - ) - .orderBy(desc(email_notifications.created_at)) + .from(notifications) + .where(eq(notifications.user_id, userId)) + .orderBy(desc(notifications.created_at)) .limit(limit); }, }; diff --git a/packages/database/src/queries/subscriptions.ts b/packages/database/src/queries/subscriptions.ts index 51248dd..e97e1c9 100644 --- a/packages/database/src/queries/subscriptions.ts +++ b/packages/database/src/queries/subscriptions.ts @@ -28,36 +28,39 @@ export const subscriptionQueries = { // Called by the DodoPayments webhook handler to create or update the subscription record. async upsertFromWebhook(userId: string, payload: DodoSubscriptionPayload) { + const data = { + user_id: userId, + dodo_subscription_id: payload.dodo_subscription_id, + dodo_customer_id: payload.dodo_customer_id, + plan: (payload.plan as any) || "seeker", + status: payload.status || "active", + current_period_end: payload.current_period_end, + updated_at: new Date(), + }; + const [result] = await db .insert(subscriptions) - .values({ user_id: userId, ...payload }) + .values(data) .onConflictDoUpdate({ target: [subscriptions.user_id], - set: { ...payload, updated_at: new Date() }, + set: data, }) .returning(); return result; }, - async updatePromoCode(userId: string, promoCodeId: string) { - await db - .update(subscriptions) - .set({ promo_code_id: promoCodeId, updated_at: new Date() }) - .where(eq(subscriptions.user_id, userId)); - }, async updateStatus( userId: string, status: SubscriptionStatus, - accessUntil?: Date, + currentPeriodEnd?: Date, ) { const [result] = await db .update(subscriptions) .set({ status, - ...(accessUntil && { access_until: accessUntil }), - ...(status === "cancelled" && { cancelled_at: new Date() }), + ...(currentPeriodEnd && { current_period_end: currentPeriodEnd }), updated_at: new Date(), }) .where(eq(subscriptions.user_id, userId)) @@ -82,7 +85,7 @@ export const subscriptionQueries = { const sub = await this.findByUserId(userId); if (!sub) return false; if (sub.status !== "active" && sub.status !== "trialing") return false; - if (sub.access_until && new Date(sub.access_until) < new Date()) + if (sub.current_period_end && new Date(sub.current_period_end) < new Date()) return false; return true; }, diff --git a/packages/database/src/queries/users.ts b/packages/database/src/queries/users.ts index 42feacd..3d1b7b3 100644 --- a/packages/database/src/queries/users.ts +++ b/packages/database/src/queries/users.ts @@ -198,7 +198,7 @@ export const userQueries = { with: { seeker_profile: true, employer_profile: true, - discord_configs: true, + bot_configs: true, subscription: true, }, }); diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index c510146..b31f2fe 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -45,11 +45,9 @@ export const subscriptionStatusEnum = pgEnum("subscription_status", [ ]); export const subscriptionPlanEnum = pgEnum("subscription_plan", [ - "free", "seeker", "employer", "discord_owner", - "enterprise", ]); export const paymentStatusEnum = pgEnum("payment_status", [ @@ -68,32 +66,13 @@ export const notificationStatusEnum = pgEnum("notification_status", [ "opened", ]); -export const integrationProviderEnum = pgEnum("integration_provider", [ - "greenhouse", - "lever", - "workday", - "bamboohr", - "linkedin", - "indeed", - "custom", -]); - -export const scrapeSourceStatusEnum = pgEnum("scrape_source_status", [ - "active", - "paused", - "broken", - "rate_limited", -]); - export const botPlatformEnum = pgEnum("bot_platform", [ "discord", "reddit", "twitter", - "slack", ]); -// ─── Users ──────────────────────────────────────────────────────────────────── -// Base auth table for all user types (job_seeker, employer, admin). +// ─── Users & Auth ───────────────────────────────────────────────────────────── export const users = pgTable( "users", @@ -119,13 +98,21 @@ export const users = pgTable( (table) => ({ emailIdx: index("idx_users_email").on(table.email), rolesIdx: index("idx_users_role").on(table.roles), - resetTokenIdx: index("idx_users_reset_token").on( - table.password_reset_token, - ), }), ); -// ─── OTP Codes ──────────────────────────────────────────────────────────────── +export const sessions = pgTable("sessions", { + id: uuid("id").primaryKey().defaultRandom(), + user_id: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + token_hash: varchar("token_hash", { length: 255 }).notNull().unique(), + ip_address: varchar("ip_address", { length: 45 }), + user_agent: text("user_agent"), + last_active_at: timestamp("last_active_at", { withTimezone: true }).defaultNow(), + expires_at: timestamp("expires_at", { withTimezone: true }).notNull(), + created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), +}); export const otp_codes = pgTable( "otp_codes", @@ -146,182 +133,73 @@ export const otp_codes = pgTable( }), ); -// ─── Scraper Infrastructure ────────────────────────────────────────────────── - -export const scrape_sources = pgTable( - "scrape_sources", - { - id: uuid("id").primaryKey().defaultRandom(), - name: varchar("name", { length: 255 }).notNull(), - base_url: text("base_url").notNull(), - scraper_type: varchar("scraper_type", { length: 50 }).notNull(), // "rss", "html", "api", "playwright" - config: jsonb("config").notNull(), // CSS selectors, pagination rules, rate limits, headers - status: scrapeSourceStatusEnum("status").default("active"), - - // Rate limiting per source - requests_per_minute: integer("requests_per_minute").default(10), - retry_after_seconds: integer("retry_after_seconds").default(60), - - // Scheduling - crawl_interval_minutes: integer("crawl_interval_minutes").default(60), - last_crawled_at: timestamp("last_crawled_at", { withTimezone: true }), - next_crawl_at: timestamp("next_crawl_at", { withTimezone: true }), +// ─── Profiles ───────────────────────────────────────────────────────────────── - // Health - consecutive_failures: integer("consecutive_failures").default(0), - last_error: text("last_error"), - total_jobs_scraped: integer("total_jobs_scraped").default(0), - - created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), - updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, - (t) => ({ - crawlQueueIdx: index("idx_scrape_sources_queue").on( - t.status, - t.next_crawl_at, - ), - }), -); - -export const scrape_runs = pgTable( - "scrape_runs", +export const seeker_profiles = pgTable( + "seeker_profiles", { id: uuid("id").primaryKey().defaultRandom(), - source_id: uuid("source_id") + user_id: uuid("user_id") .notNull() - .references(() => scrape_sources.id, { onDelete: "cascade" }), - started_at: timestamp("started_at", { withTimezone: true }).defaultNow(), - completed_at: timestamp("completed_at", { withTimezone: true }), - status: varchar("status", { length: 30 }).default("running"), // running, success, partial, failed - jobs_found: integer("jobs_found").default(0), - jobs_new: integer("jobs_new").default(0), - jobs_updated: integer("jobs_updated").default(0), - jobs_deduped: integer("jobs_deduped").default(0), - error_log: jsonb("error_log"), - }, - (t) => ({ - sourceIdx: index("idx_scrape_runs_source").on(t.source_id, t.started_at), - }), -); - -// ─── Promo Codes ───────────────────────────────────────────────────────────── - -export const promo_codes = pgTable("promo_codes", { - id: uuid("id").primaryKey().defaultRandom(), - code: varchar("code", { length: 100 }).notNull().unique(), - discount_type: varchar("discount_type", { length: 20 }).notNull(), // "percent" | "fixed" - discount_value: decimal("discount_value", { - precision: 10, - scale: 2, - }).notNull(), - applies_to_plan: subscriptionPlanEnum("applies_to_plan"), - max_uses: integer("max_uses"), - uses_count: integer("uses_count").default(0), - expires_at: timestamp("expires_at", { withTimezone: true }), - created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), -}); - -// ─── Notification Templates ─────────────────────────────────────────────────── - -export const notification_templates = pgTable( - "notification_templates", - { - id: uuid("id").primaryKey().defaultRandom(), - slug: varchar("slug", { length: 100 }).notNull().unique(), - name: varchar("name", { length: 255 }).notNull(), - subject: varchar("subject", { length: 500 }).notNull(), - html_body: text("html_body").notNull(), - text_body: text("text_body"), - metadata: jsonb("metadata"), - is_active: boolean("is_active").default(true), + .unique() + .references(() => users.id, { onDelete: "cascade" }), + headline: varchar("headline", { length: 500 }), + summary: text("summary"), + skills: jsonb("skills"), + experience_years: integer("experience_years"), + experience_level: varchar("experience_level", { length: 50 }), + education: jsonb("education"), + certifications: jsonb("certifications"), + languages: jsonb("languages"), + work_history: jsonb("work_history"), + desired_job_titles: jsonb("desired_job_titles"), + desired_locations: jsonb("desired_locations"), + desired_salary_min: decimal("desired_salary_min", { precision: 10, scale: 2 }), + desired_salary_max: decimal("desired_salary_max", { precision: 10, scale: 2 }), + desired_job_type: varchar("desired_job_type", { length: 50 }), + open_to_remote: boolean("open_to_remote").default(true), + open_to_relocation: boolean("open_to_relocation").default(false), + embedding: vector("embedding", { dimensions: 1024 }), + prompt_history_summary: text("prompt_history_summary"), + last_parsed_at: timestamp("last_parsed_at", { withTimezone: true }), created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), deleted_at: timestamp("deleted_at", { withTimezone: true }), }, (table) => ({ - slugIdx: index("idx_notif_templates_slug").on(table.slug), - }), -); - -// ─── Rate Limiting ────────────────────────────────────────────────────────── - -export const plan_quotas = pgTable("plan_quotas", { - id: uuid("id").primaryKey().defaultRandom(), - plan: subscriptionPlanEnum("plan").notNull().unique(), - // AI tokens - ai_tokens_per_day: integer("ai_tokens_per_day"), // null = unlimited (admin) - ai_tokens_per_month: integer("ai_tokens_per_month"), - // Job matches / searches - job_matches_per_day: integer("job_matches_per_day"), - job_searches_per_day: integer("job_searches_per_day"), - // API calls - api_calls_per_minute: integer("api_calls_per_minute"), - api_calls_per_day: integer("api_calls_per_day"), - // Resume parses - resume_parses_per_month: integer("resume_parses_per_month"), - // Bot posts (discord_owner tier specific) - bot_posts_per_day: integer("bot_posts_per_day"), - // Admin override flag - is_unlimited: boolean("is_unlimited").default(false), - created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), - updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), -}); - -// ─── AI Improvements ───────────────────────────────────────────────────────── - -export const system_prompts = pgTable( - "system_prompts", - { - id: uuid("id").primaryKey().defaultRandom(), - version: integer("version").notNull(), - slug: varchar("slug", { length: 100 }).notNull(), // "job_seeker_v3", "employer_v1" - content: text("content").notNull(), - is_active: boolean("is_active").default(false), - created_by: uuid("created_by").references(() => users.id), - created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), - }, - (t) => ({ - activeSlugIdx: index("idx_system_prompts_active").on(t.slug, t.is_active), + userIdIdx: index("idx_seeker_profiles_user").on(table.user_id), }), ); -// ─── Discord ────────────────────────────────────────────────────────────────── -// Discord guild/channel configuration linked to a user. - -export const discord_configs = pgTable( - "discord_configs", +export const employer_profiles = pgTable( + "employer_profiles", { id: uuid("id").primaryKey().defaultRandom(), - guild_id: varchar("guild_id", { length: 255 }).notNull().unique(), - channel_id: varchar("channel_id", { length: 255 }), user_id: uuid("user_id") .notNull() + .unique() .references(() => users.id, { onDelete: "cascade" }), - guild_name: varchar("guild_name", { length: 255 }), // for UI display - channel_name: varchar("channel_name", { length: 255 }), // #job-alerts etc - bot_webhook_url: text("bot_webhook_url"), // for posting without bot token - job_filters: jsonb("job_filters"), // { locations: [], job_types: [], keywords: [], min_salary: null } - post_format: varchar("post_format", { length: 50 }).default("embed"), // "embed" | "plain" | "compact" - ping_role_id: varchar("ping_role_id", { length: 255 }), // role to @mention on new posts - ping_everyone: boolean("ping_everyone").default(false), - max_posts_per_day: integer("max_posts_per_day").default(10), - last_post_at: timestamp("last_post_at", { withTimezone: true }), - posts_today: integer("posts_today").default(0), - posts_today_reset_at: timestamp("posts_today_reset_at", { - withTimezone: true, - }), - is_active: boolean("is_active").default(true), + company_name: varchar("company_name", { length: 255 }).notNull(), + company_website: text("company_website"), + company_logo_url: text("company_logo_url"), + company_description: text("company_description"), + company_size: varchar("company_size", { length: 50 }), + industry: varchar("industry", { length: 150 }), + headquarters_location: varchar("headquarters_location", { length: 255 }), + social_links: jsonb("social_links"), + embedding: vector("embedding", { dimensions: 1024 }), + active_job_count: integer("active_job_count").default(0), + is_verified: boolean("is_verified").default(false), created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), + deleted_at: timestamp("deleted_at", { withTimezone: true }), }, (table) => ({ - guildIdx: index("idx_discord_guild").on(table.guild_id), - userIdIdx: index("idx_discord_user").on(table.user_id), + userIdIdx: index("idx_employer_profiles_user").on(table.user_id), }), ); -// ─── Resumes ────────────────────────────────────────────────────────────────── -// Raw resume files plus parsed content and vector embedding for AI search. +// ─── Resumes & Jobs ─────────────────────────────────────────────────────────── export const resumes = pgTable( "resumes", @@ -344,9 +222,6 @@ export const resumes = pgTable( }), ); -// ─── Jobs ───────────────────────────────────────────────────────────────────── -// Job postings from employers or imported from external sources. - export const jobs = pgTable( "jobs", { @@ -359,7 +234,7 @@ export const jobs = pgTable( salary_max: decimal("salary_max", { precision: 10, scale: 2 }), job_type: varchar("job_type", { length: 50 }), remote: boolean("remote").default(false), - source: varchar("source", { length: 100 }).notNull(), + source: varchar("source", { length: 100 }).notNull(), // 'internal' or origin (e.g. 'indeed') source_url: text("source_url"), embedding: vector("embedding", { dimensions: 1024 }), skills_required: jsonb("skills_required"), @@ -367,28 +242,19 @@ export const jobs = pgTable( posted_at: timestamp("posted_at", { withTimezone: true }), expires_at: timestamp("expires_at", { withTimezone: true }), is_active: boolean("is_active").default(true), - employer_id: uuid("employer_id").references(() => users.id), - scrape_source_id: uuid("scrape_source_id").references( - () => scrape_sources.id, - ), - external_job_id: varchar("external_job_id", { length: 255 }), // the ID on the source site - fingerprint: varchar("fingerprint", { length: 64 }), // index this + employer_id: uuid("employer_id").references(() => users.id), // Nullable for scraped jobs + external_job_id: varchar("external_job_id", { length: 255 }), + fingerprint: varchar("fingerprint", { length: 64 }), created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), deleted_at: timestamp("deleted_at", { withTimezone: true }), }, (table) => ({ companyIdx: index("idx_jobs_company").on(table.company_name), - locationIdx: index("idx_jobs_location").on(table.location), - remoteIdx: index("idx_jobs_remote").on(table.remote), - jobTypeIdx: index("idx_jobs_type").on(table.job_type), activeIdx: index("idx_jobs_active").on(table.is_active), }), ); -// ─── Job Matches ────────────────────────────────────────────────────────────── -// AI-generated match scores between a seeker's resume and a job posting. - export const job_matches = pgTable( "job_matches", { @@ -410,105 +276,12 @@ export const job_matches = pgTable( deleted_at: timestamp("deleted_at", { withTimezone: true }), }, (table) => ({ - userIdIdx: index("idx_job_matches_user").on( - table.user_id, - table.match_score, - ), - jobIdIdx: index("idx_job_matches_job").on(table.job_id), uniqueMatch: unique().on(table.user_id, table.job_id), }), ); -// ─── Employer Profiles ──────────────────────────────────────────────────────── -// Extended company details for users with role = 'employer'. - -export const employer_profiles = pgTable( - "employer_profiles", - { - id: uuid("id").primaryKey().defaultRandom(), - user_id: uuid("user_id") - .notNull() - .unique() - .references(() => users.id, { onDelete: "cascade" }), - company_name: varchar("company_name", { length: 255 }).notNull(), - company_website: text("company_website"), - company_logo_url: text("company_logo_url"), - company_description: text("company_description"), - company_size: varchar("company_size", { length: 50 }), - industry: varchar("industry", { length: 150 }), - headquarters_location: varchar("headquarters_location", { length: 255 }), - social_links: jsonb("social_links"), - embedding: vector("embedding", { dimensions: 1024 }), - active_job_count: integer("active_job_count").default(0), - is_verified: boolean("is_verified").default(false), - created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), - updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), - deleted_at: timestamp("deleted_at", { withTimezone: true }), - }, - (table) => ({ - userIdIdx: index("idx_employer_profiles_user").on(table.user_id), - companyNameIdx: index("idx_employer_profiles_company").on( - table.company_name, - ), - industryIdx: index("idx_employer_profiles_industry").on(table.industry), - }), -); - -// ─── Seeker Profiles ────────────────────────────────────────────────────────── -// Extended profile for job seekers with parsed resume data, preferences, and -// a vector embedding for natural-language job matching and email alerts. - -export const seeker_profiles = pgTable( - "seeker_profiles", - { - id: uuid("id").primaryKey().defaultRandom(), - user_id: uuid("user_id") - .notNull() - .unique() - .references(() => users.id, { onDelete: "cascade" }), - headline: varchar("headline", { length: 500 }), - summary: text("summary"), - skills: jsonb("skills"), - experience_years: integer("experience_years"), - experience_level: varchar("experience_level", { length: 50 }), - education: jsonb("education"), - certifications: jsonb("certifications"), - languages: jsonb("languages"), - work_history: jsonb("work_history"), - desired_job_titles: jsonb("desired_job_titles"), - desired_locations: jsonb("desired_locations"), - desired_salary_min: decimal("desired_salary_min", { - precision: 10, - scale: 2, - }), - desired_salary_max: decimal("desired_salary_max", { - precision: 10, - scale: 2, - }), - desired_job_type: varchar("desired_job_type", { length: 50 }), - open_to_remote: boolean("open_to_remote").default(true), - open_to_relocation: boolean("open_to_relocation").default(false), - embedding: vector("embedding", { dimensions: 1024 }), - prompt_history_summary: text("prompt_history_summary"), - last_parsed_at: timestamp("last_parsed_at", { withTimezone: true }), - created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), - updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), - deleted_at: timestamp("deleted_at", { withTimezone: true }), - }, - (table) => ({ - userIdIdx: index("idx_seeker_profiles_user").on(table.user_id), - experienceLevelIdx: index("idx_seeker_profiles_level").on( - table.experience_level, - ), - jobTypeIdx: index("idx_seeker_profiles_job_type").on( - table.desired_job_type, - ), - }), -); - // ─── Applications ───────────────────────────────────────────────────────────── -// Tracks every job application submitted by a seeker, including full status -// lifecycle and a JSONB audit trail of every status change. +// ONLY for jobs generated on our platform (where employer_id is NOT NULL) export const applications = pgTable( "applications", @@ -523,322 +296,177 @@ export const applications = pgTable( resume_id: uuid("resume_id").references(() => resumes.id), status: applicationStatusEnum("status").notNull().default("applied"), cover_letter: text("cover_letter"), - notes: text("notes"), - external_url: text("external_url"), - contact_info: jsonb("contact_info"), - next_interview_at: timestamp("next_interview_at", { withTimezone: true }), - offer_details: jsonb("offer_details"), - match_score: decimal("match_score", { precision: 5, scale: 2 }), - ai_explanation: text("ai_explanation"), applied_at: timestamp("applied_at", { withTimezone: true }).defaultNow(), - deleted_at: timestamp("deleted_at", { withTimezone: true }), created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), }, (table) => ({ - seekerStatusIdx: index("idx_applications_seeker_status").on( - table.seeker_id, - table.status, - ), - jobPipelineIdx: index("idx_applications_job_pipeline").on( - table.job_id, - table.created_at, - ), seekerJobUnique: unique().on(table.seeker_id, table.job_id), }), ); -// ─── Career Site Integrations ───────────────────────────────────────────────── -// Employer-owned OAuth/API connections to external job boards for job import sync. - -export const career_site_integrations = pgTable( - "career_site_integrations", - { - id: uuid("id").primaryKey().defaultRandom(), - employer_id: uuid("employer_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - provider: integrationProviderEnum("provider").notNull(), - label: varchar("label", { length: 255 }), - access_token: text("access_token"), - refresh_token: text("refresh_token"), - token_expires_at: timestamp("token_expires_at", { withTimezone: true }), - api_key: text("api_key"), - provider_config: jsonb("provider_config"), - is_active: boolean("is_active").default(true), - sync_interval_minutes: integer("sync_interval_minutes").default(60), - last_synced_at: timestamp("last_synced_at", { withTimezone: true }), - next_sync_at: timestamp("next_sync_at", { withTimezone: true }), - last_sync_job_count: integer("last_sync_job_count").default(0), - last_sync_error: text("last_sync_error"), - created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), - updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, - (table) => ({ - employerIdx: index("idx_integrations_employer").on(table.employer_id), - syncQueueIdx: index("idx_integrations_sync_queue").on( - table.is_active, - table.next_sync_at, - ), - employerProviderUnique: unique().on(table.employer_id, table.provider), - }), -); - -// ─── Conversations ──────────────────────────────────────────────────────────── -// Chat sessions between a user and the AI, optionally grounded to a resume. - -export const conversations = pgTable( - "conversations", +export const application_status_history = pgTable( + "application_status_history", { id: uuid("id").primaryKey().defaultRandom(), - user_id: uuid("user_id") + application_id: uuid("application_id") .notNull() - .references(() => users.id, { onDelete: "cascade" }), - title: varchar("title", { length: 255 }), - resume_id: uuid("resume_id").references(() => resumes.id), - model: varchar("model", { length: 100 }), - is_archived: boolean("is_archived").default(false), - system_prompt_version: integer("system_prompt_version").default(1), - total_tokens_used: integer("total_tokens_used").default(0), // denormalized counter - context_window_used: integer("context_window_used"), // % of context used, for truncation warnings + .references(() => applications.id, { onDelete: "cascade" }), + from_status: applicationStatusEnum("from_status"), + to_status: applicationStatusEnum("to_status").notNull(), + changed_by: uuid("changed_by").references(() => users.id), + note: text("note"), created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), - updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), - deleted_at: timestamp("deleted_at", { withTimezone: true }), }, - (table) => ({ - userIdIdx: index("idx_conversations_user").on( - table.user_id, - table.updated_at, - ), - archivedIdx: index("idx_conversations_archived").on( - table.user_id, - table.is_archived, - ), - }), ); -// ─── Messages ───────────────────────────────────────────────────────────────── -// Individual messages within a conversation. Supports branching via parent_message_id -// and edit versioning; only the active branch is rendered to the user. +// ─── AI Conversations ───────────────────────────────────────────────────────── -export const messages = pgTable( - "messages", - { - id: uuid("id").primaryKey().defaultRandom(), - conversation_id: uuid("conversation_id") - .notNull() - .references(() => conversations.id, { onDelete: "cascade" }), - role: varchar("role", { length: 50 }).notNull(), - content: text("content").notNull(), - metadata: jsonb("metadata"), - parent_message_id: uuid("parent_message_id"), - version: integer("version").notNull().default(1), - is_active: boolean("is_active").notNull().default(true), - status: varchar("status", { length: 20 }).notNull().default("completed"), - tokens_used: integer("tokens_used"), // actual token count from API response - model_used: varchar("model_used", { length: 100 }), // which model served this message - latency_ms: integer("latency_ms"), // time to first token - finish_reason: varchar("finish_reason", { length: 50 }), // "stop", "max_tokens", "error" - cost_usd: decimal("cost_usd", { precision: 10, scale: 6 }), // computed from tokens - created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), - deleted_at: timestamp("deleted_at", { withTimezone: true }), - }, - (table) => ({ - conversationIdIdx: index("idx_messages_conversation").on( - table.conversation_id, - table.created_at, - ), - parentIdx: index("idx_messages_parent").on(table.parent_message_id), - activeIdx: index("idx_messages_active").on( - table.conversation_id, - table.is_active, - ), - }), -); +export const system_prompts = pgTable("system_prompts", { + id: uuid("id").primaryKey().defaultRandom(), + version: integer("version").notNull(), + slug: varchar("slug", { length: 100 }).notNull(), + content: text("content").notNull(), + is_active: boolean("is_active").default(false), + created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), +}); -// ─── Message Attachments ────────────────────────────────────────────────────── -// Files uploaded alongside a message (e.g. resumes, documents). +export const conversations = pgTable("conversations", { + id: uuid("id").primaryKey().defaultRandom(), + user_id: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + title: varchar("title", { length: 255 }), + resume_id: uuid("resume_id").references(() => resumes.id), + created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), + updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), +}); -export const message_attachments = pgTable( - "message_attachments", - { - id: uuid("id").primaryKey().defaultRandom(), - message_id: uuid("message_id") - .notNull() - .references(() => messages.id, { onDelete: "cascade" }), - file_url: text("file_url").notNull(), - file_name: varchar("file_name", { length: 255 }).notNull(), - file_type: varchar("file_type", { length: 100 }), - file_size: integer("file_size"), - created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), - }, - (table) => ({ - messageIdIdx: index("idx_attachments_message").on(table.message_id), - }), -); +export const messages = pgTable("messages", { + id: uuid("id").primaryKey().defaultRandom(), + conversation_id: uuid("conversation_id") + .notNull() + .references(() => conversations.id, { onDelete: "cascade" }), + role: varchar("role", { length: 50 }).notNull(), + content: text("content").notNull(), + tokens_used: integer("tokens_used"), + metadata: jsonb("metadata"), + created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), +}); -// ─── Subscriptions ──────────────────────────────────────────────────────────── -// DodoPayments subscription record per user. Controls plan access and feature gating. +// ─── Bots & Social ──────────────────────────────────────────────────────────── -export const subscriptions = pgTable( - "subscriptions", +export const bot_configs = pgTable( + "bot_configs", { id: uuid("id").primaryKey().defaultRandom(), user_id: uuid("user_id") .notNull() - .unique() .references(() => users.id, { onDelete: "cascade" }), - plan: subscriptionPlanEnum("plan").notNull().default("free"), - status: subscriptionStatusEnum("status").notNull().default("active"), - dodo_customer_id: varchar("dodo_customer_id", { length: 255 }), - dodo_subscription_id: varchar("dodo_subscription_id", { - length: 255, - }).unique(), - dodo_product_id: varchar("dodo_product_id", { length: 255 }), - current_period_start: timestamp("current_period_start", { - withTimezone: true, - }), - current_period_end: timestamp("current_period_end", { withTimezone: true }), - trial_ends_at: timestamp("trial_ends_at", { withTimezone: true }), - cancelled_at: timestamp("cancelled_at", { withTimezone: true }), - access_until: timestamp("access_until", { withTimezone: true }), - promo_code_id: uuid("promo_code_id").references(() => promo_codes.id), - raw_data: jsonb("raw_data"), + platform: botPlatformEnum("platform").notNull(), + + // Detailed Configuration + is_active: boolean("is_active").default(true), + + // Platform Auth (Stored as JSON for flexibility, or encrypted) + credentials: jsonb("credentials"), + + // Target Destination (e.g. Channel ID for Discord, Subreddit for Reddit) + target_id: varchar("target_id", { length: 255 }), + target_name: varchar("target_name", { length: 255 }), + webhook_url: text("webhook_url"), + + // Filtering Details: "What type of jobs they want" + filter_keywords: varchar("filter_keywords", { length: 500 }), // comma separated + filter_locations: varchar("filter_locations", { length: 500 }), + filter_min_salary: decimal("filter_min_salary", { precision: 10, scale: 2 }), + filter_job_types: varchar("filter_job_types", { length: 255 }).array(), + + last_post_at: timestamp("last_post_at", { withTimezone: true }), created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), }, - (table) => ({ - userIdx: index("idx_subscriptions_user").on(table.user_id), - statusIdx: index("idx_subscriptions_status").on(table.status), - dodoSubIdx: index("idx_subscriptions_dodo_sub").on( - table.dodo_subscription_id, - ), - periodEndIdx: index("idx_subscriptions_period_end").on( - table.current_period_end, - ), - }), -); - -// ─── Payments ───────────────────────────────────────────────────────────────── -// Immutable DodoPayments event ledger. One row per webhook event, never mutated. - -export const payments = pgTable( - "payments", - { - id: uuid("id").primaryKey().defaultRandom(), - user_id: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - subscription_id: uuid("subscription_id").references(() => subscriptions.id), - dodo_payment_id: varchar("dodo_payment_id", { length: 255 }).unique(), - dodo_customer_id: varchar("dodo_customer_id", { length: 255 }), - event_type: varchar("event_type", { length: 100 }).notNull(), - status: paymentStatusEnum("status").notNull(), - amount: integer("amount").notNull(), - currency: varchar("currency", { length: 10 }).notNull().default("USD"), - idempotency_key: varchar("idempotency_key", { length: 255 }).unique(), // dodo_payment_id is already unique, this is extra safety - raw_payload: jsonb("raw_payload"), - paid_at: timestamp("paid_at", { withTimezone: true }), - created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), - }, - (table) => ({ - userIdx: index("idx_payments_user").on(table.user_id, table.created_at), - dodoPaymentIdx: index("idx_payments_dodo_payment").on( - table.dodo_payment_id, - ), - statusIdx: index("idx_payments_status").on(table.status), + (t) => ({ + userPlatformUnique: unique().on(t.user_id, t.platform), }), ); -// ─── Notification Templates ─────────────────────────────────────────────────── -// Reusable Handlebars-style email templates keyed by a unique slug. +export const bot_posts = pgTable("bot_posts", { + id: uuid("id").primaryKey().defaultRandom(), + bot_config_id: uuid("bot_config_id") + .notNull() + .references(() => bot_configs.id, { onDelete: "cascade" }), + job_id: uuid("job_id").references(() => jobs.id, { onDelete: "set null" }), + external_post_id: varchar("external_post_id", { length: 255 }), + status: varchar("status", { length: 30 }).default("sent"), + error_message: text("error_message"), + posted_at: timestamp("posted_at", { withTimezone: true }).defaultNow(), +}); -// ─── Email Notifications ────────────────────────────────────────────────────── -// Per-user email dispatch log for job alerts and transactional emails. +// ─── Billing ────────────────────────────────────────────────────────────────── -export const email_notifications = pgTable( - "email_notifications", - { - id: uuid("id").primaryKey().defaultRandom(), - user_id: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - template_id: uuid("template_id").references( - () => notification_templates.id, - ), - to_email: varchar("to_email", { length: 255 }).notNull(), - subject: varchar("subject", { length: 500 }).notNull(), - status: notificationStatusEnum("status").notNull().default("pending"), - template_variables: jsonb("template_variables"), - job_ids: jsonb("job_ids"), - provider_message_id: varchar("provider_message_id", { length: 255 }), - error_message: text("error_message"), - retry_count: integer("retry_count").default(0), - scheduled_at: timestamp("scheduled_at", { withTimezone: true }), - sent_at: timestamp("sent_at", { withTimezone: true }), - opened_at: timestamp("opened_at", { withTimezone: true }), - created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), - deleted_at: timestamp("deleted_at", { withTimezone: true }), - }, - (table) => ({ - pendingQueueIdx: index("idx_email_notif_pending").on( - table.status, - table.scheduled_at, - ), - userIdx: index("idx_email_notif_user").on(table.user_id, table.created_at), - templateIdx: index("idx_email_notif_template").on(table.template_id), - }), -); - -// ─── Sessions ───────────────────────────────────────────────────────────────── +export const subscriptions = pgTable("subscriptions", { + id: uuid("id").primaryKey().defaultRandom(), + user_id: uuid("user_id") + .notNull() + .unique() + .references(() => users.id, { onDelete: "cascade" }), + plan: subscriptionPlanEnum("plan").notNull(), + status: subscriptionStatusEnum("status").notNull().default("active"), + dodo_subscription_id: varchar("dodo_subscription_id", { length: 255 }).unique(), + dodo_customer_id: varchar("dodo_customer_id", { length: 255 }), + current_period_end: timestamp("current_period_end", { withTimezone: true }), + raw_data: jsonb("raw_data"), + created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), + updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), +}); -export const sessions = pgTable("sessions", { +export const payments = pgTable("payments", { id: uuid("id").primaryKey().defaultRandom(), user_id: uuid("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), - token_hash: varchar("token_hash", { length: 255 }).notNull().unique(), - ip_address: varchar("ip_address", { length: 45 }), // IPv6-safe - user_agent: text("user_agent"), - last_active_at: timestamp("last_active_at", { - withTimezone: true, - }).defaultNow(), - expires_at: timestamp("expires_at", { withTimezone: true }).notNull(), + subscription_id: uuid("subscription_id").references(() => subscriptions.id), + dodo_payment_id: varchar("dodo_payment_id", { length: 255 }), + dodo_customer_id: varchar("dodo_customer_id", { length: 255 }), + event_type: varchar("event_type", { length: 100 }), + amount: integer("amount").notNull(), + currency: varchar("currency", { length: 10 }).notNull().default("USD"), + status: paymentStatusEnum("status").notNull(), + paid_at: timestamp("paid_at", { withTimezone: true }), + raw_payload: jsonb("raw_payload"), + idempotency_key: varchar("idempotency_key", { length: 255 }), created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), }); -// ─── Application Status History ─────────────────────────────────────────────── +// ─── Notifications ──────────────────────────────────────────────────────────── -export const application_status_history = pgTable( - "application_status_history", - { - id: uuid("id").primaryKey().defaultRandom(), - application_id: uuid("application_id") - .notNull() - .references(() => applications.id, { onDelete: "cascade" }), - from_status: applicationStatusEnum("from_status"), - to_status: applicationStatusEnum("to_status").notNull(), - changed_by: uuid("changed_by").references(() => users.id), - note: text("note"), - created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), - }, -); +export const notifications = pgTable("notifications", { + id: uuid("id").primaryKey().defaultRandom(), + user_id: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + type: varchar("type", { length: 100 }), // e.g. 'job_alert', 'welcome' + subject: varchar("subject", { length: 500 }).notNull(), + content: text("content").notNull(), // HTML or Text body + to_email: varchar("to_email", { length: 255 }).notNull(), + status: notificationStatusEnum("status").notNull().default("pending"), + sent_at: timestamp("sent_at", { withTimezone: true }), + error_message: text("error_message"), + created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), +}); -// ─── Audit Log ─────────────────────────────────────────────────────────────── +// ─── System ─────────────────────────────────────────────────────────────────── export const audit_log = pgTable("audit_log", { id: uuid("id").primaryKey().defaultRandom(), actor_id: uuid("actor_id").references(() => users.id), - action: varchar("action", { length: 100 }).notNull(), // e.g. "job.delete", "user.ban" + action: varchar("action", { length: 100 }).notNull(), entity_type: varchar("entity_type", { length: 100 }), entity_id: uuid("entity_id"), - diff: jsonb("diff"), // before/after snapshot - ip_address: varchar("ip_address", { length: 45 }), created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), }); -// ─── Rate Limiting ────────────────────────────────────────────────────────── - export const token_usage = pgTable( "token_usage", { @@ -846,148 +474,20 @@ export const token_usage = pgTable( user_id: uuid("user_id") .notNull() .references(() => users.id, { onDelete: "cascade" }), - window_type: varchar("window_type", { length: 20 }).notNull(), // "minute", "day", "month" window_start: timestamp("window_start", { withTimezone: true }).notNull(), tokens_used: integer("tokens_used").notNull().default(0), - api_calls: integer("api_calls").notNull().default(0), - job_matches: integer("job_matches").notNull().default(0), - created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), - updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), }, (table) => ({ - userWindowIdx: index("idx_token_usage_user_window").on( - table.user_id, - table.window_type, - table.window_start, - ), - uniqueWindow: unique().on( - table.user_id, - table.window_type, - table.window_start, - ), + uniqueWindow: unique().on(table.user_id, table.window_start), }), ); -export const rate_limit_overrides = pgTable("rate_limit_overrides", { - id: uuid("id").primaryKey().defaultRandom(), - user_id: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - override_type: varchar("override_type", { length: 50 }).notNull(), // "ai_tokens_per_day" etc - override_value: integer("override_value"), // null = unlimited - granted_by: uuid("granted_by").references(() => users.id), - expires_at: timestamp("expires_at", { withTimezone: true }), - reason: text("reason"), - created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), -}); - -// ─── Discord Improvements ───────────────────────────────────────────────────── - -export const discord_posted_jobs = pgTable( - "discord_posted_jobs", - { - id: uuid("id").primaryKey().defaultRandom(), - discord_config_id: uuid("discord_config_id") - .notNull() - .references(() => discord_configs.id, { onDelete: "cascade" }), - job_id: uuid("job_id") - .notNull() - .references(() => jobs.id, { onDelete: "cascade" }), - message_id: varchar("message_id", { length: 255 }), // Discord message ID for editing/deleting - posted_at: timestamp("posted_at", { withTimezone: true }).defaultNow(), - }, - (t) => ({ - uniquePost: unique().on(t.discord_config_id, t.job_id), - configIdx: index("idx_discord_posted_config").on(t.discord_config_id), - }), -); - -// ─── Bot Configs ───────────────────────────────────────────────────────────── - -export const bot_configs = pgTable( - "bot_configs", - { - id: uuid("id").primaryKey().defaultRandom(), - user_id: uuid("user_id") - .notNull() - .references(() => users.id, { onDelete: "cascade" }), - platform: botPlatformEnum("platform").notNull(), - - // Platform-specific identity stored as JSONB (guild_id, subreddit, twitter_handle etc) - platform_config: jsonb("platform_config").notNull(), - - // Auth — encrypted at rest in your app layer, not plain text here - credentials: jsonb("credentials"), // { access_token, refresh_token, api_key, ... } - token_expires_at: timestamp("token_expires_at", { withTimezone: true }), - - // Job filter preferences - job_filters: jsonb("job_filters"), - post_format: varchar("post_format", { length: 50 }).default("default"), - - // Rate / scheduling - max_posts_per_day: integer("max_posts_per_day").default(10), - post_schedule: jsonb("post_schedule"), // { days: [0-6], hours: [9,12,18] } - - is_active: boolean("is_active").default(true), - last_post_at: timestamp("last_post_at", { withTimezone: true }), - created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), - updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), - }, - (t) => ({ - userPlatformUnique: unique().on(t.user_id, t.platform), - activeIdx: index("idx_bot_configs_active").on(t.is_active, t.platform), - }), -); - -export const bot_posts = pgTable( - "bot_posts", - { - id: uuid("id").primaryKey().defaultRandom(), - bot_config_id: uuid("bot_config_id") - .notNull() - .references(() => bot_configs.id, { onDelete: "cascade" }), - job_id: uuid("job_id").references(() => jobs.id, { onDelete: "set null" }), - platform: botPlatformEnum("platform").notNull(), - external_post_id: varchar("external_post_id", { length: 255 }), // Discord msg ID, Reddit post ID, Tweet ID - content_snapshot: text("content_snapshot"), // what was actually sent - status: varchar("status", { length: 30 }).default("sent"), // sent, failed, deleted - error_message: text("error_message"), - posted_at: timestamp("posted_at", { withTimezone: true }).defaultNow(), - }, - (t) => ({ - configIdx: index("idx_bot_posts_config").on(t.bot_config_id, t.posted_at), - dedupeIdx: unique().on(t.bot_config_id, t.job_id), - }), -); - -// ─── Scraper Infrastructure ────────────────────────────────────────────────── - -export const job_fingerprints = pgTable("job_fingerprints", { - id: uuid("id").primaryKey().defaultRandom(), - job_id: uuid("job_id") - .notNull() - .references(() => jobs.id, { onDelete: "cascade" }), - source_id: uuid("source_id").references(() => scrape_sources.id), - fingerprint: varchar("fingerprint", { length: 64 }).notNull().unique(), // sha256 of (title+company+location normalized) - source_url: text("source_url"), - first_seen_at: timestamp("first_seen_at", { - withTimezone: true, - }).defaultNow(), - last_seen_at: timestamp("last_seen_at", { withTimezone: true }).defaultNow(), -}); - -// ─── AI Improvements ───────────────────────────────────────────────────────── - -// ─── Promo Codes ───────────────────────────────────────────────────────────── - // ─── Relations ──────────────────────────────────────────────────────────────── -// Declared after all tables to avoid forward-reference errors. export const usersRelations = relations(users, ({ one, many }) => ({ resumes: many(resumes), conversations: many(conversations), job_matches: many(job_matches), - discord_configs: many(discord_configs), employer_profile: one(employer_profiles, { fields: [users.id], references: [employer_profiles.user_id], @@ -1002,32 +502,26 @@ export const usersRelations = relations(users, ({ one, many }) => ({ references: [subscriptions.user_id], }), payments: many(payments), - email_notifications: many(email_notifications), - career_site_integrations: many(career_site_integrations), sessions: many(sessions), + bot_configs: many(bot_configs), + notifications: many(notifications), audit_logs: many(audit_log), token_usage: many(token_usage), - rate_limit_overrides: many(rate_limit_overrides), - bot_configs: many(bot_configs), - system_prompts: many(system_prompts), })); -export const discordConfigsRelations = relations( - discord_configs, - ({ one, many }) => ({ - user: one(users, { - fields: [discord_configs.user_id], - references: [users.id], - }), - posted_jobs: many(discord_posted_jobs), +export const seekerProfilesRelations = relations(seeker_profiles, ({ one }) => ({ + user: one(users, { + fields: [seeker_profiles.user_id], + references: [users.id], }), -); +})); -export const resumesRelations = relations(resumes, ({ one }) => ({ +export const employerProfilesRelations = relations(employer_profiles, ({ one, many }) => ({ user: one(users, { - fields: [resumes.user_id], + fields: [employer_profiles.user_id], references: [users.id], }), + jobs: many(jobs), })); export const jobsRelations = relations(jobs, ({ one, many }) => ({ @@ -1037,13 +531,6 @@ export const jobsRelations = relations(jobs, ({ one, many }) => ({ }), matches: many(job_matches), applications: many(applications), - discord_posts: many(discord_posted_jobs), - bot_posts: many(bot_posts), - fingerprints: many(job_fingerprints), - scrape_source: one(scrape_sources, { - fields: [jobs.scrape_source_id], - references: [scrape_sources.id], - }), })); export const jobMatchesRelations = relations(job_matches, ({ one }) => ({ @@ -1061,207 +548,48 @@ export const jobMatchesRelations = relations(job_matches, ({ one }) => ({ }), })); -export const employerProfilesRelations = relations( - employer_profiles, - ({ one, many }) => ({ - user: one(users, { - fields: [employer_profiles.user_id], - references: [users.id], - }), - jobs: many(jobs), - career_site_integrations: many(career_site_integrations), - }), -); - -export const seekerProfilesRelations = relations( - seeker_profiles, - ({ one, many }) => ({ - user: one(users, { - fields: [seeker_profiles.user_id], - references: [users.id], - }), - applications: many(applications), - }), -); - -export const applicationsRelations = relations( - applications, - ({ one, many }) => ({ - seeker: one(users, { - fields: [applications.seeker_id], - references: [users.id], - }), - job: one(jobs, { - fields: [applications.job_id], - references: [jobs.id], - }), - resume: one(resumes, { - fields: [applications.resume_id], - references: [resumes.id], - }), - seeker_profile: one(seeker_profiles, { - fields: [applications.seeker_id], - references: [seeker_profiles.user_id], - }), - status_history: many(application_status_history), - }), -); - -export const careerSiteIntegrationsRelations = relations( - career_site_integrations, - ({ one }) => ({ - employer: one(users, { - fields: [career_site_integrations.employer_id], - references: [users.id], - }), - employer_profile: one(employer_profiles, { - fields: [career_site_integrations.employer_id], - references: [employer_profiles.user_id], - }), - }), -); - -export const conversationsRelations = relations( - conversations, - ({ one, many }) => ({ - user: one(users, { - fields: [conversations.user_id], - references: [users.id], - }), - resume: one(resumes, { - fields: [conversations.resume_id], - references: [resumes.id], - }), - messages: many(messages), - }), -); - -export const messagesRelations = relations(messages, ({ one, many }) => ({ - conversation: one(conversations, { - fields: [messages.conversation_id], - references: [conversations.id], - }), - parent: one(messages, { - fields: [messages.parent_message_id], - references: [messages.id], - relationName: "message_parent", - }), - children: many(messages, { relationName: "message_parent" }), - attachments: many(message_attachments), -})); - -export const messageAttachmentsRelations = relations( - message_attachments, - ({ one }) => ({ - message: one(messages, { - fields: [message_attachments.message_id], - references: [messages.id], - }), - }), -); - -export const subscriptionsRelations = relations(subscriptions, ({ one }) => ({ - user: one(users, { - fields: [subscriptions.user_id], +export const applicationsRelations = relations(applications, ({ one, many }) => ({ + seeker: one(users, { + fields: [applications.seeker_id], references: [users.id], }), -})); - -export const paymentsRelations = relations(payments, ({ one }) => ({ - user: one(users, { - fields: [payments.user_id], - references: [users.id], + job: one(jobs, { + fields: [applications.job_id], + references: [jobs.id], }), - subscription: one(subscriptions, { - fields: [payments.subscription_id], - references: [subscriptions.id], + resume: one(resumes, { + fields: [applications.resume_id], + references: [resumes.id], }), + status_history: many(application_status_history), })); -export const notificationTemplatesRelations = relations( - notification_templates, - ({ many }) => ({ - email_notifications: many(email_notifications), - }), -); - -export const emailNotificationsRelations = relations( - email_notifications, - ({ one }) => ({ - user: one(users, { - fields: [email_notifications.user_id], - references: [users.id], - }), - template: one(notification_templates, { - fields: [email_notifications.template_id], - references: [notification_templates.id], - }), +export const applicationStatusHistoryRelations = relations(application_status_history, ({ one }) => ({ + application: one(applications, { + fields: [application_status_history.application_id], + references: [applications.id], }), -); +})); -export const sessionsRelations = relations(sessions, ({ one }) => ({ +export const conversationsRelations = relations(conversations, ({ one, many }) => ({ user: one(users, { - fields: [sessions.user_id], + fields: [conversations.user_id], references: [users.id], }), -})); - -export const applicationStatusHistoryRelations = relations( - application_status_history, - ({ one }) => ({ - application: one(applications, { - fields: [application_status_history.application_id], - references: [applications.id], - }), - actor: one(users, { - fields: [application_status_history.changed_by], - references: [users.id], - }), - }), -); - -export const auditLogRelations = relations(audit_log, ({ one }) => ({ - actor: one(users, { - fields: [audit_log.actor_id], - references: [users.id], + resume: one(resumes, { + fields: [conversations.resume_id], + references: [resumes.id], }), + messages: many(messages), })); -export const tokenUsageRelations = relations(token_usage, ({ one }) => ({ - user: one(users, { - fields: [token_usage.user_id], - references: [users.id], +export const messagesRelations = relations(messages, ({ one }) => ({ + conversation: one(conversations, { + fields: [messages.conversation_id], + references: [conversations.id], }), })); -export const rateLimitOverridesRelations = relations( - rate_limit_overrides, - ({ one }) => ({ - user: one(users, { - fields: [rate_limit_overrides.user_id], - references: [users.id], - }), - admin: one(users, { - fields: [rate_limit_overrides.granted_by], - references: [users.id], - }), - }), -); - -export const discordPostedJobsRelations = relations( - discord_posted_jobs, - ({ one }) => ({ - config: one(discord_configs, { - fields: [discord_posted_jobs.discord_config_id], - references: [discord_configs.id], - }), - job: one(jobs, { - fields: [discord_posted_jobs.job_id], - references: [jobs.id], - }), - }), -); - export const botConfigsRelations = relations(bot_configs, ({ one, many }) => ({ user: one(users, { fields: [bot_configs.user_id], @@ -1281,53 +609,34 @@ export const botPostsRelations = relations(bot_posts, ({ one }) => ({ }), })); -export const scrapeSourcesRelations = relations(scrape_sources, ({ many }) => ({ - jobs: many(jobs), - runs: many(scrape_runs), +export const subscriptionsRelations = relations(subscriptions, ({ one }) => ({ + user: one(users, { + fields: [subscriptions.user_id], + references: [users.id], + }), })); -export const jobFingerprintsRelations = relations( - job_fingerprints, - ({ one }) => ({ - job: one(jobs, { - fields: [job_fingerprints.job_id], - references: [jobs.id], - }), - source: one(scrape_sources, { - fields: [job_fingerprints.source_id], - references: [scrape_sources.id], - }), +export const paymentsRelations = relations(payments, ({ one }) => ({ + user: one(users, { + fields: [payments.user_id], + references: [users.id], }), -); - -export const scrapeRunsRelations = relations(scrape_runs, ({ one }) => ({ - source: one(scrape_sources, { - fields: [scrape_runs.source_id], - references: [scrape_sources.id], + subscription: one(subscriptions, { + fields: [payments.subscription_id], + references: [subscriptions.id], }), })); -export const systemPromptsRelations = relations(system_prompts, ({ one }) => ({ - author: one(users, { - fields: [system_prompts.created_by], +export const notificationsRelations = relations(notifications, ({ one }) => ({ + user: one(users, { + fields: [notifications.user_id], references: [users.id], }), })); -export const promoCodesRelations = relations(promo_codes, ({ many }) => ({ - subscriptions: many(subscriptions), -})); - -export const subscriptionsRelationsExtended = relations( - subscriptions, - ({ one }) => ({ - user: one(users, { - fields: [subscriptions.user_id], - references: [users.id], - }), - promo_code: one(promo_codes, { - fields: [subscriptions.promo_code_id], - references: [promo_codes.id], - }), +export const sessionsRelations = relations(sessions, ({ one }) => ({ + user: one(users, { + fields: [sessions.user_id], + references: [users.id], }), -); +})); diff --git a/packages/shared-types/src/domain.ts b/packages/shared-types/src/domain.ts index c4843ba..695637b 100644 --- a/packages/shared-types/src/domain.ts +++ b/packages/shared-types/src/domain.ts @@ -24,13 +24,6 @@ export interface Application { status: ApplicationStatus; status_history: StatusHistoryEntry[]; cover_letter?: string | null; - notes?: string | null; - external_url?: string | null; - contact_info?: unknown; - next_interview_at?: Date | null; - offer_details?: unknown; - match_score?: string | null; - ai_explanation?: string | null; applied_at?: Date | null; created_at: Date; updated_at: Date; @@ -102,12 +95,7 @@ export interface SeekerProfile extends SeekerProfileData { // ─── Subscription Types ─────────────────────────────────────────────────────── -export type SubscriptionPlan = - | "free" - | "seeker" - | "employer" - | "enterprise" - | "discord_owner"; +export type SubscriptionPlan = "seeker" | "employer" | "discord_owner"; export type SubscriptionStatus = | "active" | "cancelled" @@ -177,40 +165,20 @@ export type NotificationStatus = | "bounced" | "opened"; -export interface CreateNotificationTemplateInput { - slug: string; - name: string; - subject: string; - html_body: string; - text_body?: string; - metadata?: unknown; -} - export interface CreateNotificationInput { user_id: string; - template_id?: string; + type?: string; to_email: string; subject: string; - template_variables?: unknown; - job_ids?: string[]; + content: string; scheduled_at?: Date; } -export interface NotificationTemplate extends CreateNotificationTemplateInput { - id: string; - is_active: boolean; - created_at: Date; - updated_at: Date; -} - -export interface EmailNotification extends CreateNotificationInput { +export interface Notification extends CreateNotificationInput { id: string; status: NotificationStatus; - provider_message_id?: string | null; error_message?: string | null; - retry_count: number; sent_at?: Date | null; - opened_at?: Date | null; created_at: Date; } @@ -265,3 +233,13 @@ export interface VectorSearchResult { similarity: number; [key: string]: unknown; } + +export interface JobSearchFilters { + location?: string; + job_type?: string; + remote?: boolean; + salary_min?: number; + skills?: string[]; +} + +export type BotPlatform = "discord" | "reddit" | "twitter"; diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 49ee01a..037c3f1 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1,3 +1,7 @@ +import { JobSearchFilters, BotPlatform } from "./domain"; +export { JobSearchFilters, BotPlatform }; +export type { Notification, SubscriptionPlan } from "./domain"; + // User types export type UserRole = "job_seeker" | "employer" | "admin" | "discord_owner"; @@ -102,6 +106,8 @@ export interface Job { expires_at?: Date; is_active: boolean; employer_id?: string; + external_job_id?: string; + fingerprint?: string; created_at: Date; updated_at: Date; } @@ -118,7 +124,6 @@ export interface CreateJobInput { skills_required?: string[]; experience_required?: string; expires_at?: Date; - scrape_source_id?: string; external_job_id?: string; fingerprint?: string; } @@ -140,7 +145,6 @@ export interface ScrapedJobInput { posted_at?: Date; expires_at?: Date; embedding?: number[]; - scrape_source_id?: string; external_job_id?: string; fingerprint?: string; } @@ -188,44 +192,51 @@ export interface MatchJobsInput { limit?: number; } -export interface JobSearchFilters { - location?: string; - job_type?: JobType; - remote?: boolean; - salary_min?: number; - skills?: string[]; -} - -// Bot Subscription types -export type CommunityType = "discord" | "reddit"; -export type SubscriptionTier = "basic" | "premium"; - -export interface BotSubscription { +// Bots & Social types +export interface BotConfig { id: string; - community_type: CommunityType; - community_id: string; - admin_user_id: string; - filter_criteria?: JobSearchFilters; + user_id: string; + platform: BotPlatform; is_active: boolean; - subscription_tier: SubscriptionTier; - expires_at?: Date; + target_id?: string; + target_name?: string; + webhook_url?: string; + credentials?: any; + filter_keywords?: string; + filter_locations?: string; + filter_min_salary?: string; + filter_job_types?: string[]; + last_post_at?: Date; created_at: Date; + updated_at: Date; } -// Scraping types -export type ScrapingStatus = "pending" | "running" | "completed" | "failed"; - -export interface ScrapingJob { +export interface BotPost { id: string; - source: JobSource; - status: ScrapingStatus; - jobs_scraped: number; + bot_config_id: string; + job_id: string; + external_post_id?: string; + status: "sent" | "failed"; error_message?: string; - started_at?: Date; - completed_at?: Date; - created_at: Date; + posted_at: Date; } +export interface MessageMetadata { + usage?: { + prompt_tokens?: number; + completion_tokens?: number; + total_tokens?: number; + }; + job_matches?: OptimizedJobMatch[]; + [key: string]: any; +} + +export type StreamChatResponse = + | { type: "chunk"; content: string } + | { type: "metadata"; metadata: MessageMetadata } + | { type: "complete"; message_id: string; metadata: MessageMetadata } + | { type: "error"; error: string }; + // AI Chat types export interface ChatMessage { role: "user" | "assistant" | "system"; @@ -259,67 +270,19 @@ export interface Conversation { title: string; resume_id: string | null; model?: string; - is_archived: boolean; - state?: ConversationState; created_at: Date; updated_at: Date; } -export type ConversationState = - | "idle" - | "typing" - | "thinking" - | "streaming" - | "completed" - | "error" - | "interrupted"; - export interface Message { id: string; conversation_id: string; - role: "user" | "assistant" | "system" | "tool"; + role: string; content: string; - - /** Self-referencing — forms a message tree for branching */ - parent_message_id?: string | null; - /** Edit version number (1 = original, 2+ = edits) */ - version: number; - /** Only the active branch is rendered in the UI */ - is_active: boolean; - /** Streaming lifecycle: sending → streaming → completed / cancelled / error */ - status: MessageStreamStatus; - - metadata?: MessageMetadata; + tokens_used?: number; created_at: Date; } -/** Streaming lifecycle status stored in DB */ -export type MessageStreamStatus = - | "sending" - | "streaming" - | "completed" - | "cancelled" - | "error"; - -/** @deprecated Use MessageStreamStatus instead */ -export type MessageStatus = "sending" | "sent" | "delivered" | "error"; - -export interface MessageMetadata { - job_matches?: OptimizedJobMatch[]; - resume_feedback?: ResumeFeedback; - error?: string; - usage?: { - prompt_tokens: number; - completion_tokens: number; - total_tokens: number; - }; - timing?: { - start: number; - end: number; - duration: number; - }; -} - export interface CreateConversationRequest { resume_id?: string; initial_message?: string; @@ -330,14 +293,6 @@ export interface SendMessageRequest { conversation_id: string; } -export interface StreamChatResponse { - type: "chunk" | "complete" | "error" | "metadata"; - content?: string; - metadata?: MessageMetadata; - message_id?: string; - error?: string; -} - // API Response types export interface ApiResponse { success: boolean; @@ -367,4 +322,3 @@ export const AI_ERROR_CODES = { export type AIErrorCode = (typeof AI_ERROR_CODES)[keyof typeof AI_ERROR_CODES]; export * from "./schemas.js"; -export * from "./domain.js"; From cf402ea899103ed68eb786d747e7206a3425f13e Mon Sep 17 00:00:00 2001 From: Utsav joshi Date: Tue, 28 Apr 2026 22:27:36 +0530 Subject: [PATCH 12/15] style: apply consistent code formatting and indentation across application files and database schemas --- apps/api/src/controllers/bot.controller.ts | 23 +- apps/api/src/routes/bot.routes.ts | 6 +- apps/web/src/components/ui/PageLoader.tsx | 14 +- .../migrations/meta/0006_snapshot.json | 293 +++++------------- .../migrations/meta/0007_snapshot.json | 293 +++++------------- .../database/migrations/meta/_journal.json | 2 +- packages/database/src/queries/applications.ts | 10 +- packages/database/src/queries/bots.ts | 18 +- .../database/src/queries/subscriptions.ts | 1 - packages/database/src/schema.ts | 132 ++++---- 10 files changed, 272 insertions(+), 520 deletions(-) diff --git a/apps/api/src/controllers/bot.controller.ts b/apps/api/src/controllers/bot.controller.ts index 8820aeb..592091a 100644 --- a/apps/api/src/controllers/bot.controller.ts +++ b/apps/api/src/controllers/bot.controller.ts @@ -75,7 +75,14 @@ export class BotController { ): Promise => { try { const user = req.user as JwtPayload; - const { platform, target_id, target_name, webhook_url, credentials, filters } = req.body; + const { + platform, + target_id, + target_name, + webhook_url, + credentials, + filters, + } = req.body; const result = await botQueries.upsertConfig({ user_id: user.id, @@ -113,7 +120,12 @@ export class BotController { const [existing] = await db .select() .from(bot_configs) - .where(and(eq(bot_configs.id, id as string), eq(bot_configs.user_id, user.id))) + .where( + and( + eq(bot_configs.id, id as string), + eq(bot_configs.user_id, user.id), + ), + ) .limit(1); if (!existing) { @@ -155,7 +167,12 @@ export class BotController { const [config] = await db .select() .from(bot_configs) - .where(and(eq(bot_configs.id, id as string), eq(bot_configs.user_id, user.id))) + .where( + and( + eq(bot_configs.id, id as string), + eq(bot_configs.user_id, user.id), + ), + ) .limit(1); if (!config) { diff --git a/apps/api/src/routes/bot.routes.ts b/apps/api/src/routes/bot.routes.ts index 8472745..a789d38 100644 --- a/apps/api/src/routes/bot.routes.ts +++ b/apps/api/src/routes/bot.routes.ts @@ -8,7 +8,11 @@ const botController = new BotController(); // Some bot platforms might use callbacks (OAuth) // We keep them separate and authenticate if possible, but usually these are handled via state/session. // Here we assume authenticateToken works for our flow. -router.get("/discord/callback", authenticateToken, botController.handleDiscordCallback); +router.get( + "/discord/callback", + authenticateToken, + botController.handleDiscordCallback, +); // Protected management routes router.use(authenticateToken); diff --git a/apps/web/src/components/ui/PageLoader.tsx b/apps/web/src/components/ui/PageLoader.tsx index 9efaf7c..321e196 100644 --- a/apps/web/src/components/ui/PageLoader.tsx +++ b/apps/web/src/components/ui/PageLoader.tsx @@ -108,7 +108,12 @@ export const PageLoader: React.FC = () => { strokeLinecap="round" /> {/* Legs */} - + { strokeLinejoin="round" /> - + users.id, { onDelete: "cascade" }), platform: botPlatformEnum("platform").notNull(), - + // Detailed Configuration is_active: boolean("is_active").default(true), - + // Platform Auth (Stored as JSON for flexibility, or encrypted) - credentials: jsonb("credentials"), - + credentials: jsonb("credentials"), + // Target Destination (e.g. Channel ID for Discord, Subreddit for Reddit) - target_id: varchar("target_id", { length: 255 }), + target_id: varchar("target_id", { length: 255 }), target_name: varchar("target_name", { length: 255 }), webhook_url: text("webhook_url"), - + // Filtering Details: "What type of jobs they want" filter_keywords: varchar("filter_keywords", { length: 500 }), // comma separated filter_locations: varchar("filter_locations", { length: 500 }), - filter_min_salary: decimal("filter_min_salary", { precision: 10, scale: 2 }), + filter_min_salary: decimal("filter_min_salary", { + precision: 10, + scale: 2, + }), filter_job_types: varchar("filter_job_types", { length: 255 }).array(), - + last_post_at: timestamp("last_post_at", { withTimezone: true }), created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), @@ -413,7 +424,9 @@ export const subscriptions = pgTable("subscriptions", { .references(() => users.id, { onDelete: "cascade" }), plan: subscriptionPlanEnum("plan").notNull(), status: subscriptionStatusEnum("status").notNull().default("active"), - dodo_subscription_id: varchar("dodo_subscription_id", { length: 255 }).unique(), + dodo_subscription_id: varchar("dodo_subscription_id", { + length: 255, + }).unique(), dodo_customer_id: varchar("dodo_customer_id", { length: 255 }), current_period_end: timestamp("current_period_end", { withTimezone: true }), raw_data: jsonb("raw_data"), @@ -509,20 +522,26 @@ export const usersRelations = relations(users, ({ one, many }) => ({ token_usage: many(token_usage), })); -export const seekerProfilesRelations = relations(seeker_profiles, ({ one }) => ({ - user: one(users, { - fields: [seeker_profiles.user_id], - references: [users.id], +export const seekerProfilesRelations = relations( + seeker_profiles, + ({ one }) => ({ + user: one(users, { + fields: [seeker_profiles.user_id], + references: [users.id], + }), }), -})); +); -export const employerProfilesRelations = relations(employer_profiles, ({ one, many }) => ({ - user: one(users, { - fields: [employer_profiles.user_id], - references: [users.id], +export const employerProfilesRelations = relations( + employer_profiles, + ({ one, many }) => ({ + user: one(users, { + fields: [employer_profiles.user_id], + references: [users.id], + }), + jobs: many(jobs), }), - jobs: many(jobs), -})); +); export const jobsRelations = relations(jobs, ({ one, many }) => ({ employer: one(users, { @@ -548,40 +567,49 @@ export const jobMatchesRelations = relations(job_matches, ({ one }) => ({ }), })); -export const applicationsRelations = relations(applications, ({ one, many }) => ({ - seeker: one(users, { - fields: [applications.seeker_id], - references: [users.id], - }), - job: one(jobs, { - fields: [applications.job_id], - references: [jobs.id], - }), - resume: one(resumes, { - fields: [applications.resume_id], - references: [resumes.id], +export const applicationsRelations = relations( + applications, + ({ one, many }) => ({ + seeker: one(users, { + fields: [applications.seeker_id], + references: [users.id], + }), + job: one(jobs, { + fields: [applications.job_id], + references: [jobs.id], + }), + resume: one(resumes, { + fields: [applications.resume_id], + references: [resumes.id], + }), + status_history: many(application_status_history), }), - status_history: many(application_status_history), -})); +); -export const applicationStatusHistoryRelations = relations(application_status_history, ({ one }) => ({ - application: one(applications, { - fields: [application_status_history.application_id], - references: [applications.id], +export const applicationStatusHistoryRelations = relations( + application_status_history, + ({ one }) => ({ + application: one(applications, { + fields: [application_status_history.application_id], + references: [applications.id], + }), }), -})); +); -export const conversationsRelations = relations(conversations, ({ one, many }) => ({ - user: one(users, { - fields: [conversations.user_id], - references: [users.id], - }), - resume: one(resumes, { - fields: [conversations.resume_id], - references: [resumes.id], +export const conversationsRelations = relations( + conversations, + ({ one, many }) => ({ + user: one(users, { + fields: [conversations.user_id], + references: [users.id], + }), + resume: one(resumes, { + fields: [conversations.resume_id], + references: [resumes.id], + }), + messages: many(messages), }), - messages: many(messages), -})); +); export const messagesRelations = relations(messages, ({ one }) => ({ conversation: one(conversations, { From a8a2793910b9e1d7657f601c6c63ebd7c1b024ed Mon Sep 17 00:00:00 2001 From: Utsav joshi Date: Tue, 28 Apr 2026 22:39:37 +0530 Subject: [PATCH 13/15] feat: add discord configurations, improve database schema and types, and enhance error handling across services --- apps/api/src/services/queue.service.ts | 16 + apps/web/src/pages/TransmissionHome.tsx | 50 - apps/web/src/pages/TransmissionLogin.tsx | 7 +- apps/web/src/pages/TransmissionSettings.tsx | 20 +- apps/web/src/services/user.service.ts | 11 +- .../migrations/0008_bumpy_mac_gargan.sql | 18 + .../migrations/meta/0008_snapshot.json | 2251 +++++++++++++++++ .../database/migrations/meta/_journal.json | 7 + packages/database/src/queries/applications.ts | 13 + packages/database/src/queries/bots.ts | 2 +- .../database/src/queries/conversations.ts | 85 +- .../database/src/queries/subscriptions.ts | 2 +- packages/database/src/schema.ts | 25 + packages/shared-types/src/index.ts | 66 +- 14 files changed, 2496 insertions(+), 77 deletions(-) create mode 100644 packages/database/migrations/0008_bumpy_mac_gargan.sql create mode 100644 packages/database/migrations/meta/0008_snapshot.json diff --git a/apps/api/src/services/queue.service.ts b/apps/api/src/services/queue.service.ts index 318fce5..6b0cd66 100644 --- a/apps/api/src/services/queue.service.ts +++ b/apps/api/src/services/queue.service.ts @@ -84,6 +84,22 @@ export class QueueService { ); console.log(`✅ Job dispatched for ${config.platform} config: ${configId}`); }; + + dispatchForGuild = async (guildId: string, channelId: string) => { + await this.botQueue.add( + "send_discord_message", + { + guild_id: guildId, + channel_id: channelId, + type: "test", + timestamp: new Date().toISOString(), + }, + { + removeOnComplete: true, + removeOnFail: 3, + }, + ); + }; } export const queueService = new QueueService(); diff --git a/apps/web/src/pages/TransmissionHome.tsx b/apps/web/src/pages/TransmissionHome.tsx index 4316a7b..afba53d 100644 --- a/apps/web/src/pages/TransmissionHome.tsx +++ b/apps/web/src/pages/TransmissionHome.tsx @@ -175,53 +175,3 @@ export function TransmissionHome() {
); } - -function FeatureCard({ - title, - description, - color, -}: { - title: string; - description: string; - color: string; -}) { - return ( -
-
-

- {title} -

-

- {description} -

-
- ); -} diff --git a/apps/web/src/pages/TransmissionLogin.tsx b/apps/web/src/pages/TransmissionLogin.tsx index 00e7e6b..29ea7fe 100644 --- a/apps/web/src/pages/TransmissionLogin.tsx +++ b/apps/web/src/pages/TransmissionLogin.tsx @@ -21,8 +21,11 @@ export function TransmissionLogin() { try { await login({ email, password }); navigate("/chat?role=seeker"); - } catch (err: any) { - if (err?.response?.data?.error?.code === "EMAIL_NOT_VERIFIED") { + } catch (err: unknown) { + const error = err as { + response?: { data?: { error?: { code?: string } } }; + }; + if (error?.response?.data?.error?.code === "EMAIL_NOT_VERIFIED") { navigate(`/verify-otp?email=${encodeURIComponent(email)}`); } } diff --git a/apps/web/src/pages/TransmissionSettings.tsx b/apps/web/src/pages/TransmissionSettings.tsx index ead31a1..cdba2bd 100644 --- a/apps/web/src/pages/TransmissionSettings.tsx +++ b/apps/web/src/pages/TransmissionSettings.tsx @@ -51,7 +51,7 @@ export function TransmissionSettings() { role === "seeker" ? "var(--tx-seeker)" : "var(--tx-recruiter)"; // Form state - const [formData, setFormData] = useState({ + const [formData, setFormData] = useState>({ full_name: user?.full_name || "", avatar_url: user?.avatar_url || "", timezone: user?.timezone || "UTC", @@ -77,7 +77,7 @@ export function TransmissionSettings() { // Sync form data once profile is loaded useEffect(() => { if (profileData) { - setFormData((prev: any) => ({ + setFormData((prev) => ({ ...prev, ...profileData, skills: profileData.skills?.join(", ") || "", @@ -89,7 +89,8 @@ export function TransmissionSettings() { // Mutations const updateBaseProfile = useMutation({ - mutationFn: (data: any) => userService.updateProfile(data), + mutationFn: (data: Parameters[0]) => + userService.updateProfile(data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ["auth-me"] }); addToast({ @@ -97,7 +98,7 @@ export function TransmissionSettings() { message: "General broadcast identity updated.", }); }, - onError: (error: any) => { + onError: (error: Error) => { addToast({ type: "error", message: error.message || "Failed to update profile.", @@ -117,7 +118,7 @@ export function TransmissionSettings() { message: "Professional broadcast profile updated.", }); }, - onError: (error: any) => { + onError: (error: Error) => { addToast({ type: "error", message: error.message || "Failed to update profile.", @@ -126,7 +127,8 @@ export function TransmissionSettings() { }); const changePassword = useMutation({ - mutationFn: (data: any) => userService.changePassword(data), + mutationFn: (data: Parameters[0]) => + userService.changePassword(data), onSuccess: () => { addToast({ type: "success", @@ -138,7 +140,7 @@ export function TransmissionSettings() { confirm_password: "", }); }, - onError: (error: any) => { + onError: (error: Error) => { addToast({ type: "error", message: error.message || "Failed to change password.", @@ -207,7 +209,7 @@ export function TransmissionSettings() { const { name, value, type } = e.target; const val = type === "checkbox" ? (e.target as HTMLInputElement).checked : value; - setFormData((prev: any) => ({ ...prev, [name]: val })); + setFormData((prev) => ({ ...prev, [name]: val })); }; const handleFileChange = async (e: React.ChangeEvent) => { @@ -226,7 +228,7 @@ export function TransmissionSettings() { try { setIsUploading(true); const url = await userService.uploadAvatar(file); - setFormData((prev: any) => ({ ...prev, avatar_url: url })); + setFormData((prev) => ({ ...prev, avatar_url: url })); addToast({ type: "success", message: "Cipher image uploaded to broadcast node.", diff --git a/apps/web/src/services/user.service.ts b/apps/web/src/services/user.service.ts index ae4cc83..78cb1a0 100644 --- a/apps/web/src/services/user.service.ts +++ b/apps/web/src/services/user.service.ts @@ -18,7 +18,12 @@ export const userService = { return response.data.data; }, - async updateProfile(data: { full_name?: string }): Promise { + async updateProfile(data: { + full_name?: string; + avatar_url?: string; + timezone?: string; + locale?: string; + }): Promise { const response = await apiClient.patch>( "/users/profile", data, @@ -88,8 +93,8 @@ export const userService = { }, // Security - async changePassword(data: any): Promise { - const response = await apiClient.post>( + async changePassword(data: Record): Promise { + const response = await apiClient.post>( "/users/change-password", data, ); diff --git a/packages/database/migrations/0008_bumpy_mac_gargan.sql b/packages/database/migrations/0008_bumpy_mac_gargan.sql new file mode 100644 index 0000000..aa81c76 --- /dev/null +++ b/packages/database/migrations/0008_bumpy_mac_gargan.sql @@ -0,0 +1,18 @@ +CREATE TABLE "discord_configs" ( + "id" uuid PRIMARY KEY DEFAULT gen_random_uuid() NOT NULL, + "guild_id" varchar(255) NOT NULL, + "channel_id" varchar(255), + "user_id" uuid NOT NULL, + "is_active" boolean DEFAULT true, + "created_at" timestamp with time zone DEFAULT now(), + "updated_at" timestamp with time zone DEFAULT now(), + CONSTRAINT "discord_configs_guild_id_unique" UNIQUE("guild_id") +); +--> statement-breakpoint +ALTER TABLE "applications" ADD COLUMN "notes" text;--> statement-breakpoint +ALTER TABLE "conversations" ADD COLUMN "model" varchar(100);--> statement-breakpoint +ALTER TABLE "conversations" ADD COLUMN "is_archived" boolean DEFAULT false;--> statement-breakpoint +ALTER TABLE "subscriptions" ADD COLUMN "current_period_start" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "subscriptions" ADD COLUMN "cancelled_at" timestamp with time zone;--> statement-breakpoint +ALTER TABLE "discord_configs" ADD CONSTRAINT "discord_configs_user_id_users_id_fk" FOREIGN KEY ("user_id") REFERENCES "public"."users"("id") ON DELETE cascade ON UPDATE no action;--> statement-breakpoint +CREATE INDEX "idx_discord_guild" ON "discord_configs" USING btree ("guild_id"); \ No newline at end of file diff --git a/packages/database/migrations/meta/0008_snapshot.json b/packages/database/migrations/meta/0008_snapshot.json new file mode 100644 index 0000000..5da40d6 --- /dev/null +++ b/packages/database/migrations/meta/0008_snapshot.json @@ -0,0 +1,2251 @@ +{ + "id": "9985efb9-5bbb-4a6d-8ac5-56478be12dff", + "prevId": "294ad33b-0987-400c-8f79-09b31806134d", + "version": "7", + "dialect": "postgresql", + "tables": { + "public.application_status_history": { + "name": "application_status_history", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "application_id": { + "name": "application_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "from_status": { + "name": "from_status", + "type": "application_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": false + }, + "to_status": { + "name": "to_status", + "type": "application_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "changed_by": { + "name": "changed_by", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "note": { + "name": "note", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "application_status_history_application_id_applications_id_fk": { + "name": "application_status_history_application_id_applications_id_fk", + "tableFrom": "application_status_history", + "tableTo": "applications", + "columnsFrom": ["application_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "application_status_history_changed_by_users_id_fk": { + "name": "application_status_history_changed_by_users_id_fk", + "tableFrom": "application_status_history", + "tableTo": "users", + "columnsFrom": ["changed_by"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.applications": { + "name": "applications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "seeker_id": { + "name": "seeker_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resume_id": { + "name": "resume_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "application_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'applied'" + }, + "cover_letter": { + "name": "cover_letter", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "notes": { + "name": "notes", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "applied_at": { + "name": "applied_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "applications_seeker_id_users_id_fk": { + "name": "applications_seeker_id_users_id_fk", + "tableFrom": "applications", + "tableTo": "users", + "columnsFrom": ["seeker_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "applications_job_id_jobs_id_fk": { + "name": "applications_job_id_jobs_id_fk", + "tableFrom": "applications", + "tableTo": "jobs", + "columnsFrom": ["job_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "applications_resume_id_resumes_id_fk": { + "name": "applications_resume_id_resumes_id_fk", + "tableFrom": "applications", + "tableTo": "resumes", + "columnsFrom": ["resume_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "applications_seeker_id_job_id_unique": { + "name": "applications_seeker_id_job_id_unique", + "nullsNotDistinct": false, + "columns": ["seeker_id", "job_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.audit_log": { + "name": "audit_log", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "actor_id": { + "name": "actor_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "action": { + "name": "action", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "entity_type": { + "name": "entity_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "entity_id": { + "name": "entity_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "audit_log_actor_id_users_id_fk": { + "name": "audit_log_actor_id_users_id_fk", + "tableFrom": "audit_log", + "tableTo": "users", + "columnsFrom": ["actor_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_configs": { + "name": "bot_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "platform": { + "name": "platform", + "type": "bot_platform", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "credentials": { + "name": "credentials", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "target_id": { + "name": "target_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "target_name": { + "name": "target_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "webhook_url": { + "name": "webhook_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "filter_keywords": { + "name": "filter_keywords", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "filter_locations": { + "name": "filter_locations", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "filter_min_salary": { + "name": "filter_min_salary", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "filter_job_types": { + "name": "filter_job_types", + "type": "varchar(255)[]", + "primaryKey": false, + "notNull": false + }, + "last_post_at": { + "name": "last_post_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_configs_user_id_users_id_fk": { + "name": "bot_configs_user_id_users_id_fk", + "tableFrom": "bot_configs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "bot_configs_user_id_platform_unique": { + "name": "bot_configs_user_id_platform_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "platform"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.bot_posts": { + "name": "bot_posts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "bot_config_id": { + "name": "bot_config_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "external_post_id": { + "name": "external_post_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "status": { + "name": "status", + "type": "varchar(30)", + "primaryKey": false, + "notNull": false, + "default": "'sent'" + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "bot_posts_bot_config_id_bot_configs_id_fk": { + "name": "bot_posts_bot_config_id_bot_configs_id_fk", + "tableFrom": "bot_posts", + "tableTo": "bot_configs", + "columnsFrom": ["bot_config_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "bot_posts_job_id_jobs_id_fk": { + "name": "bot_posts_job_id_jobs_id_fk", + "tableFrom": "bot_posts", + "tableTo": "jobs", + "columnsFrom": ["job_id"], + "columnsTo": ["id"], + "onDelete": "set null", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.conversations": { + "name": "conversations", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "title": { + "name": "title", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "resume_id": { + "name": "resume_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "model": { + "name": "model", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "is_archived": { + "name": "is_archived", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "conversations_user_id_users_id_fk": { + "name": "conversations_user_id_users_id_fk", + "tableFrom": "conversations", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "conversations_resume_id_resumes_id_fk": { + "name": "conversations_resume_id_resumes_id_fk", + "tableFrom": "conversations", + "tableTo": "resumes", + "columnsFrom": ["resume_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.discord_configs": { + "name": "discord_configs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "guild_id": { + "name": "guild_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "channel_id": { + "name": "channel_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_discord_guild": { + "name": "idx_discord_guild", + "columns": [ + { + "expression": "guild_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "discord_configs_user_id_users_id_fk": { + "name": "discord_configs_user_id_users_id_fk", + "tableFrom": "discord_configs", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "discord_configs_guild_id_unique": { + "name": "discord_configs_guild_id_unique", + "nullsNotDistinct": false, + "columns": ["guild_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.employer_profiles": { + "name": "employer_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "company_website": { + "name": "company_website", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_logo_url": { + "name": "company_logo_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_description": { + "name": "company_description", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "company_size": { + "name": "company_size", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "industry": { + "name": "industry", + "type": "varchar(150)", + "primaryKey": false, + "notNull": false + }, + "headquarters_location": { + "name": "headquarters_location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "social_links": { + "name": "social_links", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "active_job_count": { + "name": "active_job_count", + "type": "integer", + "primaryKey": false, + "notNull": false, + "default": 0 + }, + "is_verified": { + "name": "is_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_employer_profiles_user": { + "name": "idx_employer_profiles_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "employer_profiles_user_id_users_id_fk": { + "name": "employer_profiles_user_id_users_id_fk", + "tableFrom": "employer_profiles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "employer_profiles_user_id_unique": { + "name": "employer_profiles_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.job_matches": { + "name": "job_matches", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "resume_id": { + "name": "resume_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "job_id": { + "name": "job_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "match_score": { + "name": "match_score", + "type": "numeric(5, 2)", + "primaryKey": false, + "notNull": true + }, + "ai_explanation": { + "name": "ai_explanation", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "is_saved": { + "name": "is_saved", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "applied": { + "name": "applied", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": {}, + "foreignKeys": { + "job_matches_user_id_users_id_fk": { + "name": "job_matches_user_id_users_id_fk", + "tableFrom": "job_matches", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "job_matches_resume_id_resumes_id_fk": { + "name": "job_matches_resume_id_resumes_id_fk", + "tableFrom": "job_matches", + "tableTo": "resumes", + "columnsFrom": ["resume_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "job_matches_job_id_jobs_id_fk": { + "name": "job_matches_job_id_jobs_id_fk", + "tableFrom": "job_matches", + "tableTo": "jobs", + "columnsFrom": ["job_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "job_matches_user_id_job_id_unique": { + "name": "job_matches_user_id_job_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "job_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.jobs": { + "name": "jobs", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "title": { + "name": "title", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "company_name": { + "name": "company_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "description": { + "name": "description", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "location": { + "name": "location", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "salary_min": { + "name": "salary_min", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "salary_max": { + "name": "salary_max", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "job_type": { + "name": "job_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "remote": { + "name": "remote", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "source": { + "name": "source", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "source_url": { + "name": "source_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "skills_required": { + "name": "skills_required", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "experience_required": { + "name": "experience_required", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "posted_at": { + "name": "posted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "employer_id": { + "name": "employer_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "external_job_id": { + "name": "external_job_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "fingerprint": { + "name": "fingerprint", + "type": "varchar(64)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_jobs_company": { + "name": "idx_jobs_company", + "columns": [ + { + "expression": "company_name", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_jobs_active": { + "name": "idx_jobs_active", + "columns": [ + { + "expression": "is_active", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "jobs_employer_id_users_id_fk": { + "name": "jobs_employer_id_users_id_fk", + "tableFrom": "jobs", + "tableTo": "users", + "columnsFrom": ["employer_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.messages": { + "name": "messages", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "conversation_id": { + "name": "conversation_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "role": { + "name": "role", + "type": "varchar(50)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "metadata": { + "name": "metadata", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "messages_conversation_id_conversations_id_fk": { + "name": "messages_conversation_id_conversations_id_fk", + "tableFrom": "messages", + "tableTo": "conversations", + "columnsFrom": ["conversation_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.notifications": { + "name": "notifications", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "type": { + "name": "type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "subject": { + "name": "subject", + "type": "varchar(500)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "to_email": { + "name": "to_email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "notification_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'pending'" + }, + "sent_at": { + "name": "sent_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "error_message": { + "name": "error_message", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "notifications_user_id_users_id_fk": { + "name": "notifications_user_id_users_id_fk", + "tableFrom": "notifications", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.otp_codes": { + "name": "otp_codes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "code_hash": { + "name": "code_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "attempts": { + "name": "attempts", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + }, + "last_attempt_at": { + "name": "last_attempt_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_otp_user": { + "name": "idx_otp_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "otp_codes_user_id_users_id_fk": { + "name": "otp_codes_user_id_users_id_fk", + "tableFrom": "otp_codes", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "otp_codes_user_id_unique": { + "name": "otp_codes_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.payments": { + "name": "payments", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "subscription_id": { + "name": "subscription_id", + "type": "uuid", + "primaryKey": false, + "notNull": false + }, + "dodo_payment_id": { + "name": "dodo_payment_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "dodo_customer_id": { + "name": "dodo_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "event_type": { + "name": "event_type", + "type": "varchar(100)", + "primaryKey": false, + "notNull": false + }, + "amount": { + "name": "amount", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "currency": { + "name": "currency", + "type": "varchar(10)", + "primaryKey": false, + "notNull": true, + "default": "'USD'" + }, + "status": { + "name": "status", + "type": "payment_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "paid_at": { + "name": "paid_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw_payload": { + "name": "raw_payload", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "idempotency_key": { + "name": "idempotency_key", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "payments_user_id_users_id_fk": { + "name": "payments_user_id_users_id_fk", + "tableFrom": "payments", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + }, + "payments_subscription_id_subscriptions_id_fk": { + "name": "payments_subscription_id_subscriptions_id_fk", + "tableFrom": "payments", + "tableTo": "subscriptions", + "columnsFrom": ["subscription_id"], + "columnsTo": ["id"], + "onDelete": "no action", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.resumes": { + "name": "resumes", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "file_url": { + "name": "file_url", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "parsed_text": { + "name": "parsed_text", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "experience_years": { + "name": "experience_years", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "education": { + "name": "education", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_resumes_user": { + "name": "idx_resumes_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "resumes_user_id_users_id_fk": { + "name": "resumes_user_id_users_id_fk", + "tableFrom": "resumes", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.seeker_profiles": { + "name": "seeker_profiles", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "headline": { + "name": "headline", + "type": "varchar(500)", + "primaryKey": false, + "notNull": false + }, + "summary": { + "name": "summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "skills": { + "name": "skills", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "experience_years": { + "name": "experience_years", + "type": "integer", + "primaryKey": false, + "notNull": false + }, + "experience_level": { + "name": "experience_level", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "education": { + "name": "education", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "certifications": { + "name": "certifications", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "languages": { + "name": "languages", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "work_history": { + "name": "work_history", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "desired_job_titles": { + "name": "desired_job_titles", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "desired_locations": { + "name": "desired_locations", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "desired_salary_min": { + "name": "desired_salary_min", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "desired_salary_max": { + "name": "desired_salary_max", + "type": "numeric(10, 2)", + "primaryKey": false, + "notNull": false + }, + "desired_job_type": { + "name": "desired_job_type", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "open_to_remote": { + "name": "open_to_remote", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": true + }, + "open_to_relocation": { + "name": "open_to_relocation", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "embedding": { + "name": "embedding", + "type": "vector(1024)", + "primaryKey": false, + "notNull": false + }, + "prompt_history_summary": { + "name": "prompt_history_summary", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_parsed_at": { + "name": "last_parsed_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + } + }, + "indexes": { + "idx_seeker_profiles_user": { + "name": "idx_seeker_profiles_user", + "columns": [ + { + "expression": "user_id", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": { + "seeker_profiles_user_id_users_id_fk": { + "name": "seeker_profiles_user_id_users_id_fk", + "tableFrom": "seeker_profiles", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "seeker_profiles_user_id_unique": { + "name": "seeker_profiles_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.sessions": { + "name": "sessions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "token_hash": { + "name": "token_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "ip_address": { + "name": "ip_address", + "type": "varchar(45)", + "primaryKey": false, + "notNull": false + }, + "user_agent": { + "name": "user_agent", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "last_active_at": { + "name": "last_active_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "expires_at": { + "name": "expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "sessions_user_id_users_id_fk": { + "name": "sessions_user_id_users_id_fk", + "tableFrom": "sessions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "sessions_token_hash_unique": { + "name": "sessions_token_hash_unique", + "nullsNotDistinct": false, + "columns": ["token_hash"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.subscriptions": { + "name": "subscriptions", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "plan": { + "name": "plan", + "type": "subscription_plan", + "typeSchema": "public", + "primaryKey": false, + "notNull": true + }, + "status": { + "name": "status", + "type": "subscription_status", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'active'" + }, + "dodo_subscription_id": { + "name": "dodo_subscription_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "dodo_customer_id": { + "name": "dodo_customer_id", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "current_period_start": { + "name": "current_period_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "current_period_end": { + "name": "current_period_end", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "cancelled_at": { + "name": "cancelled_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "raw_data": { + "name": "raw_data", + "type": "jsonb", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": { + "subscriptions_user_id_users_id_fk": { + "name": "subscriptions_user_id_users_id_fk", + "tableFrom": "subscriptions", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "subscriptions_user_id_unique": { + "name": "subscriptions_user_id_unique", + "nullsNotDistinct": false, + "columns": ["user_id"] + }, + "subscriptions_dodo_subscription_id_unique": { + "name": "subscriptions_dodo_subscription_id_unique", + "nullsNotDistinct": false, + "columns": ["dodo_subscription_id"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.system_prompts": { + "name": "system_prompts", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "version": { + "name": "version", + "type": "integer", + "primaryKey": false, + "notNull": true + }, + "slug": { + "name": "slug", + "type": "varchar(100)", + "primaryKey": false, + "notNull": true + }, + "content": { + "name": "content", + "type": "text", + "primaryKey": false, + "notNull": true + }, + "is_active": { + "name": "is_active", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": {}, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": {}, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.token_usage": { + "name": "token_usage", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "user_id": { + "name": "user_id", + "type": "uuid", + "primaryKey": false, + "notNull": true + }, + "window_start": { + "name": "window_start", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": true + }, + "tokens_used": { + "name": "tokens_used", + "type": "integer", + "primaryKey": false, + "notNull": true, + "default": 0 + } + }, + "indexes": {}, + "foreignKeys": { + "token_usage_user_id_users_id_fk": { + "name": "token_usage_user_id_users_id_fk", + "tableFrom": "token_usage", + "tableTo": "users", + "columnsFrom": ["user_id"], + "columnsTo": ["id"], + "onDelete": "cascade", + "onUpdate": "no action" + } + }, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "token_usage_user_id_window_start_unique": { + "name": "token_usage_user_id_window_start_unique", + "nullsNotDistinct": false, + "columns": ["user_id", "window_start"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + }, + "public.users": { + "name": "users", + "schema": "", + "columns": { + "id": { + "name": "id", + "type": "uuid", + "primaryKey": true, + "notNull": true, + "default": "gen_random_uuid()" + }, + "email": { + "name": "email", + "type": "varchar(255)", + "primaryKey": false, + "notNull": true + }, + "password_hash": { + "name": "password_hash", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "full_name": { + "name": "full_name", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "avatar_url": { + "name": "avatar_url", + "type": "text", + "primaryKey": false, + "notNull": false + }, + "roles": { + "name": "roles", + "type": "user_role[]", + "typeSchema": "public", + "primaryKey": false, + "notNull": true, + "default": "'{\"job_seeker\"}'" + }, + "is_verified": { + "name": "is_verified", + "type": "boolean", + "primaryKey": false, + "notNull": false, + "default": false + }, + "timezone": { + "name": "timezone", + "type": "varchar(50)", + "primaryKey": false, + "notNull": false + }, + "locale": { + "name": "locale", + "type": "varchar(20)", + "primaryKey": false, + "notNull": false + }, + "password_reset_token": { + "name": "password_reset_token", + "type": "varchar(255)", + "primaryKey": false, + "notNull": false + }, + "password_reset_expires_at": { + "name": "password_reset_expires_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "last_login_at": { + "name": "last_login_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "deleted_at": { + "name": "deleted_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false + }, + "created_at": { + "name": "created_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + }, + "updated_at": { + "name": "updated_at", + "type": "timestamp with time zone", + "primaryKey": false, + "notNull": false, + "default": "now()" + } + }, + "indexes": { + "idx_users_email": { + "name": "idx_users_email", + "columns": [ + { + "expression": "email", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + }, + "idx_users_role": { + "name": "idx_users_role", + "columns": [ + { + "expression": "roles", + "isExpression": false, + "asc": true, + "nulls": "last" + } + ], + "isUnique": false, + "concurrently": false, + "method": "btree", + "with": {} + } + }, + "foreignKeys": {}, + "compositePrimaryKeys": {}, + "uniqueConstraints": { + "users_email_unique": { + "name": "users_email_unique", + "nullsNotDistinct": false, + "columns": ["email"] + } + }, + "policies": {}, + "checkConstraints": {}, + "isRLSEnabled": false + } + }, + "enums": { + "public.application_status": { + "name": "application_status", + "schema": "public", + "values": [ + "applied", + "under_review", + "phone_screen", + "interviewed", + "offer_extended", + "accepted", + "rejected", + "withdrawn" + ] + }, + "public.bot_platform": { + "name": "bot_platform", + "schema": "public", + "values": ["discord", "reddit", "twitter"] + }, + "public.notification_status": { + "name": "notification_status", + "schema": "public", + "values": ["pending", "sent", "failed", "bounced", "opened"] + }, + "public.payment_status": { + "name": "payment_status", + "schema": "public", + "values": ["pending", "succeeded", "failed", "refunded", "disputed"] + }, + "public.subscription_plan": { + "name": "subscription_plan", + "schema": "public", + "values": ["seeker", "employer", "discord_owner"] + }, + "public.subscription_status": { + "name": "subscription_status", + "schema": "public", + "values": [ + "active", + "cancelled", + "past_due", + "trialing", + "paused", + "expired" + ] + }, + "public.user_role": { + "name": "user_role", + "schema": "public", + "values": ["job_seeker", "employer", "admin", "discord_owner"] + } + }, + "schemas": {}, + "sequences": {}, + "roles": {}, + "policies": {}, + "views": {}, + "_meta": { + "columns": {}, + "schemas": {}, + "tables": {} + } +} diff --git a/packages/database/migrations/meta/_journal.json b/packages/database/migrations/meta/_journal.json index 1ded164..7dbcfd9 100644 --- a/packages/database/migrations/meta/_journal.json +++ b/packages/database/migrations/meta/_journal.json @@ -57,6 +57,13 @@ "when": 1777394234644, "tag": "0007_tan_the_order", "breakpoints": true + }, + { + "idx": 8, + "version": "7", + "when": 1777396102324, + "tag": "0008_bumpy_mac_gargan", + "breakpoints": true } ] } diff --git a/packages/database/src/queries/applications.ts b/packages/database/src/queries/applications.ts index 9c4ca60..e67a427 100644 --- a/packages/database/src/queries/applications.ts +++ b/packages/database/src/queries/applications.ts @@ -128,6 +128,19 @@ export const applicationQueries = { return result ?? null; }, + async updateNotes(id: string, actorId: string, notes: string) { + const [result] = await db + .update(applications) + .set({ + notes, + updated_at: new Date(), + }) + .where(and(eq(applications.id, id), eq(applications.seeker_id, actorId))) + .returning(); + + return result ?? null; + }, + async delete(id: string, seekerId: string) { const [result] = await db .delete(applications) diff --git a/packages/database/src/queries/bots.ts b/packages/database/src/queries/bots.ts index 62cc820..80295e9 100644 --- a/packages/database/src/queries/bots.ts +++ b/packages/database/src/queries/bots.ts @@ -9,7 +9,7 @@ export interface CreateBotConfigInput { target_id?: string; target_name?: string; webhook_url?: string; - credentials?: any; + credentials?: Record; filter_keywords?: string; filter_locations?: string; filter_min_salary?: string; diff --git a/packages/database/src/queries/conversations.ts b/packages/database/src/queries/conversations.ts index d438800..59ceacb 100644 --- a/packages/database/src/queries/conversations.ts +++ b/packages/database/src/queries/conversations.ts @@ -1,4 +1,4 @@ -import { eq, asc, and, desc } from "drizzle-orm"; +import { eq, asc, and, desc, ilike } from "drizzle-orm"; import { db } from "../index"; import { conversations, messages } from "../schema"; import type { Conversation, Message } from "@postly/shared-types"; @@ -7,10 +7,14 @@ export const conversationQueries = { /** * Create a new conversation */ - async create(userId: string, resumeId?: string): Promise { + async create( + userId: string, + resumeId?: string, + model?: string, + ): Promise { const [result] = await db .insert(conversations) - .values({ user_id: userId, resume_id: resumeId }) + .values({ user_id: userId, resume_id: resumeId, model }) .returning(); return result as unknown as Conversation; @@ -19,11 +23,20 @@ export const conversationQueries = { /** * Get all conversations for a user */ - async findByUser(userId: string, limit = 50): Promise { + async findByUser( + userId: string, + limit = 50, + includeArchived = false, + ): Promise { const result = await db .select() .from(conversations) - .where(eq(conversations.user_id, userId)) + .where( + and( + eq(conversations.user_id, userId), + includeArchived ? undefined : eq(conversations.is_archived, false), + ), + ) .orderBy(desc(conversations.updated_at)) .limit(limit); @@ -74,6 +87,66 @@ export const conversationQueries = { return !!result; }, + async getActiveThread(conversationId: string, limit = 100) { + const result = await db + .select() + .from(messages) + .where(eq(messages.conversation_id, conversationId)) + .orderBy(asc(messages.created_at)) + .limit(limit); + + return result as unknown as Message[]; + }, + + async setArchived(id: string, isArchived: boolean) { + await db + .update(conversations) + .set({ is_archived: isArchived, updated_at: new Date() }) + .where(eq(conversations.id, id)); + }, + + async editMessage( + messageId: string, + content: string, + conversationId: string, + ) { + const [result] = await db + .insert(messages) + .values({ + conversation_id: conversationId, + role: "user", + content, + metadata: { edited_from: messageId }, + }) + .returning(); + + return result as unknown as Message; + }, + + async cancelMessage(messageId: string) { + await db + .update(messages) + .set({ + metadata: { cancelled: true }, + }) + .where(eq(messages.id, messageId)); + }, + + async getMessageVersions(parentMessageId: string, role: string) { + const result = await db + .select() + .from(messages) + .where( + and( + eq(messages.role, role), + // This is a simplified versioning logic + ilike(messages.content, `%${parentMessageId}%`), + ), + ); + + return result as unknown as Message[]; + }, + // ─── Message Operations ────────────────────────────────────────────────── /** @@ -84,7 +157,7 @@ export const conversationQueries = { role: string, content: string, tokensUsed?: number, - metadata?: any, + metadata?: unknown, ): Promise { const [result] = await db .insert(messages) diff --git a/packages/database/src/queries/subscriptions.ts b/packages/database/src/queries/subscriptions.ts index f91cc47..a5489c0 100644 --- a/packages/database/src/queries/subscriptions.ts +++ b/packages/database/src/queries/subscriptions.ts @@ -32,7 +32,7 @@ export const subscriptionQueries = { user_id: userId, dodo_subscription_id: payload.dodo_subscription_id, dodo_customer_id: payload.dodo_customer_id, - plan: (payload.plan as any) || "seeker", + plan: (payload.plan as SubscriptionPlan) || "seeker", status: payload.status || "active", current_period_end: payload.current_period_end, updated_at: new Date(), diff --git a/packages/database/src/schema.ts b/packages/database/src/schema.ts index 6ca42c0..e4483b9 100644 --- a/packages/database/src/schema.ts +++ b/packages/database/src/schema.ts @@ -304,6 +304,7 @@ export const applications = pgTable( resume_id: uuid("resume_id").references(() => resumes.id), status: applicationStatusEnum("status").notNull().default("applied"), cover_letter: text("cover_letter"), + notes: text("notes"), applied_at: timestamp("applied_at", { withTimezone: true }).defaultNow(), created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), @@ -346,6 +347,8 @@ export const conversations = pgTable("conversations", { .references(() => users.id, { onDelete: "cascade" }), title: varchar("title", { length: 255 }), resume_id: uuid("resume_id").references(() => resumes.id), + model: varchar("model", { length: 100 }), + is_archived: boolean("is_archived").default(false), created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), }); @@ -402,6 +405,24 @@ export const bot_configs = pgTable( }), ); +export const discord_configs = pgTable( + "discord_configs", + { + id: uuid("id").primaryKey().defaultRandom(), + guild_id: varchar("guild_id", { length: 255 }).notNull().unique(), + channel_id: varchar("channel_id", { length: 255 }), + user_id: uuid("user_id") + .notNull() + .references(() => users.id, { onDelete: "cascade" }), + is_active: boolean("is_active").default(true), + created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), + updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), + }, + (table) => ({ + guildIdx: index("idx_discord_guild").on(table.guild_id), + }), +); + export const bot_posts = pgTable("bot_posts", { id: uuid("id").primaryKey().defaultRandom(), bot_config_id: uuid("bot_config_id") @@ -428,7 +449,11 @@ export const subscriptions = pgTable("subscriptions", { length: 255, }).unique(), dodo_customer_id: varchar("dodo_customer_id", { length: 255 }), + current_period_start: timestamp("current_period_start", { + withTimezone: true, + }), current_period_end: timestamp("current_period_end", { withTimezone: true }), + cancelled_at: timestamp("cancelled_at", { withTimezone: true }), raw_data: jsonb("raw_data"), created_at: timestamp("created_at", { withTimezone: true }).defaultNow(), updated_at: timestamp("updated_at", { withTimezone: true }).defaultNow(), diff --git a/packages/shared-types/src/index.ts b/packages/shared-types/src/index.ts index 037c3f1..89aaccf 100644 --- a/packages/shared-types/src/index.ts +++ b/packages/shared-types/src/index.ts @@ -1,6 +1,58 @@ -import { JobSearchFilters, BotPlatform } from "./domain"; -export { JobSearchFilters, BotPlatform }; -export type { Notification, SubscriptionPlan } from "./domain"; +import { + Notification, + SubscriptionPlan, + EmbeddingInputType, + EmbeddingResult, + SingleEmbeddingResult, + ChatMetadata, + ChatResult, + ChatStreamResult, + VectorSearchResult, + JobSearchFilters, + BotPlatform, + SeekerProfile, + EmployerProfile, + ApplicationStatus, + CreateEmployerProfileInput, + UpdateEmployerProfileInput, + NotificationStatus, + CreatePaymentInput, + SeekerProfileData, + SubscriptionStatus, + DodoSubscriptionPayload, +} from "./domain"; + +export type { + Notification, + SubscriptionPlan, + EmbeddingInputType, + EmbeddingResult, + SingleEmbeddingResult, + ChatMetadata, + ChatResult, + ChatStreamResult, + VectorSearchResult, + JobSearchFilters, + BotPlatform, + SeekerProfile, + EmployerProfile, + ApplicationStatus, + CreateEmployerProfileInput, + UpdateEmployerProfileInput, + NotificationStatus, + CreatePaymentInput, + SeekerProfileData, + SubscriptionStatus, + DodoSubscriptionPayload, +}; + +export type ConversationState = + | "idle" + | "thinking" + | "streaming" + | "completed" + | "error" + | "interrupted"; // User types export type UserRole = "job_seeker" | "employer" | "admin" | "discord_owner"; @@ -201,7 +253,7 @@ export interface BotConfig { target_id?: string; target_name?: string; webhook_url?: string; - credentials?: any; + credentials?: Record; filter_keywords?: string; filter_locations?: string; filter_min_salary?: string; @@ -228,7 +280,7 @@ export interface MessageMetadata { total_tokens?: number; }; job_matches?: OptimizedJobMatch[]; - [key: string]: any; + [key: string]: unknown; } export type StreamChatResponse = @@ -280,6 +332,10 @@ export interface Message { role: string; content: string; tokens_used?: number; + status?: "sending" | "completed" | "error"; + metadata?: MessageMetadata; + version?: number; + is_active?: boolean; created_at: Date; } From 35fe314eb990e011df5f1ec7ad425a14afeee6ce Mon Sep 17 00:00:00 2001 From: Utsav joshi Date: Tue, 28 Apr 2026 22:47:04 +0530 Subject: [PATCH 14/15] feat: enable proxy trust and apply rate limiting to the health check endpoint --- apps/api/src/server.ts | 19 +++++++++++++++++-- 1 file changed, 17 insertions(+), 2 deletions(-) diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index 8ab5ad7..fe4f609 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -19,6 +19,8 @@ import applicationRoutes from "./routes/application.routes.js"; import { queueService } from "./services/queue.service.js"; const app = express(); +app.set("trust proxy", 1); + import { redis as healthRedis } from "./lib/redis.js"; import path from "path"; @@ -85,8 +87,21 @@ const apiRateLimiter = rateLimit({ }, }); -// Health check — registered BEFORE rate limiter so it's never throttled -app.get("/health", async (_req, res) => { +const healthRateLimiter = rateLimit({ + windowMs: 60 * 1000, // 1 minute + max: 30, // limit each IP to 30 requests per minute + standardHeaders: true, + legacyHeaders: false, + message: { + success: false, + error: { message: "Health check rate limit exceeded." }, + }, +}); + + +// Health check — rate limited to prevent DB/Redis connection exhaustion +app.get("/health", healthRateLimiter, async (_req, res) => { + const checks: Record = {}; // Check Postgres From 8d6350c1e7828bf1c6823990655c57e4c25f91e4 Mon Sep 17 00:00:00 2001 From: Utsav joshi Date: Tue, 28 Apr 2026 22:47:38 +0530 Subject: [PATCH 15/15] style: remove redundant whitespace in server configuration and health check route --- apps/api/src/server.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts index fe4f609..3c868d2 100644 --- a/apps/api/src/server.ts +++ b/apps/api/src/server.ts @@ -21,7 +21,6 @@ import { queueService } from "./services/queue.service.js"; const app = express(); app.set("trust proxy", 1); - import { redis as healthRedis } from "./lib/redis.js"; import path from "path"; import { fileURLToPath } from "url"; @@ -98,10 +97,8 @@ const healthRateLimiter = rateLimit({ }, }); - // Health check — rate limited to prevent DB/Redis connection exhaustion app.get("/health", healthRateLimiter, async (_req, res) => { - const checks: Record = {}; // Check Postgres