Migrate from JSON to PostgreSQL, update SLA to 99.5%

- Replace JSON file storage with PostgreSQL (pg package)
- Add db.ts service for connection pool and schema init
- Rewrite keys.ts, verification.ts, usage.ts for async PostgreSQL
- Update all routes for async function signatures
- Add migration script (scripts/migrate-to-postgres.mjs)
- Update docker-compose.yml with DATABASE_* env vars
- Change SLA from 99.9% to 99.5% in landing page
This commit is contained in:
DocFast Bot 2026-02-15 10:18:25 +00:00
parent bb1881af61
commit e9d16bf2a3
13 changed files with 395 additions and 198 deletions

65
src/services/db.ts Normal file
View file

@ -0,0 +1,65 @@
import pg from "pg";
const { Pool } = pg;
const pool = new Pool({
host: process.env.DATABASE_HOST || "172.17.0.1",
port: parseInt(process.env.DATABASE_PORT || "5432", 10),
database: process.env.DATABASE_NAME || "docfast",
user: process.env.DATABASE_USER || "docfast",
password: process.env.DATABASE_PASSWORD || "docfast",
max: 10,
idleTimeoutMillis: 30000,
});
pool.on("error", (err) => {
console.error("Unexpected PostgreSQL pool error:", err);
});
export async function initDatabase(): Promise<void> {
const client = await pool.connect();
try {
await client.query(`
CREATE TABLE IF NOT EXISTS api_keys (
key TEXT PRIMARY KEY,
tier TEXT NOT NULL DEFAULT 'free',
email TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
stripe_customer_id TEXT
);
CREATE INDEX IF NOT EXISTS idx_api_keys_email ON api_keys(email);
CREATE INDEX IF NOT EXISTS idx_api_keys_stripe ON api_keys(stripe_customer_id);
CREATE TABLE IF NOT EXISTS verifications (
id SERIAL PRIMARY KEY,
email TEXT NOT NULL,
token TEXT NOT NULL UNIQUE,
api_key TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
verified_at TIMESTAMPTZ
);
CREATE INDEX IF NOT EXISTS idx_verifications_email ON verifications(email);
CREATE INDEX IF NOT EXISTS idx_verifications_token ON verifications(token);
CREATE TABLE IF NOT EXISTS pending_verifications (
email TEXT PRIMARY KEY,
code TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
expires_at TIMESTAMPTZ NOT NULL,
attempts INT NOT NULL DEFAULT 0
);
CREATE TABLE IF NOT EXISTS usage (
key TEXT PRIMARY KEY,
count INT NOT NULL DEFAULT 0,
month_key TEXT NOT NULL
);
`);
console.log("PostgreSQL tables initialized");
} finally {
client.release();
}
}
export { pool };
export default pool;

View file

