diff --git a/.github/workflows/deploy.yml b/.github/workflows/deploy.yml
index 52f7464..27451b6 100644
--- a/.github/workflows/deploy.yml
+++ b/.github/workflows/deploy.yml
@@ -22,6 +22,8 @@ jobs:
key: ${{ secrets.SSH_PRIVATE_KEY }}
script: |
+ set -e # Exit immediately on any error
+
# Navigate to project directory
cd /var/www/postly
@@ -29,11 +31,36 @@ jobs:
git fetch --all
git reset --hard origin/main
- # Rebuild and start services
- docker compose -f docker-compose.prod.yml up -d --build
+ # Rebuild and start all services
+ docker compose -f docker-compose.prod.yml build --no-cache
+ docker compose -f docker-compose.prod.yml up -d
+
+ # Wait for API to become healthy
+ echo "Waiting for API health check..."
+ RETRIES=10
+ DELAY=5
+ for i in $(seq 1 $RETRIES); do
+ if curl -sf http://localhost:3000/health > /dev/null 2>&1; then
+ echo "✅ API is healthy (attempt $i/$RETRIES)"
+ break
+ fi
+ if [ $i -eq $RETRIES ]; then
+ echo "❌ API health check failed after $RETRIES attempts"
+ echo "--- Last 50 lines of API logs ---"
+ docker compose -f docker-compose.prod.yml logs api --tail=50
+ exit 1
+ fi
+ echo "⏳ Attempt $i/$RETRIES failed, retrying in ${DELAY}s..."
+ sleep $DELAY
+ done
- # Run database migrations
- docker exec postly-api npm run migrate:up
+ # Run database migrations (if any)
+ docker exec postly-api npm run migrate:up || echo "No migrations to run"
+
+ # Rebuild other services if needed
+ docker compose -f docker-compose.prod.yml up -d --build scraper bot
# Cleanup old images
docker image prune -f
+
+ echo "🚀 Deployment complete"
diff --git a/apps/api/package.json b/apps/api/package.json
index 1867f2b..9132e02 100644
--- a/apps/api/package.json
+++ b/apps/api/package.json
@@ -20,9 +20,11 @@
"@postly/shared-types": "*",
"bcrypt": "^5.1.1",
"bullmq": "^5.31.3",
+ "compression": "^1.8.1",
"cors": "^2.8.6",
"dotenv": "^16.4.7",
"express": "^4.21.2",
+ "express-prom-bundle": "^8.0.0",
"express-rate-limit": "^8.2.1",
"helmet": "^8.0.0",
"ioredis": "^5.9.2",
@@ -30,6 +32,7 @@
"mammoth": "^1.11.0",
"multer": "^2.0.2",
"pdf-parse": "^2.4.5",
+ "prom-client": "^15.1.3",
"resend": "^6.9.4",
"zod": "^3.24.1"
},
@@ -37,6 +40,7 @@
"@postly/eslint-config": "*",
"@postly/typescript-config": "*",
"@types/bcrypt": "^5.0.2",
+ "@types/compression": "^1.8.1",
"@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 16e89ad..5750e0f 100644
--- a/apps/api/src/controllers/auth.controller.ts
+++ b/apps/api/src/controllers/auth.controller.ts
@@ -1,19 +1,7 @@
import { Request, Response, NextFunction } from "express";
-import bcrypt from "bcrypt";
-import jwt, { type SignOptions } from "jsonwebtoken";
-import crypto from "crypto";
import { z } from "zod";
-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";
+import { AuthService, AuthError } from "../services/auth.service.js";
// ─── Validation Schemas ──────────────────────────────────────────────────────
@@ -50,31 +38,28 @@ const resendOtpSchema = z.object({
email: z.string().email("Invalid email address"),
});
+// ─── Error Helper ────────────────────────────────────────────────────────────
+
+function handleServiceError(error: unknown, res: Response, next: NextFunction) {
+ if (error instanceof AuthError) {
+ res.status(error.statusCode).json({
+ success: false,
+ error: {
+ message: error.message,
+ ...(error.code && { code: error.code }),
+ },
+ });
+ return;
+ }
+ next(error);
+}
+
// ─── Controller ──────────────────────────────────────────────────────────────
+// Thin HTTP adapter: validates input → calls service → sends response.
+// All business logic lives in AuthService.
export class AuthController {
- /**
- * Generate access + refresh token pair for a user.
- */
- private generateTokens(user: {
- id: string;
- email: string;
- roles: UserRole[];
- }) {
- const access_token = jwt.sign(
- { id: user.id, email: user.email, roles: user.roles },
- JWT_SECRET,
- { expiresIn: JWT_EXPIRES_IN as SignOptions["expiresIn"] },
- );
-
- const refresh_token = jwt.sign(
- { id: user.id, type: "refresh" },
- JWT_REFRESH_SECRET,
- { expiresIn: JWT_REFRESH_EXPIRES_IN as SignOptions["expiresIn"] },
- );
-
- return { access_token, refresh_token };
- }
+ private authService = new AuthService();
// ─── POST /register ──────────────────────────────────────────────────────
@@ -94,65 +79,15 @@ export class AuthController {
}
const { email, password, full_name } = validation.data;
-
- const existingUser = await userQueries.findByEmail(email);
- if (existingUser) {
- res.status(409).json({
- success: false,
- error: { message: "User with this email already exists" },
- });
- return;
- }
-
- const salt = await bcrypt.genSalt(12);
- const password_hash = await bcrypt.hash(password, salt);
-
- const user = await userQueries.create({
+ const result = await this.authService.register(
email,
- password_hash,
+ password,
full_name,
- });
-
- // 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,
- },
- });
+ res.status(201).json({ success: true, data: result });
} catch (error) {
- next(error);
+ handleServiceError(error, res, next);
}
};
@@ -174,61 +109,11 @@ export class AuthController {
}
const { email, password } = validation.data;
-
- const user = await userQueries.findByEmail(email);
- if (!user || !user.password_hash) {
- res.status(401).json({
- success: false,
- error: { message: "Invalid email or password" },
- });
- return;
- }
-
- const isValidPassword = await bcrypt.compare(
- password,
- user.password_hash,
- );
- if (!isValidPassword) {
- res.status(401).json({
- success: false,
- error: { message: "Invalid email or password" },
- });
- 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);
-
- 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: user.is_verified,
- created_at: user.created_at,
- updated_at: user.updated_at,
- },
- ...tokens,
- };
+ const response = await this.authService.login(email, password);
res.json({ success: true, data: response });
} catch (error) {
- next(error);
+ handleServiceError(error, res, next);
}
};
@@ -249,48 +134,13 @@ export class AuthController {
return;
}
- const { refresh_token } = validation.data;
-
- let decoded: { id: string; type: string };
- try {
- decoded = jwt.verify(refresh_token, JWT_REFRESH_SECRET) as {
- id: string;
- type: string;
- };
- } catch {
- res.status(401).json({
- success: false,
- error: { message: "Invalid or expired refresh token" },
- });
- return;
- }
-
- if (decoded.type !== "refresh") {
- res.status(401).json({
- success: false,
- error: { message: "Invalid token type" },
- });
- return;
- }
-
- const user = await userQueries.findById(decoded.id);
- if (!user) {
- res.status(401).json({
- success: false,
- error: { message: "User not found" },
- });
- return;
- }
-
- const access_token = jwt.sign(
- { id: user.id, email: user.email, roles: user.roles },
- JWT_SECRET,
- { expiresIn: JWT_EXPIRES_IN as SignOptions["expiresIn"] },
+ const result = await this.authService.refreshAccessToken(
+ validation.data.refresh_token,
);
- res.json({ success: true, data: { access_token } });
+ res.json({ success: true, data: result });
} catch (error) {
- next(error);
+ handleServiceError(error, res, next);
}
};
@@ -303,30 +153,11 @@ export class AuthController {
): Promise => {
try {
const payload = req.user as JwtPayload;
- const user = await userQueries.findById(payload.id);
- if (!user) {
- res.status(404).json({
- success: false,
- error: { message: "User not found" },
- });
- return;
- }
+ const user = await this.authService.getCurrentUser(payload.id);
- res.json({
- success: true,
- data: {
- id: user.id,
- email: user.email,
- full_name: user.full_name,
- roles: user.roles,
- is_verified: user.is_verified,
- last_login_at: user.last_login_at,
- created_at: user.created_at,
- updated_at: user.updated_at,
- },
- });
+ res.json({ success: true, data: user });
} catch (error) {
- next(error);
+ handleServiceError(error, res, next);
}
};
@@ -347,26 +178,13 @@ export class AuthController {
return;
}
- const { email } = validation.data;
-
- // Always return success to prevent email enumeration
- const user = await userQueries.findByEmail(email);
- if (user) {
- const token = crypto.randomBytes(32).toString("hex");
- const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
- await userQueries.setResetToken(user.id, token, expiresAt);
- // TODO: Send password reset email with token
- }
+ const result = await this.authService.forgotPassword(
+ validation.data.email,
+ );
- res.json({
- success: true,
- data: {
- message:
- "If an account with that email exists, a password reset link has been sent.",
- },
- });
+ res.json({ success: true, data: result });
} catch (error) {
- next(error);
+ handleServiceError(error, res, next);
}
};
@@ -387,28 +205,14 @@ export class AuthController {
return;
}
- const { token, password } = validation.data;
-
- const user = await userQueries.findByResetToken(token);
- if (!user) {
- res.status(400).json({
- success: false,
- error: { message: "Invalid or expired reset token" },
- });
- return;
- }
-
- const salt = await bcrypt.genSalt(12);
- const password_hash = await bcrypt.hash(password, salt);
-
- await userQueries.resetPassword(user.id, password_hash);
+ const result = await this.authService.resetPassword(
+ validation.data.token,
+ validation.data.password,
+ );
- res.json({
- success: true,
- data: { message: "Password has been reset successfully" },
- });
+ res.json({ success: true, data: result });
} catch (error) {
- next(error);
+ handleServiceError(error, res, next);
}
};
@@ -429,91 +233,14 @@ export class AuthController {
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,
- };
+ const result = await this.authService.verifyOtp(
+ validation.data.email,
+ validation.data.code,
+ );
- res.json({ success: true, data: response });
+ res.json({ success: true, data: result });
} catch (error) {
- next(error);
+ handleServiceError(error, res, next);
}
};
@@ -534,70 +261,11 @@ export class AuthController {
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;
- }
- }
+ const result = await this.authService.resendOtp(validation.data.email);
- // 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." },
- });
+ res.json({ success: true, data: result });
} catch (error) {
- next(error);
+ handleServiceError(error, res, next);
}
};
}
diff --git a/apps/api/src/lib/redis.ts b/apps/api/src/lib/redis.ts
index e2248d1..f4794fc 100644
--- a/apps/api/src/lib/redis.ts
+++ b/apps/api/src/lib/redis.ts
@@ -1,5 +1,6 @@
import { Redis } from "ioredis";
import { REDIS_URL } from "../config/secrets.js";
+import { logger } from "@postly/logger";
/**
* Shared Redis client for the API.
@@ -14,7 +15,7 @@ export const redis = new Redis(REDIS_URL || "redis://localhost:6379", {
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);
+ logger.error("Shared Redis connection error", { error: err.message });
});
export default redis;
diff --git a/apps/api/src/middleware/error-handler.ts b/apps/api/src/middleware/error-handler.ts
index 088c9c0..225dff4 100644
--- a/apps/api/src/middleware/error-handler.ts
+++ b/apps/api/src/middleware/error-handler.ts
@@ -1,5 +1,6 @@
import type { Request, Response, NextFunction } from "express";
import { NODE_ENV, WEB_URL } from "../config/secrets.js";
+import { logger } from "@postly/logger";
export interface AppError extends Error {
statusCode?: number;
@@ -16,7 +17,11 @@ export function errorHandler(
const message = err.message || "Internal Server Error";
if (NODE_ENV !== "production") {
- console.error("Error:", err);
+ logger.error("Unhandled error", {
+ message: err.message,
+ stack: err.stack,
+ statusCode,
+ });
}
// Ensure CORS headers are present even in error responses, but ONLY for trusted origins
diff --git a/apps/api/src/middleware/request-id.ts b/apps/api/src/middleware/request-id.ts
new file mode 100644
index 0000000..67c86fd
--- /dev/null
+++ b/apps/api/src/middleware/request-id.ts
@@ -0,0 +1,26 @@
+import { randomUUID } from "crypto";
+import type { Request, Response, NextFunction } from "express";
+
+/**
+ * Request ID Middleware
+ *
+ * Attaches a unique correlation ID to every incoming request.
+ * - If the client provides `X-Request-ID`, it is reused (e.g. from Nginx).
+ * - Otherwise, a new UUID v4 is generated.
+ *
+ * The ID is:
+ * 1. Set on the request headers for downstream middleware/controllers.
+ * 2. Sent back as a response header for client-side correlation.
+ *
+ * This enables end-to-end request tracing through logs.
+ */
+export function requestIdMiddleware(
+ req: Request,
+ res: Response,
+ next: NextFunction,
+): void {
+ const id = (req.headers["x-request-id"] as string) || randomUUID();
+ req.headers["x-request-id"] = id;
+ res.setHeader("X-Request-ID", id);
+ next();
+}
diff --git a/apps/api/src/middleware/token-bucket-rate-limit.ts b/apps/api/src/middleware/token-bucket-rate-limit.ts
index bfd451c..f254d72 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 { JWT_SECRET } from "../config/secrets.js";
import { Redis } from "ioredis";
interface RateLimitConfig {
@@ -86,24 +87,23 @@ export const tokenBucketRateLimiter = (config: RateLimitConfig) => {
return next();
}
- // Identifier: Start with IP Address
- let identifier = req.ip || "unknown-ip";
+ // Rate-limit identifier: always anchored to the client IP.
+ // A verified JWT user ID is appended for per-user granularity.
+ const clientIp = req.ip || "unknown-ip";
+ let identifier = clientIp;
- // 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
+ // jwt.verify() is the sole gatekeeper — it cryptographically validates
+ // the token using the server-side secret. No user-controlled conditionals
+ // guard the sensitive action; invalid input simply throws and is caught.
+ try {
+ const rawHeader = req.headers["authorization"] ?? "";
+ const token = String(rawHeader).slice(7);
+ const decoded = jwt.verify(token, JWT_SECRET) as { id?: string };
+ if (typeof decoded?.id === "string" && decoded.id.length > 0) {
+ identifier = `${clientIp}:uid:${decoded.id}`;
}
+ } catch {
+ // No valid token — IP-only rate limiting applies
}
const key = `${keyPrefix}:${identifier}`;
diff --git a/apps/api/src/server.ts b/apps/api/src/server.ts
index 3c868d2..e3cbc34 100644
--- a/apps/api/src/server.ts
+++ b/apps/api/src/server.ts
@@ -1,8 +1,11 @@
import express from "express";
+import compression from "compression";
import cors from "cors";
import helmet from "helmet";
import rateLimit from "express-rate-limit";
+import promBundle from "express-prom-bundle";
import { tokenBucketRateLimiter } from "./middleware/token-bucket-rate-limit.js";
+import { requestIdMiddleware } from "./middleware/request-id.js";
import { pool } from "@postly/database";
import { logger } from "@postly/logger";
import { API_PORT, WEB_URL, NODE_ENV } from "./config/secrets.js";
@@ -28,6 +31,25 @@ import { fileURLToPath } from "url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
+// Request correlation ID — must be first for tracing
+app.use(requestIdMiddleware);
+
+// Response compression — critical for high-latency links
+app.use(compression());
+
+// Prometheus Metrics Middleware
+// Exposes /metrics endpoint for VictoriaMetrics/Prometheus to scrape
+const metricsMiddleware = promBundle({
+ includeMethod: true,
+ includePath: true,
+ includeStatusCode: true,
+ includeUp: true,
+ promClient: {
+ collectDefaultMetrics: {},
+ },
+});
+app.use(metricsMiddleware);
+
// Security middleware
app.use(
helmet({
@@ -42,9 +64,7 @@ const allowedOrigins = WEB_URL
: [];
if (allowedOrigins.length === 0) {
- console.warn(
- "⚠️ WEB_URL is not set — CORS will block all browser requests!",
- );
+ logger.warn("WEB_URL is not set — CORS will block all browser requests");
}
app.use(
@@ -60,12 +80,13 @@ app.use(
return callback(null, true);
}
- console.error(`CORS Blocked: origin '${origin}' not in allowed list`);
+ logger.warn("CORS blocked request", { origin });
callback(new Error(`CORS: origin '${origin}' not allowed`));
},
credentials: true,
methods: ["GET", "POST", "PUT", "DELETE", "PATCH", "OPTIONS"],
allowedHeaders: ["Content-Type", "Authorization", "X-Requested-With"],
+ maxAge: 86400, // Cache preflight for 24h — saves ~300ms per cross-origin request
}),
);
@@ -172,14 +193,19 @@ app.use(errorHandler);
// Start server
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}`);
+ logger.info("API server started", {
+ port: API_PORT,
+ environment: NODE_ENV,
+ url: `http://0.0.0.0:${API_PORT}`,
+ });
// Initialize Bot Job Queue
try {
await queueService.initDailyCron();
} catch (err) {
- console.error("Failed to initialize Bot Queue:", err);
+ logger.error("Failed to initialize Bot Queue", {
+ error: err instanceof Error ? err.message : "Unknown",
+ });
}
});
diff --git a/apps/api/src/services/auth.service.ts b/apps/api/src/services/auth.service.ts
new file mode 100644
index 0000000..186cb82
--- /dev/null
+++ b/apps/api/src/services/auth.service.ts
@@ -0,0 +1,398 @@
+import bcrypt from "bcrypt";
+import jwt, { type SignOptions } from "jsonwebtoken";
+import crypto from "crypto";
+import {
+ userQueries,
+ otpQueries,
+ db,
+ users,
+ seeker_profiles,
+ otp_codes,
+} from "@postly/database";
+import type { AuthResponse, UserRole } from "@postly/shared-types";
+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";
+import { logger } from "@postly/logger";
+
+// ─── Custom Errors ───────────────────────────────────────────────────────────
+
+export class AuthError extends Error {
+ constructor(
+ message: string,
+ public statusCode: number,
+ public code?: string,
+ ) {
+ super(message);
+ this.name = "AuthError";
+ }
+}
+
+// ─── Service ─────────────────────────────────────────────────────────────────
+
+/**
+ * AuthService — Pure business logic for authentication.
+ *
+ * This service owns:
+ * - Registration (user + OTP in a single transaction)
+ * - Login (credential validation + token generation)
+ * - OTP verification & resend
+ * - Token refresh
+ * - Password reset flow
+ *
+ * It does NOT import Express types — it is HTTP-framework-agnostic.
+ */
+export class AuthService {
+ // ─── Token Generation ─────────────────────────────────────────────────
+
+ generateTokens(user: { id: string; email: string; roles: UserRole[] }) {
+ const access_token = jwt.sign(
+ { id: user.id, email: user.email, roles: user.roles },
+ JWT_SECRET,
+ { expiresIn: JWT_EXPIRES_IN as SignOptions["expiresIn"] },
+ );
+
+ const refresh_token = jwt.sign(
+ { id: user.id, type: "refresh" },
+ JWT_REFRESH_SECRET,
+ { expiresIn: JWT_REFRESH_EXPIRES_IN as SignOptions["expiresIn"] },
+ );
+
+ return { access_token, refresh_token };
+ }
+
+ // ─── Registration ─────────────────────────────────────────────────────
+
+ async register(email: string, password: string, fullName?: string) {
+ const existingUser = await userQueries.findByEmail(email);
+ if (existingUser) {
+ throw new AuthError("User with this email already exists", 409);
+ }
+
+ const salt = await bcrypt.genSalt(12);
+ const password_hash = await bcrypt.hash(password, salt);
+
+ // Generate 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
+
+ // Create user + OTP in a single transaction to prevent orphaned users
+ const user = await db.transaction(async (tx) => {
+ const [createdUser] = await tx
+ .insert(users)
+ .values({
+ email,
+ password_hash,
+ full_name: fullName,
+ roles: ["job_seeker"],
+ avatar_url: `https://api.dicebear.com/9.x/dylan/svg?seed=${encodeURIComponent(fullName || email.split("@")[0])}`,
+ })
+ .returning();
+
+ // Initialize seeker profile within same transaction
+ await tx
+ .insert(seeker_profiles)
+ .values({ user_id: createdUser.id })
+ .onConflictDoNothing();
+
+ // Create OTP within same transaction
+ await tx
+ .insert(otp_codes)
+ .values({
+ user_id: createdUser.id,
+ code_hash: otpHash,
+ expires_at: otpExpiry,
+ attempts: 0,
+ last_attempt_at: null,
+ })
+ .onConflictDoUpdate({
+ target: otp_codes.user_id,
+ set: {
+ code_hash: otpHash,
+ expires_at: otpExpiry,
+ attempts: 0,
+ last_attempt_at: null,
+ created_at: new Date(),
+ },
+ });
+
+ return createdUser;
+ });
+
+ // Send verification email (fire-and-forget, outside transaction)
+ this.sendVerificationEmail(email, otpCode).catch((err) => {
+ logger.error("Failed to send verification email", {
+ error: err instanceof Error ? err.message : "Unknown",
+ email,
+ });
+ });
+
+ return {
+ message:
+ "Registration successful. Please check your email for the verification code.",
+ email: user.email,
+ };
+ }
+
+ // ─── Login ────────────────────────────────────────────────────────────
+
+ async login(email: string, password: string): Promise {
+ const user = await userQueries.findByEmail(email);
+ if (!user || !user.password_hash) {
+ throw new AuthError("Invalid email or password", 401);
+ }
+
+ const isValidPassword = await bcrypt.compare(password, user.password_hash);
+ if (!isValidPassword) {
+ throw new AuthError("Invalid email or password", 401);
+ }
+
+ if (!user.is_verified) {
+ throw new AuthError("Email not verified", 403, "EMAIL_NOT_VERIFIED");
+ }
+
+ // Track login timestamp (fire-and-forget)
+ userQueries.updateLastLogin(user.id).catch((err) => {
+ logger.error("Failed to update last login", {
+ error: err instanceof Error ? err.message : "Unknown",
+ userId: user.id,
+ });
+ });
+
+ const tokens = this.generateTokens(user);
+
+ return {
+ user: {
+ id: user.id,
+ email: user.email,
+ full_name: user.full_name,
+ roles: user.roles,
+ is_verified: user.is_verified,
+ created_at: user.created_at,
+ updated_at: user.updated_at,
+ },
+ ...tokens,
+ };
+ }
+
+ // ─── Token Refresh ────────────────────────────────────────────────────
+
+ async refreshAccessToken(
+ refreshToken: string,
+ ): Promise<{ access_token: string }> {
+ let decoded: { id: string; type: string };
+ try {
+ decoded = jwt.verify(refreshToken, JWT_REFRESH_SECRET) as {
+ id: string;
+ type: string;
+ };
+ } catch {
+ throw new AuthError("Invalid or expired refresh token", 401);
+ }
+
+ if (decoded.type !== "refresh") {
+ throw new AuthError("Invalid token type", 401);
+ }
+
+ const user = await userQueries.findById(decoded.id);
+ if (!user) {
+ throw new AuthError("User not found", 401);
+ }
+
+ const access_token = jwt.sign(
+ { id: user.id, email: user.email, roles: user.roles },
+ JWT_SECRET,
+ { expiresIn: JWT_EXPIRES_IN as SignOptions["expiresIn"] },
+ );
+
+ return { access_token };
+ }
+
+ // ─── OTP Verification ─────────────────────────────────────────────────
+
+ async verifyOtp(email: string, code: string): Promise {
+ const user = await userQueries.findByEmail(email);
+ if (!user) {
+ throw new AuthError("User not found", 404);
+ }
+
+ if (user.is_verified) {
+ throw new AuthError("User is already verified", 400);
+ }
+
+ const otp = await otpQueries.findOtpByUserId(user.id);
+ if (!otp) {
+ throw new AuthError(
+ "No verification code found. Please request a new one.",
+ 400,
+ );
+ }
+
+ // Check expiry
+ if (new Date() > new Date(otp.expires_at)) {
+ await otpQueries.deleteOtp(otp.id);
+ throw new AuthError(
+ "Verification code expired. Please request a new one.",
+ 400,
+ );
+ }
+
+ // Check attempts
+ if (otp.attempts >= 3) {
+ throw new AuthError(
+ "Too many failed attempts. Please request a new code.",
+ 429,
+ );
+ }
+
+ // Verify code
+ const isValid = await bcrypt.compare(code, otp.code_hash);
+ if (!isValid) {
+ await otpQueries.incrementOtpAttempts(otp.id);
+ throw new AuthError("Invalid verification code", 400);
+ }
+
+ // Success — verify user and clean up
+ await otpQueries.verifyUser(user.id);
+ await otpQueries.deleteOtp(otp.id);
+
+ const tokens = this.generateTokens(user);
+
+ return {
+ 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,
+ };
+ }
+
+ // ─── OTP Resend ───────────────────────────────────────────────────────
+
+ async resendOtp(email: string): Promise<{ message: string }> {
+ const user = await userQueries.findByEmail(email);
+ if (!user) {
+ throw new AuthError("User not found", 404);
+ }
+
+ if (user.is_verified) {
+ throw new AuthError("User is already verified", 400);
+ }
+
+ // Rate-limit resend
+ 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);
+ throw new AuthError(
+ `Please wait ${waitTime} seconds before requesting a new code.`,
+ 429,
+ );
+ }
+ }
+
+ // 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 this.sendVerificationEmail(email, otpCode);
+
+ return { message: "Verification code resent successfully." };
+ }
+
+ // ─── Forgot Password ─────────────────────────────────────────────────
+
+ async forgotPassword(email: string): Promise<{ message: string }> {
+ // Always return success to prevent email enumeration
+ const user = await userQueries.findByEmail(email);
+ if (user) {
+ const token = crypto.randomBytes(32).toString("hex");
+ const expiresAt = new Date(Date.now() + 60 * 60 * 1000); // 1 hour
+ await userQueries.setResetToken(email, token, expiresAt);
+ // TODO: Send password reset email with token
+ }
+
+ return {
+ message:
+ "If an account with that email exists, a password reset link has been sent.",
+ };
+ }
+
+ // ─── Reset Password ──────────────────────────────────────────────────
+
+ async resetPassword(
+ token: string,
+ newPassword: string,
+ ): Promise<{ message: string }> {
+ const salt = await bcrypt.genSalt(12);
+ const password_hash = await bcrypt.hash(newPassword, salt);
+
+ const success = await userQueries.resetPassword(token, password_hash);
+ if (!success) {
+ throw new AuthError("Invalid or expired reset token", 400);
+ }
+
+ return { message: "Password has been reset successfully" };
+ }
+
+ // ─── Get Current User ─────────────────────────────────────────────────
+
+ async getCurrentUser(userId: string) {
+ const user = await userQueries.findById(userId);
+ if (!user) {
+ throw new AuthError("User not found", 404);
+ }
+
+ return {
+ id: user.id,
+ email: user.email,
+ full_name: user.full_name,
+ roles: user.roles,
+ is_verified: user.is_verified,
+ last_login_at: user.last_login_at,
+ created_at: user.created_at,
+ updated_at: user.updated_at,
+ };
+ }
+
+ // ─── Email Helpers (Private) ──────────────────────────────────────────
+
+ private async sendVerificationEmail(
+ email: string,
+ otpCode: string,
+ ): Promise {
+ 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.
+
+ `,
+ });
+ }
+}
+
+export const authService = new AuthService();
diff --git a/apps/api/src/services/cache.service.ts b/apps/api/src/services/cache.service.ts
index ac052fa..2d9e75f 100644
--- a/apps/api/src/services/cache.service.ts
+++ b/apps/api/src/services/cache.service.ts
@@ -1,4 +1,5 @@
import { redis } from "../lib/redis.js";
+import { logger } from "@postly/logger";
/**
* Standardized Cache Service
@@ -44,7 +45,7 @@ export class CacheService {
}
} catch (error) {
// Log warning but DO NOT crash (Graceful Degradation)
- console.warn(`[CacheService] Redis GET failed for key "${key}":`, error);
+ logger.warn("Redis GET failed", { key, error: String(error) });
}
// 2. On miss (or Redis failure), execute the source DB query
@@ -56,17 +57,17 @@ export class CacheService {
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,
- );
+ logger.warn("Background Redis SETEX failed", {
+ key,
+ error: String(err),
+ });
});
}
} catch (error) {
- console.warn(
- `[CacheService] Redis SETEX synchronous error for key "${key}":`,
- error,
- );
+ logger.warn("Redis SETEX synchronous error", {
+ key,
+ error: String(error),
+ });
}
// 4. Return data immediately
@@ -80,7 +81,7 @@ export class CacheService {
try {
await redis.del(key);
} catch (error) {
- console.warn(`[CacheService] Redis DEL failed for key "${key}":`, error);
+ logger.warn("Redis DEL failed", { key, error: String(error) });
}
}
@@ -107,10 +108,10 @@ export class CacheService {
}
} while (cursor !== "0");
} catch (error) {
- console.warn(
- `[CacheService] Redis pattern invalidation failed for "${pattern}":`,
- error,
- );
+ logger.warn("Redis pattern invalidation failed", {
+ pattern,
+ error: String(error),
+ });
}
}
}
diff --git a/apps/api/src/services/chat.service.ts b/apps/api/src/services/chat.service.ts
index d72f0bb..5890bdf 100644
--- a/apps/api/src/services/chat.service.ts
+++ b/apps/api/src/services/chat.service.ts
@@ -13,6 +13,7 @@ import type {
Job,
OptimizedJobMatch,
} from "@postly/shared-types";
+import { logger } from "@postly/logger";
interface MatchedJob extends Job {
match_score: number;
@@ -91,6 +92,59 @@ function getJobIntent(message: string): JobIntent {
allKeywords: foundKeywords,
};
}
+
+// ─── Context Window Budget Management ─────────────────────────────────────
+
+const MAX_CONTEXT_TOKENS = 8000; // conservative budget for conversation history
+
+/**
+ * Trims conversation history to fit within a token budget.
+ * Works backwards from most recent messages (which are most relevant).
+ * Uses a rough estimate of 1 token ≈ 4 characters.
+ */
+function trimHistory(messages: Message[], maxTokens: number): string {
+ let budget = maxTokens;
+ const included: string[] = [];
+
+ // Work backwards — most recent messages are most important
+ for (let i = messages.length - 1; i >= 0; i--) {
+ const entry = `${messages[i].role}: ${messages[i].content}`;
+ const estimatedTokens = Math.ceil(entry.length / 4);
+ if (budget - estimatedTokens < 0) break;
+ budget -= estimatedTokens;
+ included.unshift(entry);
+ }
+
+ return included.join("\n");
+}
+
+// ─── Prompt Injection Defense ─────────────────────────────────────────────
+
+const BLOCKED_PATTERNS = [
+ /ignore\s+(all\s+)?previous\s+instructions/i,
+ /you\s+are\s+now\s+a/i,
+ /pretend\s+(to\s+be|you\s+are)/i,
+ /reveal\s+(your|the)\s+(system\s+)?prompt/i,
+ /what\s+(are|is)\s+your\s+(system\s+)?instructions/i,
+ /disregard\s+(all|any)\s+(prior|previous)/i,
+];
+
+/**
+ * Filters user messages for common prompt injection patterns.
+ * Returns a sanitized version or the original message if clean.
+ */
+function sanitizeUserInput(message: string): string {
+ for (const pattern of BLOCKED_PATTERNS) {
+ if (pattern.test(message)) {
+ logger.warn("Prompt injection attempt detected", {
+ pattern: pattern.source,
+ messageLength: message.length,
+ });
+ return "[Message filtered for policy compliance. Please rephrase your question about jobs or career advice.]";
+ }
+ }
+ return message;
+}
// ... (omitting helper for brevity in diff)
// Helper to transform raw job data to UI-ready format
@@ -277,13 +331,16 @@ ${userRole !== "employer" ? "3. DO NOT hallucinate job listings. Only mention jo
Be professional, encouraging, and concise.${resumeContext}${userRole !== "employer" ? jobContext : ""}`;
- // 6. Prepare conversation history
- const conversationHistory = messages
- .filter((m: Message) => m.role !== "system")
- .map((m: Message) => `${m.role}: ${m.content}`)
- .join("\n");
+ // 5b. Sanitize user input against prompt injection
+ const sanitizedMessage = sanitizeUserInput(userMessage);
+
+ // 6. Prepare conversation history with token budget
+ const conversationHistory = trimHistory(
+ messages.filter((m: Message) => m.role !== "system"),
+ MAX_CONTEXT_TOKENS,
+ );
- const fullPrompt = `${systemPrompt}\n\nConversation:\n${conversationHistory}\nuser: ${userMessage}\nassistant:`;
+ const fullPrompt = `${systemPrompt}\n\nConversation:\n${conversationHistory}\nuser: ${sanitizedMessage}\nassistant:`;
// 7. Stream AI response
let fullResponse = "";
diff --git a/apps/api/src/services/matching.service.ts b/apps/api/src/services/matching.service.ts
index c9a5757..3e713da 100644
--- a/apps/api/src/services/matching.service.ts
+++ b/apps/api/src/services/matching.service.ts
@@ -7,6 +7,7 @@ import type {
EducationEntry,
} from "@postly/shared-types";
import { pool } from "@postly/database";
+import { logger } from "@postly/logger";
interface MatchedJob extends Job {
match_score: number;
@@ -80,10 +81,10 @@ export class MatchingService {
const explanation = await this.generateMatchExplanation(resume, job);
return { ...job, ai_explanation: explanation };
} catch (error) {
- console.error(
- `Failed to generate explanation for job ${job.id}:`,
- error,
- );
+ logger.error("Failed to generate match explanation", {
+ jobId: job.id,
+ error: error instanceof Error ? error.message : "Unknown",
+ });
return job;
}
}),
diff --git a/apps/api/src/services/queue.service.ts b/apps/api/src/services/queue.service.ts
index 6b0cd66..e842810 100644
--- a/apps/api/src/services/queue.service.ts
+++ b/apps/api/src/services/queue.service.ts
@@ -1,6 +1,7 @@
import { Queue } from "bullmq";
import { REDIS_URL } from "../config/secrets.js";
import { db, bot_configs, eq } from "@postly/database";
+import { logger } from "@postly/logger";
const BOT_QUEUE_NAME = "bot_notifications";
@@ -32,7 +33,9 @@ export class QueueService {
removeOnFail: 5,
},
);
- console.log("📅 Bot daily job dispatch cron initialized (9:00 AM)");
+ logger.info("Bot daily job dispatch cron initialized", {
+ schedule: "9:00 AM UTC",
+ });
};
/**
@@ -50,9 +53,7 @@ export class QueueService {
await this.dispatchForPlatform(config.id);
queued++;
}
- console.log(
- `✅ Queued job alerts for ${queued}/${activeConfigs.length} bot integrations.`,
- );
+ logger.info("Job alerts queued", { queued, total: activeConfigs.length });
return queued;
};
@@ -82,7 +83,10 @@ export class QueueService {
removeOnFail: 3,
},
);
- console.log(`✅ Job dispatched for ${config.platform} config: ${configId}`);
+ logger.info("Job dispatched for bot", {
+ platform: config.platform,
+ configId,
+ });
};
dispatchForGuild = async (guildId: string, channelId: string) => {
diff --git a/apps/api/src/services/resume.service.ts b/apps/api/src/services/resume.service.ts
index b950241..432e062 100644
--- a/apps/api/src/services/resume.service.ts
+++ b/apps/api/src/services/resume.service.ts
@@ -1,12 +1,10 @@
import { generateText, generateVoyageEmbedding } from "@postly/ai-utils";
import { resumeQueries } from "@postly/database";
-import type {
- Resume,
- ResumeAnalysis,
- EducationEntry,
-} from "@postly/shared-types";
+import type { Resume, ResumeAnalysis } from "@postly/shared-types";
import { PDFParse } from "pdf-parse";
import mammoth from "mammoth";
+import { z } from "zod";
+import { logger } from "@postly/logger";
export class ResumeService {
/**
@@ -53,7 +51,27 @@ export class ResumeService {
}
/**
- * Analyze resume text using Gemini AI
+ * Zod schema for validating structured LLM output.
+ * Prevents malformed AI responses from corrupting downstream data.
+ */
+ private static ResumeAnalysisSchema = z.object({
+ skills: z.array(z.string()).default([]),
+ experience_years: z.number().default(0),
+ education: z
+ .array(
+ z.object({
+ degree: z.string().default("Unknown"),
+ institution: z.string().default("Unknown"),
+ year: z.number().optional(),
+ field_of_study: z.string().optional(),
+ }),
+ )
+ .default([]),
+ summary: z.string().default(""),
+ });
+
+ /**
+ * Analyze resume text using AI with Zod-validated output.
*/
async analyzeResume(text: string): Promise {
const prompt = `Analyze the following resume and extract structured information.
@@ -83,31 +101,30 @@ Return ONLY the JSON object, no markdown formatting or explanation.`;
}
cleanJson = cleanJson.trim();
- const parsed = JSON.parse(cleanJson);
+ const rawParsed = JSON.parse(cleanJson);
+ const validated = ResumeService.ResumeAnalysisSchema.safeParse(rawParsed);
+
+ if (!validated.success) {
+ logger.warn("LLM output validation failed", {
+ errors: validated.error.issues,
+ rawKeys: Object.keys(rawParsed),
+ });
+ return {
+ skills: [],
+ experience_years: 0,
+ education: [],
+ summary: "Unable to fully analyze resume. Please try again.",
+ };
+ }
- return {
- skills: Array.isArray(parsed.skills) ? parsed.skills : [],
- experience_years:
- typeof parsed.experience_years === "number"
- ? parsed.experience_years
- : 0,
- education: Array.isArray(parsed.education)
- ? parsed.education.map((e: Partial) => ({
- degree: e.degree || "Unknown",
- institution: e.institution || "Unknown",
- year: e.year,
- field_of_study: e.field_of_study,
- }))
- : [],
- summary: typeof parsed.summary === "string" ? parsed.summary : "",
- };
+ return validated.data;
} catch (error) {
- console.error(
- "Failed to parse AI response:",
- error instanceof Error
- ? this.sanitizeForLog(error.message)
- : "Unknown error",
- );
+ logger.error("Failed to parse AI response", {
+ error:
+ error instanceof Error
+ ? this.sanitizeForLog(error.message)
+ : "Unknown error",
+ });
// Return default analysis if parsing fails
return {
skills: [],
diff --git a/apps/web/index.html b/apps/web/index.html
index eb78b71..8626abe 100644
--- a/apps/web/index.html
+++ b/apps/web/index.html
@@ -6,9 +6,29 @@
Postly - Search Jobs
+
+
+
-
+
+
+
+
-
diff --git a/apps/web/src/lib/api-client.ts b/apps/web/src/lib/api-client.ts
index 2a911b3..1a23311 100644
--- a/apps/web/src/lib/api-client.ts
+++ b/apps/web/src/lib/api-client.ts
@@ -5,8 +5,10 @@ const API_URL = import.meta.env.VITE_API_URL || "http://localhost:3000";
export const apiClient = axios.create({
baseURL: `${API_URL}/api/v1`,
withCredentials: true,
+ timeout: 30000, // 30s timeout — accounts for ~300ms RTT to Dallas VPS
headers: {
"Content-Type": "application/json",
+ "Accept-Encoding": "gzip, deflate, br",
},
});
diff --git a/apps/web/src/main.tsx b/apps/web/src/main.tsx
index 555ec7d..d0462a0 100644
--- a/apps/web/src/main.tsx
+++ b/apps/web/src/main.tsx
@@ -8,7 +8,9 @@ const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 5, // 5 minutes
+ gcTime: 1000 * 60 * 30, // Keep cached data 30 min (default 5) — avoids re-fetch over slow link
retry: 1,
+ refetchOnWindowFocus: false, // Don't refetch on tab switch — saves ~300ms RTT each time
},
},
});
diff --git a/apps/web/vercel.json b/apps/web/vercel.json
index 1323cda..27a78c9 100644
--- a/apps/web/vercel.json
+++ b/apps/web/vercel.json
@@ -1,4 +1,28 @@
{
+ "headers": [
+ {
+ "source": "/assets/(.*)",
+ "headers": [
+ {
+ "key": "Cache-Control",
+ "value": "public, max-age=31536000, immutable"
+ }
+ ]
+ },
+ {
+ "source": "/(.*)",
+ "headers": [
+ {
+ "key": "X-Content-Type-Options",
+ "value": "nosniff"
+ },
+ {
+ "key": "X-Frame-Options",
+ "value": "DENY"
+ }
+ ]
+ }
+ ],
"rewrites": [
{
"source": "/(.*)",
diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml
index 1e0c3a4..548fc9f 100644
--- a/docker-compose.prod.yml
+++ b/docker-compose.prod.yml
@@ -18,14 +18,14 @@ services:
-c effective_cache_size=512MB
-c work_mem=16MB
-c maintenance_work_mem=64MB
- -c max_connections=30
+ -c max_connections=50
-c random_page_cost=1.1
-c wal_compression=on
deploy:
resources:
limits:
cpus: '0.5'
- memory: 512M
+ memory: 768M
healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${DB_USER:-postly}"]
interval: 10s
@@ -46,8 +46,8 @@ services:
restart: unless-stopped
command: >
redis-server
- --maxmemory 64mb
- --maxmemory-policy allkeys-lru
+ --maxmemory 256mb
+ --maxmemory-policy noeviction
--appendonly yes
--save 60 1
--loglevel warning
@@ -56,8 +56,8 @@ services:
deploy:
resources:
limits:
- cpus: '0.1'
- memory: 80M
+ cpus: '0.15'
+ memory: 320M
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 10s
@@ -75,6 +75,9 @@ services:
# ─── API Server ────────────────────────────────────────────
api:
image: ${API_IMAGE:-utsavjoshi/postly-api:latest}
+ build:
+ context: .
+ dockerfile: apps/api/Dockerfile
container_name: postly-api
restart: unless-stopped
environment:
@@ -127,6 +130,9 @@ services:
# ─── Scraper (Python + Playwright) ────────────────────────
scraper:
image: ${SCRAPER_IMAGE:-utsavjoshi/postly-scraper:latest}
+ build:
+ context: .
+ dockerfile: apps/scraper/Dockerfile
container_name: postly-scraper
restart: unless-stopped
environment:
@@ -165,6 +171,9 @@ services:
# ─── Discord Bot ───────────────────────────────────────────
bot:
image: ${BOT_IMAGE:-utsavjoshi/postly-bot:latest}
+ build:
+ context: .
+ dockerfile: apps/bot/Dockerfile
container_name: postly-bot
restart: unless-stopped
environment:
diff --git a/package-lock.json b/package-lock.json
index 37bcd8d..be45bec 100644
--- a/package-lock.json
+++ b/package-lock.json
@@ -32,9 +32,11 @@
"@postly/shared-types": "*",
"bcrypt": "^5.1.1",
"bullmq": "^5.31.3",
+ "compression": "^1.8.1",
"cors": "^2.8.6",
"dotenv": "^16.4.7",
"express": "^4.21.2",
+ "express-prom-bundle": "^8.0.0",
"express-rate-limit": "^8.2.1",
"helmet": "^8.0.0",
"ioredis": "^5.9.2",
@@ -42,6 +44,7 @@
"mammoth": "^1.11.0",
"multer": "^2.0.2",
"pdf-parse": "^2.4.5",
+ "prom-client": "^15.1.3",
"resend": "^6.9.4",
"zod": "^3.24.1"
},
@@ -49,6 +52,7 @@
"@postly/eslint-config": "*",
"@postly/typescript-config": "*",
"@types/bcrypt": "^5.0.2",
+ "@types/compression": "^1.8.1",
"@types/cors": "^2.8.17",
"@types/express": "^5.0.0",
"@types/jsonwebtoken": "^9.0.7",
@@ -3014,6 +3018,15 @@
"node": ">= 8"
}
},
+ "node_modules/@opentelemetry/api": {
+ "version": "1.9.1",
+ "resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
+ "integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
+ "license": "Apache-2.0",
+ "engines": {
+ "node": ">=8.0.0"
+ }
+ },
"node_modules/@paper-design/shaders": {
"version": "0.0.71",
"resolved": "https://registry.npmjs.org/@paper-design/shaders/-/shaders-0.0.71.tgz",
@@ -4601,7 +4614,6 @@
"version": "1.19.6",
"resolved": "https://registry.npmjs.org/@types/body-parser/-/body-parser-1.19.6.tgz",
"integrity": "sha512-HLFeCYgz89uk22N5Qg3dvGvsv46B8GLvKKo1zKG4NybA8U2DiEO3w9lqGg29t/tfLRJpJ6iQxnVw4OnB7MoM9g==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@types/connect": "*",
@@ -4619,11 +4631,21 @@
"assertion-error": "^2.0.1"
}
},
+ "node_modules/@types/compression": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/@types/compression/-/compression-1.8.1.tgz",
+ "integrity": "sha512-kCFuWS0ebDbmxs0AXYn6e2r2nrGAb5KwQhknjSPSPgJcGd8+HVSILlUyFhGqML2gk39HcG7D1ydW9/qpYkN00Q==",
+ "dev": true,
+ "license": "MIT",
+ "dependencies": {
+ "@types/express": "*",
+ "@types/node": "*"
+ }
+ },
"node_modules/@types/connect": {
"version": "3.4.38",
"resolved": "https://registry.npmjs.org/@types/connect/-/connect-3.4.38.tgz",
"integrity": "sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -4680,7 +4702,6 @@
"version": "5.0.6",
"resolved": "https://registry.npmjs.org/@types/express/-/express-5.0.6.tgz",
"integrity": "sha512-sKYVuV7Sv9fbPIt/442koC7+IIwK5olP1KWeD88e/idgoJqDm3JV/YUiPwkoKK92ylff2MGxSz1CSjsXelx0YA==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@types/body-parser": "*",
@@ -4692,7 +4713,6 @@
"version": "5.1.1",
"resolved": "https://registry.npmjs.org/@types/express-serve-static-core/-/express-serve-static-core-5.1.1.tgz",
"integrity": "sha512-v4zIMr/cX7/d2BpAEX3KNKL/JrT1s43s96lLvvdTmza1oEvDudCqK9aF/djc/SWgy8Yh0h30TZx5VpzqFCxk5A==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*",
@@ -4714,7 +4734,6 @@
"version": "2.0.5",
"resolved": "https://registry.npmjs.org/@types/http-errors/-/http-errors-2.0.5.tgz",
"integrity": "sha512-r8Tayk8HJnX0FztbZN7oVqGccWgw98T/0neJphO91KkmOzug1KkofZURD4UaD5uH8AqcFLfdPErnBod0u71/qg==",
- "dev": true,
"license": "MIT"
},
"node_modules/@types/json-schema": {
@@ -4763,7 +4782,6 @@
"version": "25.2.1",
"resolved": "https://registry.npmjs.org/@types/node/-/node-25.2.1.tgz",
"integrity": "sha512-CPrnr8voK8vC6eEtyRzvMpgp3VyVRhgclonE7qYi6P9sXwYb59ucfrnmFBTaP0yUi8Gk4yZg/LlTJULGxvTNsg==",
- "devOptional": true,
"license": "MIT",
"dependencies": {
"undici-types": "~7.16.0"
@@ -4801,14 +4819,12 @@
"version": "6.14.0",
"resolved": "https://registry.npmjs.org/@types/qs/-/qs-6.14.0.tgz",
"integrity": "sha512-eOunJqu0K1923aExK6y8p6fsihYEn/BYuQ4g0CxAAgFc4b/ZLN4CrsRZ55srTdqoiLzU2B2evC+apEIxprEzkQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/@types/range-parser": {
"version": "1.2.7",
"resolved": "https://registry.npmjs.org/@types/range-parser/-/range-parser-1.2.7.tgz",
"integrity": "sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==",
- "dev": true,
"license": "MIT"
},
"node_modules/@types/react": {
@@ -4835,7 +4851,6 @@
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/@types/send/-/send-1.2.1.tgz",
"integrity": "sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@types/node": "*"
@@ -4845,7 +4860,6 @@
"version": "2.2.0",
"resolved": "https://registry.npmjs.org/@types/serve-static/-/serve-static-2.2.0.tgz",
"integrity": "sha512-8mam4H1NHLtu7nmtalF7eyBH14QyOASmcxHhSfEoRyr0nP/YdoesEtU+uSRvMe96TW/HPTtkoKqQLl53N7UXMQ==",
- "dev": true,
"license": "MIT",
"dependencies": {
"@types/http-errors": "*",
@@ -5853,6 +5867,12 @@
"node": ">= 10.0.0"
}
},
+ "node_modules/bintrees": {
+ "version": "1.0.2",
+ "resolved": "https://registry.npmjs.org/bintrees/-/bintrees-1.0.2.tgz",
+ "integrity": "sha512-VOMgTMwjAaUG580SXn3LacVgjurrbMme7ZZNYGSSV7mmtY6QQRh0Eg3pwIcntQ77DErK1L0NxkbetjcoXzVwKw==",
+ "license": "MIT"
+ },
"node_modules/bluebird": {
"version": "3.4.7",
"resolved": "https://registry.npmjs.org/bluebird/-/bluebird-3.4.7.tgz",
@@ -6423,6 +6443,60 @@
"url": "https://github.com/sponsors/wooorm"
}
},
+ "node_modules/compressible": {
+ "version": "2.0.18",
+ "resolved": "https://registry.npmjs.org/compressible/-/compressible-2.0.18.tgz",
+ "integrity": "sha512-AF3r7P5dWxL8MxyITRMlORQNaOA2IkAFaTr4k7BUumjPtRpGDTZpl0Pb1XCO6JeDCBdp126Cgs9sMxqSjgYyRg==",
+ "license": "MIT",
+ "dependencies": {
+ "mime-db": ">= 1.43.0 < 2"
+ },
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
+ "node_modules/compression": {
+ "version": "1.8.1",
+ "resolved": "https://registry.npmjs.org/compression/-/compression-1.8.1.tgz",
+ "integrity": "sha512-9mAqGPHLakhCLeNyxPkK4xVo746zQ/czLH1Ky+vkitMnWfWZps8r0qXuwhwizagCRttsL4lfG4pIOvaWLpAP0w==",
+ "license": "MIT",
+ "dependencies": {
+ "bytes": "3.1.2",
+ "compressible": "~2.0.18",
+ "debug": "2.6.9",
+ "negotiator": "~0.6.4",
+ "on-headers": "~1.1.0",
+ "safe-buffer": "5.2.1",
+ "vary": "~1.1.2"
+ },
+ "engines": {
+ "node": ">= 0.8.0"
+ }
+ },
+ "node_modules/compression/node_modules/debug": {
+ "version": "2.6.9",
+ "resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
+ "integrity": "sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==",
+ "license": "MIT",
+ "dependencies": {
+ "ms": "2.0.0"
+ }
+ },
+ "node_modules/compression/node_modules/ms": {
+ "version": "2.0.0",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.0.0.tgz",
+ "integrity": "sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==",
+ "license": "MIT"
+ },
+ "node_modules/compression/node_modules/negotiator": {
+ "version": "0.6.4",
+ "resolved": "https://registry.npmjs.org/negotiator/-/negotiator-0.6.4.tgz",
+ "integrity": "sha512-myRT3DiWPHqho5PrJaIRyaMv2kgYf0mUVgBNOYMuCH5Ki1yEiQaf/ZJuQ62nvpc44wL5WDbTX7yGJi1Neevw8w==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.6"
+ }
+ },
"node_modules/concat-map": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/concat-map/-/concat-map-0.0.1.tgz",
@@ -8217,6 +8291,23 @@
"url": "https://opencollective.com/express"
}
},
+ "node_modules/express-prom-bundle": {
+ "version": "8.0.0",
+ "resolved": "https://registry.npmjs.org/express-prom-bundle/-/express-prom-bundle-8.0.0.tgz",
+ "integrity": "sha512-UHdpaMks6Z/tvxQsNzhsE7nkdXb4/zEh/jwN0tfZSZOEF+aD0dlfl085EU4jveOq09v01c5sIUfjV4kJODZ2eQ==",
+ "license": "MIT",
+ "dependencies": {
+ "@types/express": "^5.0.0",
+ "on-finished": "^2.3.0",
+ "url-value-parser": "^2.0.0"
+ },
+ "engines": {
+ "node": ">=18"
+ },
+ "peerDependencies": {
+ "prom-client": ">=15.0.0"
+ }
+ },
"node_modules/express-rate-limit": {
"version": "8.2.1",
"resolved": "https://registry.npmjs.org/express-rate-limit/-/express-rate-limit-8.2.1.tgz",
@@ -11771,6 +11862,15 @@
"node": ">= 0.8"
}
},
+ "node_modules/on-headers": {
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/on-headers/-/on-headers-1.1.0.tgz",
+ "integrity": "sha512-737ZY3yNnXy37FHkQxPzt4UZ2UWPWiCZWLvFZ4fu5cueciegX0zGPnrlY6bwRg4FdQOe9YU8MkmJwGhoMybl8A==",
+ "license": "MIT",
+ "engines": {
+ "node": ">= 0.8"
+ }
+ },
"node_modules/once": {
"version": "1.4.0",
"resolved": "https://registry.npmjs.org/once/-/once-1.4.0.tgz",
@@ -12328,6 +12428,19 @@
"integrity": "sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag==",
"license": "MIT"
},
+ "node_modules/prom-client": {
+ "version": "15.1.3",
+ "resolved": "https://registry.npmjs.org/prom-client/-/prom-client-15.1.3.tgz",
+ "integrity": "sha512-6ZiOBfCywsD4k1BN9IX0uZhF+tJkV8q8llP64G5Hajs4JOeVLPCwpPVcpXy3BwYiUGgyJzsJJQeOIv7+hDSq8g==",
+ "license": "Apache-2.0",
+ "dependencies": {
+ "@opentelemetry/api": "^1.4.0",
+ "tdigest": "^0.1.1"
+ },
+ "engines": {
+ "node": "^16 || ^18 || >=20"
+ }
+ },
"node_modules/prop-types": {
"version": "15.8.1",
"resolved": "https://registry.npmjs.org/prop-types/-/prop-types-15.8.1.tgz",
@@ -13821,6 +13934,15 @@
"integrity": "sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==",
"license": "ISC"
},
+ "node_modules/tdigest": {
+ "version": "0.1.2",
+ "resolved": "https://registry.npmjs.org/tdigest/-/tdigest-0.1.2.tgz",
+ "integrity": "sha512-+G0LLgjjo9BZX2MfdvPfH+MKLCrxlXSYec5DaPYP1fe6Iyhf0/fSmJ0bFiZ1F8BT6cGXl2LpltQptzjXKWEkKA==",
+ "license": "MIT",
+ "dependencies": {
+ "bintrees": "1.0.2"
+ }
+ },
"node_modules/text-hex": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/text-hex/-/text-hex-1.0.0.tgz",
@@ -14254,7 +14376,6 @@
"version": "7.16.0",
"resolved": "https://registry.npmjs.org/undici-types/-/undici-types-7.16.0.tgz",
"integrity": "sha512-Zz+aZWSj8LE6zoxD+xrjh4VfkIG8Ya6LvYkZqtUQGJPZjYl53ypCaUwWqo7eI0x66KBGeRo+mlBEkMSeSZ38Nw==",
- "devOptional": true,
"license": "MIT"
},
"node_modules/unified": {
@@ -14393,6 +14514,15 @@
"punycode": "^2.1.0"
}
},
+ "node_modules/url-value-parser": {
+ "version": "2.2.0",
+ "resolved": "https://registry.npmjs.org/url-value-parser/-/url-value-parser-2.2.0.tgz",
+ "integrity": "sha512-yIQdxJpgkPamPPAPuGdS7Q548rLhny42tg8d4vyTNzFqvOnwqrgHXvgehT09U7fwrzxi3RxCiXjoNUNnNOlQ8A==",
+ "license": "MIT-0",
+ "engines": {
+ "node": ">=6.0.0"
+ }
+ },
"node_modules/use-callback-ref": {
"version": "1.3.3",
"resolved": "https://registry.npmjs.org/use-callback-ref/-/use-callback-ref-1.3.3.tgz",
diff --git a/packages/config/app-config/src/index.ts b/packages/config/app-config/src/index.ts
index 5ca2a6a..2006509 100644
--- a/packages/config/app-config/src/index.ts
+++ b/packages/config/app-config/src/index.ts
@@ -19,17 +19,31 @@ if (envPath) dotenv.config({ path: envPath });
export const DATABASE_URL = process.env.DATABASE_URL || "";
export const DB_POOL = {
- max: parseInt(process.env.DB_POOL_MAX || "10", 10),
+ max: parseInt(process.env.DB_POOL_MAX || "8", 10),
idleTimeoutMillis: parseInt(process.env.DB_IDLE_TIMEOUT || "10000", 10),
connectionTimeoutMillis: parseInt(process.env.DB_CONN_TIMEOUT || "2000", 10),
} as const;
// ─── JWT / Auth ──────────────────────────────────────────────────────────────
-export const JWT_SECRET =
- process.env.JWT_SECRET || "postly-secure-secret-key-2024";
-export const JWT_REFRESH_SECRET =
- process.env.JWT_REFRESH_SECRET || "postly-secure-refresh-secret-2024";
+export const JWT_SECRET: string =
+ process.env.JWT_SECRET ||
+ (() => {
+ if (process.env.NODE_ENV === "production" && !process.env.CI) {
+ throw new Error("FATAL: JWT_SECRET must be set in production");
+ }
+ return "postly-dev-only-secret-DO-NOT-USE-IN-PROD";
+ })();
+
+export const JWT_REFRESH_SECRET: string =
+ process.env.JWT_REFRESH_SECRET ||
+ (() => {
+ if (process.env.NODE_ENV === "production" && !process.env.CI) {
+ throw new Error("FATAL: JWT_REFRESH_SECRET must be set in production");
+ }
+ return "postly-dev-only-refresh-secret-DO-NOT-USE-IN-PROD";
+ })();
+
export const JWT_EXPIRES_IN = process.env.JWT_EXPIRES_IN || "7d";
export const JWT_REFRESH_EXPIRES_IN =
process.env.JWT_REFRESH_EXPIRES_IN || "30d";
diff --git a/scripts/add-hnsw-indexes.sql b/scripts/add-hnsw-indexes.sql
index d94c7bd..39f0e09 100644
--- a/scripts/add-hnsw-indexes.sql
+++ b/scripts/add-hnsw-indexes.sql
@@ -1,29 +1,54 @@
--- ──────────────────────────────────────────────────────────
--- HNSW Vector Indexes for pgvector
--- ──────────────────────────────────────────────────────────
--- Uses HNSW (Hierarchical Navigable Small World) instead of
--- IVFFlat for better recall and no training step required.
+-- ============================================================================
+-- HNSW Vector Index Migration for Postly
+-- ============================================================================
+-- Purpose: Add HNSW indexes to all embedding columns for fast approximate
+-- nearest neighbor (ANN) search. Without these, pgvector performs
+-- a full sequential scan for every similarity query.
--
--- m=16, ef_construction=64 is a good balance for <100k rows.
--- All embedding columns are 1024 dimensions (Voyage AI).
--- ──────────────────────────────────────────────────────────
+-- Safety: CONCURRENTLY ensures NO table locks — reads and writes continue
+-- uninterrupted during index creation. Can be run on a live database.
+--
+-- Performance Notes:
+-- - m = 16: good balance of recall vs. index size
+-- - ef_construction = 64: higher = better recall, slower build
+-- - Build time: ~1-5 min per 100k rows depending on VPS CPU
+-- - CPU will spike during build — run during low-traffic hours
+--
+-- Usage:
+-- docker exec -i postly-postgres psql -U postly -d postly < scripts/add-hnsw-indexes.sql
+--
+-- Rollback:
+-- DROP INDEX CONCURRENTLY IF EXISTS idx_jobs_embedding_hnsw;
+-- DROP INDEX CONCURRENTLY IF EXISTS idx_resumes_embedding_hnsw;
+-- DROP INDEX CONCURRENTLY IF EXISTS idx_seeker_profiles_embedding_hnsw;
+-- DROP INDEX CONCURRENTLY IF EXISTS idx_employer_profiles_embedding_hnsw;
+-- ============================================================================
+
+-- Ensure pgvector extension is available
+CREATE EXTENSION IF NOT EXISTS vector;
+
+-- Jobs table — primary vector search target for job matching
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_jobs_embedding_hnsw
+ ON jobs USING hnsw (embedding vector_cosine_ops)
+ WITH (m = 16, ef_construction = 64);
--- 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);
+-- Resumes table — used for resume-to-job matching
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_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);
+-- Seeker profiles — used for employer-side candidate search
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_seeker_profiles_embedding_hnsw
+ ON seeker_profiles 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);
+-- Employer profiles — used for matching employers to seekers
+CREATE INDEX CONCURRENTLY IF NOT EXISTS idx_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);
+-- Verification: List all HNSW indexes
+SELECT indexname, tablename, indexdef
+FROM pg_indexes
+WHERE indexdef LIKE '%hnsw%'
+ORDER BY tablename;