feat: external Learning Hub toggle + applicant help button
All checks were successful
Build and Push Docker Image / build (push) Successful in 8m52s

- Add admin settings: learning_hub_external, learning_hub_external_url, support_email
- Jury/Mentor nav respects external Learning Hub URL (opens in new tab)
- RoleNav supports external nav items with ExternalLink icon
- Applicant header shows Help button with configurable support email
- Settings update mutation now upserts (creates on first use)
- Shared inferSettingCategory for consistent category assignment

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-03 23:09:29 +01:00
parent 924f8071e1
commit 1d4e31ddd1
6 changed files with 160 additions and 43 deletions

View File

@@ -25,24 +25,47 @@ function categorizeModel(modelId: string): string {
return 'other'
}
function inferSettingCategory(key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' | 'FEATURE_FLAGS' {
if (key.startsWith('openai') || key.startsWith('ai_') || key.startsWith('anthropic')) return 'AI'
if (key.startsWith('smtp_') || key.startsWith('email_')) return 'EMAIL'
if (key.startsWith('storage_') || key.startsWith('local_storage') || key.startsWith('max_file') || key.startsWith('avatar_') || key.startsWith('allowed_file')) return 'STORAGE'
if (key.startsWith('brand_') || key.startsWith('logo_') || key.startsWith('primary_') || key.startsWith('theme_')) return 'BRANDING'
if (key.startsWith('whatsapp_')) return 'WHATSAPP'
if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY'
if (key.startsWith('learning_hub_') || key.startsWith('jury_compare_') || key.startsWith('support_')) return 'FEATURE_FLAGS'
return 'DEFAULTS'
}
export const settingsRouter = router({
/**
* Get public feature flags (no auth required)
* These are non-sensitive settings that can be exposed to any user
*/
getFeatureFlags: protectedProcedure.query(async ({ ctx }) => {
const [whatsappEnabled, juryCompareEnabled] = await Promise.all([
const [whatsappEnabled, juryCompareEnabled, learningHubExternal, learningHubExternalUrl, supportEmail] = await Promise.all([
ctx.prisma.systemSettings.findUnique({
where: { key: 'whatsapp_enabled' },
}),
ctx.prisma.systemSettings.findUnique({
where: { key: 'jury_compare_enabled' },
}),
ctx.prisma.systemSettings.findUnique({
where: { key: 'learning_hub_external' },
}),
ctx.prisma.systemSettings.findUnique({
where: { key: 'learning_hub_external_url' },
}),
ctx.prisma.systemSettings.findUnique({
where: { key: 'support_email' },
}),
])
return {
whatsappEnabled: whatsappEnabled?.value === 'true',
juryCompareEnabled: juryCompareEnabled?.value === 'true',
learningHubExternal: learningHubExternal?.value === 'true',
learningHubExternalUrl: learningHubExternalUrl?.value || '',
supportEmail: supportEmail?.value || '',
}
}),
@@ -120,12 +143,18 @@ export const settingsRouter = router({
})
)
.mutation(async ({ ctx, input }) => {
const setting = await ctx.prisma.systemSettings.update({
const setting = await ctx.prisma.systemSettings.upsert({
where: { key: input.key },
data: {
update: {
value: input.value,
updatedBy: ctx.user.id,
},
create: {
key: input.key,
value: input.value,
category: inferSettingCategory(input.key),
updatedBy: ctx.user.id,
},
})
// Clear storage provider cache when storage_provider setting changes
@@ -161,23 +190,12 @@ export const settingsRouter = router({
z.object({
key: z.string(),
value: z.string(),
category: z.enum(['AI', 'BRANDING', 'EMAIL', 'STORAGE', 'SECURITY', 'DEFAULTS', 'WHATSAPP']).optional(),
category: z.enum(['AI', 'BRANDING', 'EMAIL', 'STORAGE', 'SECURITY', 'DEFAULTS', 'WHATSAPP', 'FEATURE_FLAGS']).optional(),
})
),
})
)
.mutation(async ({ ctx, input }) => {
// Infer category from key prefix if not provided
const inferCategory = (key: string): 'AI' | 'BRANDING' | 'EMAIL' | 'STORAGE' | 'SECURITY' | 'DEFAULTS' | 'WHATSAPP' => {
if (key.startsWith('openai') || key.startsWith('ai_') || key.startsWith('anthropic')) return 'AI'
if (key.startsWith('smtp_') || key.startsWith('email_')) return 'EMAIL'
if (key.startsWith('storage_') || key.startsWith('local_storage') || key.startsWith('max_file') || key.startsWith('avatar_') || key.startsWith('allowed_file')) return 'STORAGE'
if (key.startsWith('brand_') || key.startsWith('logo_') || key.startsWith('primary_') || key.startsWith('theme_')) return 'BRANDING'
if (key.startsWith('whatsapp_')) return 'WHATSAPP'
if (key.startsWith('security_') || key.startsWith('session_')) return 'SECURITY'
return 'DEFAULTS'
}
const results = await Promise.all(
input.settings.map((s) =>
ctx.prisma.systemSettings.upsert({
@@ -189,7 +207,7 @@ export const settingsRouter = router({
create: {
key: s.key,
value: s.value,
category: s.category || inferCategory(s.key),
category: s.category || inferSettingCategory(s.key),
updatedBy: ctx.user.id,
},
})