@ -1,11 +1,5 @@
import { randomBytes } from "crypto";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, "../../data");
const KEYS_FILE = path.join(DATA_DIR, "keys.json");
import pool from "./db.js";
export interface ApiKey {
key: string;
@ -15,47 +9,48 @@ export interface ApiKey {
stripeCustomerId?: string;
}
interface KeyStore {
keys: ApiKey[];
}
// In-memory cache for fast lookups, synced with PostgreSQL
let keysCache: ApiKey[] = [];
let store: KeyStore = { keys: [] };
function ensureDataDir(): void {
if (!existsSync(DATA_DIR)) {
mkdirSync(DATA_DIR, { recursive: true });
export async function loadKeys(): Promise<void> {
try {
const result = await pool.query(
"SELECT key, tier, email, created_at, stripe_customer_id FROM api_keys"
);
keysCache = result.rows.map((r) => ({
key: r.key,
tier: r.tier as "free" | "pro",
email: r.email,
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
stripeCustomerId: r.stripe_customer_id || undefined,
}));
} catch (err) {
console.error("Failed to load keys from PostgreSQL:", err);
keysCache = [];
}
}
export function loadKeys(): void {
ensureDataDir();
if (existsSync(KEYS_FILE)) {
try {
store = JSON.parse(readFileSync(KEYS_FILE, "utf-8"));
} catch {
store = { keys: [] };
}
}
// Also load seed keys from env
const envKeys = process.env.API_KEYS?.split(",").map((k) => k.trim()).filter(Boolean) || [];
for (const k of envKeys) {
if (!store.keys.find((e) => e.key === k)) {
store.keys.push({ key: k, tier: "pro", email: "seed@docfast.dev", createdAt: new Date().toISOString() });
if (!keysCache.find((e) => e.key === k)) {
const entry: ApiKey = { key: k, tier: "pro", email: "seed@docfast.dev", createdAt: new Date().toISOString() };
keysCache.push(entry);
// Upsert into DB
await pool.query(
`INSERT INTO api_keys (key, tier, email, created_at) VALUES ($1, $2, $3, $4)
ON CONFLICT (key) DO NOTHING`,
[k, "pro", "seed@docfast.dev", new Date().toISOString()]
).catch(() => {});
}
}
}
function save(): void {
ensureDataDir();
writeFileSync(KEYS_FILE, JSON.stringify(store, null, 2));
}
export function isValidKey(key: string): boolean {
return store.keys.some((k) => k.key === key);
return keysCache.some((k) => k.key === key);
}
export function getKeyInfo(key: string): ApiKey | undefined {
return store.keys.find((k) => k.key === key);
return keysCache.find((k) => k.key === key);
}
export function isProKey(key: string): boolean {
@ -67,10 +62,9 @@ function generateKey(prefix: string): string {
return `${prefix}_${randomBytes(24).toString("hex")}`;
}
export function createFreeKey(email?: string): ApiKey {
// If email provided, check if it already has a free key
export async function createFreeKey(email?: string): Promise<ApiKey> {
if (email) {
const existing = store.keys.find((k) => k.email === email && k.tier === "free");
const existing = keysCache.find((k) => k.email === email && k.tier === "free");
if (existing) return existing;
}
@ -80,16 +74,20 @@ export function createFreeKey(email?: string): ApiKey {
email: email || "",
createdAt: new Date().toISOString(),
};
store.keys.push(entry);
save();
await pool.query(
"INSERT INTO api_keys (key, tier, email, created_at) VALUES ($1, $2, $3, $4)",
[entry.key, entry.tier, entry.email, entry.createdAt]
);
keysCache.push(entry);
return entry;
}
export function createProKey(email: string, stripeCustomerId: string): ApiKey {
const existing = store.keys.find((k) => k.stripeCustomerId === stripeCustomerId);
export async function createProKey(email: string, stripeCustomerId: string): Promise<ApiKey> {
const existing = keysCache.find((k) => k.stripeCustomerId === stripeCustomerId);
if (existing) {
existing.tier = "pro";
save();
await pool.query("UPDATE api_keys SET tier = 'pro' WHERE key = $1", [existing.key]);
return existing;
}
@ -100,29 +98,34 @@ export function createProKey(email: string, stripeCustomerId: string): ApiKey {
createdAt: new Date().toISOString(),
stripeCustomerId,
};
store.keys.push(entry);
save();
await pool.query(
"INSERT INTO api_keys (key, tier, email, created_at, stripe_customer_id) VALUES ($1, $2, $3, $4, $5)",
[entry.key, entry.tier, entry.email, entry.createdAt, entry.stripeCustomerId]
);
keysCache.push(entry);
return entry;
}
export function revokeByCustomer(stripeCustomerId: string): boolean {
const idx = store.keys.findIndex((k) => k.stripeCustomerId === stripeCustomerId);
export async function revokeByCustomer(stripeCustomerId: string): Promise<boolean> {
const idx = keysCache.findIndex((k) => k.stripeCustomerId === stripeCustomerId);
if (idx >= 0) {
store.keys.splice(idx, 1);
save();
const key = keysCache[idx].key;
keysCache.splice(idx, 1);
await pool.query("DELETE FROM api_keys WHERE key = $1", [key]);
return true;
}
return false;
}
export function getAllKeys(): ApiKey[] {
return [...store.keys];
return [...keysCache];
}
export function updateKeyEmail(apiKey: string, newEmail: string): boolean {
const entry = store.keys.find(k => k.key === apiKey);
export async function updateKeyEmail(apiKey: string, newEmail: string): Promise<boolean> {
const entry = keysCache.find((k) => k.key === apiKey);
if (!entry) return false;
entry.email = newEmail;
save();
await pool.query("UPDATE api_keys SET email = $1 WHERE key = $2", [newEmail, apiKey]);
return true;
}

View file

@ -1,11 +1,5 @@
import { randomBytes, randomInt } from "crypto";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "fs";
import path from "path";
import { fileURLToPath } from "url";
const __dirname = path.dirname(fileURLToPath(import.meta.url));
const DATA_DIR = path.join(__dirname, "../../data");
const DB_PATH = path.join(DATA_DIR, "verifications.json");
import pool from "./db.js";
export interface Verification {
email: string;
@ -23,79 +17,68 @@ export interface PendingVerification {
attempts: number;
}
let verifications: Verification[] = [];
let pendingVerifications: PendingVerification[] = [];
function ensureDataDir(): void {
if (!existsSync(DATA_DIR)) mkdirSync(DATA_DIR, { recursive: true });
}
function load(): void {
ensureDataDir();
if (existsSync(DB_PATH)) {
try {
const data = JSON.parse(readFileSync(DB_PATH, "utf-8"));
// Support both old format (array) and new format (object)
if (Array.isArray(data)) {
verifications = data;
pendingVerifications = [];
} else {
verifications = data.verifications || [];
pendingVerifications = data.pendingVerifications || [];
}
} catch {
verifications = [];
pendingVerifications = [];
}
}
}
function save(): void {
ensureDataDir();
writeFileSync(DB_PATH, JSON.stringify({ verifications, pendingVerifications }, null, 2));
}
load();
const TOKEN_EXPIRY_MS = 24 * 60 * 60 * 1000;
const CODE_EXPIRY_MS = 15 * 60 * 1000; // 15 minutes
const CODE_EXPIRY_MS = 15 * 60 * 1000;
const MAX_ATTEMPTS = 3;
// Legacy token-based verification (keep for existing links)
export function createVerification(email: string, apiKey: string): Verification {
const existing = verifications.find(v => v.email === email && !v.verifiedAt);
if (existing) {
const age = Date.now() - new Date(existing.createdAt).getTime();
if (age < TOKEN_EXPIRY_MS) return existing;
verifications = verifications.filter(v => v !== existing);
export async function createVerification(email: string, apiKey: string): Promise<Verification> {
// Check for existing unexpired, unverified
const existing = await pool.query(
"SELECT * FROM verifications WHERE email = $1 AND verified_at IS NULL AND created_at > NOW() - INTERVAL '24 hours' LIMIT 1",
[email]
);
if (existing.rows.length > 0) {
const r = existing.rows[0];
return { email: r.email, token: r.token, apiKey: r.api_key, createdAt: r.created_at.toISOString(), verifiedAt: null };
}
const verification: Verification = {
email,
token: randomBytes(32).toString("hex"),
apiKey,
createdAt: new Date().toISOString(),
verifiedAt: null,
};
verifications.push(verification);
save();
return verification;
// Remove old unverified
await pool.query("DELETE FROM verifications WHERE email = $1 AND verified_at IS NULL", [email]);
const token = randomBytes(32).toString("hex");
const now = new Date().toISOString();
await pool.query(
"INSERT INTO verifications (email, token, api_key, created_at) VALUES ($1, $2, $3, $4)",
[email, token, apiKey, now]
);
return { email, token, apiKey, createdAt: now, verifiedAt: null };
}
export function verifyToken(token: string): { status: "ok"; verification: Verification } | { status: "invalid" | "expired" | "already_verified"; verification?: Verification } {
const v = verifications.find(v => v.token === token);
// Synchronous wrapper — we'll make it async-compatible
// Actually need to keep sync for the GET /verify route. Use sync query workaround or refactor.
// For simplicity, we'll cache verifications in memory too.
return verifyTokenSync(token);
}
// In-memory cache for verifications (loaded on startup, updated on changes)
let verificationsCache: Verification[] = [];
export async function loadVerifications(): Promise<void> {
const result = await pool.query("SELECT * FROM verifications");
verificationsCache = result.rows.map((r) => ({
email: r.email,
token: r.token,
apiKey: r.api_key,
createdAt: r.created_at instanceof Date ? r.created_at.toISOString() : r.created_at,
verifiedAt: r.verified_at ? (r.verified_at instanceof Date ? r.verified_at.toISOString() : r.verified_at) : null,
}));
}
function verifyTokenSync(token: string): { status: "ok"; verification: Verification } | { status: "invalid" | "expired" | "already_verified"; verification?: Verification } {
const v = verificationsCache.find((v) => v.token === token);
if (!v) return { status: "invalid" };
if (v.verifiedAt) return { status: "already_verified", verification: v };
const age = Date.now() - new Date(v.createdAt).getTime();
if (age > TOKEN_EXPIRY_MS) return { status: "expired" };
v.verifiedAt = new Date().toISOString();
save();
// Update DB async
pool.query("UPDATE verifications SET verified_at = $1 WHERE token = $2", [v.verifiedAt, token]).catch(console.error);
return { status: "ok", verification: v };
}
// New 6-digit code verification
export function createPendingVerification(email: string): PendingVerification {
// Remove any existing pending for this email
pendingVerifications = pendingVerifications.filter(p => p.email !== email);
export async function createPendingVerification(email: string): Promise<PendingVerification> {
await pool.query("DELETE FROM pending_verifications WHERE email = $1", [email]);
const now = new Date();
const pending: PendingVerification = {
@ -105,47 +88,53 @@ export function createPendingVerification(email: string): PendingVerification {
expiresAt: new Date(now.getTime() + CODE_EXPIRY_MS).toISOString(),
attempts: 0,
};
pendingVerifications.push(pending);
save();
await pool.query(
"INSERT INTO pending_verifications (email, code, created_at, expires_at, attempts) VALUES ($1, $2, $3, $4, $5)",
[pending.email, pending.code, pending.createdAt, pending.expiresAt, pending.attempts]
);
return pending;
}
export function verifyCode(email: string, code: string): { status: "ok" | "invalid" | "expired" | "max_attempts" } {
export async function verifyCode(email: string, code: string): Promise<{ status: "ok" | "invalid" | "expired" | "max_attempts" }> {
const cleanEmail = email.trim().toLowerCase();
const pending = pendingVerifications.find(p => p.email === cleanEmail);
const result = await pool.query("SELECT * FROM pending_verifications WHERE email = $1", [cleanEmail]);
const pending = result.rows[0];
if (!pending) return { status: "invalid" };
if (new Date() > new Date(pending.expiresAt)) {
pendingVerifications = pendingVerifications.filter(p => p !== pending);
save();
if (new Date() > new Date(pending.expires_at)) {
await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]);
return { status: "expired" };
}
if (pending.attempts >= MAX_ATTEMPTS) {
pendingVerifications = pendingVerifications.filter(p => p !== pending);
save();
await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]);
return { status: "max_attempts" };
}
pending.attempts++;
await pool.query("UPDATE pending_verifications SET attempts = attempts + 1 WHERE email = $1", [cleanEmail]);
if (pending.code !== code) {
save();
return { status: "invalid" };
}
// Success - remove pending
pendingVerifications = pendingVerifications.filter(p => p !== pending);
save();
await pool.query("DELETE FROM pending_verifications WHERE email = $1", [cleanEmail]);
return { status: "ok" };
}
export function isEmailVerified(email: string): boolean {
return verifications.some(v => v.email === email && v.verifiedAt !== null);
export async function isEmailVerified(email: string): Promise<boolean> {
const result = await pool.query(
"SELECT 1 FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1",
[email]
);
return result.rows.length > 0;
}
export function getVerifiedApiKey(email: string): string | null {
const v = verifications.find(v => v.email === email && v.verifiedAt !== null);
return v?.apiKey ?? null;
export async function getVerifiedApiKey(email: string): Promise<string | null> {
const result = await pool.query(
"SELECT api_key FROM verifications WHERE email = $1 AND verified_at IS NOT NULL LIMIT 1",
[email]
);
return result.rows[0]?.api_key ?? null;
}