diff --git a/packages/backend/src/db/index.ts b/packages/backend/src/db/index.ts index 78541f9..6517e32 100644 --- a/packages/backend/src/db/index.ts +++ b/packages/backend/src/db/index.ts @@ -3,3 +3,4 @@ export * from './schema/users.js' export * from './schema/accounts.js' export * from './schema/inventory.js' export * from './schema/pos.js' +export * from './schema/settings.js' diff --git a/packages/backend/src/db/migrations/0039_app_settings.sql b/packages/backend/src/db/migrations/0039_app_settings.sql new file mode 100644 index 0000000..6176ce2 --- /dev/null +++ b/packages/backend/src/db/migrations/0039_app_settings.sql @@ -0,0 +1,8 @@ +CREATE TABLE IF NOT EXISTS "app_settings" ( + "key" varchar(100) PRIMARY KEY, + "value" text, + "is_encrypted" boolean NOT NULL DEFAULT false, + "iv" text, + "created_at" timestamptz NOT NULL DEFAULT now(), + "updated_at" timestamptz NOT NULL DEFAULT now() +); diff --git a/packages/backend/src/db/schema/settings.ts b/packages/backend/src/db/schema/settings.ts new file mode 100644 index 0000000..4e31e05 --- /dev/null +++ b/packages/backend/src/db/schema/settings.ts @@ -0,0 +1,13 @@ +import { pgTable, varchar, text, boolean, timestamp } from 'drizzle-orm/pg-core' + +export const appSettings = pgTable('app_settings', { + key: varchar('key', { length: 100 }).primaryKey(), + value: text('value'), + isEncrypted: boolean('is_encrypted').notNull().default(false), + iv: text('iv'), + createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + updatedAt: timestamp('updated_at', { withTimezone: true }).notNull().defaultNow(), +}) + +export type AppSetting = typeof appSettings.$inferSelect +export type AppSettingInsert = typeof appSettings.$inferInsert diff --git a/packages/backend/src/main.ts b/packages/backend/src/main.ts index 6483550..3849e19 100644 --- a/packages/backend/src/main.ts +++ b/packages/backend/src/main.ts @@ -34,6 +34,42 @@ import { configRoutes } from './routes/v1/config.js' import { RbacService } from './services/rbac.service.js' import { ModuleService } from './services/module.service.js' import { AppConfigService } from './services/config.service.js' +import { SettingsService } from './services/settings.service.js' +import { users } from './db/schema/users.js' +import bcrypt from 'bcryptjs' + +async function seedInitialUser(app: Awaited>) { + const email = process.env.INITIAL_USER_EMAIL + const password = process.env.INITIAL_USER_PASSWORD + const firstName = process.env.INITIAL_USER_FIRST_NAME + const lastName = process.env.INITIAL_USER_LAST_NAME + if (!email || !password || !firstName || !lastName) return + + const existing = await app.db.select({ id: users.id }).from(users).limit(1) + if (existing.length > 0) return + + const passwordHash = await bcrypt.hash(password, 10) + await app.db.insert(users).values({ email, passwordHash, firstName, lastName, role: 'admin' }) + app.log.info({ email }, 'Initial admin user created') +} + +async function seedEmailSettings(app: Awaited>) { + const apiKey = process.env.RESEND_API_KEY + if (!apiKey) return + + const existing = await SettingsService.get(app.db, 'email.provider') + if (existing) return + + await SettingsService.set(app.db, 'email.provider', 'resend') + await SettingsService.set(app.db, 'email.resend_api_key', apiKey, true) + if (process.env.MAIL_FROM) { + await SettingsService.set(app.db, 'email.from_address', process.env.MAIL_FROM) + } + if (process.env.BUSINESS_NAME) { + await SettingsService.set(app.db, 'email.business_name', process.env.BUSINESS_NAME) + } + app.log.info('Email settings seeded from environment') +} export async function buildApp() { const app = Fastify({ @@ -159,6 +195,16 @@ export async function buildApp() { } catch (err) { app.log.error({ err }, 'Failed to load app config') } + try { + await seedInitialUser(app) + } catch (err) { + app.log.error({ err }, 'Failed to seed initial user') + } + try { + await seedEmailSettings(app) + } catch (err) { + app.log.error({ err }, 'Failed to seed email settings') + } }) return app diff --git a/packages/backend/src/plugins/database.ts b/packages/backend/src/plugins/database.ts index e41dc3e..0492fba 100644 --- a/packages/backend/src/plugins/database.ts +++ b/packages/backend/src/plugins/database.ts @@ -6,8 +6,9 @@ import * as userSchema from '../db/schema/users.js' import * as accountSchema from '../db/schema/accounts.js' import * as inventorySchema from '../db/schema/inventory.js' import * as posSchema from '../db/schema/pos.js' +import * as settingsSchema from '../db/schema/settings.js' -const schema = { ...storeSchema, ...userSchema, ...accountSchema, ...inventorySchema, ...posSchema } +const schema = { ...storeSchema, ...userSchema, ...accountSchema, ...inventorySchema, ...posSchema, ...settingsSchema } declare module 'fastify' { interface FastifyInstance { diff --git a/packages/backend/src/services/email.service.ts b/packages/backend/src/services/email.service.ts new file mode 100644 index 0000000..95ee795 --- /dev/null +++ b/packages/backend/src/services/email.service.ts @@ -0,0 +1,106 @@ +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { SettingsService } from './settings.service.js' + +interface SendOpts { + to: string + subject: string + html: string + text?: string +} + +interface EmailProvider { + send(opts: SendOpts): Promise +} + +class ResendProvider implements EmailProvider { + constructor(private db: PostgresJsDatabase) {} + + async send(opts: SendOpts): Promise { + const apiKey = await SettingsService.get(this.db, 'email.resend_api_key') + ?? process.env.RESEND_API_KEY + const from = await SettingsService.get(this.db, 'email.from_address') + ?? process.env.MAIL_FROM + + if (!apiKey) throw new Error('Resend API key not configured') + if (!from) throw new Error('email.from_address not configured') + + const res = await fetch('https://api.resend.com/emails', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + from, + to: opts.to, + subject: opts.subject, + html: opts.html, + ...(opts.text ? { text: opts.text } : {}), + }), + }) + + if (!res.ok) { + const body = await res.text() + throw new Error(`Resend API error ${res.status}: ${body}`) + } + } +} + +class SendGridProvider implements EmailProvider { + constructor(private db: PostgresJsDatabase) {} + + async send(opts: SendOpts): Promise { + const apiKey = await SettingsService.get(this.db, 'email.sendgrid_api_key') + const from = await SettingsService.get(this.db, 'email.from_address') + ?? process.env.MAIL_FROM + + if (!apiKey) throw new Error('SendGrid API key not configured') + if (!from) throw new Error('email.from_address not configured') + + const res = await fetch('https://api.sendgrid.com/v3/mail/send', { + method: 'POST', + headers: { + Authorization: `Bearer ${apiKey}`, + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ + personalizations: [{ to: [{ email: opts.to }] }], + from: { email: from }, + subject: opts.subject, + content: [ + { type: 'text/html', value: opts.html }, + ...(opts.text ? [{ type: 'text/plain', value: opts.text }] : []), + ], + }), + }) + + if (!res.ok) { + const body = await res.text() + throw new Error(`SendGrid API error ${res.status}: ${body}`) + } + } +} + +class SmtpProvider implements EmailProvider { + async send(_opts: SendOpts): Promise { + throw new Error('SMTP email provider is not yet implemented') + } +} + +export const EmailService = { + async send(db: PostgresJsDatabase, opts: SendOpts): Promise { + const provider = await SettingsService.get(db, 'email.provider') + ?? process.env.MAIL_PROVIDER + + switch (provider) { + case 'resend': + return new ResendProvider(db).send(opts) + case 'sendgrid': + return new SendGridProvider(db).send(opts) + case 'smtp': + return new SmtpProvider().send(opts) + default: + throw new Error('Email provider not configured. Set email.provider in app_settings.') + } + }, +} diff --git a/packages/backend/src/services/settings.service.ts b/packages/backend/src/services/settings.service.ts new file mode 100644 index 0000000..c0eb8d5 --- /dev/null +++ b/packages/backend/src/services/settings.service.ts @@ -0,0 +1,41 @@ +import { eq } from 'drizzle-orm' +import type { PostgresJsDatabase } from 'drizzle-orm/postgres-js' +import { appSettings } from '../db/schema/settings.js' +import { encrypt, decrypt } from '../utils/encryption.js' + +export const SettingsService = { + async get(db: PostgresJsDatabase, key: string): Promise { + const [row] = await db + .select() + .from(appSettings) + .where(eq(appSettings.key, key)) + .limit(1) + + if (!row || row.value === null) return null + if (row.isEncrypted && row.iv) return decrypt(row.value, row.iv) + return row.value + }, + + async set(db: PostgresJsDatabase, key: string, value: string, encrypted = false): Promise { + let storedValue = value + let iv: string | null = null + + if (encrypted) { + const result = encrypt(value) + storedValue = result.ciphertext + iv = result.iv + } + + await db + .insert(appSettings) + .values({ key, value: storedValue, isEncrypted: encrypted, iv, updatedAt: new Date() }) + .onConflictDoUpdate({ + target: appSettings.key, + set: { value: storedValue, isEncrypted: encrypted, iv, updatedAt: new Date() }, + }) + }, + + async delete(db: PostgresJsDatabase, key: string): Promise { + await db.delete(appSettings).where(eq(appSettings.key, key)) + }, +} diff --git a/packages/backend/src/utils/encryption.ts b/packages/backend/src/utils/encryption.ts new file mode 100644 index 0000000..19f6c1c --- /dev/null +++ b/packages/backend/src/utils/encryption.ts @@ -0,0 +1,34 @@ +import { createCipheriv, createDecipheriv, randomBytes } from 'crypto' + +const ALGORITHM = 'aes-256-gcm' + +function getKey(): Buffer { + const hex = process.env.ENCRYPTION_KEY + if (!hex) throw new Error('ENCRYPTION_KEY env var is required for encrypted settings') + const key = Buffer.from(hex, 'hex') + if (key.length !== 32) throw new Error('ENCRYPTION_KEY must be a 64-character hex string (32 bytes)') + return key +} + +export function encrypt(plaintext: string): { ciphertext: string; iv: string } { + const key = getKey() + const iv = randomBytes(12) + const cipher = createCipheriv(ALGORITHM, key, iv) + let encrypted = cipher.update(plaintext, 'utf8', 'hex') + encrypted += cipher.final('hex') + const authTag = cipher.getAuthTag().toString('hex') + return { + ciphertext: `${encrypted}:${authTag}`, + iv: iv.toString('hex'), + } +} + +export function decrypt(ciphertext: string, iv: string): string { + const key = getKey() + const [encrypted, authTag] = ciphertext.split(':') + const decipher = createDecipheriv(ALGORITHM, key, Buffer.from(iv, 'hex')) + decipher.setAuthTag(Buffer.from(authTag, 'hex')) + let decrypted = decipher.update(encrypted, 'hex', 'utf8') + decrypted += decipher.final('utf8') + return decrypted +}