Deep-Dive: Settings & AppConfig Data Flow Analysis¶
Date: 2026-02-25 Scope: Full tracing of how Setting and AppConfig data originates, flows through the backend API, reaches the frontend via API hooks, enters Redux, and is consumed by every component in the application.
TABLE OF CONTENTS¶
- Backend Entities & Storage
- Backend API Layer
- Backend Consumers of Settings
- Frontend API Hooks Layer
- Redux Store Layer
- SSR Settings Access (Server Components)
- CSR Settings Access (Client Components)
- Complete Consumer Component Registry
- Complete Setting Key Registry
- Settings Data Flow Map
- Caching Strategy Analysis
- Error States & Fallback Analysis
- Public vs Authenticated Access Patterns
- AppConfig: Separate System
- Issues Found
1. BACKEND ENTITIES & STORAGE¶
1.1 Setting Entity (saas-boilerplate/src/entity/Setting.ts)¶
@Entity("settings")
export class Setting {
@PrimaryGeneratedColumn() id: number;
@Column({ varchar, 255 }) key: string; // e.g., "site.footer_copyright"
@Column({ varchar, 255, nullable }) display_name: string; // e.g., "Footer Copyright"
@Column({ text, nullable }) value: string; // The actual setting value
@Column({ text, nullable }) details: string; // JSON string for select_dropdown options
@Column("simple-array", nullable) backupCodes: string[]; // MISPLACED - belongs on UserSetting
@Column({ varchar, 255, nullable }) type: string; // "text"|"textarea"|"select_dropdown"|"image"|"checkbox"|"input"
@Column({ int, nullable, default:1})order: number; // Display order within group
@Column({ varchar, 255, nullable }) group: string; // Grouping label: "site"|"admin"|"auth"|"contact"
@DeleteDateColumn({ nullable }) deletedAt?: Date; // Soft delete
}
Key design: Settings use a dot-notation key (group.field_name) where the group prefix matches the group column. This is the foundation of the entire grouping/prefix system.
1.2 AppConfig Entity (saas-boilerplate/src/entity/AppConfig.ts)¶
@Entity("app_configs")
@Index(["key"], { unique: true })
export class AppConfig {
@PrimaryGeneratedColumn() id: number;
@Column({ varchar, 150 }) key: string; // e.g., "stripe_secret_key"
@Column({ varchar, 255 }) displayName: string;
@Column({ text }) value: string; // Potentially encrypted
@Column({ default: true }) isEncrypted: boolean;
@CreateDateColumn() createdAt: Date;
@UpdateDateColumn() updatedAt: Date;
@DeleteDateColumn({ nullable }) deletedAt?: Date;
}
Key difference: AppConfig is for server-side secrets (API keys, encrypted values). Setting is for UI-facing configuration. AppConfig is NEVER exposed to the frontend.
1.3 Seed Data (saas-boilerplate/src/seed/data/setting.ts)¶
17 settings are seeded across 4 groups:
| Group | Key | Type | Seeded Value |
|---|---|---|---|
| admin | admin.site_name | input | "SASS Boilerplate" |
| admin | admin.test_5 | select_dropdown | "option_2" |
| admin | admin.favicon | image | Cloudinary URL |
| admin | admin.meta_description | textarea | Long description text |
| admin | admin.meta_image | image | Cloudinary URL |
| auth | auth.login_page_type | select_dropdown | "1" (options: ½/3) |
| auth | auth.register_page_type | select_dropdown | "2" (options: ½/3) |
| auth | auth.login_image | image | Cloudinary URL |
| auth | auth.register_image | image | Cloudinary URL |
| auth | auth.website_type | select_dropdown | "2" (1=default, 2=invite only) |
| contact | contact.contact_address | input | Florida HQ address |
| contact | contact.page_desc | textarea | Contact page description |
| contact | contact.contact_phone | input | "(800) 690-3072" |
| contact | contact.contact_email | input | "sass@gmail.com" |
| contact | contact.page_title | input | "We're here to help you" |
| site | site.footer_copyright | input | "2025 SaaSify..." |
| site | site.site_logo | image | Cloudinary URL |
| site | site.site_logo_dark | image | Cloudinary URL |
| site | site.footer_text | textarea | Footer description text |
NOT seeded but consumed by frontend (see Issues):
- admin.meta_title -- used by root layout generateMetadata()
- admin.admin_site_title -- used by dashboard layout <title> tag
- site.footer_site_link -- used by footer-credit.jsx
- site.footer_site_name -- used by footer-credit.jsx
- site.footer_heart_link -- used by footer-credit.jsx
- site.site_name -- used by new sidebar fallback text
- admin.image_size -- referenced in setting.service.ts getImageSizeByKey()
2. BACKEND API LAYER¶
2.1 Setting Routes (saas-boilerplate/src/app/modules/v1/Setting/setting.routes.ts)¶
Mounted at /api/v1/settings.
| Method | Path | Auth Required | Controller Method | Description |
|---|---|---|---|---|
| GET | / |
NO | getAllSettings |
Returns ALL settings |
| GET | /:prefix/prefix |
NO | getSettingByPrefix |
Returns settings matching prefix via LIKE |
| GET | /:id |
NO | getSettingById |
Returns single setting by ID |
| POST | / |
YES | createSetting |
Creates new setting (multipart) |
| PATCH | /bulk-update |
YES | updateSettings |
Bulk update by key |
| PATCH | /:id |
YES | updateSettingById |
Update single setting (multipart) |
| DELETE | /:id |
YES | deleteSetting |
Soft delete setting |
CRITICAL FINDING: The GET endpoints (/, /:prefix/prefix, /:id) have NO authentication. The auth() middleware is applied AFTER the GET routes via router.use(auth(), contextMiddleware). This means ALL settings (including any sensitive ones) are publicly readable.
2.2 Setting Service Key Methods¶
getAllSettings(): Returns all non-deleted settings. No pagination, no filtering. Used by the GET/endpoint.getSettingsByPrefix(prefix): UsesLIKE '${prefix}%'query. Used by GET/:prefix/prefix. This is the primary method the frontend uses for grouped access.getSettingByKey(key): Exact key lookup. Used internally by auth service and registration policy.getImageSizeByKey(): Hardcoded to look upadmin.image_size. Returns numeric value or default 5. Defined but NEVER called anywhere in the codebase.
2.3 AppConfig Routes (saas-boilerplate/src/app/modules/v1/AppConfig/app_config.routes.ts)¶
Mounted at /api/v1/app-configs.
| Method | Path | Auth Required | Permission Check |
|---|---|---|---|
| GET | / |
NO (!) | app_config.read in service |
| GET | /:id |
NO (!) | app_config.read in service |
| POST | / |
YES | create_app_config.create |
| PATCH | /:id |
YES | update_app_config.update |
| DELETE | /:id |
YES | app_config.delete |
Note: GET routes lack route-level auth but have service-level permission checks via checkPermissionAndThrow. This means unauthenticated users will fail at the service layer, not the route layer.
3. BACKEND CONSUMERS OF SETTINGS¶
3.1 Auth Service (auth.service.ts)¶
Setting consumed: auth.website_type
How: SettingService.getSettingByKey("auth.website_type")
Purpose: Determines registration behavior:
- Value "2": Invite-only registration. Checks if user email exists in invite table.
- Any other value: Open registration allowed.
Called in: registerUser() function (lines 61, 119 across main and refactored versions)
3.2 Registration Policy (RegistrationPolicy.ts)¶
Setting consumed: auth.website_type
How: SettingService.getSettingByKey("auth.website_type")
Purpose: Policy check before registration. If value is "2", verifies user is in the invite list.
Called in: can() method of RegisterPolicy
3.3 Stripe Service (stripe.service.ts)¶
Config consumed: stripe_secret_key (AppConfig, NOT Setting)
How: AppConfigService.getConfigByKey("stripe_secret_key")
Purpose: Retrieves the Stripe secret key at runtime from the database instead of environment variables.
Called in: Line 229 of stripe.service.ts
3.4 Image Size (Dead Code)¶
Setting referenced: admin.image_size
How: SettingService.getImageSizeByKey() in setting.service.ts
Purpose: Would return max image upload size in MB.
Status: DEAD CODE -- defined in the service but never called by any controller, route, or other service.
4. FRONTEND API HOOKS LAYER¶
4.1 Authenticated Settings Hook (src/api/settings/index.js)¶
useSettings() -- Fetch ALL settings (authenticated)¶
const useSettings = () => {
return useQuery({
queryKey: ["settings"],
queryFn: async () => {
const res = await api.get("/settings"); // GET /api/v1/settings
const formattedSettings = res?.data?.data?.reduce((acc, item) => {
const [group, field] = item.key.split(".");
if (!acc[group]) acc[group] = {};
acc[group][field] = item.value;
return acc;
}, {});
return { settings: formattedSettings, data: res?.data?.data || [] };
},
staleTime: (parseInt(process.env.NEXT_PUBLIC_CACHE_TIME) || 1) * 60 * 60 * 1000,
});
};
Data transformation: Raw array [{key: "site.footer_text", value: "..."}] is transformed into nested object { site: { footer_text: "..." }, admin: { ... } }.
Returns two shapes: settings (nested by group) and data (raw array). The settings shape goes to Redux; the data shape is used by the site-settings admin page.
Stale time: Configurable via NEXT_PUBLIC_CACHE_TIME env var (default: 1 hour in milliseconds).
useSettingByGroup(group) -- Fetch settings by group prefix (authenticated)¶
const useSettingByGroup = (group) => {
return useQuery({
queryKey: ["settings", group],
queryFn: async () => {
const res = await api.get(`/settings/${group}/prefix`);
// Same dot-split transformation...
return settings; // Nested object: { site: { footer_text: "..." } }
},
staleTime: ...,
});
};
Used by: sidebar-old.jsx only (fetches "site" group directly via authenticated API).
useUpdateSetting(), useCreateSetting(), useDeleteSetting() -- Mutation hooks¶
All invalidate the ["settings"] query key on success. useUpdateSetting and useCreateSetting also call revalidate("settings-group") to bust the Next.js server-side cache.
4.2 Public Settings (Server-Side) (src/api/public/settings.js)¶
export const getSettingByGroup = unstable_cache(
async (group) => {
const response = await fetch(`${process.env.API_URL}/settings/${group}/prefix`);
const data = await response.json();
const settings = data?.data?.reduce(/* same dot-split transform */);
return settings;
},
["settings-group"], // Cache key
{
revalidate: (parseInt(process.env.NEXT_PUBLIC_CACHE_TIME) || 1) * 60 * 60,
tags: ["settings-group"], // Revalidation tag
}
);
Key difference from authenticated hook: Uses fetch() directly (not axios), uses process.env.API_URL (server-side env var), and wraps in Next.js unstable_cache for ISR/SSR caching.
Cache invalidation: When a setting is updated via useUpdateSetting or useCreateSetting, they call revalidate("settings-group") which triggers revalidateTag("settings-group") server action, busting this cache.
4.3 Account Settings (src/api/settings/account-setting.js)¶
NOT part of the Setting entity. This is the UserSetting entity (per-user 2FA configuration). Separate system:
- useAccountSettings() -- GET /user-settings/me
- useSetupTwoFactor() -- PUT /user-settings/2fa/google/setup
- useVerifyOTP() -- Various 2FA verification endpoints
- useBackupCodes() -- POST /user-settings/2fa/generate-backup-codes
These are user-scoped settings, not application-wide settings.
5. REDUX STORE LAYER¶
5.1 Store Configuration (src/store/index.js)¶
export const store = configureStore({
reducer: {
auth: authReducer,
setting: settinReducer, // NOTE: typo "settinReducer" (missing 'g')
},
});
5.2 Setting Slice (src/store/slices/settingSlice.js)¶
const initialState = { settings: {} };
const settingSlice = createSlice({
name: "setting",
reducers: {
setSettings: (state, action) => {
state.settings = action.payload;
},
},
});
export const { setSettings } = settingSlice.actions;
const EMPTY_OBJECT = {};
export const getAllSettings = (state) => state.setting.settings;
export const getSettingByGroup = (group) => (state) =>
state.setting.settings[group] ?? EMPTY_OBJECT;
State shape: { setting: { settings: { site: { footer_text: "..." }, admin: { site_name: "..." }, auth: { ... }, contact: { ... } } } }
IMPORTANT: getSettingByGroup returns EMPTY_OBJECT (a stable reference) when the group doesn't exist, preventing unnecessary re-renders. This is a good pattern.
5.3 How Settings Enter Redux¶
The ONLY entry point is dashboard/layout.js:
const { data: settings } = useSettings(); // Fetch from API
const dispatch = useDispatch();
useEffect(() => {
if (settings?.settings) {
dispatch(setSettings(settings.settings)); // Push nested object to Redux
}
}, [settings, dispatch]);
This means: 1. Settings are ONLY loaded into Redux when the dashboard layout mounts. 2. Public pages (main layout) do NOT populate Redux with settings. 3. Redux settings are only available in the dashboard context.
6. SSR SETTINGS ACCESS (SERVER COMPONENTS)¶
Server components use getSettingByGroup() from @/api/public/settings. This function:
1. Calls the backend API at build/request time using fetch()
2. Uses unstable_cache with a revalidation period
3. Returns the nested object format
6.1 SSR Consumer: Root Layout (src/app/layout.js)¶
Function: fetchMetaData() (NOT using the shared getSettingByGroup)
API Call: fetch(\${process.env.API_URL}/settings/admin/prefix`)**Caching**: Usesnext: { revalidate: ... }(Next.js fetch cache, NOTunstable_cache)
**Fields accessed**:
-settings?.admin?.meta_title-- Page title, fallback:"Sass Boilerplate"-settings?.admin?.site_name-- Title template suffix
-settings?.admin?.meta_description-- SEO description, fallback:"SaaS Boilerplate"-settings?.admin?.favicon-- Favicon URL
-settings?.admin?.meta_image` -- Open Graph / Twitter card image
Error handling: Catches errors and returns the string "SASS Boilerplate" (not an object). This will cause settings?.admin?.meta_title to be undefined, triggering the fallback.
6.2 SSR Consumer: Login Page (src/app/(auth)/login/page.js)¶
API Calls:
- getSettingByGroup("auth") -- for page metadata and auth settings
- getSettingByGroup("site") -- for site logo
Fields accessed (metadata):
- settings?.auth?.login_page_title -- Page title, fallback: "Login"
Props passed to LoginPage component:
- settings?.auth as settings
- siteSettings?.site as siteSettings
6.3 SSR Consumer: Register Page (src/app/(auth)/register/page.js)¶
API Calls:
- getSettingByGroup("auth") -- for page metadata and auth settings
- getSettingByGroup("site") -- for site logo
Fields accessed (metadata):
- settings?.auth?.register_page_title -- Page title, fallback: "Register"
Props passed to Register component:
- settings?.auth as settings
- siteSettings?.site as siteSettings
6.4 SSR Consumer: Contact Page (src/app/(main-layout)/contact/page.js)¶
API Call: getSettingByGroup("contact")
Props passed to ContactPage component: settings?.contact as settings
6.5 SSR Consumer: Header Wrapper (src/components/layout/header-wrapper.jsx)¶
API Call: getSettingByGroup("site")
Props passed to Header component: settings?.site as settings
6.6 SSR Consumer: Footer Wrapper (src/components/layout/footer-wrapper.jsx)¶
API Call: getSettingByGroup("site")
Props passed to Footer component: settings?.site as settings
7. CSR SETTINGS ACCESS (CLIENT COMPONENTS)¶
Client components access settings in two ways:
1. Via Redux (populated by dashboard layout) using useSelector(getSettingByGroup("group"))
2. Via React Query hooks directly (useSettings(), useSettingByGroup())
3. Via props (passed from SSR server components)
7.1 Dashboard Layout as Settings Gateway¶
src/app/(dashboard-layout)/dashboard/layout.js is the central hub for dashboard settings:
useSettings() API call
|
v
useEffect -> dispatch(setSettings(settings.settings)) -> Redux Store
|
v
useSelector(getSettingByGroup("site")) -> passed as prop to Sidebar
useSelector(getSettingByGroup("admin")) -> used for <title> tag
Fields accessed directly in layout:
- adminSettings?.admin_site_title -- Dashboard page title (NOT SEEDED)
- adminSettings?.site_name -- Dashboard page title suffix (SEEDED as admin.site_name)
- siteSettings -- passed entirely to Sidebar component
8. COMPLETE CONSUMER COMPONENT REGISTRY¶
CONSUMER 1: footer-credit.jsx¶
| Aspect | Detail |
|---|---|
| File | src/components/layout/footer-credit.jsx |
| Access method | useSelector(getSettingByGroup("site")) (Redux) |
| Fields accessed | footer_site_link, footer_site_name, footer_copyright, footer_heart_link |
| Fallbacks | footer_site_link -> "https://algorizetech.com", footer_site_name -> "Algorize Tech", footer_heart_link -> "https://algorizetech.com", footer_copyright -> NO FALLBACK (renders empty) |
| Data flow | API -> useSettings() -> Redux -> useSelector |
| Context | Only renders inside dashboard layout (after Redux is populated) |
| Used for | Copyright text, credit links in dashboard footer |
CONSUMER 2: dashboard/layout.js¶
| Aspect | Detail |
|---|---|
| File | src/app/(dashboard-layout)/dashboard/layout.js |
| Access method | useSettings() hook + Redux dispatch + useSelector() |
| Fields accessed | adminSettings?.admin_site_title, adminSettings?.site_name (from "admin" group), entire siteSettings object (from "site" group) |
| Fallbacks | None for title fields. If settings API is loading, shows <Loading fullScreen /> |
| Data flow | API -> useSettings() -> dispatch to Redux AND useSelector |
| Used for | <title> tag, passing siteSettings to Sidebar, passing isSettingsLoading to Sidebar |
CONSUMER 3: top-nav.jsx¶
| Aspect | Detail |
|---|---|
| File | src/app/(dashboard-layout)/dashboard/components/top-nav.jsx |
| Access method | NONE -- does NOT consume settings |
| Note | Has a commented-out import: // import { useSettings } from "@/contexts/settings-context". Settings context was planned but never implemented. |
| Used for | Breadcrumb navigation, user dropdown menu (uses user data, not settings) |
CONSUMER 4: sidebar-old.jsx (DEPRECATED)¶
| Aspect | Detail |
|---|---|
| File | src/app/(dashboard-layout)/dashboard/components/sidebar-old.jsx |
| Access method | useSettingByGroup("site") (direct React Query hook, NOT Redux) |
| Fields accessed | siteSettings?.site?.site_logo, siteSettings?.site?.site_logo_dark |
| Fallbacks | Falls back to "/logo.png" for dark logo if missing |
| Data flow | API -> useSettingByGroup("site") -> local query state |
| Used for | Sidebar logo display (light/dark theme variants) |
| Note | This is the OLD sidebar. Has a commented-out reference to siteSettings?.admin?.site_name |
CONSUMER 5: sidebar.jsx (CURRENT)¶
| Aspect | Detail |
|---|---|
| File | src/app/(dashboard-layout)/dashboard/components/sidebar.jsx |
| Access method | Receives siteSettings as prop from dashboard layout |
| Fields accessed | siteSettings?.site_logo, siteSettings?.site_logo_dark, siteSettings?.site_name |
| Fallbacks | If site_logo is falsy or equals "undefined", displays siteSettings?.site_name as text, with final fallback "Dashboard". Dark logo falls back to "/logo.png" |
| Data flow | API -> useSettings() -> Redux -> layout useSelector -> prop drill -> Sidebar |
| Used for | Sidebar header logo (light/dark), fallback text when no logo |
CONSUMER 6: site-settings-page.jsx¶
| Aspect | Detail |
|---|---|
| File | src/components/pages/settings/site-settings-page.jsx |
| Access method | useSettings() (React Query hook) |
| Fields accessed | settings?.data (raw array), iterates ALL settings |
| Fallbacks | Shows "No Settings Found" message if groups are empty. Shows <Loading semiScreen /> while loading |
| Data flow | API -> useSettings() -> local state |
| Used for | Admin settings management UI. Groups settings by group field, renders editable SettingField per setting. Sorts by order. |
| Permission | Wrapped in <PermissionWrapper permission="setting.view"> |
CONSUMER 7: setting-field.jsx¶
| Aspect | Detail |
|---|---|
| File | src/components/pages/settings/setting-field.jsx |
| Access method | Receives individual setting object as prop from site-settings-page |
| Fields accessed | setting.id, setting.value, setting.type, setting.display_name, setting.key, setting.details |
| Data flow | API -> useSettings() -> site-settings-page -> prop -> setting-field |
| Used for | Individual setting editor. Renders appropriate input type (text/textarea/select/image/checkbox). Updates via useUpdateSetting(), deletes via useDeleteSetting() |
| Permissions | Update wrapped in <PermissionWrapper permission="setting.update">, delete in <PermissionWrapper permission="setting.delete"> |
CONSUMER 8: create-setting-form.jsx¶
| Aspect | Detail |
|---|---|
| File | src/components/pages/settings/create-setting-form.jsx |
| Access method | Creates/updates settings via useCreateSetting() and useUpdateSetting() |
| Fields accessed | In edit mode: setting.key, setting.display_name, setting.type, setting.order, setting.group, setting.value, setting.details |
| Data flow | Props from setting-field (edit mode) or standalone (create mode) |
| Used for | Modal form for creating new settings or editing existing ones |
| Permission | Create wrapped in <PermissionWrapper permission="setting.create"> |
CONSUMER 9: header.jsx (Public Site Header)¶
| Aspect | Detail |
|---|---|
| File | src/components/layout/header.jsx |
| Access method | Receives settings as prop from SSR HeaderWrapper |
| Fields accessed | settings?.site_logo, settings?.site_logo_dark |
| Fallbacks | None. If logos are undefined, <Image> will have undefined src |
| Data flow | SSR: getSettingByGroup("site") -> HeaderWrapper -> prop -> Header |
| Used for | Public site header logo (light/dark variants) |
CONSUMER 10: footer.jsx (Public Site Footer)¶
| Aspect | Detail |
|---|---|
| File | src/components/layout/footer.jsx |
| Access method | Receives settings as prop from SSR FooterWrapper |
| Fields accessed | settings?.site_logo, settings?.site_logo_dark, settings?.footer_text, settings?.footer_copyright |
| Fallbacks | None. All fields render as empty if missing |
| Data flow | SSR: getSettingByGroup("site") -> FooterWrapper -> prop -> Footer |
| Used for | Public site footer: logo, description text, copyright |
CONSUMER 11: login.jsx (Login Page Component)¶
| Aspect | Detail |
|---|---|
| File | src/components/pages/auth/login.jsx |
| Access method | Receives settings and siteSettings as props from SSR login page |
Fields accessed from settings (auth group) |
login_page_type, login_image |
Fields accessed from siteSettings (site group) |
site_logo, site_logo_dark |
| Fallbacks | login_page_type -> "1", login_image -> imported loginImg static asset |
| Data flow | SSR: getSettingByGroup("auth") + getSettingByGroup("site") -> page.js -> props -> LoginPage |
| Used for | Controls login page layout type (1=default with bg, 2=custom image, 3=form only), custom background image, site logo |
CONSUMER 12: register.jsx (Register Page Component)¶
| Aspect | Detail |
|---|---|
| File | src/components/pages/auth/register.jsx |
| Access method | Receives settings and siteSettings as props from SSR register page |
Fields accessed from settings (auth group) |
website_type, register_page_type, register_image |
Fields accessed from siteSettings (site group) |
site_logo, site_logo_dark |
| Fallbacks | website_type -> 1, register_page_type -> "1" |
| Data flow | SSR: getSettingByGroup("auth") + getSettingByGroup("site") -> page.js -> props -> Register |
| Used for | Controls registration behavior (open/invite-only/approval), page layout type, custom background image, site logo. website_type controls invite-check logic |
CONSUMER 13: contact-page.jsx (Contact Page Component)¶
| Aspect | Detail |
|---|---|
| File | src/components/pages/contact-frontend/contact-page.jsx |
| Access method | Receives settings as prop from SSR contact page |
| Fields accessed | page_title, page_desc, contact_phone, contact_email, contact_address |
| Fallbacks | None. All fields render empty if missing |
| Data flow | SSR: getSettingByGroup("contact") -> page.js -> props -> ContactPage |
| Used for | Contact page: title, description, phone number, email, physical address |
CONSUMER 14: Root Layout generateMetadata() (SSR)¶
| Aspect | Detail |
|---|---|
| File | src/app/layout.js |
| Access method | Direct fetch() to /settings/admin/prefix (NOT using shared getSettingByGroup) |
| Fields accessed | admin.meta_title, admin.site_name, admin.meta_description, admin.favicon, admin.meta_image |
| Fallbacks | meta_title -> "Sass Boilerplate", meta_description -> "SaaS Boilerplate" |
| Data flow | SSR: fetch() -> transform -> generateMetadata return |
| Used for | HTML <title>, meta description, favicon, Open Graph tags, Twitter cards |
9. COMPLETE SETTING KEY REGISTRY¶
Keys consumed by FRONTEND (all access paths):¶
| Full Key | Group | Field | Where Consumed | Access Path |
|---|---|---|---|---|
admin.site_name |
admin | site_name |
Root layout (title template), Dashboard layout | SSR fetch + Redux |
admin.meta_title |
admin | meta_title |
Root layout generateMetadata | SSR fetch |
admin.meta_description |
admin | meta_description |
Root layout generateMetadata | SSR fetch |
admin.favicon |
admin | favicon |
Root layout generateMetadata | SSR fetch |
admin.meta_image |
admin | meta_image |
Root layout generateMetadata | SSR fetch |
admin.admin_site_title |
admin | admin_site_title |
Dashboard layout <title> |
Redux |
site.site_logo |
site | site_logo |
Header, Footer, Sidebar, Sidebar-old, Login, Register | SSR props + Redux prop drill |
site.site_logo_dark |
site | site_logo_dark |
Header, Footer, Sidebar, Sidebar-old, Login, Register | SSR props + Redux prop drill |
site.footer_text |
site | footer_text |
Footer (public) | SSR props |
site.footer_copyright |
site | footer_copyright |
Footer (public), FooterCredit (dashboard) | SSR props + Redux |
site.footer_site_link |
site | footer_site_link |
FooterCredit (dashboard) | Redux |
site.footer_site_name |
site | footer_site_name |
FooterCredit (dashboard) | Redux |
site.footer_heart_link |
site | footer_heart_link |
FooterCredit (dashboard) | Redux |
site.site_name |
site | site_name |
Sidebar (current) -- fallback text | Redux prop drill |
auth.login_page_type |
auth | login_page_type |
Login page, Login page metadata | SSR props |
auth.login_image |
auth | login_image |
Login page | SSR props |
auth.register_page_type |
auth | register_page_type |
Register page | SSR props |
auth.register_image |
auth | register_image |
Register page | SSR props |
auth.website_type |
auth | website_type |
Register page (invite-only logic) | SSR props |
auth.login_page_title |
auth | login_page_title |
Login page generateMetadata | SSR |
auth.register_page_title |
auth | register_page_title |
Register page generateMetadata | SSR |
contact.page_title |
contact | page_title |
Contact page | SSR props |
contact.page_desc |
contact | page_desc |
Contact page | SSR props |
contact.contact_phone |
contact | contact_phone |
Contact page | SSR props |
contact.contact_email |
contact | contact_email |
Contact page | SSR props |
contact.contact_address |
contact | contact_address |
Contact page | SSR props |
Keys consumed by BACKEND:¶
| Full Key | Where Consumed | Purpose |
|---|---|---|
auth.website_type |
auth.service.ts, RegistrationPolicy.ts | Invite-only registration check |
admin.image_size |
setting.service.ts (DEAD CODE) | Max image upload size |
stripe_secret_key |
stripe.service.ts (AppConfig) | Stripe API authentication |
10. SETTINGS DATA FLOW MAP¶
┌─────────────────────────────┐
│ PostgreSQL "settings" │
│ table (Setting entity) │
│ 17 seeded rows │
└─────────────┬───────────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
SettingService SettingService SettingService
.getAllSettings() .getSettingsByPrefix() .getSettingByKey()
│ │ │
│ │ │
SettingController SettingController (Internal only)
.getAllSettings .getSettingByPrefix │
│ │ │
┌─────────┴──────┐ ┌────────┴───────┐ ┌─────┴──────────┐
│ GET /settings │ │ GET /settings/ │ │ auth.service │
│ (NO AUTH!) │ │ :prefix/prefix │ │ Registration │
│ │ │ (NO AUTH!) │ │ Policy │
└───────┬────────┘ └───────┬────────┘ └────────────────┘
│ │
┌─────────────┘ └──────────────────┐
│ │
┌─────────┴──────────┐ ┌──────────────┴────────────────┐
│ FRONTEND CSR │ │ FRONTEND SSR │
│ (Dashboard) │ │ (Server Components) │
│ │ │ │
│ useSettings() │ │ getSettingByGroup(group) │
│ queryKey: │ │ unstable_cache │
│ ["settings"] │ │ tags: ["settings-group"] │
│ GET /settings │ │ fetch() /settings/:grp/prefix │
└────────┬───────────┘ └──────────────┬────────────────┘
│ │
┌────────┴───────────┐ ┌──────────────┴────────────────┐
│ Transform: │ │ Transform: │
│ [{key:"a.b", │ │ Same dot-split │
│ value:"x"}] │ │ -> { group: { field: val } } │
│ -> { a: {b:"x"} } │ └──────────────┬────────────────┘
└────────┬───────────┘ │
│ ┌───────────┴───────────────┐
┌────────┴───────────┐ │ │
│ Redux Dispatch │ ┌─────────┴──────────┐ ┌───────────┴──────────┐
│ setSettings( │ │ layout.js │ │ page.js files │
│ {settings} │ │ fetchMetaData() │ │ (login, register, │
│ ) │ │ generateMetadata │ │ contact) │
└────────┬───────────┘ │ -> <meta> tags │ │ generateMetadata │
│ └────────────────────┘ │ -> page title │
┌────────┴───────────┐ │ -> props to client │
│ Redux Store │ │ components │
│ state.setting │ └──────────┬───────────┘
│ .settings │ │
│ { site: {...}, │ ┌──────────┴───────────┐
│ admin: {...}, │ │ Client Components │
│ auth: {...}, │ │ via Props: │
│ contact: {...}} │ │ - LoginPage │
└────────┬───────────┘ │ - Register │
│ │ - ContactPage │
┌────────┴────────────────────────┐ │ - Header │
│ useSelector( │ │ - Footer │
│ getSettingByGroup("group") │ └──────────────────────┘
│ ) │
│ │
│ Dashboard consumers: │
│ - layout.js (admin, site) │
│ - footer-credit.jsx (site) │
│ - sidebar.jsx (site via prop) │
└─────────────────────────────────┘
Dual Access Path for "site" Group (Dashboard Context)¶
The "site" settings traverse TWO paths in the dashboard:
Path A (Redux -> direct selector):
useSettings() -> Redux -> useSelector(getSettingByGroup("site")) -> footer-credit.jsx
Path B (Redux -> selector -> prop drill):
useSettings() -> Redux -> useSelector(getSettingByGroup("site")) -> layout.js
-> <Sidebar siteSettings={siteSettings} /> -> sidebar.jsx
Dual Access Path for "site" Group (Old Sidebar)¶
sidebar-old.jsx uses BOTH:
Path A: useSettingByGroup("site") -> direct API call (authenticated, separate cache)
Path B: The old sidebar is NOT used in the current layout, so this is dead code
11. CACHING STRATEGY ANALYSIS¶
11.1 Three Caching Layers¶
| Layer | Mechanism | Duration | Scope | Invalidation |
|---|---|---|---|---|
| Next.js fetch cache | next: { revalidate } |
NEXT_PUBLIC_CACHE_TIME hours (default: 1h) |
Per-fetch, server-side | Time-based only |
| Next.js unstable_cache | unstable_cache() |
NEXT_PUBLIC_CACHE_TIME hours (default: 1h) |
Shared across requests | revalidateTag("settings-group") + time-based |
| React Query cache | staleTime |
NEXT_PUBLIC_CACHE_TIME hours (default: 1h) |
Per-client, client-side | invalidateQueries(["settings"]) on mutation |
11.2 Cache Invalidation Flow¶
When an admin updates a setting:
useUpdateSetting.mutate({id, value})
|
├── onSuccess:
│ ├── queryClient.invalidateQueries({ queryKey: ["settings"] })
│ │ -> React Query refetches useSettings() on next component mount
│ │ -> BUT does NOT invalidate useSettingByGroup() queries (different key!)
│ │
│ └── await revalidate("settings-group")
│ -> Server action calls revalidateTag("settings-group")
│ -> Busts unstable_cache for SSR pages
│ -> Does NOT bust the fetch cache in root layout (uses different mechanism)
ISSUE: The root layout's fetchMetaData() uses next: { revalidate } which is time-based only. Admin settings updates (meta_title, favicon, etc.) will NOT be reflected until the time-based cache expires. The revalidateTag mechanism does not reach this cache.
11.3 Environment-Dependent Caching¶
| Variable | Used For | Default |
|---|---|---|
NEXT_PUBLIC_CACHE_TIME |
All three caching layers (hours) | 1 (hour) |
API_URL |
Server-side fetch (SSR) | Must be set |
NEXT_PUBLIC_API_URL |
Client-side API calls via axios | Must be set |
No dev vs. production differentiation in caching strategy. The stale time is the same in all environments.
12. ERROR STATES & FALLBACK ANALYSIS¶
12.1 If Settings API Is Down¶
| Context | Behavior |
|---|---|
| Root layout SSR | Catches error, returns "SASS Boilerplate" string. Fallbacks activate for meta tags. |
| Public getSettingByGroup SSR | Catches error, returns [] (empty array). All setting fields will be undefined. |
| Dashboard layout CSR | useSettings() throws, isSettingsLoading stays truthy. Infinite loading screen -- the layout renders <Loading fullScreen /> until settings load. No timeout or error UI. |
| sidebar-old.jsx CSR | useSettingByGroup() throws. isSettingsLoading stays true. Logo section stays hidden (guarded by !isSettingsLoading). |
| Login/Register SSR | getSettingByGroup() returns []. Props will be undefined. Components use hardcoded fallbacks for page type. |
| Contact page SSR | getSettingByGroup() returns []. Props will be undefined. Contact info renders empty. |
12.2 Fallback Values Summary¶
| Field | Component | Fallback |
|---|---|---|
footer_site_link |
footer-credit.jsx | "https://algorizetech.com" |
footer_site_name |
footer-credit.jsx | "Algorize Tech" |
footer_heart_link |
footer-credit.jsx | "https://algorizetech.com" |
footer_copyright |
footer-credit.jsx | NONE (renders empty) |
login_page_type |
login.jsx | "1" |
login_image |
login.jsx | Imported loginImg static asset |
register_page_type |
register.jsx | "1" |
website_type |
register.jsx | 1 (number, not string!) |
site_logo |
sidebar.jsx | Falls through to site_name, then "Dashboard" |
site_logo_dark |
sidebar.jsx | "/logo.png" |
site_logo_dark |
sidebar-old.jsx | "/logo.png" |
meta_title |
layout.js | "Sass Boilerplate" |
meta_description |
layout.js | "SaaS Boilerplate" |
login_page_title |
login page.js | "Login" |
register_page_title |
register page.js | "Register" |
| All site fields | header.jsx | NONE |
| All site fields | footer.jsx | NONE |
| All contact fields | contact-page.jsx | NONE |
13. PUBLIC VS AUTHENTICATED ACCESS PATTERNS¶
13.1 Public Access (No Auth Required)¶
All three GET endpoints on /settings are public (no auth middleware):
- GET /settings -- returns ALL settings
- GET /settings/:prefix/prefix -- returns settings by group prefix
- GET /settings/:id -- returns single setting by ID
Frontend public consumers (SSR, using process.env.API_URL):
- Root layout: fetches admin group
- Login page: fetches auth and site groups
- Register page: fetches auth and site groups
- Contact page: fetches contact group
- Header wrapper: fetches site group
- Footer wrapper: fetches site group
13.2 Authenticated Access¶
Write operations require auth + permission checks:
- POST /settings -- requires setting.create permission
- PATCH /settings/:id -- requires setting.update permission
- PATCH /settings/bulk-update -- requires setting.bulkUpdate permission
- DELETE /settings/:id -- requires setting.delete permission
Frontend authenticated consumers (CSR, using axios with auth interceptor):
- Dashboard layout: useSettings() -- fetches ALL settings via GET /settings
- Site settings page: useSettings() -- fetches ALL settings
- sidebar-old.jsx: useSettingByGroup("site") -- fetches site group
13.3 Security Observation¶
The GET /settings endpoint returns ALL settings including their full key, value, type, details, group, and order. There is no filtering of sensitive data. While current seed data contains only UI configuration, any setting added to this table (including potentially sensitive ones) would be publicly accessible.
14. APPCONFIG: SEPARATE SYSTEM¶
AppConfig is a completely separate system from Settings:
| Aspect | Setting | AppConfig |
|---|---|---|
| Purpose | UI configuration, site branding | Server-side secrets (API keys) |
| Entity | Setting (settings table) |
AppConfig (app_configs table) |
| Frontend access | Yes (public GET, authenticated CRUD) | NO (only backend) |
| Encryption | No | Yes (isEncrypted flag, encrypt() function) |
| Key format | group.field_name |
Simple key (e.g., stripe_secret_key) |
| Only backend consumer | auth.service.ts, RegistrationPolicy | stripe.service.ts |
| Route | /api/v1/settings |
/api/v1/app-configs |
| Seed data | 17 rows | None |
| Permission | setting.* |
app_config.* / create_app_config.* / update_app_config.* |
Note: AppConfig has inconsistent permission strings: app_config.read, create_app_config.create, update_app_config.update, app_config.delete. The create and update permissions use different prefixes than read and delete.
15. ISSUES FOUND¶
CRITICAL Issues¶
| # | Issue | Severity | Details |
|---|---|---|---|
| C1 | Dashboard infinite loading if settings API fails | Critical | dashboard/layout.js line 39: if (!user \|\| isLoading \|\| bottomLinksLoading \|\| isSettingsLoading) return <Loading fullScreen />. If the settings API is unreachable, the entire dashboard is permanently blocked with no error message, no timeout, and no retry UI. |
| C2 | All settings publicly readable without authentication | Critical | setting.routes.ts places GET routes BEFORE router.use(auth()). All 17+ settings (and any future sensitive settings) are exposed via GET /settings. |
| C3 | admin.admin_site_title key consumed but NEVER seeded or defined |
Critical | dashboard/layout.js line 46 reads adminSettings?.admin_site_title. This key does not exist in seed data, the key naming convention would require the full key to be admin.admin_site_title, and the dot-split transform would produce { admin: { admin_site_title: "..." } }. Since admin.admin_site_title is not seeded, the dashboard <title> tag renders undefined | undefined. |
| C4 | admin.meta_title key consumed but NOT seeded |
Critical | Root layout generateMetadata() reads settings?.admin?.meta_title but this key is not in the seed data. Fallback "Sass Boilerplate" activates. The setting must be manually created by an admin. |
HIGH Issues¶
| # | Issue | Severity | Details |
|---|---|---|---|
| H1 | Root layout fetch cache not invalidated on setting update | High | layout.js uses next: { revalidate } (time-based), NOT unstable_cache with tags. When admin updates meta_title/favicon via the settings UI, the revalidateTag("settings-group") call does NOT bust this cache. Changes only appear after the time-based revalidation period expires. |
| H2 | Duplicate settings fetch in root layout | High | layout.js has its own fetchMetaData() that duplicates the exact same transform logic as getSettingByGroup() in api/public/settings.js. Different caching mechanism (fetch cache vs unstable_cache). Should use the shared function. |
| H3 | useSettingByGroup() cache NOT invalidated on mutation |
High | useUpdateSetting.onSuccess invalidates ["settings"] but NOT ["settings", group]. The sidebar-old.jsx (which uses useSettingByGroup("site") with queryKey ["settings", "site"]) would NOT get the updated data until its stale time expires. |
| H4 | 5 setting keys consumed by frontend but never seeded | High | Keys admin.admin_site_title, admin.meta_title, site.footer_site_link, site.footer_site_name, site.footer_heart_link, auth.login_page_title, auth.register_page_title are all consumed but not in seed data. Requires manual creation by admin. |
| H5 | backupCodes column on Setting entity is misplaced |
High | The Setting entity has backupCodes: string[] and verifyBackupCode() method references a userId column that does NOT exist on Setting. This is clearly UserSetting functionality incorrectly placed on the Setting entity. The verifyBackupCode() method in setting.service.ts would crash with Property 'userId' does not exist on type 'FindOptionsWhere<Setting>'. |
MEDIUM Issues¶
| # | Issue | Severity | Details |
|---|---|---|---|
| M1 | Store reducer typo: settinReducer |
Medium | src/store/index.js line 3: import settinReducer from "./slices/settingSlice". Missing 'g'. Functionally correct but confusing. |
| M2 | Error log message mismatch in public settings | Medium | api/public/settings.js line 16: console.log("Failed to fetch navigation links") -- should say "settings", not "navigation links". Copy-paste error from navigation-links module. |
| M3 | website_type type inconsistency between frontend and backend |
Medium | register.jsx line 52: const websiteType = settings?.website_type \|\| 1 (number fallback). But seed data has value: "2" (string). Comparison websiteType == 2 uses loose equality which works, but websiteType === 2 would fail. Backend checks setting.value === "2" (strict string). |
| M4 | sidebar-old.jsx is orphaned/dead code | Medium | The current dashboard/layout.js imports sidebar.jsx (not sidebar-old.jsx). The old sidebar has a completely different settings access pattern (direct API hook vs prop drill) and is never rendered. |
| M5 | Seed data type inconsistency: "input" vs "text" | Medium | Seed data uses type: "input" for text fields, but create-setting-form.jsx SETTING_TYPES lists "text" (not "input"). The setting-field.jsx switch/case handles "text" and "textarea" but NOT "input" -- seeded settings with type "input" will fall through to the default case which happens to render a plain Input. Works by accident. |
| M6 | getSettingByGroup name collision: two different functions with same name |
Medium | getSettingByGroup exists in BOTH @/api/public/settings (SSR, uses unstable_cache) AND @/store/slices/settingSlice (Redux selector). Different signatures, different purposes, same export name. Imports must be carefully traced. |
| M7 | AppConfig permission strings inconsistent | Medium | Read uses app_config.read, but create uses create_app_config.create and update uses update_app_config.update. The verb-prefixed pattern breaks the standard entity.action convention. |
| M8 | deleteAppConfig uses hard delete, not soft delete | Medium | AppConfigService.deleteAppConfig() calls configRepo.delete(id) (hard delete) despite the entity having @DeleteDateColumn(). All other entities use soft delete. |
LOW Issues¶
| # | Issue | Severity | Details |
|---|---|---|---|
| L1 | No <Image> fallback in header.jsx |
Low | If settings?.site_logo is undefined, Next.js <Image> will receive src={undefined} which may cause a runtime warning or broken image. |
| L2 | footer_copyright no fallback in footer-credit.jsx |
Low | All other footer-credit fields have fallbacks except footer_copyright which renders {siteSettings?.footer_copyright} with no fallback (empty space). |
| L3 | keepPreviousData is deprecated in React Query v5 |
Low | useSettings() and useSettingByGroup() both use keepPreviousData: true which is deprecated in TanStack Query v5 (should use placeholderData: keepPreviousData). |
| L4 | getImageSizeByKey() is dead code |
Low | Defined in setting.service.ts but never called anywhere. |
| L5 | Settings error console.error says "navigation links" | Low | api/public/settings.js line 28: console.error("Error fetching navigation links:", error) should say "settings". |
| L6 | updateSettingById controller returns "Setting created" message |
Low | setting.controller.ts line 79: message: "Setting created successfully" on the update endpoint. Should say "updated". |
| L7 | Login page uses window.location.href instead of Next.js router |
Low | Multiple locations in login.jsx use window.location.href = "/dashboard" for navigation after login, causing full page reload instead of client-side navigation. Not directly a settings issue but part of the settings-consuming component. |
| L8 | unstable_cache API may change |
Low | The SSR settings access relies on unstable_cache from next/cache, which is labeled as unstable and may change in future Next.js versions. |
SUMMARY¶
Architecture Quality Assessment¶
Strengths:
1. Clean separation between public (Setting) and private (AppConfig) configuration
2. EMPTY_OBJECT pattern in Redux selector prevents unnecessary re-renders
3. Consistent dot-notation key format enables automatic grouping
4. SSR settings access for public pages (login, register, contact, header, footer)
5. Cache invalidation chain from client mutations to server cache via revalidateTag
Weaknesses:
1. Dual settings access patterns (Redux vs direct API hooks) create confusion
2. Settings are a blocking dependency for the entire dashboard (infinite loading on failure)
3. Five+ settings keys are consumed but never seeded
4. Root layout uses a separate fetch mechanism that bypasses the shared invalidation
5. No authentication on GET settings endpoints
6. backupCodes and verifyBackupCode are misplaced on the Setting entity
7. Seed data type ("input") doesn't match the create form's type options ("text")
Issue Count¶
| Severity | Count |
|---|---|
| Critical | 4 |
| High | 5 |
| Medium | 8 |
| Low | 8 |
| Total | 25 |