Initial commit

This commit is contained in:
Developer
2026-02-06 21:44:04 -06:00
commit f85e93c7a6
151 changed files with 22916 additions and 0 deletions

71
lib/app-setup.ts Normal file
View File

@@ -0,0 +1,71 @@
import { getPrisma } from "./db";
export type AppSetupData = {
googleAuthEnabled: boolean;
googleClientId?: string | null;
googleClientSecret?: string | null;
socials?: {
twitter?: string;
instagram?: string;
linkedin?: string;
youtube?: string;
[key: string]: string | undefined;
};
categories?: string[];
};
const defaultSetup: AppSetupData = {
googleAuthEnabled: false,
googleClientId: null,
googleClientSecret: null,
socials: {},
categories: ["Basics", "Planning", "Tax", "Healthcare", "Advanced"],
};
export async function getAppSetup(): Promise<AppSetupData> {
const prisma = await getPrisma();
if (!prisma) return defaultSetup;
const row = await prisma.appSetup.findUnique({ where: { id: 1 } });
if (!row) return defaultSetup;
return {
googleAuthEnabled: row.googleAuthEnabled,
googleClientId: row.googleClientId,
googleClientSecret: row.googleClientSecret,
socials: (row.socials || {}) as any,
categories: (row.categories || []) as any,
};
}
export async function saveAppSetup(data: AppSetupData) {
const prisma = await getPrisma();
if (!prisma) return defaultSetup;
const saved = await prisma.appSetup.upsert({
where: { id: 1 },
update: {
googleAuthEnabled: data.googleAuthEnabled,
googleClientId: data.googleClientId || null,
googleClientSecret: data.googleClientSecret || null,
socials: (data.socials || {}) as any,
categories: (data.categories || []) as any,
},
create: {
id: 1,
googleAuthEnabled: data.googleAuthEnabled,
googleClientId: data.googleClientId || null,
googleClientSecret: data.googleClientSecret || null,
socials: (data.socials || {}) as any,
categories: (data.categories || []) as any,
},
});
return {
googleAuthEnabled: saved.googleAuthEnabled,
googleClientId: saved.googleClientId,
googleClientSecret: saved.googleClientSecret,
socials: (saved.socials || {}) as any,
categories: (saved.categories || []) as any,
} as AppSetupData;
}

9
lib/auth-client.ts Normal file
View File

@@ -0,0 +1,9 @@
import { createAuthClient } from "better-auth/react";
const baseURL = typeof window !== "undefined" ? window.location.origin : "http://localhost:3001";
export const authClient = createAuthClient({
baseURL: baseURL,
});
export const { signUp, signIn, signOut, useSession } = authClient;

128
lib/auth.ts Normal file
View File

