diff --git a/package-lock.json b/package-lock.json index 755535f..be3f565 100644 --- a/package-lock.json +++ b/package-lock.json @@ -2644,13 +2644,11 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "aix" ], - "peer": true, "engines": { "node": ">=18" } @@ -2662,13 +2660,11 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -2680,13 +2676,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -2698,13 +2692,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "android" ], - "peer": true, "engines": { "node": ">=18" } @@ -2716,13 +2708,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -2734,13 +2724,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "darwin" ], - "peer": true, "engines": { "node": ">=18" } @@ -2752,13 +2740,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2770,13 +2756,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2788,13 +2772,11 @@ "cpu": [ "arm" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2806,13 +2788,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2824,13 +2804,11 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2842,13 +2820,11 @@ "cpu": [ "loong64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2860,13 +2836,11 @@ "cpu": [ "mips64el" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2878,13 +2852,11 @@ "cpu": [ "ppc64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2896,13 +2868,11 @@ "cpu": [ "riscv64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2914,13 +2884,11 @@ "cpu": [ "s390x" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2932,13 +2900,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "linux" ], - "peer": true, "engines": { "node": ">=18" } @@ -2950,13 +2916,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2968,13 +2932,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "netbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -2986,13 +2948,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3004,13 +2964,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openbsd" ], - "peer": true, "engines": { "node": ">=18" } @@ -3022,13 +2980,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "openharmony" ], - "peer": true, "engines": { "node": ">=18" } @@ -3040,13 +2996,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "sunos" ], - "peer": true, "engines": { "node": ">=18" } @@ -3058,13 +3012,11 @@ "cpu": [ "arm64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -3076,13 +3028,11 @@ "cpu": [ "ia32" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } @@ -3094,13 +3044,11 @@ "cpu": [ "x64" ], - "dev": true, "license": "MIT", "optional": true, "os": [ "win32" ], - "peer": true, "engines": { "node": ">=18" } diff --git a/packages/server/src/__tests__/unit/email.test.ts b/packages/server/src/__tests__/unit/email.test.ts index 65a8575..b75343f 100644 --- a/packages/server/src/__tests__/unit/email.test.ts +++ b/packages/server/src/__tests__/unit/email.test.ts @@ -1,89 +1,105 @@ -import { describe, it, expect } from 'vitest'; +import { describe, it, expect } from "vitest"; import { orderConfirmationEmail, orderStatusEmail, reservationConfirmationEmail, -} from '../../lib/email.js'; + getSiteName, +} from "../../lib/email.js"; -describe('Email Templates', () => { - describe('orderConfirmationEmail', () => { - it('generates correct subject', () => { +describe("Email Templates", () => { + describe("orderConfirmationEmail", () => { + it("generates correct subject", () => { const result = orderConfirmationEmail({ - orderNumber: 'KA-123', - orderType: 'DELIVERY', + orderNumber: "KA-123", + orderType: "DELIVERY", total: 29.99, - items: [{ name: 'Pizza', quantity: 2, subtotal: 29.98 }], + items: [{ name: "Pizza", quantity: 2, subtotal: 29.98 }], }); - expect(result.subject).toBe('Order Confirmed - #KA-123'); + expect(result.subject).toBe("Order Confirmed - #KA-123"); }); - it('includes order number in html', () => { + it("includes order number in html", () => { const result = orderConfirmationEmail({ - orderNumber: 'KA-456', - orderType: 'PICKUP', - total: 15.00, - items: [{ name: 'Burger', quantity: 1, subtotal: 15.00 }], + orderNumber: "KA-456", + orderType: "PICKUP", + total: 15.0, + items: [{ name: "Burger", quantity: 1, subtotal: 15.0 }], }); - expect(result.html).toContain('KA-456'); - expect(result.html).toContain('PICKUP'); - expect(result.html).toContain('$15.00'); + expect(result.html).toContain("KA-456"); + expect(result.html).toContain("PICKUP"); + expect(result.html).toContain("$15.00"); }); - it('includes item details', () => { + it("includes item details", () => { const result = orderConfirmationEmail({ - orderNumber: 'KA-789', - orderType: 'DELIVERY', - total: 45.00, + orderNumber: "KA-789", + orderType: "DELIVERY", + total: 45.0, items: [ - { name: 'Pizza', quantity: 2, subtotal: 30.00 }, - { name: 'Salad', quantity: 1, subtotal: 15.00 }, + { name: "Pizza", quantity: 2, subtotal: 30.0 }, + { name: "Salad", quantity: 1, subtotal: 15.0 }, ], }); - expect(result.html).toContain('2x Pizza'); - expect(result.html).toContain('1x Salad'); + expect(result.html).toContain("2x Pizza"); + expect(result.html).toContain("1x Salad"); }); }); - describe('orderStatusEmail', () => { - it('generates correct subject', () => { - const result = orderStatusEmail({ orderNumber: 'KA-123', status: 'PREPARING' }); - expect(result.subject).toBe('Order #KA-123 - PREPARING'); + describe("orderStatusEmail", () => { + it("generates correct subject", () => { + const result = orderStatusEmail({ + orderNumber: "KA-123", + status: "PREPARING", + }); + expect(result.subject).toBe("Order #KA-123 - PREPARING"); }); - it('includes status message', () => { - const result = orderStatusEmail({ orderNumber: 'KA-123', status: 'OUT_FOR_DELIVERY' }); - expect(result.html).toContain('OUT FOR DELIVERY'); - expect(result.html).toContain('on its way'); + it("includes status message", () => { + const result = orderStatusEmail({ + orderNumber: "KA-123", + status: "OUT_FOR_DELIVERY", + }); + expect(result.html).toContain("OUT FOR DELIVERY"); + expect(result.html).toContain("on its way"); }); - it('handles cancelled status', () => { - const result = orderStatusEmail({ orderNumber: 'KA-123', status: 'CANCELLED' }); - expect(result.html).toContain('cancelled'); + it("handles cancelled status", () => { + const result = orderStatusEmail({ + orderNumber: "KA-123", + status: "CANCELLED", + }); + expect(result.html).toContain("cancelled"); }); }); - describe('reservationConfirmationEmail', () => { - it('generates correct subject', () => { + describe("reservationConfirmationEmail", () => { + it("generates correct subject", () => { const result = reservationConfirmationEmail({ - date: '2026-03-15', - time: '19:00', + date: "2026-03-15", + time: "19:00", partySize: 4, - locationName: 'Downtown Kitchen', + locationName: "Downtown Kitchen", }); - expect(result.subject).toBe('Reservation Confirmed - Downtown Kitchen'); + expect(result.subject).toBe("Reservation Confirmed - Downtown Kitchen"); }); - it('includes reservation details', () => { + it("includes reservation details", () => { const result = reservationConfirmationEmail({ - date: '2026-03-15', - time: '19:00', + date: "2026-03-15", + time: "19:00", partySize: 4, - locationName: 'Downtown Kitchen', + locationName: "Downtown Kitchen", }); - expect(result.html).toContain('2026-03-15'); - expect(result.html).toContain('19:00'); - expect(result.html).toContain('4 guests'); - expect(result.html).toContain('Downtown Kitchen'); + expect(result.html).toContain("2026-03-15"); + expect(result.html).toContain("19:00"); + expect(result.html).toContain("4 guests"); + expect(result.html).toContain("Downtown Kitchen"); }); }); }); + +describe("getSiteName", () => { + it("fetches the siteName from the database and kithenasty if not configured", async () => { + expect(await getSiteName("nameNotInDB")).toBe("KitchenAsty"); + }); +}); diff --git a/packages/server/src/lib/email.ts b/packages/server/src/lib/email.ts index 75dbd6d..2c6c347 100644 --- a/packages/server/src/lib/email.ts +++ b/packages/server/src/lib/email.ts @@ -1,29 +1,34 @@ -import nodemailer from 'nodemailer'; -import type { Transporter } from 'nodemailer'; -import prisma from './db.js'; +import nodemailer from "nodemailer"; +import type { Transporter } from "nodemailer"; +import { prisma } from "./db.js"; let cachedTransporter: Transporter | null = null; -let cachedFrom: string = ''; +let cachedFrom: string = ""; let cacheExpiry = 0; const CACHE_TTL = 5 * 60 * 1000; // 5 minutes -async function getMailConfig(): Promise<{ transporter: Transporter; from: string }> { +async function getMailConfig(): Promise<{ + transporter: Transporter; + from: string; +}> { const now = Date.now(); if (cachedTransporter && now < cacheExpiry) { return { transporter: cachedTransporter, from: cachedFrom }; } - let host = process.env.SMTP_HOST || 'localhost'; - let port = parseInt(process.env.SMTP_PORT || '1025'); + let host = process.env.SMTP_HOST || "localhost"; + let port = parseInt(process.env.SMTP_PORT || "1025"); let secure = false; let user = process.env.SMTP_USER; let pass = process.env.SMTP_PASS; - let senderName = 'KitchenAsty'; - let senderEmail = 'noreply@kitchenasty.com'; + let senderName = "KitchenAsty"; + let senderEmail = "noreply@kitchenasty.com"; let requireTLS = false; try { - const settings = await prisma.siteSettings.findUnique({ where: { id: 'default' } }); + const settings = await prisma.siteSettings.findUnique({ + where: { id: "default" }, + }); const mail = (settings?.mailSettings as Record) || {}; if (mail.smtpHost) host = mail.smtpHost; if (mail.smtpPort) port = mail.smtpPort; @@ -31,8 +36,8 @@ async function getMailConfig(): Promise<{ transporter: Transporter; from: string if (mail.smtpPass) pass = mail.smtpPass; if (mail.senderName) senderName = mail.senderName; if (mail.senderEmail) senderEmail = mail.senderEmail; - if (mail.encryption === 'ssl') secure = true; - if (mail.encryption === 'tls') requireTLS = true; + if (mail.encryption === "ssl") secure = true; + if (mail.encryption === "tls") requireTLS = true; } catch { // DB unavailable — fall back to env vars } @@ -67,7 +72,7 @@ interface EmailOptions { } export async function sendEmail(options: EmailOptions): Promise { - if (process.env.NODE_ENV === 'test') return; + if (process.env.NODE_ENV === "test") return; try { const { transporter, from } = await getMailConfig(); @@ -78,7 +83,20 @@ export async function sendEmail(options: EmailOptions): Promise { html: options.html, }); } catch (err) { - console.error('Failed to send email:', err); + console.error("Failed to send email:", err); + } +} + +export async function getSiteName(name: String) { + try { + const placeName = await prisma.siteSettings.findFirst({ + where: { siteName: `${name.split(" ")[0]}` }, + }); + + return placeName; + } catch (error) { + console.log("failed to grab name", error); + return "KitchenAsty"; } } @@ -90,16 +108,19 @@ export function orderConfirmationEmail(order: { total: number; items: { name: string; quantity: number; subtotal: number }[]; }): { subject: string; html: string } { - const itemRows = order.items.map((i) => - `${i.quantity}x ${i.name}$${i.subtotal.toFixed(2)}` - ).join(''); + const itemRows = order.items + .map( + (i) => + `${i.quantity}x ${i.name}$${i.subtotal.toFixed(2)}`, + ) + .join(""); return { subject: `Order Confirmed - #${order.orderNumber}`, html: `
-

KitchenAsty

+

${getSiteName(process.env.EMAIL_FROM)}

Order Confirmed!

@@ -122,28 +143,28 @@ export function orderStatusEmail(order: { status: string; }): { subject: string; html: string } { const statusMessages: Record = { - CONFIRMED: 'Your order has been confirmed and will be prepared soon.', - PREPARING: 'Your order is now being prepared!', - READY: 'Your order is ready!', - OUT_FOR_DELIVERY: 'Your order is on its way!', - DELIVERED: 'Your order has been delivered. Enjoy!', - PICKED_UP: 'Your order has been picked up. Enjoy!', - CANCELLED: 'Your order has been cancelled.', + CONFIRMED: "Your order has been confirmed and will be prepared soon.", + PREPARING: "Your order is now being prepared!", + READY: "Your order is ready!", + OUT_FOR_DELIVERY: "Your order is on its way!", + DELIVERED: "Your order has been delivered. Enjoy!", + PICKED_UP: "Your order has been picked up. Enjoy!", + CANCELLED: "Your order has been cancelled.", }; return { - subject: `Order #${order.orderNumber} - ${order.status.replace(/_/g, ' ')}`, + subject: `Order #${order.orderNumber} - ${order.status.replace(/_/g, " ")}`, html: `
-

KitchenAsty

+

${getSiteName(process.env.EMAIL_FROM)}

Order Update

Order #${order.orderNumber}

-

${order.status.replace(/_/g, ' ')}

-

${statusMessages[order.status] || 'Your order status has been updated.'}

+

${order.status.replace(/_/g, " ")}

+

${statusMessages[order.status] || "Your order status has been updated."}

@@ -157,15 +178,15 @@ export function staffInvitationEmail(invite: { inviteLink: string; }): { subject: string; html: string } { return { - subject: 'You\'re Invited to Join KitchenAsty', + subject: "You're Invited to Join KitchenAsty", html: `
-

KitchenAsty

+

${getSiteName(process.env.EMAIL_FROM)}

You're Invited!

-

You've been invited to join the KitchenAsty team as ${invite.role.replace(/_/g, ' ')}.

+

You've been invited to join the KitchenAsty team as ${invite.role.replace(/_/g, " ")}.

@@ -187,7 +208,7 @@ export function reservationConfirmationEmail(reservation: { html: `
-

KitchenAsty

+

${getSiteName(process.env.EMAIL_FROM)}

Reservation Confirmed!

diff --git a/prisma/migrations/20260222130037_/migration.sql b/prisma/migrations/20260222130037_/migration.sql new file mode 100644 index 0000000..0065be9 --- /dev/null +++ b/prisma/migrations/20260222130037_/migration.sql @@ -0,0 +1,97 @@ +-- AlterTable +ALTER TABLE "customers" ADD COLUMN "expoPushToken" TEXT; + +-- CreateTable +CREATE TABLE "site_settings" ( + "id" TEXT NOT NULL DEFAULT 'default', + "siteName" TEXT NOT NULL DEFAULT 'KitchenAsty', + "siteTitle" TEXT NOT NULL DEFAULT 'KitchenAsty - Order Online', + "favicon" TEXT, + "logo" TEXT, + "colorPrimary" TEXT NOT NULL DEFAULT '#ea580c', + "colorSecondary" TEXT NOT NULL DEFAULT '#9333ea', + "darkMode" TEXT NOT NULL DEFAULT 'light', + "heroSection" JSONB, + "featuresSection" JSONB, + "ctaSection" JSONB, + "generalSettings" JSONB, + "orderSettings" JSONB, + "reservationSettings" JSONB, + "mailSettings" JSONB, + "paymentSettings" JSONB, + "reviewSettings" JSONB, + "advancedSettings" JSONB, + "updatedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "site_settings_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "legal_pages" ( + "id" TEXT NOT NULL, + "slug" TEXT NOT NULL, + "title" TEXT NOT NULL, + "content" TEXT NOT NULL, + "updatedAt" TIMESTAMP(3) NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "legal_pages_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "cookie_categories" ( + "id" TEXT NOT NULL, + "name" TEXT NOT NULL, + "label" TEXT NOT NULL, + "description" TEXT NOT NULL, + "isRequired" BOOLEAN NOT NULL DEFAULT false, + "isActive" BOOLEAN NOT NULL DEFAULT true, + "sortOrder" INTEGER NOT NULL DEFAULT 0, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + "updatedAt" TIMESTAMP(3) NOT NULL, + + CONSTRAINT "cookie_categories_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "cookie_consents" ( + "id" TEXT NOT NULL, + "customerId" TEXT, + "cookieCategoryId" TEXT NOT NULL, + "accepted" BOOLEAN NOT NULL, + "ipAddress" TEXT, + "userAgent" TEXT, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "cookie_consents_pkey" PRIMARY KEY ("id") +); + +-- CreateTable +CREATE TABLE "invite_tokens" ( + "id" TEXT NOT NULL, + "token" TEXT NOT NULL, + "email" TEXT NOT NULL, + "role" "Role" NOT NULL DEFAULT 'STAFF', + "invitedBy" TEXT NOT NULL, + "expiresAt" TIMESTAMP(3) NOT NULL, + "usedAt" TIMESTAMP(3), + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "invite_tokens_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE UNIQUE INDEX "legal_pages_slug_key" ON "legal_pages"("slug"); + +-- CreateIndex +CREATE UNIQUE INDEX "cookie_categories_name_key" ON "cookie_categories"("name"); + +-- CreateIndex +CREATE UNIQUE INDEX "invite_tokens_token_key" ON "invite_tokens"("token"); + +-- AddForeignKey +ALTER TABLE "cookie_consents" ADD CONSTRAINT "cookie_consents_customerId_fkey" FOREIGN KEY ("customerId") REFERENCES "customers"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "cookie_consents" ADD CONSTRAINT "cookie_consents_cookieCategoryId_fkey" FOREIGN KEY ("cookieCategoryId") REFERENCES "cookie_categories"("id") ON DELETE CASCADE ON UPDATE CASCADE;