x
This commit is contained in:
1 parent
78b722e120
commit
75770ca122
18 files changed
+1119
-318
No files matched your search
@@ -0,0 +1,2 @@
|
||||
-- AlterTable
|
||||
ALTER TABLE "StoreSettings" ADD COLUMN "footerLayout" JSONB;
|
||||
@@ -301,6 +301,7 @@ model StoreSettings {
|
||||
newsletterButtonText String?
|
||||
footerSupportHeading Json?
|
||||
footerCopyrightText Json?
|
||||
footerLayout Json?
|
||||
scrollRevealEnabled Boolean @default(true)
|
||||
scrollRevealStyle String @default("fade-up")
|
||||
scrollRevealDuration Int @default(700)
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { DEFAULT_FOOTER_LAYOUT_SETTINGS } from '@homaroma/types'
|
||||
import {
|
||||
mergeFooterLayoutSettings,
|
||||
normalizeFooterLayoutSettings,
|
||||
validateFooterLayoutSettingsPatch,
|
||||
} from './footerLayoutSettings'
|
||||
|
||||
describe('footer layout settings', () => {
|
||||
test('normalizes missing and invalid persisted values to the shared default', () => {
|
||||
expect(normalizeFooterLayoutSettings(null)).toEqual(DEFAULT_FOOTER_LAYOUT_SETTINGS)
|
||||
expect(normalizeFooterLayoutSettings({ columns: ['brand'] })).toEqual(
|
||||
DEFAULT_FOOTER_LAYOUT_SETTINGS,
|
||||
)
|
||||
})
|
||||
|
||||
test('preserves a valid persisted layout', () => {
|
||||
const layout = { ...DEFAULT_FOOTER_LAYOUT_SETTINGS, mobileColumns: 2 as const }
|
||||
expect(normalizeFooterLayoutSettings(layout)).toEqual(layout)
|
||||
})
|
||||
|
||||
test('merges a partial finale patch without losing sibling settings', () => {
|
||||
expect(mergeFooterLayoutSettings(DEFAULT_FOOTER_LAYOUT_SETTINGS, {
|
||||
containerWidth: 'wide',
|
||||
finale: { enabled: false },
|
||||
})).toEqual({
|
||||
...DEFAULT_FOOTER_LAYOUT_SETTINGS,
|
||||
containerWidth: 'wide',
|
||||
finale: { ...DEFAULT_FOOTER_LAYOUT_SETTINGS.finale, enabled: false },
|
||||
})
|
||||
})
|
||||
|
||||
test('strictly rejects unknown patch fields', () => {
|
||||
const result = validateFooterLayoutSettingsPatch({ arbitraryCss: 'display:none' })
|
||||
expect(result.success).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,32 @@
|
||||
import {
|
||||
DEFAULT_FOOTER_LAYOUT_SETTINGS,
|
||||
FooterLayoutSettingsPatchSchema,
|
||||
FooterLayoutSettingsSchema,
|
||||
type FooterLayoutSettings,
|
||||
type FooterLayoutSettingsPatch,
|
||||
} from '@homaroma/types'
|
||||
|
||||
export function normalizeFooterLayoutSettings(value: unknown): FooterLayoutSettings {
|
||||
const result = FooterLayoutSettingsSchema.safeParse(value)
|
||||
if (result.success) return result.data
|
||||
|
||||
return FooterLayoutSettingsSchema.parse(DEFAULT_FOOTER_LAYOUT_SETTINGS)
|
||||
}
|
||||
|
||||
export function validateFooterLayoutSettingsPatch(value: unknown) {
|
||||
return FooterLayoutSettingsPatchSchema.safeParse(value)
|
||||
}
|
||||
|
||||
export function mergeFooterLayoutSettings(
|
||||
current: FooterLayoutSettings,
|
||||
patch: FooterLayoutSettingsPatch,
|
||||
): FooterLayoutSettings {
|
||||
return FooterLayoutSettingsSchema.parse({
|
||||
...current,
|
||||
...patch,
|
||||
finale: {
|
||||
...current.finale,
|
||||
...patch.finale,
|
||||
},
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { requireAdmin } from './auth'
|
||||
|
||||
function context(authorization?: string, payload: unknown = null) {
|
||||
return {
|
||||
jwt: { verify: async () => payload },
|
||||
headers: { authorization },
|
||||
set: { status: 200 },
|
||||
}
|
||||
}
|
||||
|
||||
describe('requireAdmin', () => {
|
||||
test('rejects a missing bearer token', async () => {
|
||||
const request = context()
|
||||
expect(await requireAdmin(request)).toEqual({
|
||||
error: 'Unauthorized: Missing or invalid token format',
|
||||
})
|
||||
expect(request.set.status).toBe(401)
|
||||
})
|
||||
|
||||
test('rejects a non-admin token', async () => {
|
||||
const request = context('Bearer customer-token', { role: 'CUSTOMER' })
|
||||
expect(await requireAdmin(request)).toEqual({
|
||||
error: 'Unauthorized: Admin access required',
|
||||
})
|
||||
expect(request.set.status).toBe(401)
|
||||
})
|
||||
|
||||
test('allows an admin token', async () => {
|
||||
const request = context('Bearer admin-token', { role: 'ADMIN' })
|
||||
expect(await requireAdmin(request)).toBeUndefined()
|
||||
expect(request.set.status).toBe(200)
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,27 @@
|
||||
import { Elysia } from "elysia";
|
||||
import { jwt } from "@elysiajs/jwt";
|
||||
|
||||
export async function requireAdmin({ jwt, headers, set }: any) {
|
||||
const auth = headers.authorization;
|
||||
if (!auth?.startsWith("Bearer ")) {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized: Missing or invalid token format" };
|
||||
}
|
||||
|
||||
const token = auth.substring(7);
|
||||
const payload = await jwt.verify(token);
|
||||
|
||||
if (!payload) {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized: Invalid token" };
|
||||
}
|
||||
|
||||
if ((payload as any).role !== "ADMIN") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized: Admin access required" };
|
||||
}
|
||||
}
|
||||
|
||||
export const adminOnly = new Elysia({ name: "adminOnly" })
|
||||
.use(
|
||||
jwt({
|
||||
@@ -8,23 +29,4 @@ export const adminOnly = new Elysia({ name: "adminOnly" })
|
||||
secret: process.env.JWT_SECRET || "fallback-secret"
|
||||
})
|
||||
)
|
||||
.onBeforeHandle(async ({ jwt, headers, set }) => {
|
||||
const auth = headers.authorization;
|
||||
if (!auth?.startsWith("Bearer ")) {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized: Missing or invalid token format" };
|
||||
}
|
||||
|
||||
const token = auth.substring(7);
|
||||
const payload = await jwt.verify(token);
|
||||
|
||||
if (!payload) {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized: Invalid token" };
|
||||
}
|
||||
|
||||
if ((payload as any).role !== "ADMIN") {
|
||||
set.status = 401;
|
||||
return { error: "Unauthorized: Admin access required" };
|
||||
}
|
||||
});
|
||||
.onBeforeHandle(requireAdmin);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { describe, expect, test } from 'bun:test'
|
||||
import { settingsRoute } from './settings'
|
||||
|
||||
function jsonRequest(path: string, method: string, body: unknown) {
|
||||
return new Request(`http://localhost${path}`, {
|
||||
method,
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
})
|
||||
}
|
||||
|
||||
describe('footer layout route boundaries', () => {
|
||||
for (const method of ['PUT', 'POST']) {
|
||||
test(`${method} /settings rejects footerLayout writes`, async () => {
|
||||
const response = await settingsRoute.handle(jsonRequest('/settings', method, {
|
||||
footerLayout: { columns: ['brand'] },
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(400)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Use PATCH /settings/footer-layout to update footer layout',
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
test('PATCH /settings/footer-layout requires admin authentication', async () => {
|
||||
const response = await settingsRoute.handle(jsonRequest('/settings/footer-layout', 'PATCH', {
|
||||
dryRun: true,
|
||||
}))
|
||||
|
||||
expect(response.status).toBe(401)
|
||||
expect(await response.json()).toEqual({
|
||||
error: 'Unauthorized: Missing or invalid token format',
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -1,6 +1,25 @@
|
||||
import { Elysia, t } from "elysia";
|
||||
import { FooterLayoutSettingsPatchSchema } from "@homaroma/types";
|
||||
import { z } from "zod";
|
||||
import prisma from "../lib/prisma";
|
||||
import { adminOnly } from "../middleware/auth";
|
||||
import { adminOnly, requireAdmin } from "../middleware/auth";
|
||||
import {
|
||||
mergeFooterLayoutSettings,
|
||||
normalizeFooterLayoutSettings,
|
||||
} from "../lib/footerLayoutSettings";
|
||||
|
||||
const FooterLayoutUpdateRequestSchema = FooterLayoutSettingsPatchSchema.extend({
|
||||
expectedVersion: z.string().optional(),
|
||||
dryRun: z.boolean().optional(),
|
||||
}).strict().superRefine((value, context) => {
|
||||
if (!value.dryRun && !value.expectedVersion) {
|
||||
context.addIssue({
|
||||
code: "custom",
|
||||
path: ["expectedVersion"],
|
||||
message: "expectedVersion is required when updating footer layout",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export async function saveStoreSettingsTranslations(settingsId: number, payload: any) {
|
||||
const results: any[] = [];
|
||||
@@ -171,6 +190,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
const mobileHeaderStyle = (settings.themeTokens as any)?.mobileHeaderStyle ?? 'logo_center';
|
||||
return {
|
||||
...settingsData,
|
||||
footerLayout: normalizeFooterLayoutSettings((settingsData as any).footerLayout),
|
||||
translations,
|
||||
headerBehavior,
|
||||
mobileHeaderBehavior,
|
||||
@@ -220,6 +240,21 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
return { error: "Failed to fetch footer brand" };
|
||||
}
|
||||
})
|
||||
.get("/footer-layout", async ({ set }) => {
|
||||
try {
|
||||
const settings = await prisma.storeSettings.findFirst();
|
||||
const current = settings ?? await prisma.storeSettings.create({ data: {} });
|
||||
|
||||
return {
|
||||
version: current.updatedAt.toISOString(),
|
||||
layout: normalizeFooterLayoutSettings((current as any).footerLayout),
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[Settings] Footer layout fetch error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to fetch footer layout" };
|
||||
}
|
||||
})
|
||||
.use(adminOnly)
|
||||
.patch("/footer-brand", async ({ body, set }: { body: any; set: any }) => {
|
||||
try {
|
||||
@@ -271,10 +306,74 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
set.status = 400;
|
||||
return { error: err?.message || "Failed to update footer brand" };
|
||||
}
|
||||
}, { body: t.Any() })
|
||||
}, { beforeHandle: requireAdmin, body: t.Any() })
|
||||
.patch("/footer-layout", async ({ body, set }: { body: unknown; set: any }) => {
|
||||
const request = FooterLayoutUpdateRequestSchema.safeParse(body);
|
||||
if (!request.success) {
|
||||
set.status = 400;
|
||||
return { error: "Invalid footer layout", issues: request.error.issues };
|
||||
}
|
||||
|
||||
try {
|
||||
const { expectedVersion, dryRun, ...patch } = request.data;
|
||||
const settings = await prisma.storeSettings.findFirst();
|
||||
const current = settings ?? await prisma.storeSettings.create({ data: {} });
|
||||
const version = current.updatedAt.toISOString();
|
||||
|
||||
if (expectedVersion && expectedVersion !== version) {
|
||||
set.status = 409;
|
||||
return {
|
||||
error: "Footer layout has changed since it was read",
|
||||
code: "VERSION_CONFLICT",
|
||||
expectedVersion,
|
||||
currentVersion: version,
|
||||
};
|
||||
}
|
||||
|
||||
const currentLayout = normalizeFooterLayoutSettings((current as any).footerLayout);
|
||||
const layout = mergeFooterLayoutSettings(currentLayout, patch);
|
||||
if (dryRun) {
|
||||
return { dryRun: true, version, wouldUpdate: layout };
|
||||
}
|
||||
|
||||
const updated = await prisma.$transaction(async (transaction) => {
|
||||
const result = await transaction.storeSettings.updateMany({
|
||||
where: { id: current.id, updatedAt: current.updatedAt },
|
||||
data: { footerLayout: layout as any },
|
||||
});
|
||||
if (result.count === 0) return null;
|
||||
return transaction.storeSettings.findUniqueOrThrow({ where: { id: current.id } });
|
||||
});
|
||||
|
||||
if (!updated) {
|
||||
const latest = await prisma.storeSettings.findUnique({ where: { id: current.id } });
|
||||
set.status = 409;
|
||||
return {
|
||||
error: "Footer layout has changed since it was read",
|
||||
code: "VERSION_CONFLICT",
|
||||
expectedVersion,
|
||||
currentVersion: latest?.updatedAt.toISOString() ?? version,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
success: true,
|
||||
version: updated.updatedAt.toISOString(),
|
||||
layout,
|
||||
};
|
||||
} catch (err) {
|
||||
console.error("[Settings] Footer layout update error:", err);
|
||||
set.status = 500;
|
||||
return { error: "Failed to update footer layout" };
|
||||
}
|
||||
}, { beforeHandle: requireAdmin, body: t.Any() })
|
||||
.put("/", async ({ body, set }: { body: any; set: any }) => {
|
||||
try {
|
||||
const { headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
|
||||
const { footerLayout, headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
|
||||
if (footerLayout !== undefined) {
|
||||
set.status = 400;
|
||||
return { error: "Use PATCH /settings/footer-layout to update footer layout" };
|
||||
}
|
||||
const currentTokens = (typeof data.themeTokens === 'object' && data.themeTokens !== null)
|
||||
? data.themeTokens
|
||||
: {};
|
||||
@@ -354,12 +453,17 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
defaultSeoDescription: t.Optional(t.Nullable(t.String())),
|
||||
defaultOgImage: t.Optional(t.Nullable(t.String())),
|
||||
brandPrompt: t.Optional(t.Nullable(t.String())),
|
||||
footerLayout: t.Optional(t.Any()),
|
||||
translations: t.Optional(t.Any()),
|
||||
})
|
||||
})
|
||||
.post("/", async ({ body, set }: { body: any; set: any }) => {
|
||||
try {
|
||||
const { headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
|
||||
const { footerLayout, headerBehavior, mobileHeaderBehavior, mobileHeaderStyle, translations, ...data } = body as any;
|
||||
if (footerLayout !== undefined) {
|
||||
set.status = 400;
|
||||
return { error: "Use PATCH /settings/footer-layout to update footer layout" };
|
||||
}
|
||||
const currentTokens = (typeof data.themeTokens === 'object' && data.themeTokens !== null)
|
||||
? data.themeTokens
|
||||
: {};
|
||||
@@ -439,6 +543,7 @@ export const settingsRoute = new Elysia({ prefix: "/settings" })
|
||||
defaultSeoDescription: t.Optional(t.Nullable(t.String())),
|
||||
defaultOgImage: t.Optional(t.Nullable(t.String())),
|
||||
brandPrompt: t.Optional(t.Nullable(t.String())),
|
||||
footerLayout: t.Optional(t.Any()),
|
||||
translations: t.Optional(t.Any()),
|
||||
})
|
||||
})
|
||||
|
||||
Reference in new issue
Block a user