feat: add server modules (audit, upload, email, content, tracking)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-26 11:59:00 -05:00
co-authored by Claude Sonnet 4.6
parent 649c324f37
commit 3669c0d4f8
5 changed files with 371 additions and 0 deletions
+20
View File
@@ -0,0 +1,20 @@
import { db } from './db';
import { auditLog } from './schema';
export async function logAudit(params: {
userId?: number;
action: string;
entity?: string;
entityId?: number;
diff?: Record<string, unknown>;
ipAddress?: string;
}): Promise<void> {
await db.insert(auditLog).values({
userId: params.userId ?? null,
action: params.action,
entity: params.entity ?? null,
entityId: params.entityId ?? null,
diff: params.diff ?? null,
ipAddress: params.ipAddress ?? null
});
}
+37
View File
@@ -0,0 +1,37 @@
import { db } from './db';
import { contentBlocks } from './schema';
import { eq, and } from 'drizzle-orm';
export async function getContentBlock(page: string, sectionKey: string): Promise<unknown | null> {
const result = await db
.select()
.from(contentBlocks)
.where(and(eq(contentBlocks.page, page), eq(contentBlocks.sectionKey, sectionKey)))
.limit(1);
if (result.length === 0) return null;
const content = result[0].content;
if (typeof content === 'string') {
try { return JSON.parse(content); } catch { return content; }
}
return content;
}
export async function getPageBlocks(page: string): Promise<Record<string, unknown>> {
const rows = await db
.select()
.from(contentBlocks)
.where(eq(contentBlocks.page, page));
const blocks: Record<string, unknown> = {};
for (const row of rows) {
const content = row.content;
if (typeof content === 'string') {
try { blocks[row.sectionKey] = JSON.parse(content); } catch { blocks[row.sectionKey] = content; }
} else {
blocks[row.sectionKey] = content;
}
}
return blocks;
}
+212
View File
@@ -0,0 +1,212 @@
import nodemailer from 'nodemailer';
function getTransporter() {
if (!process.env.SMTP_USER) return null;
return nodemailer.createTransport({
host: process.env.SMTP_HOST || 'smtp.gmail.com',
port: parseInt(process.env.SMTP_PORT || '587'),
secure: false,
auth: {
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS
}
});
}
const from = process.env.SMTP_FROM || 'noreply@example.com';
async function sendEmail(to: string, subject: string, html: string): Promise<void> {
const transporter = getTransporter();
if (!transporter) return;
await transporter.sendMail({ from, to, subject, html });
}
export async function sendApplicationNotification(
orgName: string,
staffEmail: string,
applicantName: string,
type: string,
petName?: string
): Promise<void> {
const subject = `[${orgName}] New ${type} application from ${applicantName}`;
const html = `
<h2>New ${type} Application</h2>
<p><strong>Applicant:</strong> ${applicantName}</p>
${petName ? `<p><strong>Pet:</strong> ${petName}</p>` : ''}
<p>Log in to your admin dashboard to review this application.</p>
`;
await sendEmail(staffEmail, subject, html);
}
export async function sendApplicationStatusUpdate(
orgName: string,
to: string,
applicantName: string,
type: string,
status: string,
siteUrl: string
): Promise<void> {
const subject = `[${orgName}] Your ${type} application has been ${status}`;
const html = `
<h2>Application Update</h2>
<p>Hi ${applicantName},</p>
<p>Your ${type} application has been <strong>${status}</strong>.</p>
<p>Visit <a href="${siteUrl}">${siteUrl}</a> for more information.</p>
`;
await sendEmail(to, subject, html);
}
export async function sendFosterWelcomeEmail(
orgName: string,
to: string,
name: string,
tempPassword: string,
loginUrl: string
): Promise<void> {
const subject = `[${orgName}] Welcome to the foster team!`;
const html = `
<h2>Welcome, ${name}!</h2>
<p>Your foster account has been created.</p>
<p><strong>Login URL:</strong> <a href="${loginUrl}">${loginUrl}</a></p>
<p><strong>Email:</strong> ${to}</p>
<p><strong>Temporary Password:</strong> ${tempPassword}</p>
<p>You will be asked to change your password on first login.</p>
`;
await sendEmail(to, subject, html);
}
export async function sendRegistrationNotification(
orgName: string,
staffEmail: string,
name: string,
email: string,
reason: string
): Promise<void> {
const subject = `[${orgName}] New registration request from ${name}`;
const html = `
<h2>New Registration Request</h2>
<p><strong>Name:</strong> ${name}</p>
<p><strong>Email:</strong> ${email}</p>
<p><strong>Reason:</strong> ${reason}</p>
<p>Log in to your admin dashboard to approve or deny this request.</p>
`;
await sendEmail(staffEmail, subject, html);
}
export async function sendRegistrationApproved(
orgName: string,
to: string,
name: string,
loginUrl: string
): Promise<void> {
const subject = `[${orgName}] Your account has been approved!`;
const html = `
<h2>Account Approved</h2>
<p>Hi ${name}, your account at ${orgName} has been approved.</p>
<p>Log in at: <a href="${loginUrl}">${loginUrl}</a></p>
`;
await sendEmail(to, subject, html);
}
export async function sendRegistrationDenied(
orgName: string,
to: string,
name: string
): Promise<void> {
const subject = `[${orgName}] Registration update`;
const html = `
<h2>Registration Update</h2>
<p>Hi ${name}, unfortunately your registration request at ${orgName} was not approved at this time.</p>
<p>If you believe this is an error, please contact us.</p>
`;
await sendEmail(to, subject, html);
}
export async function sendFosterSubmissionNotification(
orgName: string,
staffEmail: string,
fosterName: string,
petName: string,
type: string
): Promise<void> {
const subject = `[${orgName}] New foster ${type} for ${petName}`;
const html = `
<h2>New Foster Submission</h2>
<p><strong>Foster:</strong> ${fosterName}</p>
<p><strong>Pet:</strong> ${petName}</p>
<p><strong>Type:</strong> ${type}</p>
<p>Log in to your admin dashboard to review.</p>
`;
await sendEmail(staffEmail, subject, html);
}
export async function sendFosterReviewNotification(
orgName: string,
to: string,
fosterName: string,
petName: string,
approved: boolean
): Promise<void> {
const status = approved ? 'approved' : 'not approved';
const subject = `[${orgName}] Your submission for ${petName} was ${status}`;
const html = `
<h2>Submission Review</h2>
<p>Hi ${fosterName}, your submission for ${petName} has been <strong>${status}</strong>.</p>
`;
await sendEmail(to, subject, html);
}
export async function sendSupplyRequestNotification(
orgName: string,
staffEmail: string,
fosterName: string,
items: string,
urgency: string
): Promise<void> {
const urgentTag = urgency === 'high' ? '[URGENT] ' : '';
const subject = `${urgentTag}[${orgName}] Supply request from ${fosterName}`;
const html = `
<h2>Supply Request</h2>
<p><strong>Foster:</strong> ${fosterName}</p>
<p><strong>Items:</strong> ${items}</p>
<p><strong>Urgency:</strong> ${urgency}</p>
`;
await sendEmail(staffEmail, subject, html);
}
export async function sendNewsletter(
orgName: string,
to: string,
subject: string,
body: string,
siteUrl: string
): Promise<void> {
const html = `
${body}
<hr />
<p style="font-size: 12px; color: #999;">
Sent by ${orgName}. <a href="${siteUrl}">Visit our website</a>.
</p>
`;
await sendEmail(to, `[${orgName}] ${subject}`, html);
}
export async function sendContactNotification(
orgName: string,
staffEmail: string,
senderName: string,
senderEmail: string,
messageSubject: string,
message: string
): Promise<void> {
const subject = `[${orgName}] Contact form: ${messageSubject || 'New message'}`;
const html = `
<h2>Contact Form Message</h2>
<p><strong>From:</strong> ${senderName} (${senderEmail})</p>
<p><strong>Subject:</strong> ${messageSubject || 'N/A'}</p>
<p>${message}</p>
`;
await sendEmail(staffEmail, subject, html);
}
+36
View File
@@ -0,0 +1,36 @@
import { db } from './db';
import { pageViews } from './schema';
const BOT_PATTERNS = [
/bot/i, /crawl/i, /spider/i, /slurp/i, /mediapartners/i,
/googlebot/i, /bingbot/i, /yandex/i, /baidu/i, /duckduckbot/i,
/facebookexternalhit/i, /twitterbot/i, /linkedinbot/i,
/whatsapp/i, /telegrambot/i, /discordbot/i, /slack/i,
/semrush/i, /ahref/i, /mj12bot/i, /dotbot/i, /petalbot/i,
/bytespider/i, /gptbot/i, /claudebot/i, /anthropic/i,
/headlesschrome/i, /phantomjs/i, /selenium/i, /puppeteer/i,
/curl/i, /wget/i, /python-requests/i, /httpx/i, /axios/i, /node-fetch/i
];
function isBot(userAgent: string): boolean {
return BOT_PATTERNS.some((pattern) => pattern.test(userAgent));
}
export async function trackPageView(params: {
path: string;
petId?: number;
ipAddress?: string;
userAgent?: string;
referrer?: string;
}): Promise<void> {
const bot = params.userAgent ? isBot(params.userAgent) : false;
await db.insert(pageViews).values({
path: params.path,
petId: params.petId ?? null,
ipAddress: params.ipAddress ?? null,
userAgent: params.userAgent ?? null,
referrer: params.referrer ?? null,
isBot: bot
});
}
+66
View File
@@ -0,0 +1,66 @@
import fs from 'fs/promises';
import path from 'path';
import crypto from 'crypto';
const UPLOAD_DIR = process.env.UPLOAD_DIR || './uploads';
const MAX_SIZE = 10 * 1024 * 1024; // 10MB
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp', 'image/gif'];
const MAGIC_BYTES: Record<string, number[]> = {
'image/jpeg': [0xff, 0xd8, 0xff],
'image/png': [0x89, 0x50, 0x4e, 0x47],
'image/gif': [0x47, 0x49, 0x46],
'image/webp': [0x52, 0x49, 0x46, 0x46]
};
function validateMagicBytes(buffer: Buffer, mimeType: string): boolean {
const expected = MAGIC_BYTES[mimeType];
if (!expected) return false;
for (let i = 0; i < expected.length; i++) {
if (buffer[i] !== expected[i]) return false;
}
return true;
}
export async function saveUpload(
file: File,
subdir = 'general'
): Promise<{ url: string; path: string }> {
if (file.size > MAX_SIZE) {
throw new Error('File too large (max 10MB)');
}
if (!ALLOWED_TYPES.includes(file.type)) {
throw new Error('File type not allowed');
}
const buffer = Buffer.from(await file.arrayBuffer());
if (!validateMagicBytes(buffer, file.type)) {
throw new Error('File content does not match declared type');
}
const ext = file.name.split('.').pop()?.toLowerCase() || 'jpg';
const filename = `${crypto.randomUUID()}.${ext}`;
const dir = path.join(UPLOAD_DIR, subdir);
await fs.mkdir(dir, { recursive: true });
const filePath = path.join(dir, filename);
await fs.writeFile(filePath, buffer);
return {
url: `/uploads/${subdir}/${filename}`,
path: filePath
};
}
export async function deleteUpload(url: string): Promise<void> {
if (!url.startsWith('/uploads/')) return;
const filePath = path.join(UPLOAD_DIR, url.replace('/uploads/', ''));
try {
await fs.unlink(filePath);
} catch {
// File already gone
}
}