From 77f2718fddc9ae3b07b3f51a8e25c0c1aeb4bd9c Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 30 Aug 2026 19:03:26 +0100 Subject: [PATCH 1/4] feat(admin-api): expose quiz, dares loop, and profile settings resources Extend the admin allow-list so upcoming admin screens can reach quiz content and activity, sent dares, and dare chat messages. Also fix a schema drift where question_packs.scheduled_release_at and release_notified existed in the database but were missing from the drizzle TS schema, which could have let a future generate propose dropping them. Note: no profile_settings table exists in the schema. The profile-settings domain reads and writes the existing profiles and notification_preferences tables, so that entry was intentionally omitted pending a product decision on which table it should map to. --- apps/api/src/domains/admin/repository.ts | 5 ++++ apps/api/src/domains/packs/schema.ts | 2 ++ apps/api/test/admin.integration.test.ts | 37 +++++++++++++++++++++++- 3 files changed, 43 insertions(+), 1 deletion(-) diff --git a/apps/api/src/domains/admin/repository.ts b/apps/api/src/domains/admin/repository.ts index 563e37a..7693ad0 100644 --- a/apps/api/src/domains/admin/repository.ts +++ b/apps/api/src/domains/admin/repository.ts @@ -35,6 +35,11 @@ const resources: Record = { media_objects: { read: 'view_media' }, dare_packs: { read: 'manage_packs', write: 'manage_packs' }, dares: { read: 'manage_questions', write: 'manage_questions' }, + quiz_questions: { read: ['manage_questions', 'view_activity', 'view_users'], write: 'manage_questions' }, + quiz_sessions: { read: ['view_users', 'view_activity'] }, + quiz_answers: { read: ['view_users', 'view_activity'] }, + sent_dares: { read: ['view_users', 'view_activity', 'view_chats'] }, + dare_messages: { read: ['view_chats', 'view_activity'] }, }; function identifier(value: string): string { diff --git a/apps/api/src/domains/packs/schema.ts b/apps/api/src/domains/packs/schema.ts index f8a7718..abb5a22 100644 --- a/apps/api/src/domains/packs/schema.ts +++ b/apps/api/src/domains/packs/schema.ts @@ -35,6 +35,8 @@ export const questionPacks = pgTable('question_packs', { contentReviewedAt: timestamp('content_reviewed_at', { withTimezone: true }), contentReviewedBy: uuid('content_reviewed_by').references(() => profiles.id, { onDelete: 'set null' }), createdAt: timestamp('created_at', { withTimezone: true }).notNull().defaultNow(), + scheduledReleaseAt: timestamp('scheduled_release_at', { withTimezone: true }), + releaseNotified: boolean('release_notified').notNull().default(false), }, (table) => [index('question_packs_category_id_idx').on(table.categoryId)]); export const questions = pgTable('questions', { diff --git a/apps/api/test/admin.integration.test.ts b/apps/api/test/admin.integration.test.ts index 2b25de1..bd4855d 100644 --- a/apps/api/test/admin.integration.test.ts +++ b/apps/api/test/admin.integration.test.ts @@ -21,13 +21,14 @@ describe.skipIf(!databaseUrl || !isLocal)('admin repository authorization + audi const responseQuestion = '88888888-8888-4888-8888-888888888888'; const responseId = '99999999-9999-4999-8999-999999999999'; const responseMediaId = 'aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa'; + const quizCouple = 'bbbbbbbb-bbbb-4bbb-8bbb-bbbbbbbbbbbb'; let pool: Pool; let repository: PostgresAdminRepository; beforeAll(async () => { await adminPool.query(`create schema "${schema}"`); const isolated = new URL(databaseUrl!); isolated.searchParams.set('options', `-c search_path=${schema}`); pool = new Pool({ connectionString: isolated.toString() }); repository = new PostgresAdminRepository(isolated.toString()); - for (const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0007_billing_redemption.sql','0008_residual_realtime.sql','0009_operations_workers.sql','0010_admin.sql']) { + for (const name of ['0000_identity_and_feature_interests.sql','0001_couples.sql','0002_packs_catalog_progress.sql','0003_answers_matches.sql','0004_chat.sql','0005_profile_settings.sql','0006_media_storage.sql','0007_billing_redemption.sql','0008_residual_realtime.sql','0009_operations_workers.sql','0010_admin.sql','0011_question_pack_average_precision.sql','0012_catalogue_field_parity.sql','0013_dares_loop.sql','0014_daily_limit_local_reset.sql','0015_daily_limit_response_index.sql','0016_couple_streak_locality.sql','0017_streak_reminder_preference.sql','0018_cutover_source_parity.sql','0019_quiz.sql','0020_quiz_question_pool.sql']) { const migration = await readFile(new URL(`../drizzle/${name}`, import.meta.url), 'utf8'); for (const statement of migration.split('--> statement-breakpoint')) if (statement.trim()) await pool.query(statement); } @@ -161,6 +162,40 @@ describe.skipIf(!databaseUrl || !isLocal)('admin repository authorization + audi await expect(repository.responseMedia(responseViewer, responseId)).rejects.toMatchObject({ code: 'response_media_not_found' }); }); + it('exposes writable quiz content and read-only quiz activity resources', async () => { + await pool.query('insert into couples(id,invite_code) values($1,$2)', [quizCouple, 'QUIZADMN']); + await pool.query("update admin_users set permissions=array_append(permissions,'manage_questions') where user_id=$1", [creatorUser]); + const creator = await repository.principal(creatorUser); + const [question] = await repository.insert(creator, 'quiz_questions', [{ + prompt_self: 'How do you show affection?', prompt_guess: 'How does your partner show affection?', options: ['Touch', 'Words'], + }]); + expect(question).toMatchObject({ prompt_self: 'How do you show affection?' }); + + const principal = await repository.principal(superUser); + const listed = await repository.query(principal, 'quiz_questions', { filters: [{ column: 'id', op: 'eq', value: question!.id }] }); + expect(listed.count).toBe(1); + expect((await repository.query(principal, 'quiz_sessions', {})).count).toBe(0); + await expect(repository.insert(principal, 'quiz_sessions', [{ + couple_id: quizCouple, status: 'active', question_ids: [question!.id], + }])).rejects.toMatchObject({ code: 'forbidden' }); + }); + + it('reads sent dares for user support visibility without granting write access', async () => { + await pool.query( + `insert into sent_dares(couple_id,custom_dare_text,dare_text_snapshot,dare_intensity_snapshot,sender_id,recipient_id,status) + values($1,'Give a compliment','Give a compliment',2,$2,$3,'pending')`, + [quizCouple, creatorUser, moderatorUser], + ); + const principal = await repository.principal(superUser); + const result = await repository.query(principal, 'sent_dares', {}); + expect(result.count).toBe(1); + expect(result.rows[0]).toMatchObject({ dare_text_snapshot: 'Give a compliment' }); + await expect(repository.insert(principal, 'sent_dares', [{ + couple_id: quizCouple, custom_dare_text: 'x', dare_text_snapshot: 'x', + dare_intensity_snapshot: 1, sender_id: creatorUser, recipient_id: moderatorUser, status: 'pending', + }])).rejects.toMatchObject({ code: 'forbidden' }); + }); + it('creates all legacy admin and dare cutover tables with preserved foreign keys', async () => { const tables = await pool.query<{ table_name: string }>( `select table_name from information_schema.tables where table_schema=current_schema() From 25b7c6238ea9d3ac82a29bb9fab81ea04325ec60 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 30 Aug 2026 19:04:54 +0100 Subject: [PATCH 2/4] feat(admin-api): expose notification_preferences as read-only Resolve the earlier open question by mapping the profile-settings domain's notification toggles to the existing notification_preferences table (primary key user_id, no sensitive columns). Read-only, gated on view_users, with a test covering read access and rejected writes. --- apps/api/src/domains/admin/repository.ts | 1 + apps/api/test/admin.integration.test.ts | 15 +++++++++++++++ 2 files changed, 16 insertions(+) diff --git a/apps/api/src/domains/admin/repository.ts b/apps/api/src/domains/admin/repository.ts index 7693ad0..4fda17f 100644 --- a/apps/api/src/domains/admin/repository.ts +++ b/apps/api/src/domains/admin/repository.ts @@ -40,6 +40,7 @@ const resources: Record = { quiz_answers: { read: ['view_users', 'view_activity'] }, sent_dares: { read: ['view_users', 'view_activity', 'view_chats'] }, dare_messages: { read: ['view_chats', 'view_activity'] }, + notification_preferences: { read: 'view_users', idColumn: 'user_id' }, }; function identifier(value: string): string { diff --git a/apps/api/test/admin.integration.test.ts b/apps/api/test/admin.integration.test.ts index bd4855d..be67b6c 100644 --- a/apps/api/test/admin.integration.test.ts +++ b/apps/api/test/admin.integration.test.ts @@ -196,6 +196,21 @@ describe.skipIf(!databaseUrl || !isLocal)('admin repository authorization + audi }])).rejects.toMatchObject({ code: 'forbidden' }); }); + it('reads notification preferences for user support visibility without granting write access', async () => { + await pool.query( + "insert into notification_preferences(user_id,matches_enabled,dares_enabled) values($1,true,false)", + [creatorUser], + ); + const principal = await repository.principal(superUser); + const result = await repository.query(principal, 'notification_preferences', { + filters: [{ column: 'user_id', op: 'eq', value: creatorUser }], + }); + expect(result).toMatchObject({ count: 1, rows: [expect.objectContaining({ matches_enabled: true, dares_enabled: false })] }); + await expect(repository.insert(principal, 'notification_preferences', [{ + user_id: moderatorUser, + }])).rejects.toMatchObject({ code: 'forbidden' }); + }); + it('creates all legacy admin and dare cutover tables with preserved foreign keys', async () => { const tables = await pool.query<{ table_name: string }>( `select table_name from information_schema.tables where table_schema=current_schema() From eab73e631467adb10749b2e6dcc0392d233b87e6 Mon Sep 17 00:00:00 2001 From: jjbrunton Date: Sun, 30 Aug 2026 19:13:08 +0100 Subject: [PATCH 3/4] feat(admin): manage quiz questions and dare intensity Add a Quiz Questions content page (list/filter/create/edit/activate- deactivate, no delete since quiz_answers reference questions by id), route it under manage_questions, and add a sidebar entry. Fix DaresPage so intensity (1-5) is editable and shown in the table; previously every admin-created dare silently defaulted to intensity 1. Add authored min/max intensity fields to DarePacksPage: no trigger or worker in apps/api computes these columns, so they are admin-set thresholds gating pack visibility, not derived aggregates. avg_intensity is displayed read-only since nothing writes to it and it is not used for filtering. Extract dare intensity and quiz option validation into small lib modules with focused unit tests. --- apps/admin/src/App.tsx | 9 + apps/admin/src/components/layout/Sidebar.tsx | 7 + apps/admin/src/hooks/useAuditedAdminData.ts | 1 + apps/admin/src/lib/dareIntensity.test.ts | 47 ++ apps/admin/src/lib/dareIntensity.ts | 44 ++ apps/admin/src/lib/quizQuestions.test.ts | 40 ++ apps/admin/src/lib/quizQuestions.ts | 31 ++ .../admin/src/pages/content/DarePacksPage.tsx | 66 +++ apps/admin/src/pages/content/DaresPage.tsx | 40 +- .../src/pages/content/QuizQuestionsPage.tsx | 487 ++++++++++++++++++ 10 files changed, 771 insertions(+), 1 deletion(-) create mode 100644 apps/admin/src/lib/dareIntensity.test.ts create mode 100644 apps/admin/src/lib/dareIntensity.ts create mode 100644 apps/admin/src/lib/quizQuestions.test.ts create mode 100644 apps/admin/src/lib/quizQuestions.ts create mode 100644 apps/admin/src/pages/content/QuizQuestionsPage.tsx 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. +

+
+