@@ -0,0 +1,128 @@
import { betterAuth } from "better-auth";
import { prismaAdapter } from "better-auth/adapters/prisma";
import { getPrisma } from "./db";
import { loadSystemConfig } from "./system-config";
import { setCached, getCached, deleteCached, cacheKeys } from "./redis";
let authInstance: ReturnType<typeof betterAuth> | null = null;
export async function getAuth() {
if (authInstance) {
return authInstance;
}
const prisma = await getPrisma();
const cfg = await loadSystemConfig();
if (!prisma) {
throw new Error("Database not configured");
}
const isDevelopment = process.env.NODE_ENV === "development";
const baseURL = process.env.APP_BASE_URL ||
(isDevelopment ? "http://localhost:3001" : "https://estate-platform.com");
authInstance = betterAuth({
database: prismaAdapter(prisma, {
provider: "postgresql",
}),
secret: cfg.auth?.jwtSecret || process.env.BETTER_AUTH_SECRET || "default-secret-change-me",
baseURL: baseURL,
trustHost: true,
// Disable origin validation in development
...(isDevelopment && {
skipOriginValidation: true,
}),
csrfProtection: false, // Disable CSRF protection completely
advanced: {
useSecureCookies: !isDevelopment, // Allow non-secure cookies in development
disableMultipleTabsWarning: isDevelopment,
sessionTimeout: 86400 * 7, // 7 days
defaultCookieMaxAge: 86400 * 7, // 7 days
},
emailAndPassword: {
enabled: true,
autoSignUpDisabled: false,
minPasswordLength: 8,
maxPasswordLength: 20,
requireEmailVerification: cfg.email?.enabled || false,
},
socialProviders: {
google: {
clientId: cfg.oauth?.google?.clientId || process.env.GOOGLE_CLIENT_ID || "",
clientSecret: cfg.oauth?.google?.clientSecret || process.env.GOOGLE_CLIENT_SECRET || "",
},
github: {
clientId: cfg.oauth?.github?.clientId || process.env.GITHUB_CLIENT_ID || "",
clientSecret: cfg.oauth?.github?.clientSecret || process.env.GITHUB_CLIENT_SECRET || "",
},
facebook: {
clientId: cfg.oauth?.facebook?.clientId || process.env.FACEBOOK_CLIENT_ID || "",
clientSecret: cfg.oauth?.facebook?.clientSecret || process.env.FACEBOOK_CLIENT_SECRET || "",
},
discord: {
clientId: cfg.oauth?.discord?.clientId || process.env.DISCORD_CLIENT_ID || "",
clientSecret: cfg.oauth?.discord?.clientSecret || process.env.DISCORD_CLIENT_SECRET || "",
},
},
});
return authInstance;
}
// Session caching helper
export async function cacheSession(sessionId: string, sessionData: any) {
try {
await setCached(cacheKeys.session(sessionId), sessionData, 604800); // 7 days
} catch (error) {
console.error("[Auth] Error caching session:", error);
}
}
export async function getCachedSession(sessionId: string) {
try {
return await getCached(cacheKeys.session(sessionId));
} catch (error) {
console.error("[Auth] Error getting cached session:", error);
return null;
}
}
export async function invalidateSessionCache(sessionId: string) {
try {
await deleteCached(cacheKeys.session(sessionId));
} catch (error) {
console.error("[Auth] Error invalidating session cache:", error);
}
}
// User caching helper
export async function cacheUser(userId: string, userData: any) {
try {
await setCached(cacheKeys.user(userId), userData, 3600); // 1 hour
} catch (error) {
console.error("[Auth] Error caching user:", error);
}
}
export async function getCachedUser(userId: string) {
try {
return await getCached(cacheKeys.user(userId));
} catch (error) {
console.error("[Auth] Error getting cached user:", error);
return null;
}
}
export async function invalidateUserCache(userId: string) {
try {
await deleteCached(cacheKeys.user(userId));
} catch (error) {
console.error("[Auth] Error invalidating user cache:", error);
}
}
// Re-export BetterAuth session types
export type { Session } from "better-auth";
export type { User } from "better-auth";

38
lib/auth/jwt.ts Normal file
View File

@@ -0,0 +1,38 @@
import { SignJWT, jwtVerify } from "jose";
import { loadSystemConfig } from "../system-config";
export type SessionToken = {
sub: string; // userId
role: "ADMIN" | "USER";
email: string;
forcePasswordReset: boolean;
};
async function getSecret() {
const cfg = await loadSystemConfig();
const s = cfg.auth?.jwtSecret || process.env.JWT_SECRET;
if (!s) return null;
return new TextEncoder().encode(s);
}
export async function signSession(payload: SessionToken) {
const secret = await getSecret();
if (!secret) throw new Error("JWT secret not configured");
const token = await new SignJWT(payload)
.setProtectedHeader({ alg: "HS256" })
.setIssuedAt()
.setExpirationTime("7d")
.sign(secret);
return token;
}
export async function verifySession(token: string) {
const secret = await getSecret();
if (!secret) return null;
try {
const { payload } = await jwtVerify(token, secret);
return payload as any as SessionToken;
} catch {
return null;
}
}

