Initial commit: Korrith website, assessment API, PocketBase schema, Docker/Coolify deployment

This commit is contained in:
Korrith
2026-09-17 20:47:51 +00:00
commit c17f11f591
248 changed files with 39721 additions and 0 deletions
+19
View File
@@ -0,0 +1,19 @@
# API environment — copy to apps/api/.env for local development.
PORT=3001
CORS_ORIGIN=*
# PocketBase runs on localhost:8090 (fixed). In docker-compose the API reaches
# it automatically via a loopback forwarder — no configuration needed.
PB_SUPERUSER_EMAIL=admin@example.com
PB_SUPERUSER_PASSWORD=change-me-strong-password
# SMTP (assessment confirmation + admin notification emails)
SMTP_HOST=smtp.example.com
SMTP_PORT=587
SMTP_USER=smtp@example.com
SMTP_PASSWORD=change-me
# Optional overrides
# MAIL_FROM=smtp@example.com
# MAIL_REPLY_TO=noreply@example.com
# ADMIN_EMAILS=hello@example.com,ops@example.com
+17
View File
@@ -0,0 +1,17 @@
# Express API — build context must be the REPO ROOT (npm workspaces).
FROM node:22-alpine
WORKDIR /app
# Install workspace dependencies first for better layer caching.
COPY package.json package-lock.json ./
COPY apps/api/package.json ./apps/api/package.json
RUN npm ci --omit=dev --workspace apps/api
COPY apps/api ./apps/api
WORKDIR /app/apps/api
ENV NODE_ENV=production
# Environment variables are injected by Coolify / docker-compose — no .env file needed.
EXPOSE 3001
CMD ["node", "docker-entrypoint.js"]
+30
View File
@@ -0,0 +1,30 @@
// Docker-only entrypoint.
// The API's PocketBase client targets http://localhost:8090 (fixed in a
// platform-managed file). In docker-compose, PocketBase runs in its own
// container, so forward localhost:8090 -> the pocketbase service before
// starting the API. Local development (npm run dev) does not use this file.
import net from 'node:net';
import { spawn } from 'node:child_process';
const UPSTREAM_HOST = process.env.PB_UPSTREAM_HOST || 'pocketbase';
const UPSTREAM_PORT = 8090;
function forward(socket) {
const upstream = net.connect(UPSTREAM_PORT, UPSTREAM_HOST);
socket.pipe(upstream).pipe(socket);
socket.on('error', () => socket.destroy());
upstream.on('error', () => socket.destroy());
}
function bind(host) {
const server = net.createServer(forward);
server.on('error', () => {}); // e.g. IPv6 unavailable in the container
server.listen(UPSTREAM_PORT, host);
}
bind('127.0.0.1');
bind('::1');
const child = spawn('node', ['src/main.js'], { stdio: 'inherit', env: process.env });
child.on('exit', (code) => process.exit(code ?? 0));
+35
View File
@@ -0,0 +1,35 @@
import js from '@eslint/js';
import importPlugin from 'eslint-plugin-import';
import globals from 'globals';
export default [
{ ignores: ['node_modules/**'] },
js.configs.recommended,
{
files: ['**/*.js'],
plugins: { import: importPlugin },
languageOptions: {
ecmaVersion: 'latest',
sourceType: 'module',
globals: {
...globals.node,
}
},
settings: {
'import/resolver': {
node: { extensions: ['.js'] },
},
},
rules: {
...importPlugin.flatConfigs.recommended.rules,
'no-console': 'off',
'no-unused-vars': 'off',
'no-undef': 'error',
'no-empty': ['error', { allowEmptyCatch: true }],
'import/no-named-as-default': 'off',
'import/no-named-as-default-member': 'off',
'import/no-self-import': 'error',
'import/no-cycle': 'off',
},
},
];
+27
View File
@@ -0,0 +1,27 @@
{
"name": "api",
"type": "module",
"version": "0.0.0",
"private": true,
"scripts": {
"dev": "node --trace-deprecation --env-file=.env src/main.js",
"start": "node --env-file=.env src/main.js",
"lint": "eslint . --quiet"
},
"dependencies": {
"cors": "^2.8.5",
"dotenv": "^17.4.2",
"express": "^5.0.1",
"express-rate-limit": "^8.3.1",
"helmet": "^8.0.0",
"morgan": "^1.10.0",
"nodemailer": "^6.9.0",
"pocketbase": "^0.27.0"
},
"devDependencies": {
"globals": "^15.14.0",
"@eslint/js": "^9.39.4",
"eslint": "^9.39.4",
"eslint-plugin-import": "^2.32.0"
}
}
+8
View File
@@ -0,0 +1,8 @@
const NodeEnv = {
Development: 'development',
Production: 'production',
};
const BodyLimit = 1024 * 1024 * 20;
export { NodeEnv, BodyLimit };
+67
View File
@@ -0,0 +1,67 @@
import express from 'express';
import cors from 'cors';
import helmet from 'helmet';
import morgan from 'morgan';
import routes from './routes/index.js';
import { errorMiddleware } from './middleware/error.js';
import { globalRateLimit } from './middleware/global-rate-limit.js';
import logger from './utils/logger.js';
import { BodyLimit } from './constants/common.js';
const app = express();
app.set('trust proxy', true);
process.on('uncaughtException', (error) => {
logger.error('Uncaught exception:', error);
});
process.on('unhandledRejection', (reason, promise) => {
logger.error('Unhandled rejection at:', promise, 'reason:', reason);
});
process.on('SIGINT', async () => {
logger.info('Interrupted');
process.exit(0);
});
process.on('SIGTERM', async () => {
logger.info('SIGTERM signal received');
await new Promise(resolve => setTimeout(resolve, 3000));
logger.info('Exiting');
process.exit();
});
app.use(helmet());
app.use(cors({
origin: process.env.CORS_ORIGIN,
credentials: true,
}));
app.use(morgan('combined'));
app.use(globalRateLimit);
app.use(express.json({
limit: BodyLimit,
}));
app.use(express.urlencoded({
extended: true,
limit: BodyLimit,
}));
app.use('/', routes());
app.use(errorMiddleware);
app.use((req, res) => {
res.status(404).json({ error: 'Route not found' });
});
const port = process.env.PORT || 3001;
app.listen(port, () => {
logger.info(`🚀 API Server running on http://localhost:${port}`);
});
export default app;
+24
View File
@@ -0,0 +1,24 @@
import logger from '../utils/logger.js';
import { NodeEnv } from '../constants/common.js';
const errorMiddleware = (err, req, res, next) => {
logger.error(err.message, err.stack);
if (res.headersSent) {
return next(err);
}
res.status(500).json({
message: 'Something went wrong!',
...(process.env.NODE_ENV !== NodeEnv.Production && {
error: {
name: err.name,
message: err.message,
stack: err.stack,
},
}),
});
};
export default errorMiddleware;
export { errorMiddleware };
@@ -0,0 +1,10 @@
import rateLimit from 'express-rate-limit';
export const globalRateLimit = rateLimit({
windowMs: 5 * 60 * 1000,
max: 100,
standardHeaders: true,
legacyHeaders: false,
message: { error: 'Too many requests, please try again later' },
validate: { trustProxy: false },
});
+247
View File
@@ -0,0 +1,247 @@
import express from 'express';
import pb from '../utils/pocketbaseClient.js';
import { sendEmail } from '../utils/mailService.js';
import logger from '../utils/logger.js';
const router = express.Router();
const ADMIN_EMAILS = (process.env.ADMIN_EMAILS || 'hello@korrith.com,smtp@korrith.com')
.split(',')
.map((address) => address.trim())
.filter(Boolean);
router.post('/submit', async (req, res) => {
const { firstName, lastName, email, industry, websites, applications, systems } = req.body;
// Validate required fields
if (!firstName || !lastName || !email) {
return res.status(400).json({
error: 'Missing required fields: firstName, lastName, and email are required'
});
}
// Validate industry is provided
if (!industry) {
return res.status(400).json({
error: 'Industry selection is required'
});
}
// Log the exact request body being sent to PocketBase
logger.info('Assessment submission received', {
firstName,
lastName,
email,
industry,
websites,
applications,
systems
});
// Prepare the data object for PocketBase
const assessmentData = {
firstName,
lastName,
email,
industry,
websites: websites || '',
applications: applications || '',
systems: systems || ''
};
logger.info('Sending to PocketBase with data:', assessmentData);
// Create assessment record in PocketBase
// If validation fails, PocketBase will throw an error with details
const record = await pb.collection('assessments').create(assessmentData, { $autoCancel: false });
logger.info('Assessment record created successfully', { id: record.id });
// Build client confirmation email body
const clientHtmlBody = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #f8f9fa; padding: 20px; border-radius: 5px; margin-bottom: 20px; }
.header h1 { margin: 0; color: #2c3e50; font-size: 24px; }
.section { margin-bottom: 20px; }
.section-title { font-weight: bold; color: #2c3e50; margin-bottom: 10px; font-size: 16px; }
.field { margin-bottom: 10px; }
.field-label { font-weight: bold; color: #555; }
.field-value { color: #333; margin-left: 10px; }
.footer { border-top: 1px solid #ddd; padding-top: 20px; margin-top: 30px; font-size: 12px; color: #666; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>Assessment Submission Received</h1>
</div>
<div class="section">
<p>Dear ${firstName},</p>
<p>Thank you for submitting your assessment. We have received your submission and will review it shortly.</p>
</div>
<div class="section">
<div class="section-title">Submission Summary</div>
<div class="field">
<span class="field-label">Industry:</span>
<span class="field-value">${industry}</span>
</div>
${websites ? `
<div class="field">
<span class="field-label">Websites:</span>
<span class="field-value">${websites}</span>
</div>
` : ''}
${applications ? `
<div class="field">
<span class="field-label">Applications:</span>
<span class="field-value">${applications}</span>
</div>
` : ''}
${systems ? `
<div class="field">
<span class="field-label">Systems:</span>
<span class="field-value">${systems}</span>
</div>
` : ''}
</div>
<div class="section">
<p>We will be in touch with you soon regarding your assessment.</p>
</div>
<div class="footer">
<p>This is an automated email from the Assessment System. Please do not reply to this email.</p>
</div>
</div>
</body>
</html>
`;
// Send confirmation email to client
logger.info('Sending confirmation email to client', {
recipient: email,
subject: 'Assessment Submission Confirmation',
timestamp: new Date().toISOString()
});
await sendEmail(email, 'Assessment Submission Confirmation', clientHtmlBody);
logger.info('Client confirmation email sent successfully', {
recipient: email,
timestamp: new Date().toISOString()
});
// Build admin notification email body
const adminHtmlBody = `
<!DOCTYPE html>
<html>
<head>
<meta charset="UTF-8">
<style>
body { font-family: Arial, sans-serif; line-height: 1.6; color: #333; }
.container { max-width: 600px; margin: 0 auto; padding: 20px; }
.header { background-color: #f8f9fa; padding: 20px; border-radius: 5px; margin-bottom: 20px; }
.header h1 { margin: 0; color: #2c3e50; font-size: 24px; }
.section { margin-bottom: 20px; }
.section-title { font-weight: bold; color: #2c3e50; margin-bottom: 10px; font-size: 16px; }
.field { margin-bottom: 10px; }
.field-label { font-weight: bold; color: #555; }
.field-value { color: #333; margin-left: 10px; }
.footer { border-top: 1px solid #ddd; padding-top: 20px; margin-top: 30px; font-size: 12px; color: #666; }
</style>
</head>
<body>
<div class="container">
<div class="header">
<h1>New Assessment Submission</h1>
</div>
<div class="section">
<p>A new assessment has been submitted and requires review.</p>
</div>
<div class="section">
<div class="section-title">Submission Details</div>
<div class="field">
<span class="field-label">Name:</span>
<span class="field-value">${firstName} ${lastName}</span>
</div>
<div class="field">
<span class="field-label">Email:</span>
<span class="field-value">${email}</span>
</div>
<div class="field">
<span class="field-label">Industry:</span>
<span class="field-value">${industry}</span>
</div>
${websites ? `
<div class="field">
<span class="field-label">Websites:</span>
<span class="field-value">${websites}</span>
</div>
` : ''}
${applications ? `
<div class="field">
<span class="field-label">Applications:</span>
<span class="field-value">${applications}</span>
</div>
` : ''}
${systems ? `
<div class="field">
<span class="field-label">Systems:</span>
<span class="field-value">${systems}</span>
</div>
` : ''}
<div class="field">
<span class="field-label">Record ID:</span>
<span class="field-value">${record.id}</span>
</div>
<div class="field">
<span class="field-label">Submitted:</span>
<span class="field-value">${new Date().toISOString()}</span>
</div>
</div>
<div class="section">
<p>Please review this assessment in the admin panel.</p>
</div>
<div class="footer">
<p>This is an automated email from the Assessment System. Please do not reply to this email.</p>
</div>
</div>
</body>
</html>
`;
// Send notification email to admin addresses
logger.info('Sending notification email to admin addresses', {
recipients: ADMIN_EMAILS.join(', '),
subject: 'New Assessment Submission',
timestamp: new Date().toISOString()
});
await sendEmail(ADMIN_EMAILS, 'New Assessment Submission', adminHtmlBody);
logger.info('Admin notification email sent successfully', {
recipients: ADMIN_EMAILS.join(', '),
timestamp: new Date().toISOString()
});
// Return success response
res.status(201).json({
success: true,
message: 'Assessment request submitted successfully',
id: record.id
});
});
export default router;
+11
View File
@@ -0,0 +1,11 @@
import express from 'express';
const router = express.Router();
router.get('/', async (req, res) => {
res.json({
status: 'ok',
});
});
export default router;
+10
View File
@@ -0,0 +1,10 @@
import express from 'express';
import healthCheckRoutes from './health-check.js';
import assessmentRoutes from './assessment.js';
export default function routes() {
const router = express.Router();
router.use('/health-check', healthCheckRoutes);
router.use('/assessment', assessmentRoutes);
return router;
}
+31
View File
@@ -0,0 +1,31 @@
// Helpers for third-party integrations that need a USER-SUPPLIED secret
// (Stripe, PayPal, Twilio, TMDB, ...). A missing user key is NOT a server
// error: do NOT throw it. errorMiddleware would turn a throw into a 500 the
// user cannot fix, and the site would retry it on every page load. Report
// "not configured yet" instead, so the UI can show a setup state and the user
// can add the key.
export const INTEGRATION_NOT_CONFIGURED = 'INTEGRATION_NOT_CONFIGURED';
// True only when at least one key is listed and every listed env var holds a
// non-empty, non-whitespace value. Fails closed: an unset, blank, or
// whitespace-only key (or no keys at all) reads as "not configured".
export function isIntegrationConfigured(...envKeys) {
return envKeys.length > 0 && envKeys.every((key) => String(process.env[key] ?? '').trim() !== '');
}
// Canonical response for a missing user-supplied secret. 503 = the feature is
// unavailable until the key is provided. The INTEGRATION_NOT_CONFIGURED marker
// tells the platform this is an expected setup state, not a bug to auto-fix.
export function respondNotConfigured(res, { integration, envKeys }) {
const keys = Array.isArray(envKeys) ? envKeys : [envKeys];
return res.status(503).json({
error: INTEGRATION_NOT_CONFIGURED,
integration,
envKeys: keys,
message: `${integration} is not set up yet. Add the required key(s) to enable this feature.`,
});
}
export default { INTEGRATION_NOT_CONFIGURED, isIntegrationConfigured, respondNotConfigured };
+28
View File
@@ -0,0 +1,28 @@
const logger = {
// Application errors - goes to stdout
error: (...args) => {
console.log('[ERROR]', ...args);
},
// Critical system errors - goes to stderr
fatal: (...args) => {
console.error('[FATAL]', ...args);
},
info: (...args) => {
console.log('[INFO]', ...args);
},
debug: (...args) => {
console.log('[DEBUG]', ...args);
},
warn: (...args) => {
console.log('[WARN]', ...args);
}
};
export default logger;
export { logger };
+73
View File
@@ -0,0 +1,73 @@
import 'dotenv/config';
import nodemailer from 'nodemailer';
import logger from './logger.js';
const FROM_EMAIL = process.env.MAIL_FROM || 'smtp@korrith.com';
const REPLY_TO_EMAIL = process.env.MAIL_REPLY_TO || 'noreply@korrith.com';
const smtpPort = parseInt(process.env.SMTP_PORT, 10) || 587;
// Initialize nodemailer transporter with SMTP credentials
const transporter = nodemailer.createTransport({
host: process.env.SMTP_HOST,
port: smtpPort,
secure: smtpPort === 465, // SSL on port 465, STARTTLS otherwise (e.g. 587)
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASSWORD,
},
});
/**
* Send email using nodemailer with SMTP
* @param {string|string[]} to - Recipient email address(es)
* @param {string} subject - Email subject
* @param {string} htmlBody - HTML email body
* @returns {Promise<Object>} - Mail service response
* @throws {Error} - If email sending fails
*/
async function sendEmail(to, subject, htmlBody) {
const recipients = Array.isArray(to) ? to : [to];
logger.info('Attempting to send email via SMTP', {
recipients: recipients.join(', '),
subject,
from: FROM_EMAIL,
timestamp: new Date().toISOString()
});
try {
const mailOptions = {
from: FROM_EMAIL,
to: recipients.join(', '),
subject,
html: htmlBody,
replyTo: REPLY_TO_EMAIL,
};
const result = await transporter.sendMail(mailOptions);
logger.info('Email sent successfully via SMTP', {
recipients: recipients.join(', '),
subject,
from: FROM_EMAIL,
messageId: result.messageId,
timestamp: new Date().toISOString()
});
return result;
} catch (error) {
logger.error('Email send failed', {
recipients: recipients.join(', '),
subject,
from: FROM_EMAIL,
errorMessage: error.message,
timestamp: new Date().toISOString()
});
throw new Error(`SMTP email error: ${error.message}`);
}
}
export { sendEmail };
export default sendEmail;
+84
View File
@@ -0,0 +1,84 @@
import Pocketbase from 'pocketbase';
import logger from './logger.js';
const POCKETBASE_HOST = `http://localhost:8090`;
async function waitForHealth({ retries = 10, delayMs = 1000 } = {}) {
for (let i = 1; i <= retries; i++) {
try {
const response = await fetch(`${POCKETBASE_HOST}/api/health`, { method: 'HEAD' });
if (response.ok) {
return;
}
} catch {
// PocketBase not reachable yet; retry below
}
logger.warn(`PocketBase not ready, retrying (${i}/${retries})...`);
await new Promise((r) => setTimeout(r, delayMs));
}
throw new Error(`PocketBase health check failed after ${retries} retries`);
}
const pocketbaseClient = new Pocketbase(POCKETBASE_HOST);
pocketbaseClient.autoCancellation(false);
let authPromise = null;
pocketbaseClient.beforeSend = async function (url, options) {
if (url.includes('/api/collections/_superusers/auth-with-password')) {
return { url, options };
}
if (!pocketbaseClient.authStore.isValid && !authPromise) {
authPromise = pocketbaseClient.collection('_superusers').authWithPassword(
process.env.PB_SUPERUSER_EMAIL,
process.env.PB_SUPERUSER_PASSWORD,
).finally(() => {
authPromise = null;
});
}
if (authPromise) {
await authPromise;
}
if (pocketbaseClient.authStore.isValid && pocketbaseClient.authStore.token) {
options.headers = options.headers || {};
options.headers['Authorization'] = pocketbaseClient.authStore.token;
}
return { url, options };
};
(async () => {
try {
await waitForHealth();
if (!pocketbaseClient.authStore.isValid && !authPromise) {
authPromise = pocketbaseClient.collection('_superusers').authWithPassword(
process.env.PB_SUPERUSER_EMAIL,
process.env.PB_SUPERUSER_PASSWORD,
).finally(() => {
authPromise = null;
});
}
if (authPromise) {
await authPromise;
}
logger.info('PocketBase client initialized successfully');
} catch (err) {
logger.error('Failed to initialize PocketBase client:', err);
process.exit(1);
}
})();
export default pocketbaseClient;
export { pocketbaseClient };