All files / services/calculator-config index.ts

100% Statements 13/13
100% Branches 4/4
100% Functions 3/3
100% Lines 13/13

Press n or j to go to the next uncovered block, b, p or k for the previous block.

1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121                            5x   5x             5x             5x                                                                                                                         9x       2x     2x               3x 3x     2x 3x 3x         2x      
// Cost Calculator config service.
//
// Reads/writes the single admin-managed pricing config. getConfig falls back to
// DEFAULT_CALCULATOR_CONFIG when no DB row exists (so the calculator works before
// any admin save). updateConfig validates the full shape with zod and bumps the
// version. KV cache invalidation lives in the admin route (matches home-page).
 
import {
	type CalculatorConfig,
	DEFAULT_CALCULATOR_CONFIG,
} from "@interioring/utils/calculator";
import { z } from "zod";
import type { Dal } from "../../dal";
 
export const CALCULATOR_CONFIG_KEY = "default";
 
const lineItemSchema = z.tuple([
	z.string(),
	z.string(),
	z.number(),
	z.string(),
]);
 
const roomDefSchema = z.object({
	name: z.string(),
	icon: z.string(),
	repeat: z.enum(["once", "perExtraBed", "perBath"]),
	items: z.array(lineItemSchema),
});
 
export const calculatorConfigSchema = z.object({
	version: z.number(),
	currency: z.literal("INR"),
	designFeePct: z.number().min(0),
	gstPct: z.number().min(0),
	bands: z.object({ full: z.number().min(0), partial: z.number().min(0) }),
	ui: z.object({
		gated: z.boolean(),
		tone: z.enum(["friendly", "expert"]),
		chartStyle: z.enum(["donut", "stacked", "bars"]),
		accent: z.enum(["terracotta", "sage", "slate"]),
	}),
	categories: z.record(
		z.string(),
		z.object({ label: z.string(), short: z.string(), color: z.string() }),
	),
	cities: z.array(
		z.object({
			id: z.string(),
			name: z.string(),
			zone: z.string(),
			mult: z.number(),
			tag: z.string(),
		}),
	),
	homes: z.array(
		z.object({
			id: z.string(),
			name: z.string(),
			beds: z.number(),
			sqft: z.number(),
			baths: z.number(),
		}),
	),
	tiers: z.array(
		z.object({
			id: z.string(),
			name: z.string(),
			mult: z.number(),
			blurb: z.string(),
			materials: z.string(),
		}),
	),
	works: z.record(
		z.string(),
		z.object({
			label: z.string(),
			icon: z.string(),
			sub: z.string(),
			core: z.boolean().optional(),
		}),
	),
	rooms: z.record(z.string(), roomDefSchema),
	timeline: z.object({
		baseWeeksByHome: z.record(z.string(), z.number()),
		tierAddWeeks: z.record(z.string(), z.number()),
		spread: z.number(),
	}),
});
 
export class CalculatorConfigService {
	constructor(private dal: Dal) {}
 
	/** Current config, or the bundled default when none has been saved yet. */
	async getConfig(): Promise<CalculatorConfig> {
		const row = await this.dal.calculatorConfig.findByKey(
			CALCULATOR_CONFIG_KEY,
		);
		return row?.config ?? DEFAULT_CALCULATOR_CONFIG;
	}
 
	/**
	 * Validate + persist a new config. Throws ZodError on a malformed body.
	 * Version is server-controlled: it's the previous version + 1.
	 */
	async updateConfig(input: unknown): Promise<CalculatorConfig> {
		const parsed = calculatorConfigSchema.parse(input);
		const current = await this.dal.calculatorConfig.findByKey(
			CALCULATOR_CONFIG_KEY,
		);
		const version = (current?.version ?? 0) + 1;
		const config: CalculatorConfig = { ...parsed, version };
		const row = await this.dal.calculatorConfig.upsert(
			CALCULATOR_CONFIG_KEY,
			config,
			version,
		);
		return row.config;
	}
}