9
lib/auth/password.ts Normal file
View File

@@ -0,0 +1,9 @@
import bcrypt from "bcryptjs";
export async function hashPassword(pw: string) {
return bcrypt.hash(pw, 10);
}
export async function verifyPassword(pw: string, hash: string) {
return bcrypt.compare(pw, hash);
}

94
lib/auth/rate-limit.ts Normal file
View File

@@ -0,0 +1,94 @@
/**
* Simple in-memory rate limiter for login attempts
* Tracks login attempts per email address
*/
interface RateLimitEntry {
attempts: number;
resetTime: number;
}
const loginAttempts = new Map<string, RateLimitEntry>();
const ATTEMPT_LIMIT = 5;
const TIME_WINDOW_MS = 15 * 60 * 1000; // 15 minutes
export function checkRateLimit(email: string): {
allowed: boolean;
attempts: number;
remainingTime: number;
} {
const now = Date.now();
const entry = loginAttempts.get(email);
// No previous attempts
if (!entry) {
loginAttempts.set(email, {
attempts: 1,
resetTime: now + TIME_WINDOW_MS,
});
return {
allowed: true,
attempts: 1,
remainingTime: TIME_WINDOW_MS,
};
}
// Window has expired
if (now > entry.resetTime) {
loginAttempts.set(email, {
attempts: 1,
resetTime: now + TIME_WINDOW_MS,
});
return {
allowed: true,
attempts: 1,
remainingTime: TIME_WINDOW_MS,
};
}
// Still within the window
const remainingTime = entry.resetTime - now;
const allowed = entry.attempts < ATTEMPT_LIMIT;
if (!allowed) {
// Still increment attempts to track that we rejected this attempt
return {
allowed: false,
attempts: entry.attempts,
remainingTime,
};
}
// Increment attempts
entry.attempts++;
return {
allowed: true,
attempts: entry.attempts,
remainingTime,
};
}
/**
* Record a failed login attempt (call this on invalid credentials)
* This increments the counter without checking the limit
*/
export function recordFailedAttempt(email: string): void {
const now = Date.now();
const entry = loginAttempts.get(email);
if (!entry || now > entry.resetTime) {
loginAttempts.set(email, {
attempts: 1,
resetTime: now + TIME_WINDOW_MS,
});
} else {
entry.attempts++;
}
}
/**
* Clear all rate limit entries (useful for testing or admin reset)
*/
export function clearRateLimits(): void {
loginAttempts.clear();
}

11
lib/auth/session.ts Normal file
View File

@@ -0,0 +1,11 @@
import { cookies } from "next/headers";
import { verifySession } from "./jwt";
export const SESSION_COOKIE = "ep_session";
export async function getSession() {
const cookieStore = await cookies();
const c = cookieStore.get(SESSION_COOKIE)?.value;
if (!c) return null;
return await verifySession(c);
}

45
lib/auth/validation.ts Normal file
View File

