feat: add server modules (audit, upload, email, content, tracking)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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);
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user