diff --git a/apps/admin/src/App.tsx b/apps/admin/src/App.tsx index 66f6b20..fa1c980 100755 --- a/apps/admin/src/App.tsx +++ b/apps/admin/src/App.tsx @@ -11,6 +11,7 @@ import { QuestionAnalyticsPage } from '@/pages/content/QuestionAnalyticsPage'; import { TagsPage } from '@/pages/content/TagsPage'; import { DarePacksPage } from '@/pages/content/DarePacksPage'; import { DaresPage } from '@/pages/content/DaresPage'; +import { QuizQuestionsPage } from '@/pages/content/QuizQuestionsPage'; import { UsersPage } from '@/pages/users/UsersPage'; import { UserDetailPage } from '@/pages/users/UserDetailPage'; import { MatchChatPage } from '@/pages/users/MatchChatPage'; @@ -108,6 +109,14 @@ function App() { } /> + + + + } + /> {/* User management */} , requiredPermission: PERMISSION_KEYS.MANAGE_PACKS, }, + { + label: 'Quiz Questions', + href: '/quiz-questions', + icon: , + requiredPermission: PERMISSION_KEYS.MANAGE_QUESTIONS, + }, ]; const usersNav: NavItem[] = [ diff --git a/apps/admin/src/hooks/useAuditedAdminData.ts b/apps/admin/src/hooks/useAuditedAdminData.ts index a1ad98f..cb71b71 100644 --- a/apps/admin/src/hooks/useAuditedAdminData.ts +++ b/apps/admin/src/hooks/useAuditedAdminData.ts @@ -7,6 +7,7 @@ const AUDITED_TABLES = [ 'categories', 'dare_packs', 'dares', + 'quiz_questions', 'profiles', 'couples', 'responses', diff --git a/apps/admin/src/lib/dareIntensity.test.ts b/apps/admin/src/lib/dareIntensity.test.ts new file mode 100644 index 0000000..dc0d142 --- /dev/null +++ b/apps/admin/src/lib/dareIntensity.test.ts @@ -0,0 +1,47 @@ +import { describe, expect, it } from 'vitest'; +import { clampDareIntensity, parseIntensityRange } from './dareIntensity'; + +describe('clampDareIntensity', () => { + it('accepts values within the 1-5 range', () => { + expect(clampDareIntensity('1')).toBe(1); + expect(clampDareIntensity('3')).toBe(3); + expect(clampDareIntensity('5')).toBe(5); + }); + + it('falls back to 1 for missing, non-numeric, or out-of-range values', () => { + expect(clampDareIntensity('')).toBe(1); + expect(clampDareIntensity('abc')).toBe(1); + expect(clampDareIntensity('0')).toBe(1); + expect(clampDareIntensity('6')).toBe(1); + }); + + it('falls back to a custom default when provided', () => { + expect(clampDareIntensity('', 3)).toBe(3); + expect(clampDareIntensity('9', 3)).toBe(3); + }); +}); + +describe('parseIntensityRange', () => { + it('allows both bounds blank', () => { + expect(parseIntensityRange('', '')).toEqual({ range: { min: null, max: null }, error: null }); + }); + + it('parses valid bounds', () => { + expect(parseIntensityRange('2', '4')).toEqual({ range: { min: 2, max: 4 }, error: null }); + }); + + it('rejects a minimum outside 1-5', () => { + const { error } = parseIntensityRange('0', ''); + expect(error).toMatch(/Minimum intensity/); + }); + + it('rejects a maximum outside 1-5', () => { + const { error } = parseIntensityRange('', '6'); + expect(error).toMatch(/Maximum intensity/); + }); + + it('rejects a minimum greater than the maximum', () => { + const { error } = parseIntensityRange('4', '2'); + expect(error).toMatch(/cannot be greater than/); + }); +}); diff --git a/apps/admin/src/lib/dareIntensity.ts b/apps/admin/src/lib/dareIntensity.ts new file mode 100644 index 0000000..f7d7220 --- /dev/null +++ b/apps/admin/src/lib/dareIntensity.ts @@ -0,0 +1,44 @@ +// Dares and dare packs both gate on a 1-5 intensity scale (see +// apps/api/drizzle/0010_admin.sql and 0013_dares_loop.sql). These helpers keep +// the admin form validation consistent with that range. + +export const MIN_DARE_INTENSITY = 1; +export const MAX_DARE_INTENSITY = 5; + +/** + * Clamp a raw form value to a valid dare intensity (1-5), falling back to 1 + * when the value is missing or not a finite number in range. + */ +export function clampDareIntensity(rawValue: string, fallback: number = MIN_DARE_INTENSITY): number { + const parsed = parseInt(rawValue, 10); + if (!Number.isFinite(parsed) || parsed < MIN_DARE_INTENSITY || parsed > MAX_DARE_INTENSITY) { + return fallback; + } + return parsed; +} + +export interface IntensityRange { + min: number | null; + max: number | null; +} + +/** + * Parse and validate an optional min/max intensity pair for a dare pack. + * Returns the parsed range, or an error message when the values are invalid. + */ +export function parseIntensityRange(rawMin: string, rawMax: string): { range: IntensityRange; error: string | null } { + const min = rawMin.trim() ? parseInt(rawMin, 10) : null; + const max = rawMax.trim() ? parseInt(rawMax, 10) : null; + + if (min !== null && (!Number.isFinite(min) || min < MIN_DARE_INTENSITY || min > MAX_DARE_INTENSITY)) { + return { range: { min: null, max: null }, error: 'Minimum intensity must be between 1 and 5' }; + } + if (max !== null && (!Number.isFinite(max) || max < MIN_DARE_INTENSITY || max > MAX_DARE_INTENSITY)) { + return { range: { min: null, max: null }, error: 'Maximum intensity must be between 1 and 5' }; + } + if (min !== null && max !== null && min > max) { + return { range: { min: null, max: null }, error: 'Minimum intensity cannot be greater than maximum intensity' }; + } + + return { range: { min, max }, error: null }; +} diff --git a/apps/admin/src/lib/quizQuestions.test.ts b/apps/admin/src/lib/quizQuestions.test.ts new file mode 100644 index 0000000..24ebf0e --- /dev/null +++ b/apps/admin/src/lib/quizQuestions.test.ts @@ -0,0 +1,40 @@ +import { describe, expect, it } from 'vitest'; +import { sanitizeQuizOptions, validateQuizQuestionForm } from './quizQuestions'; + +describe('sanitizeQuizOptions', () => { + it('trims and drops empty options', () => { + expect(sanitizeQuizOptions([' A ', '', ' ', 'B'])).toEqual(['A', 'B']); + }); +}); + +describe('validateQuizQuestionForm', () => { + const base = { + prompt_self: 'What is your favourite season?', + prompt_guess: "What is your partner's favourite season?", + options: ['Spring', 'Summer'], + }; + + it('accepts a valid form', () => { + expect(validateQuizQuestionForm(base)).toBeNull(); + }); + + it('requires the self prompt', () => { + expect(validateQuizQuestionForm({ ...base, prompt_self: ' ' })).toMatch(/about yourself/); + }); + + it('requires the guess prompt', () => { + expect(validateQuizQuestionForm({ ...base, prompt_guess: '' })).toMatch(/about your partner/); + }); + + it('requires at least two options', () => { + expect(validateQuizQuestionForm({ ...base, options: ['Only one'] })).toMatch(/At least 2 options/); + }); + + it('allows at most four options', () => { + expect(validateQuizQuestionForm({ ...base, options: ['A', 'B', 'C', 'D', 'E'] })).toMatch(/At most 4 options/); + }); + + it('ignores blank options when counting', () => { + expect(validateQuizQuestionForm({ ...base, options: ['Spring', 'Summer', '', ''] })).toBeNull(); + }); +}); diff --git a/apps/admin/src/lib/quizQuestions.ts b/apps/admin/src/lib/quizQuestions.ts new file mode 100644 index 0000000..b163f4a --- /dev/null +++ b/apps/admin/src/lib/quizQuestions.ts @@ -0,0 +1,31 @@ +// Quiz question options are constrained by the database check +// `array_length(options, 1) between 2 and 4` (see +// apps/api/drizzle/0019_quiz.sql). These helpers keep the admin form +// validation consistent with that constraint. + +export const MIN_QUIZ_OPTIONS = 2; +export const MAX_QUIZ_OPTIONS = 4; + +export interface QuizQuestionFormValues { + prompt_self: string; + prompt_guess: string; + options: string[]; +} + +/** Trim options and drop any that are empty. */ +export function sanitizeQuizOptions(options: string[]): string[] { + return options.map((option) => option.trim()).filter(Boolean); +} + +/** + * Validate a quiz question form. Returns an error message, or null when the + * form is valid. + */ +export function validateQuizQuestionForm(form: QuizQuestionFormValues): string | null { + if (!form.prompt_self.trim()) return 'The "about yourself" prompt is required'; + if (!form.prompt_guess.trim()) return 'The "about your partner" prompt is required'; + const cleanedOptions = sanitizeQuizOptions(form.options); + if (cleanedOptions.length < MIN_QUIZ_OPTIONS) return `At least ${MIN_QUIZ_OPTIONS} options are required`; + if (cleanedOptions.length > MAX_QUIZ_OPTIONS) return `At most ${MAX_QUIZ_OPTIONS} options are allowed`; + return null; +} diff --git a/apps/admin/src/pages/content/DarePacksPage.tsx b/apps/admin/src/pages/content/DarePacksPage.tsx index b9e422e..024953c 100644 --- a/apps/admin/src/pages/content/DarePacksPage.tsx +++ b/apps/admin/src/pages/content/DarePacksPage.tsx @@ -33,6 +33,7 @@ import { Plus, Zap, Pencil, Trash2, Loader2, Crown, Eye, EyeOff, ChevronUp, Chev import { toast } from 'sonner'; import { ContentReviewControl } from '@/components/content/ContentReviewControl'; import type { ContentReviewStatus } from '@sauci/shared'; +import { parseIntensityRange } from '@/lib/dareIntensity'; // ============================================================================= // Types @@ -54,6 +55,9 @@ interface DarePack { is_explicit: boolean; sort_order: number; category_id: string | null; + min_intensity: number | null; + max_intensity: number | null; + avg_intensity: number | null; created_at: string | null; dare_count?: number; content_status?: ContentReviewStatus | null; @@ -68,6 +72,8 @@ interface DarePackFormData { is_public: boolean; is_explicit: boolean; category_id: string; + min_intensity: string; + max_intensity: string; } // ============================================================================= @@ -96,6 +102,8 @@ export function DarePacksPage() { is_public: false, is_explicit: false, category_id: categoryId ?? '', + min_intensity: '', + max_intensity: '', }, (pack) => ({ name: pack.name, @@ -105,6 +113,8 @@ export function DarePacksPage() { is_public: pack.is_public, is_explicit: pack.is_explicit, category_id: pack.category_id || '', + min_intensity: pack.min_intensity != null ? String(pack.min_intensity) : '', + max_intensity: pack.max_intensity != null ? String(pack.max_intensity) : '', }) ); @@ -209,6 +219,14 @@ export function DarePacksPage() { return; } + const { range, error: intensityError } = parseIntensityRange(form.formData.min_intensity, form.formData.max_intensity); + if (intensityError) { + toast.error(intensityError); + return; + } + const parsedMin = range.min; + const parsedMax = range.max; + form.setSaving(true); try { if (form.editingItem) { @@ -220,6 +238,8 @@ export function DarePacksPage() { is_public: form.formData.is_public, is_explicit: form.formData.is_explicit, category_id: form.formData.category_id || null, + min_intensity: parsedMin, + max_intensity: parsedMax, }); if (error) throw error; toast.success('Dare pack updated'); @@ -232,6 +252,8 @@ export function DarePacksPage() { is_public: form.formData.is_public, is_explicit: form.formData.is_explicit, category_id: form.formData.category_id || null, + min_intensity: parsedMin, + max_intensity: parsedMax, sort_order: totalCount, }); if (error) throw error; @@ -410,6 +432,36 @@ export function DarePacksPage() { +
+
+ + form.setField('min_intensity', e.target.value)} + placeholder="1" + /> +
+
+ + form.setField('max_intensity', e.target.value)} + placeholder="5" + /> +
+
+

