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.
This commit is contained in:
jjbrunton committed 2026-08-30 19:03:26 +01:00
1 parent f22d4a3649
commit 77f2718fdd
3 files changed
+43 -1

No files matched your search

+5
View File
@@ -35,6 +35,11 @@ const resources: Record<string, ResourceRule> = {
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 {
+2
View File
@@ -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', {
+36 -1
View File
@@ -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()