From 3669c0d4f8d014d6120061a119da51f6b4d8f665 Mon Sep 17 00:00:00 2001 From: Justin Reiners Date: Thu, 26 Mar 2026 11:59:00 -0500 Subject: [PATCH] feat: add server modules (audit, upload, email, content, tracking) Co-Authored-By: Claude Sonnet 4.6 --- src/lib/server/audit.ts | 20 ++++ src/lib/server/content.ts | 37 +++++++ src/lib/server/email.ts | 212 ++++++++++++++++++++++++++++++++++++++ src/lib/server/track.ts | 36 +++++++ src/lib/server/upload.ts | 66 ++++++++++++ 5 files changed, 371 insertions(+) create mode 100644 src/lib/server/audit.ts create mode 100644 src/lib/server/content.ts create mode 100644 src/lib/server/email.ts create mode 100644 src/lib/server/track.ts create mode 100644 src/lib/server/upload.ts diff --git a/src/lib/server/audit.ts b/src/lib/server/audit.ts new file mode 100644 index 0000000..aa18d48 --- /dev/null +++ b/src/lib/server/audit.ts @@ -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; + ipAddress?: string; +}): Promise { + 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 + }); +} diff --git a/src/lib/server/content.ts b/src/lib/server/content.ts new file mode 100644 index 0000000..3ac7e30 --- /dev/null +++ b/src/lib/server/content.ts @@ -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 { + 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> { + const rows = await db + .select() + .from(contentBlocks) + .where(eq(contentBlocks.page, page)); + + const blocks: Record = {}; + 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; +} diff --git a/src/lib/server/email.ts b/src/lib/server/email.ts new file mode 100644 index 0000000..ff327a5 --- /dev/null +++ b/src/lib/server/email.ts @@ -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 { + 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 { + const subject = `[${orgName}] New ${type} application from ${applicantName}`; + const html = ` +

New ${type} Application

+

Applicant: ${applicantName}

+ ${petName ? `

Pet: ${petName}

` : ''} +

Log in to your admin dashboard to review this application.

+ `; + await sendEmail(staffEmail, subject, html); +} + +export async function sendApplicationStatusUpdate( + orgName: string, + to: string, + applicantName: string, + type: string, + status: string, + siteUrl: string +): Promise { + const subject = `[${orgName}] Your ${type} application has been ${status}`; + const html = ` +

Application Update

+

Hi ${applicantName},

+

Your ${type} application has been ${status}.

+

Visit ${siteUrl} for more information.

+ `; + await sendEmail(to, subject, html); +} + +export async function sendFosterWelcomeEmail( + orgName: string, + to: string, + name: string, + tempPassword: string, + loginUrl: string +): Promise { + const subject = `[${orgName}] Welcome to the foster team!`; + const html = ` +

Welcome, ${name}!

+

Your foster account has been created.

+

Login URL: ${loginUrl}

+

Email: ${to}

+

Temporary Password: ${tempPassword}

+

You will be asked to change your password on first login.

+ `; + await sendEmail(to, subject, html); +} + +export async function sendRegistrationNotification( + orgName: string, + staffEmail: string, + name: string, + email: string, + reason: string +): Promise { + const subject = `[${orgName}] New registration request from ${name}`; + const html = ` +

New Registration Request

+

Name: ${name}

+

Email: ${email}

+

Reason: ${reason}

+

Log in to your admin dashboard to approve or deny this request.

+ `; + await sendEmail(staffEmail, subject, html); +} + +export async function sendRegistrationApproved( + orgName: string, + to: string, + name: string, + loginUrl: string +): Promise { + const subject = `[${orgName}] Your account has been approved!`; + const html = ` +

Account Approved

+

Hi ${name}, your account at ${orgName} has been approved.

+

Log in at: ${loginUrl}

+ `; + await sendEmail(to, subject, html); +} + +export async function sendRegistrationDenied( + orgName: string, + to: string, + name: string +): Promise { + const subject = `[${orgName}] Registration update`; + const html = ` +

Registration Update

+

Hi ${name}, unfortunately your registration request at ${orgName} was not approved at this time.

+

If you believe this is an error, please contact us.

+ `; + await sendEmail(to, subject, html); +} + +export async function sendFosterSubmissionNotification( + orgName: string, + staffEmail: string, + fosterName: string, + petName: string, + type: string +): Promise { + const subject = `[${orgName}] New foster ${type} for ${petName}`; + const html = ` +

New Foster Submission

+

Foster: ${fosterName}

+

Pet: ${petName}

+

Type: ${type}

+

Log in to your admin dashboard to review.

+ `; + await sendEmail(staffEmail, subject, html); +} + +export async function sendFosterReviewNotification( + orgName: string, + to: string, + fosterName: string, + petName: string, + approved: boolean +): Promise { + const status = approved ? 'approved' : 'not approved'; + const subject = `[${orgName}] Your submission for ${petName} was ${status}`; + const html = ` +

Submission Review

+

Hi ${fosterName}, your submission for ${petName} has been ${status}.

+ `; + await sendEmail(to, subject, html); +} + +export async function sendSupplyRequestNotification( + orgName: string, + staffEmail: string, + fosterName: string, + items: string, + urgency: string +): Promise { + const urgentTag = urgency === 'high' ? '[URGENT] ' : ''; + const subject = `${urgentTag}[${orgName}] Supply request from ${fosterName}`; + const html = ` +

Supply Request

+

Foster: ${fosterName}

+

Items: ${items}

+

Urgency: ${urgency}

+ `; + await sendEmail(staffEmail, subject, html); +} + +export async function sendNewsletter( + orgName: string, + to: string, + subject: string, + body: string, + siteUrl: string +): Promise { + const html = ` + ${body} +
+

+ Sent by ${orgName}. Visit our website. +

+ `; + await sendEmail(to, `[${orgName}] ${subject}`, html); +} + +export async function sendContactNotification( + orgName: string, + staffEmail: string, + senderName: string, + senderEmail: string, + messageSubject: string, + message: string +): Promise { + const subject = `[${orgName}] Contact form: ${messageSubject || 'New message'}`; + const html = ` +

Contact Form Message

+

From: ${senderName} (${senderEmail})

+

Subject: ${messageSubject || 'N/A'}

+

${message}

+ `; + await sendEmail(staffEmail, subject, html); +} diff --git a/src/lib/server/track.ts b/src/lib/server/track.ts new file mode 100644 index 0000000..0731731 --- /dev/null +++ b/src/lib/server/track.ts @@ -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 { + 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 + }); +} diff --git a/src/lib/server/upload.ts b/src/lib/server/upload.ts new file mode 100644 index 0000000..ede3387 --- /dev/null +++ b/src/lib/server/upload.ts @@ -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 = { + '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 { + if (!url.startsWith('/uploads/')) return; + const filePath = path.join(UPLOAD_DIR, url.replace('/uploads/', '')); + try { + await fs.unlink(filePath); + } catch { + // File already gone + } +}