@@ -0,0 +1,45 @@
export function sanitizeText(value: string) {
return value.trim();
}
export function sanitizeMessage(value: string) {
// Allow only alphanumeric, spaces, underscores, newlines, and basic punctuation
// Remove any HTML tags, scripts, and dangerous characters
return value
.trim()
.replace(/<[^>]*>/g, '') // Remove HTML tags
.replace(/[^\w\s\n\r_.,!?'-]/g, '') // Keep only safe characters
.slice(0, 5000); // Limit length
}
export interface PasswordRequirement {
name: string;
met: boolean;
}
export function getPasswordRequirements(value: string): PasswordRequirement[] {
return [
{
name: "8-20 Characters",
met: value.length >= 8 && value.length <= 20,
},
{
name: "At least one capital letter",
met: /[A-Z]/.test(value),
},
{
name: "At least one number",
met: /\d/.test(value),
},
{
name: "No spaces",
met: !/\s/.test(value),
},
];
}
export function isStrongPassword(value: string) {
// 8-20 chars, at least one uppercase, at least one number, no spaces
return /^(?=.*[A-Z])(?=.*\d)[A-Za-z0-9!@#$%^&*()_+\-=\[\]{};':"\\|,.<>\/?]{8,20}$/.test(value) && !/\s/.test(value);
}

175
lib/calendar.ts Normal file
View File

@@ -0,0 +1,175 @@
import { loadSystemConfig } from "./system-config";
export type CalendarEvent = {
summary: string;
description: string;
startTime: Date;
endTime: Date;
location?: string;
attendees?: string[];
};
/**
* Generate ICS file content for calendar invitation
*/
export function generateIcsFile(event: CalendarEvent): string {
const formatDate = (date: Date): string => {
return date
.toISOString()
.replace(/[-:]/g, "")
.replace(/\.\d{3}/, "");
};
const startDateStr = formatDate(event.startTime);
const endDateStr = formatDate(event.endTime);
const now = formatDate(new Date());
const uid = `${now}@estate-platform.com`;
// Escape special characters in text
const escape = (text: string) => {
return text
.replace(/\\/g, "\\\\")
.replace(/;/g, "\\;")
.replace(/,/g, "\\,")
.replace(/\n/g, "\\n");
};
let icsContent = [
"BEGIN:VCALENDAR",
"VERSION:2.0",
"PRODID:-//Estate Platform//Webinar Calendar//EN",
"CALSCALE:GREGORIAN",
"METHOD:REQUEST",
"BEGIN:VEVENT",
`UID:${uid}`,
`DTSTAMP:${now}`,
`DTSTART:${startDateStr}`,
`DTEND:${endDateStr}`,
`SUMMARY:${escape(event.summary)}`,
`DESCRIPTION:${escape(event.description)}`,
];
if (event.location) {
icsContent.push(`LOCATION:${escape(event.location)}`);
}
// Add organizer
icsContent.push("ORGANIZER;CN=Estate Platform:mailto:noreply@estate-platform.com");
// Add attendees
if (event.attendees && event.attendees.length > 0) {
event.attendees.forEach((email) => {
icsContent.push(
`ATTENDEE;CUTYPE=INDIVIDUAL;ROLE=REQ-PARTICIPANT;PARTSTAT=NEEDS-ACTION;RSVP=TRUE:mailto:${email}`
);
});
}
icsContent.push(
"STATUS:CONFIRMED",
"SEQUENCE:0",
"BEGIN:VALARM",
"TRIGGER:-PT15M",
"ACTION:DISPLAY",
"DESCRIPTION:Reminder: Webinar starts in 15 minutes",
"END:VALARM",
"END:VEVENT",
"END:VCALENDAR",
);
return icsContent.join("\r\n");
}
/**
* Create a Google Calendar event (if service account is configured)
*/
export async function createGoogleCalendarEvent(
event: CalendarEvent
): Promise<{ success: boolean; eventId?: string; error?: string }> {
try {
const config = await loadSystemConfig();
const calendarConfig = config.googleCalendar;
if (!calendarConfig?.enabled) {
return {
success: false,
error: "Google Calendar is not enabled",
};
}
if (
!calendarConfig.serviceAccountEmail ||
!calendarConfig.serviceAccountKey ||
!calendarConfig.calendarId
) {
return {
success: false,
error: "Google Calendar credentials are not configured",
};
}
// In a real implementation, you would use the googleapis library
// to create the event using service account credentials
// For now, we'll just return a mock success
// TODO: Implement actual Google Calendar API integration
console.log("[CALENDAR] Would create event:", event);
return {
success: true,
eventId: `mock-event-${Date.now()}`,
};
} catch (error) {
console.error("[CALENDAR] Error creating event:", error);
return {
success: false,
error: error instanceof Error ? error.message : "Failed to create calendar event",
};
}
}
/**
* Helper to calculate end time based on duration in minutes
*/
export function calculateEndTime(startTime: Date, durationMinutes: number): Date {
return new Date(startTime.getTime() + durationMinutes * 60 * 1000);
}
/**
* Create a calendar event for a webinar registration
*/
export async function createWebinarCalendarEvent(
webinar: {
title: string;
description: string;
startAt: Date;
duration: number;
meetingInfo: any;
},
attendeeEmail: string
): Promise<{ icsContent: string; googleEventId?: string }> {
const endTime = calculateEndTime(webinar.startAt, webinar.duration);
const meetingLink = webinar.meetingInfo?.meetingLink || "TBD";
const description = `${webinar.description}\n\nJoin Link: ${meetingLink}`;
const event: CalendarEvent = {
summary: webinar.title,
description,
startTime: webinar.startAt,
endTime,
location: meetingLink,
attendees: [attendeeEmail],
};
// Generate ICS file
const icsContent = generateIcsFile(event);
// Optionally create Google Calendar event
const googleResult = await createGoogleCalendarEvent(event);
return {
icsContent,
googleEventId: googleResult.success ? googleResult.eventId : undefined,
};
}

76
lib/captcha.ts Normal file
View File

@@ -0,0 +1,76 @@
import crypto from "crypto";
// Simple in-memory CAPTCHA store (in production, use Redis or database)
const captchaStore: Map<string, { code: string; createdAt: number; attempts: number }> = new Map();
const CAPTCHA_TTL = 5 * 60 * 1000; // 5 minutes
const MAX_ATTEMPTS = 5;
const CLEANUP_INTERVAL = 10 * 60 * 1000; // 10 minutes
// Cleanup old captchas periodically
setInterval(() => {
const now = Date.now();
for (const [key, value] of captchaStore.entries()) {
if (now - value.createdAt > CAPTCHA_TTL) {
captchaStore.delete(key);
}
}
}, CLEANUP_INTERVAL);
export function generateCaptcha(identifier: string): { id: string; code: string } {
const code = Math.random().toString().slice(2, 8); // 6-digit code
const id = crypto.randomBytes(16).toString("hex");
captchaStore.set(id, {
code,
createdAt: Date.now(),
attempts: 0,
});
return { id, code };
}
export interface CaptchaVerificationResult {
success: boolean;
error?: string;
attemptsRemaining?: number;
}
export function verifyCaptcha(id: string, code: string): CaptchaVerificationResult {
const captcha = captchaStore.get(id);
if (!captcha) {
return { success: false, error: "CAPTCHA not found or expired. Please reload and try again." };
}
// Check if expired
if (Date.now() - captcha.createdAt > CAPTCHA_TTL) {
captchaStore.delete(id);
return { success: false, error: "CAPTCHA code has expired. Please request a new one." };
}
// Check attempts
if (captcha.attempts >= MAX_ATTEMPTS) {
captchaStore.delete(id);
return { success: false, error: "Too many incorrect attempts. Please request a new CAPTCHA code." };
}
// Verify code
if (captcha.code === code) {
captchaStore.delete(id);
return { success: true };
}
// Increment attempts
captcha.attempts++;
const remaining = MAX_ATTEMPTS - captcha.attempts;
return {
success: false,
error: `Incorrect CAPTCHA code. ${remaining} attempt${remaining === 1 ? "" : "s"} remaining.`,
attemptsRemaining: remaining,
};
}
export function getMaxAttempts(): number {
return MAX_ATTEMPTS;
}

26
lib/db.ts Normal file
View File

@@ -0,0 +1,26 @@
import { PrismaClient } from "@prisma/client";
import { loadSystemConfig } from "./system-config";
let prisma: PrismaClient | null = null;
export async function getPrisma(): Promise<PrismaClient | null> {
if (prisma) return prisma;
const cfg = await loadSystemConfig();
const url = cfg.db?.databaseUrl || process.env.DATABASE_URL;
if (!url) return null;
// Set env so Prisma picks it up at runtime
process.env.DATABASE_URL = url;
try {
prisma = new PrismaClient();
// quick connectivity check (non-fatal if it fails)
await prisma.$queryRaw`SELECT 1`;
return prisma;
} catch {
prisma = null;
return null;
}
}

39
lib/email.ts Normal file
View File

@@ -0,0 +1,39 @@
import nodemailer from "nodemailer";
import { loadSystemConfig } from "./system-config";
export type EmailAttachment = {
filename: string;
content: string;
contentType?: string;
};
export async function sendEmail(opts: {
to: string;
subject: string;
html: string;
attachments?: EmailAttachment[];
}) {
const cfg = await loadSystemConfig();
if (!cfg.email?.enabled || !cfg.email?.smtp?.host || !cfg.email?.smtp?.port || !cfg.email?.from) {
return { ok: false, message: "Email not configured" };
}
const transporter = nodemailer.createTransport({
host: cfg.email.smtp.host,
port: cfg.email.smtp.port,
secure: false,
auth: cfg.email.smtp.user
? { user: cfg.email.smtp.user, pass: cfg.email.smtp.pass }
: undefined,
});
await transporter.sendMail({
from: cfg.email.from,
to: opts.to,
subject: opts.subject,
html: opts.html,
attachments: opts.attachments,
});
return { ok: true };
}

20
lib/errors.ts Normal file
View File

@@ -0,0 +1,20 @@
export class PublicError extends Error {
code: string;
constructor(code: string, message?: string) {
super(message ?? code);
this.code = code;
}
}
export function publicMessageFor(code: string) {
// Consumer-safe message - only for sensitive pages like auth
return "Something went wrong. Please contact the website owner.";
}
export function createValidationError(message: string) {
return new PublicError("VALIDATION_ERROR", message);
}
export function isPublicError(err: unknown): err is PublicError {
return typeof err === "object" && err !== null && "code" in err && err instanceof Error;
}

23
lib/http.ts Normal file
View File

@@ -0,0 +1,23 @@
import { NextResponse } from "next/server";
import { PublicError, publicMessageFor } from "./errors";
export function ok(data: any = {}) {
return NextResponse.json({ ok: true, ...data });
}
export function fail(err: unknown, opts?: { adminMessage?: string; status?: number; isAdmin?: boolean }) {
const status = opts?.status ?? 400;
if (opts?.isAdmin) {
const msg =
err instanceof PublicError
? err.message
: err instanceof Error
? err.message
: "Unknown error";
return NextResponse.json({ ok: false, message: msg }, { status });
}
// consumer-safe
return NextResponse.json({ ok: false, message: publicMessageFor("GENERIC") }, { status });
}

142
lib/redis.ts Normal file
View File

@@ -0,0 +1,142 @@
import Redis from "ioredis";
let redisInstance: Redis | null = null;
export async function getRedis(): Promise<Redis> {
if (redisInstance) {
return redisInstance;
}
const redisUrl = process.env.REDIS_URL || "redis://localhost:6379";
try {
redisInstance = new Redis(redisUrl, {
lazyConnect: false,
maxRetriesPerRequest: null,
enableReadyCheck: false,
enableOfflineQueue: true,
retryStrategy: (times) => {
const delay = Math.min(times * 50, 2000);
return delay;
},
});
redisInstance.on("error", (err) => {
console.error("[Redis] Error:", err.message);
});
redisInstance.on("connect", () => {
console.log("[Redis] Connected");
});
redisInstance.on("ready", () => {
console.log("[Redis] Ready");
});
redisInstance.on("reconnecting", () => {
console.log("[Redis] Reconnecting...");
});
// Test connection
await redisInstance.ping();
console.log("[Redis] Ping successful");
return redisInstance;
} catch (error) {
console.error("[Redis] Connection failed:", error);
throw error;
}
}
export async function closeRedis(): Promise<void> {
if (redisInstance) {
await redisInstance.quit();
redisInstance = null;
}
}
// Cache helper functions
export async function getCached<T>(key: string): Promise<T | null> {
try {
const redis = await getRedis();
const data = await redis.get(key);
return data ? JSON.parse(data) : null;
} catch (error) {
console.error(`[Redis] Error getting cache for ${key}:`, error);
return null;
}
}
export async function setCached<T>(
key: string,
value: T,
expirationSeconds?: number
): Promise<boolean> {
try {
const redis = await getRedis();
const data = JSON.stringify(value);
if (expirationSeconds) {
await redis.setex(key, expirationSeconds, data);
} else {
await redis.set(key, data);
}
return true;
} catch (error) {
console.error(`[Redis] Error setting cache for ${key}:`, error);
return false;
}
}
export async function deleteCached(key: string): Promise<boolean> {
try {
const redis = await getRedis();
const result = await redis.del(key);
return result > 0;
} catch (error) {
console.error(`[Redis] Error deleting cache for ${key}:`, error);
return false;
}
}
export async function invalidateCachePattern(pattern: string): Promise<number> {
try {
const redis = await getRedis();
const keys = await redis.keys(pattern);
if (keys.length === 0) return 0;
return await redis.del(...keys);
} catch (error) {
console.error(`[Redis] Error invalidating cache pattern ${pattern}:`, error);
return 0;
}
}
// Session helpers for BetterAuth
export async function getSession(sessionId: string): Promise<any> {
return getCached(`session:${sessionId}`);
}
export async function setSession(
sessionId: string,
sessionData: any,
expirationSeconds: number = 86400 // 24 hours
): Promise<boolean> {
return setCached(`session:${sessionId}`, sessionData, expirationSeconds);
}
export async function deleteSession(sessionId: string): Promise<boolean> {
return deleteCached(`session:${sessionId}`);
}
// Cache key generators
export const cacheKeys = {
user: (userId: string) => `user:${userId}`,
userByEmail: (email: string) => `user:email:${email}`,
session: (sessionId: string) => `session:${sessionId}`,
webinar: (webinarId: string) => `webinar:${webinarId}`,
webinars: (page: number = 1) => `webinars:list:${page}`,
registrations: (userId: string) => `registrations:${userId}`,
contact: (contactId: string) => `contact:${contactId}`,
config: "system:config",
adminSetup: "admin:setup",
};

13
lib/stripe.ts Normal file
View File

@@ -0,0 +1,13 @@
import Stripe from "stripe";
import { loadSystemConfig } from "./system-config";
let stripe: Stripe | null = null;
export async function getStripe() {
if (stripe) return stripe;
const cfg = await loadSystemConfig();
const key = cfg.stripe?.secretKey || process.env.STRIPE_SECRET_KEY;
if (!cfg.stripe?.enabled || !key) return null;
stripe = new Stripe(key, { apiVersion: "2024-06-20" as any });
return stripe;
}

147
lib/system-config.ts Normal file
View File

@@ -0,0 +1,147 @@
import { promises as fs } from "fs";
import path from "path";
import { PrismaClient } from "@prisma/client";
export type SystemConfigData = {
app?: { initialized?: boolean };
db?: { databaseUrl?: string };
auth?: { jwtSecret?: string };
email?: {
enabled?: boolean;
provider?: "smtp";
fromAddress?: string;
from?: string;
smtp?: { host?: string; port?: number; user?: string; pass?: string };
};
stripe?: {
enabled?: boolean;
secretKey?: string;
webhookSecret?: string;
publishableKey?: string;
};
oauth?: {
google?: { enabled?: boolean; clientId?: string; clientSecret?: string };
github?: { enabled?: boolean; clientId?: string; clientSecret?: string };
facebook?: { enabled?: boolean; clientId?: string; clientSecret?: string };
discord?: { enabled?: boolean; clientId?: string; clientSecret?: string };
};
googleAuth?: {
enabled?: boolean;
clientId?: string;
clientSecret?: string;
};
googleCalendar?: {
enabled?: boolean;
serviceAccountEmail?: string;
serviceAccountKey?: string;
calendarId?: string;
};
};
const DATA_DIR = process.env.CONFIG_DIR || path.join(process.cwd(), "data");
const FILE_PATH = path.join(DATA_DIR, "system-config.json");
const defaultConfig: SystemConfigData = {
app: { initialized: false },
db: { databaseUrl: process.env.DATABASE_URL },
auth: { jwtSecret: process.env.JWT_SECRET || process.env.BETTER_AUTH_SECRET },
email: {
enabled: Boolean(process.env.EMAIL_FROM),
provider: (process.env.EMAIL_PROVIDER as any) || undefined,
fromAddress: process.env.EMAIL_FROM,
from: process.env.EMAIL_FROM,
smtp: {
host: process.env.SMTP_HOST,
port: process.env.SMTP_PORT ? Number(process.env.SMTP_PORT) : undefined,
user: process.env.SMTP_USER,
pass: process.env.SMTP_PASS,
},
},
stripe: {
enabled: Boolean(process.env.STRIPE_SECRET_KEY),
secretKey: process.env.STRIPE_SECRET_KEY,
webhookSecret: process.env.STRIPE_WEBHOOK_SECRET,
publishableKey: process.env.STRIPE_PUBLISHABLE_KEY,
},
oauth: {
google: {
enabled: Boolean(process.env.GOOGLE_CLIENT_ID),
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
github: {
enabled: Boolean(process.env.GITHUB_CLIENT_ID),
clientId: process.env.GITHUB_CLIENT_ID,
clientSecret: process.env.GITHUB_CLIENT_SECRET,
},
facebook: {
enabled: Boolean(process.env.FACEBOOK_CLIENT_ID),
clientId: process.env.FACEBOOK_CLIENT_ID,
clientSecret: process.env.FACEBOOK_CLIENT_SECRET,
},
discord: {
enabled: Boolean(process.env.DISCORD_CLIENT_ID),
clientId: process.env.DISCORD_CLIENT_ID,
clientSecret: process.env.DISCORD_CLIENT_SECRET,
},
},
googleAuth: {
enabled: Boolean(process.env.GOOGLE_CLIENT_ID),
clientId: process.env.GOOGLE_CLIENT_ID,
clientSecret: process.env.GOOGLE_CLIENT_SECRET,
},
};
async function ensureDir() {
await fs.mkdir(DATA_DIR, { recursive: true });
}
export async function loadSystemConfig(): Promise<SystemConfigData> {
await ensureDir();
try {
const raw = await fs.readFile(FILE_PATH, "utf-8");
const fileCfg = JSON.parse(raw) as SystemConfigData;
return deepMerge(defaultConfig, fileCfg);
} catch {
// create initial file config
await fs.writeFile(FILE_PATH, JSON.stringify(defaultConfig, null, 2), "utf-8");
return defaultConfig;
}
}
export async function saveSystemConfig(partial: SystemConfigData, prisma?: PrismaClient | null) {
const current = await loadSystemConfig();
const merged = deepMerge(current, partial);
await ensureDir();
await fs.writeFile(FILE_PATH, JSON.stringify(merged, null, 2), "utf-8");
// Also persist to DB if available (best-effort)
if (prisma) {
try {
await prisma.systemConfig.upsert({
where: { id: 1 },
update: { data: merged as any },
create: { id: 1, data: merged as any },
});
} catch {
// ignore
}
}
return merged;
}
function isObject(v: any) {
return v && typeof v === "object" && !Array.isArray(v);
}
function deepMerge<T>(base: T, patch: any): T {
if (!isObject(base) || !isObject(patch)) return (patch ?? base) as T;
const out: any = { ...base };
for (const k of Object.keys(patch)) {
const bv = (base as any)[k];
const pv = patch[k];
out[k] = isObject(bv) && isObject(pv) ? deepMerge(bv, pv) : pv;
}
return out;
}