Initial commit: Korrith website, assessment API, PocketBase schema, Docker/Coolify deployment
This commit is contained in:
@@ -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
|
||||
@@ -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"]
|
||||
@@ -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));
|
||||
@@ -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',
|
||||
},
|
||||
},
|
||||
];
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
const NodeEnv = {
|
||||
Development: 'development',
|
||||
Production: 'production',
|
||||
};
|
||||
|
||||
const BodyLimit = 1024 * 1024 * 20;
|
||||
|
||||
export { NodeEnv, BodyLimit };
|
||||
@@ -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;
|
||||
@@ -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 },
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1,11 @@
|
||||
import express from 'express';
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get('/', async (req, res) => {
|
||||
res.json({
|
||||
status: 'ok',
|
||||
});
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
@@ -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 };
|
||||
|
||||
@@ -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;
|
||||
@@ -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 };
|
||||
Reference in New Issue
Block a user