+ Gates whether this pack shows for a couple based on their intensity preference. Leave blank for no gate. +

+
@@ -572,6 +624,20 @@ export function DarePacksPage() { + {(pack.min_intensity != null || pack.max_intensity != null || pack.avg_intensity != null) && ( +
+ {(pack.min_intensity != null || pack.max_intensity != null) && ( + + Intensity {pack.min_intensity ?? 1}-{pack.max_intensity ?? 5} + + )} + {pack.avg_intensity != null && ( + + Avg {pack.avg_intensity} + + )} +
+ )}
{pack.dare_count} dare{pack.dare_count !== 1 ? 's' : ''} diff --git a/apps/admin/src/pages/content/DaresPage.tsx b/apps/admin/src/pages/content/DaresPage.tsx index 9b33f45..218b5a3 100644 --- a/apps/admin/src/pages/content/DaresPage.tsx +++ b/apps/admin/src/pages/content/DaresPage.tsx @@ -39,6 +39,7 @@ import { toast } from 'sonner'; import { IconPreview } from '@/components/ui/icon-picker'; import { ContentReviewControl } from '@/components/content/ContentReviewControl'; import type { ContentReviewStatus } from '@sauci/shared'; +import { clampDareIntensity, MAX_DARE_INTENSITY, MIN_DARE_INTENSITY } from '@/lib/dareIntensity'; // ============================================================================= // Types @@ -102,9 +103,11 @@ export function DaresPage() { // Form state const [formData, setFormData] = useState<{ text: string; + intensity: string; suggested_duration_hours: string; }>({ text: '', + intensity: '1', suggested_duration_hours: '', }); @@ -166,7 +169,7 @@ export function DaresPage() { const openCreateDialog = () => { setEditingDare(null); - setFormData({ text: '', suggested_duration_hours: '' }); + setFormData({ text: '', intensity: '1', suggested_duration_hours: '' }); setDialogOpen(true); }; @@ -174,6 +177,7 @@ export function DaresPage() { setEditingDare(dare); setFormData({ text: dare.text, + intensity: String(dare.intensity ?? 1), suggested_duration_hours: dare.suggested_duration_hours?.toString() || '', }); setDialogOpen(true); @@ -185,10 +189,13 @@ export function DaresPage() { return; } + const intensity = clampDareIntensity(formData.intensity); + setSaving(true); try { const dareData = { text: formData.text, + intensity, suggested_duration_hours: formData.suggested_duration_hours ? parseInt(formData.suggested_duration_hours, 10) : null, @@ -338,6 +345,31 @@ export function DaresPage() { />
+
+ + +

+ 1 is mild, 5 is the most intense. Used to gate dares against a couple's intensity preference. +

+
+