feat: add app settings table, encryption utility, and generic email service
Some checks failed
Build & Release / build (push) Failing after 35s
Some checks failed
Build & Release / build (push) Failing after 35s
- app_settings table with encrypted field support (AES-256-GCM, key from ENCRYPTION_KEY env) - SettingsService for transparent encrypt/decrypt on get/set - EmailService factory with Resend and SendGrid providers (SMTP stub) — provider config lives in app_settings - Seeds initial admin user and email settings from env vars on first startup if not already present - Migration 0039_app_settings.sql Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -3,3 +3,4 @@ export * from './schema/users.js'
|
|||||||
export * from './schema/accounts.js'
|
export * from './schema/accounts.js'
|
||||||
export * from './schema/inventory.js'
|
export * from './schema/inventory.js'
|
||||||
export * from './schema/pos.js'
|
export * from './schema/pos.js'
|
||||||
|
export * from './schema/settings.js'
|
||||||
|
|||||||
8
packages/backend/src/db/migrations/0039_app_settings.sql
Normal file
8
packages/backend/src/db/migrations/0039_app_settings.sql
Normal file
@@ -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()
|
||||||
|
);
|
||||||
13
packages/backend/src/db/schema/settings.ts
Normal file
13
packages/backend/src/db/schema/settings.ts
Normal file
@@ -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
|
||||||
@@ -34,6 +34,42 @@ import { configRoutes } from './routes/v1/config.js'
|
|||||||
import { RbacService } from './services/rbac.service.js'
|
import { RbacService } from './services/rbac.service.js'
|
||||||
import { ModuleService } from './services/module.service.js'
|
import { ModuleService } from './services/module.service.js'
|
||||||
import { AppConfigService } from './services/config.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<ReturnType<typeof buildApp>>) {
|
||||||
|
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<ReturnType<typeof buildApp>>) {
|
||||||
|
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() {
|
export async function buildApp() {
|
||||||
const app = Fastify({
|
const app = Fastify({
|
||||||
@@ -159,6 +195,16 @@ export async function buildApp() {
|
|||||||
} catch (err) {
|
} catch (err) {
|
||||||
app.log.error({ err }, 'Failed to load app config')
|
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
|
return app
|
||||||
|
|||||||
@@ -6,8 +6,9 @@ import * as userSchema from '../db/schema/users.js'
|
|||||||
import * as accountSchema from '../db/schema/accounts.js'
|
import * as accountSchema from '../db/schema/accounts.js'
|
||||||
import * as inventorySchema from '../db/schema/inventory.js'
|
import * as inventorySchema from '../db/schema/inventory.js'
|
||||||
import * as posSchema from '../db/schema/pos.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' {
|
declare module 'fastify' {
|
||||||
interface FastifyInstance {
|
interface FastifyInstance {
|
||||||
|
|||||||
106
packages/backend/src/services/email.service.ts
Normal file
106
packages/backend/src/services/email.service.ts
Normal file
@@ -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<void>
|
||||||
|
}
|
||||||
|
|
||||||
|
class ResendProvider implements EmailProvider {
|
||||||
|
constructor(private db: PostgresJsDatabase<any>) {}
|
||||||
|
|
||||||
|
async send(opts: SendOpts): Promise<void> {
|
||||||
|
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<any>) {}
|
||||||
|
|
||||||
|
async send(opts: SendOpts): Promise<void> {
|
||||||
|
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<void> {
|
||||||
|
throw new Error('SMTP email provider is not yet implemented')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EmailService = {
|
||||||
|
async send(db: PostgresJsDatabase<any>, opts: SendOpts): Promise<void> {
|
||||||
|
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.')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
41
packages/backend/src/services/settings.service.ts
Normal file
41
packages/backend/src/services/settings.service.ts
Normal file
@@ -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<any>, key: string): Promise<string | null> {
|
||||||
|
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<any>, key: string, value: string, encrypted = false): Promise<void> {
|
||||||
|
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<any>, key: string): Promise<void> {
|
||||||
|
await db.delete(appSettings).where(eq(appSettings.key, key))
|
||||||
|
},
|
||||||
|
}
|
||||||
34
packages/backend/src/utils/encryption.ts
Normal file
34
packages/backend/src/utils/encryption.ts
Normal file
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user