Barazo AppView backend barazo.forum

feat: handle-based blocking and system age onboarding (#111)

* feat(profiles,onboarding): handle-based blocking and system age onboarding

Add handle resolution endpoint and enrich preferences with blocked user
profiles so the frontend can display handles instead of raw DIDs.

- GET /api/users/resolve-handles: resolve handles to profiles via DB
lookup with Bluesky public API fallback
- GET /api/users/me/preferences: include blockedProfiles in response
- GET /api/users/me/preferences/communities: include blockedProfiles
per community with batch resolution
- GET /api/onboarding/status: inject system-level age_confirmation
field when user has no declaredAge and no admin age field exists
- POST /api/onboarding/submit: sync age_confirmation responses to
user_preferences and users tables
- checkOnboardingComplete: include system age field in completeness
check

Closes barazo-forum/barazo-workspace#59
Closes barazo-forum/barazo-workspace#60

* fix(tests): update mocks for onboarding gate system age field queries

The checkOnboardingComplete function was extended with two new DB queries
(all community fields for age_confirmation check, and user preferences
for declaredAge). Test mocks needed updating:

- onboarding-gate.test.ts: queue 4 select results instead of 1-2 to
cover the new allCommunityFields + userPreferences queries
- onboarding.test.ts: queue 3rd select result for the userPreferences
query added to GET /api/onboarding/status
- reactions, votes, replies, integration tests: mock checkOnboardingComplete
at module level (matching the pattern in topics.test.ts) instead of
fragile inline DB mock ordering

authored by

Guido X Jansen and committed by
GitHub
7ffe8e18 92763077

+431 -82
+31 -4
src/lib/onboarding-gate.ts
··· 3 communityOnboardingFields, 4 userOnboardingResponses, 5 } from '../db/schema/onboarding-fields.js' 6 import type { Database } from '../db/index.js' 7 8 export interface OnboardingCheckResult { 9 complete: boolean ··· 14 * Check whether a user has completed all mandatory onboarding fields 15 * for a community. Returns complete=true if no fields are configured 16 * or all mandatory ones have responses. 17 */ 18 export async function checkOnboardingComplete( 19 db: Database, ··· 31 ) 32 ) 33 34 - if (fields.length === 0) { 35 - return { complete: true, missingFields: [] } 36 - } 37 - 38 // Get user's responses for this community 39 const responses = await db 40 .select() ··· 51 const missingFields = fields 52 .filter((f) => !answeredFieldIds.has(f.id)) 53 .map((f) => ({ id: f.id, label: f.label, fieldType: f.fieldType })) 54 55 return { 56 complete: missingFields.length === 0,
··· 3 communityOnboardingFields, 4 userOnboardingResponses, 5 } from '../db/schema/onboarding-fields.js' 6 + import { userPreferences } from '../db/schema/user-preferences.js' 7 import type { Database } from '../db/index.js' 8 + 9 + const SYSTEM_AGE_FIELD_ID = 'system-age-confirmation' 10 11 export interface OnboardingCheckResult { 12 complete: boolean ··· 17 * Check whether a user has completed all mandatory onboarding fields 18 * for a community. Returns complete=true if no fields are configured 19 * or all mandatory ones have responses. 20 + * 21 + * Also checks for the system-level age declaration: if no admin-configured 22 + * age_confirmation field exists and the user has no declaredAge, the system 23 + * age field is treated as a missing mandatory field. 24 */ 25 export async function checkOnboardingComplete( 26 db: Database, ··· 38 ) 39 ) 40 41 // Get user's responses for this community 42 const responses = await db 43 .select() ··· 54 const missingFields = fields 55 .filter((f) => !answeredFieldIds.has(f.id)) 56 .map((f) => ({ id: f.id, label: f.label, fieldType: f.fieldType })) 57 + 58 + // Check for system-level age field: inject if no admin age field and user has no declaredAge 59 + const allCommunityFields = await db 60 + .select({ fieldType: communityOnboardingFields.fieldType }) 61 + .from(communityOnboardingFields) 62 + .where(eq(communityOnboardingFields.communityDid, communityDid)) 63 + 64 + const hasAdminAgeField = allCommunityFields.some((f) => f.fieldType === 'age_confirmation') 65 + 66 + if (!hasAdminAgeField) { 67 + const prefRows = await db 68 + .select({ declaredAge: userPreferences.declaredAge }) 69 + .from(userPreferences) 70 + .where(eq(userPreferences.did, did)) 71 + 72 + const declaredAge = prefRows[0]?.declaredAge ?? null 73 + if (declaredAge === null) { 74 + missingFields.unshift({ 75 + id: SYSTEM_AGE_FIELD_ID, 76 + label: 'Age Declaration', 77 + fieldType: 'age_confirmation', 78 + }) 79 + } 80 + } 81 82 return { 83 complete: missingFields.length === 0,
+133 -7
src/routes/onboarding.ts
··· 14 communityOnboardingFields, 15 userOnboardingResponses, 16 } from '../db/schema/onboarding-fields.js' 17 18 // --------------------------------------------------------------------------- 19 // OpenAPI JSON Schema definitions ··· 52 }, 53 }, 54 } 55 56 // --------------------------------------------------------------------------- 57 // Helpers ··· 444 445 const responseMap = new Map(responses.map((r) => [r.fieldId, r.response])) 446 447 - const fieldsWithStatus = fields.map((field) => ({ 448 ...serializeField(field), 449 completed: responseMap.has(field.id), 450 response: responseMap.get(field.id) ?? null, 451 })) 452 453 - const complete = fields.filter((f) => f.isMandatory).every((f) => responseMap.has(f.id)) 454 455 return reply.status(200).send({ 456 complete, ··· 516 517 const fieldMap = new Map(fields.map((f) => [f.id, f])) 518 519 - // Validate each response 520 const errors: string[] = [] 521 - for (const submission of parsed.data) { 522 const field = fieldMap.get(submission.fieldId) 523 if (!field) { 524 errors.push(`Unknown field: ${submission.fieldId}`) ··· 531 } 532 } 533 534 if (errors.length > 0) { 535 throw badRequest(errors.join('; ')) 536 } 537 538 - // Upsert responses (idempotent) 539 - for (const submission of parsed.data) { 540 await db 541 .insert(userOnboardingResponses) 542 .values({ ··· 556 completedAt: new Date(), 557 }, 558 }) 559 } 560 561 // Check completeness (all mandatory fields answered?) ··· 570 ) 571 572 const answeredFieldIds = new Set(existingResponses.map((r) => r.fieldId)) 573 - const complete = fields 574 .filter((f) => f.isMandatory) 575 .every((f) => answeredFieldIds.has(f.id)) 576 577 app.log.info( 578 {
··· 14 communityOnboardingFields, 15 userOnboardingResponses, 16 } from '../db/schema/onboarding-fields.js' 17 + import { userPreferences } from '../db/schema/user-preferences.js' 18 + import { users } from '../db/schema/users.js' 19 + import { ageDeclarationSchema } from '../validation/profiles.js' 20 21 // --------------------------------------------------------------------------- 22 // OpenAPI JSON Schema definitions ··· 55 }, 56 }, 57 } 58 + 59 + // --------------------------------------------------------------------------- 60 + // Constants 61 + // --------------------------------------------------------------------------- 62 + 63 + const SYSTEM_AGE_FIELD_ID = 'system-age-confirmation' 64 65 // --------------------------------------------------------------------------- 66 // Helpers ··· 453 454 const responseMap = new Map(responses.map((r) => [r.fieldId, r.response])) 455 456 + // Check if we need to inject a system-level age_confirmation field 457 + const hasAdminAgeField = fields.some((f) => f.fieldType === 'age_confirmation') 458 + 459 + // Look up user's declared age 460 + const prefRows = await db 461 + .select({ declaredAge: userPreferences.declaredAge }) 462 + .from(userPreferences) 463 + .where(eq(userPreferences.did, user.did)) 464 + 465 + const declaredAge = prefRows[0]?.declaredAge ?? null 466 + const needsSystemAgeField = !hasAdminAgeField && declaredAge === null 467 + 468 + type FieldWithStatus = { 469 + id: string 470 + communityDid: string 471 + fieldType: string 472 + label: string 473 + description: string | null 474 + isMandatory: boolean 475 + sortOrder: number 476 + config: Record<string, unknown> | null 477 + createdAt: string 478 + updatedAt: string 479 + completed: boolean 480 + response: unknown 481 + } 482 + 483 + const fieldsWithStatus: FieldWithStatus[] = fields.map((field) => ({ 484 ...serializeField(field), 485 completed: responseMap.has(field.id), 486 response: responseMap.get(field.id) ?? null, 487 })) 488 489 + // Inject system age field at the beginning if needed 490 + if (needsSystemAgeField) { 491 + const now = new Date().toISOString() 492 + fieldsWithStatus.unshift({ 493 + id: SYSTEM_AGE_FIELD_ID, 494 + communityDid, 495 + fieldType: 'age_confirmation', 496 + label: 'Age Declaration', 497 + description: 498 + 'Please select your age bracket. This determines which content is available to you.', 499 + isMandatory: true, 500 + sortOrder: -1, 501 + config: null, 502 + createdAt: now, 503 + updatedAt: now, 504 + completed: false, 505 + response: null, 506 + }) 507 + } 508 + 509 + // Check completeness: all mandatory fields (including system age field) must be answered 510 + const mandatoryFieldsComplete = fields 511 + .filter((f) => f.isMandatory) 512 + .every((f) => responseMap.has(f.id)) 513 + const complete = mandatoryFieldsComplete && !needsSystemAgeField 514 515 return reply.status(200).send({ 516 complete, ··· 576 577 const fieldMap = new Map(fields.map((f) => [f.id, f])) 578 579 + // Separate system-level age submissions from admin-configured ones 580 + const systemAgeSubmission = parsed.data.find((s) => s.fieldId === SYSTEM_AGE_FIELD_ID) 581 + const adminSubmissions = parsed.data.filter((s) => s.fieldId !== SYSTEM_AGE_FIELD_ID) 582 + 583 + // Validate admin-configured field responses 584 const errors: string[] = [] 585 + for (const submission of adminSubmissions) { 586 const field = fieldMap.get(submission.fieldId) 587 if (!field) { 588 errors.push(`Unknown field: ${submission.fieldId}`) ··· 595 } 596 } 597 598 + // Validate system age submission 599 + if (systemAgeSubmission) { 600 + const ageParsed = ageDeclarationSchema.safeParse({ 601 + declaredAge: systemAgeSubmission.response, 602 + }) 603 + if (!ageParsed.success) { 604 + errors.push('Age Declaration: must be one of 0, 13, 14, 15, 16, 18') 605 + } 606 + } 607 + 608 + // Also validate admin-configured age_confirmation fields the same way 609 + for (const submission of adminSubmissions) { 610 + const field = fieldMap.get(submission.fieldId) 611 + if (field?.fieldType === 'age_confirmation') { 612 + const ageParsed = ageDeclarationSchema.safeParse({ 613 + declaredAge: submission.response, 614 + }) 615 + if (!ageParsed.success) { 616 + errors.push(`${field.label}: must be one of 0, 13, 14, 15, 16, 18`) 617 + } 618 + } 619 + } 620 + 621 if (errors.length > 0) { 622 throw badRequest(errors.join('; ')) 623 } 624 625 + // Upsert admin-field responses (idempotent) 626 + for (const submission of adminSubmissions) { 627 await db 628 .insert(userOnboardingResponses) 629 .values({ ··· 643 completedAt: new Date(), 644 }, 645 }) 646 + 647 + // Sync age_confirmation to user preferences 648 + const field = fieldMap.get(submission.fieldId) 649 + if (field?.fieldType === 'age_confirmation' && typeof submission.response === 'number') { 650 + const now = new Date() 651 + await db 652 + .insert(userPreferences) 653 + .values({ did: user.did, declaredAge: submission.response, updatedAt: now }) 654 + .onConflictDoUpdate({ 655 + target: userPreferences.did, 656 + set: { declaredAge: submission.response, updatedAt: now }, 657 + }) 658 + await db 659 + .update(users) 660 + .set({ declaredAge: submission.response }) 661 + .where(eq(users.did, user.did)) 662 + } 663 + } 664 + 665 + // Handle system-level age submission (syncs to user_preferences + users) 666 + if (systemAgeSubmission && typeof systemAgeSubmission.response === 'number') { 667 + const declaredAge = systemAgeSubmission.response 668 + const now = new Date() 669 + 670 + await db 671 + .insert(userPreferences) 672 + .values({ did: user.did, declaredAge, updatedAt: now }) 673 + .onConflictDoUpdate({ 674 + target: userPreferences.did, 675 + set: { declaredAge, updatedAt: now }, 676 + }) 677 + 678 + await db.update(users).set({ declaredAge }).where(eq(users.did, user.did)) 679 } 680 681 // Check completeness (all mandatory fields answered?) ··· 690 ) 691 692 const answeredFieldIds = new Set(existingResponses.map((r) => r.fieldId)) 693 + const adminFieldsComplete = fields 694 .filter((f) => f.isMandatory) 695 .every((f) => answeredFieldIds.has(f.id)) 696 + 697 + // System age field counts as complete if user now has a declaredAge 698 + const systemAgeComplete = systemAgeSubmission 699 + ? typeof systemAgeSubmission.response === 'number' 700 + : true 701 + const complete = adminFieldsComplete && systemAgeComplete 702 703 app.log.info( 704 {
+172 -10
src/routes/profiles.ts
··· 1 - import { eq, and, sql } from 'drizzle-orm' 2 import type { FastifyPluginCallback } from 'fastify' 3 import { notFound, badRequest, errorResponseSchema } from '../lib/api-errors.js' 4 import { 5 userPreferencesSchema, 6 communityPreferencesSchema, 7 ageDeclarationSchema, 8 } from '../validation/profiles.js' 9 import { users } from '../db/schema/users.js' 10 import { communityProfiles } from '../db/schema/community-profiles.js' ··· 22 import { sybilClusters } from '../db/schema/sybil-clusters.js' 23 import { interactionGraph } from '../db/schema/interaction-graph.js' 24 import { pdsTrustFactors } from '../db/schema/pds-trust-factors.js' 25 26 // --------------------------------------------------------------------------- 27 // OpenAPI JSON Schema definitions ··· 95 }, 96 } 97 98 const preferencesJsonSchema = { 99 type: 'object' as const, 100 properties: { ··· 106 blockedDids: { 107 type: 'array' as const, 108 items: { type: 'string' as const }, 109 }, 110 mutedDids: { type: 'array' as const, items: { type: 'string' as const } }, 111 crossPostBluesky: { type: 'boolean' as const }, ··· 175 } 176 } 177 178 // --------------------------------------------------------------------------- 179 // Profile routes plugin 180 // --------------------------------------------------------------------------- ··· 197 const { db, authMiddleware } = app 198 199 // ------------------------------------------------------------------- 200 // GET /api/users/:handle (public, optionalAuth) 201 // ------------------------------------------------------------------- 202 ··· 718 719 const prefs = rows[0] 720 if (!prefs) { 721 - return reply.status(200).send(defaultPreferences()) 722 } 723 724 return reply.status(200).send({ 725 maturityLevel: prefs.maturityLevel, 726 declaredAge: prefs.declaredAge ?? null, 727 mutedWords: prefs.mutedWords, 728 blockedDids: prefs.blockedDids, 729 mutedDids: prefs.mutedDids, 730 crossPostBluesky: prefs.crossPostBluesky, 731 crossPostFrontpage: prefs.crossPostFrontpage, ··· 827 828 const prefs = rows[0] 829 if (!prefs) { 830 - return reply.status(200).send(defaultPreferences()) 831 } 832 833 return reply.status(200).send({ 834 maturityLevel: prefs.maturityLevel, 835 declaredAge: prefs.declaredAge ?? null, 836 mutedWords: prefs.mutedWords, 837 blockedDids: prefs.blockedDids, 838 mutedDids: prefs.mutedDids, 839 crossPostBluesky: prefs.crossPostBluesky, 840 crossPostFrontpage: prefs.crossPostFrontpage, ··· 875 type: 'array' as const, 876 items: { type: 'string' as const }, 877 }, 878 }, 879 }, 880 }, ··· 905 ) 906 .where(eq(userCommunityPreferences.did, requestUser.did)) 907 908 - const communities = rows.map((row) => ({ 909 - communityDid: row.communityDid, 910 - communityName: row.communityName ?? row.communityDid, 911 - maturityLevel: row.maturityOverride ?? 'inherit', 912 - mutedWords: row.mutedWords ?? [], 913 - blockedDids: row.blockedDids ?? [], 914 - })) 915 916 // Always include the current community so the settings page shows it 917 // even if the user has never saved per-community preferences. ··· 932 maturityLevel: 'inherit', 933 mutedWords: [], 934 blockedDids: [], 935 }) 936 } 937
··· 1 + import { eq, and, sql, inArray } from 'drizzle-orm' 2 import type { FastifyPluginCallback } from 'fastify' 3 import { notFound, badRequest, errorResponseSchema } from '../lib/api-errors.js' 4 import { 5 userPreferencesSchema, 6 communityPreferencesSchema, 7 ageDeclarationSchema, 8 + resolveHandlesSchema, 9 } from '../validation/profiles.js' 10 import { users } from '../db/schema/users.js' 11 import { communityProfiles } from '../db/schema/community-profiles.js' ··· 23 import { sybilClusters } from '../db/schema/sybil-clusters.js' 24 import { interactionGraph } from '../db/schema/interaction-graph.js' 25 import { pdsTrustFactors } from '../db/schema/pds-trust-factors.js' 26 + import { resolveAuthors, type AuthorProfile } from '../lib/resolve-authors.js' 27 + import type { Database } from '../db/index.js' 28 29 // --------------------------------------------------------------------------- 30 // OpenAPI JSON Schema definitions ··· 98 }, 99 } 100 101 + const authorProfileJsonSchema = { 102 + type: 'object' as const, 103 + properties: { 104 + did: { type: 'string' as const }, 105 + handle: { type: 'string' as const }, 106 + displayName: { type: ['string', 'null'] as const }, 107 + avatarUrl: { type: ['string', 'null'] as const }, 108 + }, 109 + } 110 + 111 const preferencesJsonSchema = { 112 type: 'object' as const, 113 properties: { ··· 119 blockedDids: { 120 type: 'array' as const, 121 items: { type: 'string' as const }, 122 + }, 123 + blockedProfiles: { 124 + type: 'array' as const, 125 + items: authorProfileJsonSchema, 126 }, 127 mutedDids: { type: 'array' as const, items: { type: 'string' as const } }, 128 crossPostBluesky: { type: 'boolean' as const }, ··· 192 } 193 } 194 195 + /** Resolve a list of DIDs to AuthorProfile[], preserving order. */ 196 + async function resolveBlockedProfiles(dids: string[], db: Database): Promise<AuthorProfile[]> { 197 + if (dids.length === 0) return [] 198 + const profileMap = await resolveAuthors(dids, null, db) 199 + return dids.map( 200 + (did) => 201 + profileMap.get(did) ?? { 202 + did, 203 + handle: did, 204 + displayName: null, 205 + avatarUrl: null, 206 + } 207 + ) 208 + } 209 + 210 // --------------------------------------------------------------------------- 211 // Profile routes plugin 212 // --------------------------------------------------------------------------- ··· 229 const { db, authMiddleware } = app 230 231 // ------------------------------------------------------------------- 232 + // GET /api/users/resolve-handles (auth required) 233 + // ------------------------------------------------------------------- 234 + 235 + app.get( 236 + '/api/users/resolve-handles', 237 + { 238 + preHandler: [authMiddleware.requireAuth], 239 + schema: { 240 + tags: ['Profiles'], 241 + summary: 'Resolve handles to user profiles', 242 + security: [{ bearerAuth: [] }], 243 + querystring: { 244 + type: 'object', 245 + required: ['handles'], 246 + properties: { 247 + handles: { type: 'string' }, 248 + }, 249 + }, 250 + response: { 251 + 200: { 252 + type: 'object' as const, 253 + properties: { 254 + users: { 255 + type: 'array' as const, 256 + items: authorProfileJsonSchema, 257 + }, 258 + }, 259 + }, 260 + 400: errorResponseSchema, 261 + 401: errorResponseSchema, 262 + }, 263 + }, 264 + }, 265 + async (request, reply) => { 266 + const requestUser = request.user 267 + if (!requestUser) { 268 + return reply.status(401).send({ error: 'Authentication required' }) 269 + } 270 + 271 + const parsed = resolveHandlesSchema.safeParse(request.query) 272 + if (!parsed.success) { 273 + throw badRequest('handles query parameter is required (comma-separated, max 25)') 274 + } 275 + 276 + const handles = parsed.data.handles 277 + 278 + // Look up handles in our users table 279 + const userRows = await db 280 + .select({ 281 + did: users.did, 282 + handle: users.handle, 283 + displayName: users.displayName, 284 + avatarUrl: users.avatarUrl, 285 + }) 286 + .from(users) 287 + .where(inArray(users.handle, handles)) 288 + 289 + const foundMap = new Map<string, AuthorProfile>() 290 + for (const row of userRows) { 291 + foundMap.set(row.handle, { 292 + did: row.did, 293 + handle: row.handle, 294 + displayName: row.displayName, 295 + avatarUrl: row.avatarUrl, 296 + }) 297 + } 298 + 299 + // For handles not found locally, try AT Protocol identity resolution 300 + // via the public Bluesky AppView XRPC endpoint 301 + const missingHandles = handles.filter((h) => !foundMap.has(h)) 302 + for (const handle of missingHandles) { 303 + try { 304 + const url = `https://public.api.bsky.app/xrpc/com.atproto.identity.resolveHandle?handle=${encodeURIComponent(handle)}` 305 + const res = await fetch(url, { signal: AbortSignal.timeout(5000) }) 306 + if (res.ok) { 307 + const data = (await res.json()) as { did?: string } 308 + if (data.did) { 309 + foundMap.set(handle, { 310 + did: data.did, 311 + handle, 312 + displayName: null, 313 + avatarUrl: null, 314 + }) 315 + } 316 + } 317 + } catch { 318 + // Handle not resolvable -- skip silently 319 + } 320 + } 321 + 322 + // Return in request order 323 + const resolved: AuthorProfile[] = handles 324 + .map((h) => foundMap.get(h)) 325 + .filter((p): p is AuthorProfile => p !== undefined) 326 + 327 + return reply.status(200).send({ users: resolved }) 328 + } 329 + ) 330 + 331 + // ------------------------------------------------------------------- 332 // GET /api/users/:handle (public, optionalAuth) 333 // ------------------------------------------------------------------- 334 ··· 850 851 const prefs = rows[0] 852 if (!prefs) { 853 + return reply.status(200).send({ ...defaultPreferences(), blockedProfiles: [] }) 854 } 855 + 856 + const blockedProfiles = await resolveBlockedProfiles(prefs.blockedDids, db) 857 858 return reply.status(200).send({ 859 maturityLevel: prefs.maturityLevel, 860 declaredAge: prefs.declaredAge ?? null, 861 mutedWords: prefs.mutedWords, 862 blockedDids: prefs.blockedDids, 863 + blockedProfiles, 864 mutedDids: prefs.mutedDids, 865 crossPostBluesky: prefs.crossPostBluesky, 866 crossPostFrontpage: prefs.crossPostFrontpage, ··· 962 963 const prefs = rows[0] 964 if (!prefs) { 965 + return reply.status(200).send({ ...defaultPreferences(), blockedProfiles: [] }) 966 } 967 + 968 + const updatedBlockedProfiles = await resolveBlockedProfiles(prefs.blockedDids, db) 969 970 return reply.status(200).send({ 971 maturityLevel: prefs.maturityLevel, 972 declaredAge: prefs.declaredAge ?? null, 973 mutedWords: prefs.mutedWords, 974 blockedDids: prefs.blockedDids, 975 + blockedProfiles: updatedBlockedProfiles, 976 mutedDids: prefs.mutedDids, 977 crossPostBluesky: prefs.crossPostBluesky, 978 crossPostFrontpage: prefs.crossPostFrontpage, ··· 1013 type: 'array' as const, 1014 items: { type: 'string' as const }, 1015 }, 1016 + blockedProfiles: { 1017 + type: 'array' as const, 1018 + items: authorProfileJsonSchema, 1019 + }, 1020 }, 1021 }, 1022 }, ··· 1047 ) 1048 .where(eq(userCommunityPreferences.did, requestUser.did)) 1049 1050 + // Collect all blocked DIDs across communities for batch resolution 1051 + const allBlockedDids = rows.flatMap((row) => row.blockedDids ?? []) 1052 + const profileMap = 1053 + allBlockedDids.length > 0 1054 + ? await resolveAuthors([...new Set(allBlockedDids)], null, db) 1055 + : new Map<string, AuthorProfile>() 1056 + 1057 + const communities = rows.map((row) => { 1058 + const dids = row.blockedDids ?? [] 1059 + return { 1060 + communityDid: row.communityDid, 1061 + communityName: row.communityName ?? row.communityDid, 1062 + maturityLevel: row.maturityOverride ?? 'inherit', 1063 + mutedWords: row.mutedWords ?? [], 1064 + blockedDids: dids, 1065 + blockedProfiles: dids.map( 1066 + (did) => 1067 + profileMap.get(did) ?? { 1068 + did, 1069 + handle: did, 1070 + displayName: null, 1071 + avatarUrl: null, 1072 + } 1073 + ), 1074 + } 1075 + }) 1076 1077 // Always include the current community so the settings page shows it 1078 // even if the user has never saved per-community preferences. ··· 1093 maturityLevel: 'inherit', 1094 mutedWords: [], 1095 blockedDids: [], 1096 + blockedProfiles: [], 1097 }) 1098 } 1099
+20
src/validation/profiles.ts
··· 50 }) 51 52 export type AgeDeclarationInput = z.infer<typeof ageDeclarationSchema>
··· 50 }) 51 52 export type AgeDeclarationInput = z.infer<typeof ageDeclarationSchema> 53 + 54 + // --------------------------------------------------------------------------- 55 + // Query schemas 56 + // --------------------------------------------------------------------------- 57 + 58 + /** Schema for GET /api/users/resolve-handles query string. */ 59 + export const resolveHandlesSchema = z.object({ 60 + handles: z 61 + .string() 62 + .min(1) 63 + .transform((val) => 64 + val 65 + .split(',') 66 + .map((h) => h.trim()) 67 + .filter(Boolean) 68 + ) 69 + .pipe(z.array(z.string().min(1)).min(1).max(25)), 70 + }) 71 + 72 + export type ResolveHandlesInput = z.infer<typeof resolveHandlesSchema>
+30 -5
tests/unit/lib/onboarding-gate.test.ts
··· 61 }) 62 63 it('returns complete=true when community has no onboarding fields', async () => { 64 - queueSelectResults([]) // no mandatory fields 65 66 const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) 67 ··· 71 72 it('returns complete=true when user has completed all mandatory fields', async () => { 73 const field = sampleField() 74 - queueSelectResults([field], [sampleResponse()]) 75 76 const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) 77 ··· 81 82 it("returns complete=false with missing fields when user hasn't completed mandatory fields", async () => { 83 const field = sampleField() 84 - queueSelectResults([field], []) // no responses 85 86 const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) 87 ··· 96 const field2 = sampleField({ id: 'field-002', label: 'ToS', fieldType: 'tos_acceptance' }) 97 98 // Only field-001 answered 99 - queueSelectResults([field1, field2], [sampleResponse({ fieldId: 'field-001' })]) 100 101 const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) 102 ··· 107 108 it('only checks mandatory fields (ignores optional)', async () => { 109 // Only query returns mandatory fields, so optional are not fetched 110 - queueSelectResults([], []) // no mandatory fields, no responses 111 112 const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) 113
··· 61 }) 62 63 it('returns complete=true when community has no onboarding fields', async () => { 64 + queueSelectResults( 65 + [], // no mandatory fields 66 + [], // no user responses 67 + [], // no community fields at all (no admin age field) 68 + [{ declaredAge: 18 }] // user has declared age 69 + ) 70 71 const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) 72 ··· 76 77 it('returns complete=true when user has completed all mandatory fields', async () => { 78 const field = sampleField() 79 + queueSelectResults( 80 + [field], // mandatory fields 81 + [sampleResponse()], // user responses 82 + [], // no community fields with age_confirmation 83 + [{ declaredAge: 18 }] // user has declared age 84 + ) 85 86 const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) 87 ··· 91 92 it("returns complete=false with missing fields when user hasn't completed mandatory fields", async () => { 93 const field = sampleField() 94 + queueSelectResults( 95 + [field], // mandatory fields 96 + [], // no responses 97 + [], // no community fields with age_confirmation 98 + [{ declaredAge: 18 }] // user has declared age (age check passes) 99 + ) 100 101 const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) 102 ··· 111 const field2 = sampleField({ id: 'field-002', label: 'ToS', fieldType: 'tos_acceptance' }) 112 113 // Only field-001 answered 114 + queueSelectResults( 115 + [field1, field2], // mandatory fields 116 + [sampleResponse({ fieldId: 'field-001' })], // only one response 117 + [], // no community fields with age_confirmation 118 + [{ declaredAge: 18 }] // user has declared age 119 + ) 120 121 const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) 122 ··· 127 128 it('only checks mandatory fields (ignores optional)', async () => { 129 // Only query returns mandatory fields, so optional are not fetched 130 + queueSelectResults( 131 + [], // no mandatory fields 132 + [], // no responses 133 + [], // no community fields with age_confirmation 134 + [{ declaredAge: 18 }] // user has declared age 135 + ) 136 137 const result = await checkOnboardingComplete(mockDb as never, USER_DID, COMMUNITY_DID) 138
+17 -4
tests/unit/routes/onboarding.test.ts
··· 588 }) 589 590 it('returns complete=true when no onboarding fields exist', async () => { 591 - queueSelectResults([], []) // fields, responses 592 593 const response = await app.inject({ 594 method: 'GET', ··· 604 605 it('returns complete=false when mandatory field not answered', async () => { 606 const field = sampleField({ isMandatory: true }) 607 - queueSelectResults([field], []) // fields, no responses 608 609 const response = await app.inject({ 610 method: 'GET', ··· 620 621 it('returns complete=true when all mandatory fields answered', async () => { 622 const field = sampleField({ isMandatory: true }) 623 - queueSelectResults([field], [sampleResponse()]) 624 625 const response = await app.inject({ 626 method: 'GET', ··· 646 // Only mandatory field answered 647 queueSelectResults( 648 [mandatoryField, optionalField], 649 - [sampleResponse({ fieldId: 'field-001' })] 650 ) 651 652 const response = await app.inject({
··· 588 }) 589 590 it('returns complete=true when no onboarding fields exist', async () => { 591 + queueSelectResults( 592 + [], // fields 593 + [], // responses 594 + [{ declaredAge: 18 }] // user preferences (has declared age) 595 + ) 596 597 const response = await app.inject({ 598 method: 'GET', ··· 608 609 it('returns complete=false when mandatory field not answered', async () => { 610 const field = sampleField({ isMandatory: true }) 611 + queueSelectResults( 612 + [field], // fields 613 + [], // no responses 614 + [{ declaredAge: 18 }] // user preferences (has declared age) 615 + ) 616 617 const response = await app.inject({ 618 method: 'GET', ··· 628 629 it('returns complete=true when all mandatory fields answered', async () => { 630 const field = sampleField({ isMandatory: true }) 631 + queueSelectResults( 632 + [field], // fields 633 + [sampleResponse()], // responses 634 + [{ declaredAge: 18 }] // user preferences (has declared age) 635 + ) 636 637 const response = await app.inject({ 638 method: 'GET', ··· 658 // Only mandatory field answered 659 queueSelectResults( 660 [mandatoryField, optionalField], 661 + [sampleResponse({ fieldId: 'field-001' })], 662 + [{ declaredAge: 18 }] // user preferences (has declared age) 663 ) 664 665 const response = await app.inject({
+6 -18
tests/unit/routes/reactions.test.ts
··· 29 }), 30 })) 31 32 // Import routes AFTER mocking 33 import { reactionRoutes } from '../../../src/routes/reactions.js' 34 ··· 227 }) 228 229 it('creates a reaction on a topic and returns 201', async () => { 230 - // 0. Onboarding gate: no mandatory fields 231 - selectChain.where.mockResolvedValueOnce([]) 232 // 1. Community settings query -> reactionSet includes "like" 233 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like', 'heart'] }]) 234 // 2. Subject existence check -> topic found ··· 272 }) 273 274 it('creates a reaction on a reply and returns 201', async () => { 275 - // 0. Onboarding gate: no mandatory fields 276 - selectChain.where.mockResolvedValueOnce([]) 277 // 1. Community settings 278 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 279 // 2. Subject existence check -> reply found ··· 305 isTrackedFn.mockResolvedValue(false) 306 trackRepoFn.mockResolvedValue(undefined) 307 308 - // 0. Onboarding gate: no mandatory fields 309 - selectChain.where.mockResolvedValueOnce([]) 310 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 311 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 312 insertChain.returning.mockResolvedValueOnce([sampleReactionRow()]) ··· 396 }) 397 398 it("returns 400 when reaction type is not in community's reaction set", async () => { 399 - // 0. Onboarding gate: no mandatory fields 400 - selectChain.where.mockResolvedValueOnce([]) 401 // Community only allows "like" 402 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 403 ··· 416 }) 417 418 it("uses default reaction set ['like'] when no settings exist", async () => { 419 - // 0. Onboarding gate: no mandatory fields 420 - selectChain.where.mockResolvedValueOnce([]) 421 // No settings row found 422 selectChain.where.mockResolvedValueOnce([]) 423 // Subject exists ··· 439 }) 440 441 it('returns 404 when subject does not exist', async () => { 442 - // 0. Onboarding gate: no mandatory fields 443 - selectChain.where.mockResolvedValueOnce([]) 444 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 445 // Subject not found 446 selectChain.where.mockResolvedValueOnce([]) ··· 460 }) 461 462 it('returns 404 when subject URI has unknown collection', async () => { 463 - // 0. Onboarding gate: no mandatory fields 464 - selectChain.where.mockResolvedValueOnce([]) 465 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 466 // Unknown collection -> subjectExists stays false 467 ··· 480 }) 481 482 it('returns 409 when duplicate reaction (unique constraint)', async () => { 483 - // 0. Onboarding gate: no mandatory fields 484 - selectChain.where.mockResolvedValueOnce([]) 485 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 486 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 487 // onConflictDoNothing -> returning() returns empty array ··· 502 }) 503 504 it('returns 502 when PDS write fails', async () => { 505 - // 0. Onboarding gate: no mandatory fields 506 - selectChain.where.mockResolvedValueOnce([]) 507 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 508 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 509 createRecordFn.mockRejectedValueOnce(new Error('PDS unreachable'))
··· 29 }), 30 })) 31 32 + // Mock onboarding gate (tested separately in onboarding-gate.test.ts) 33 + const checkOnboardingCompleteFn = vi.fn().mockResolvedValue({ complete: true, missingFields: [] }) 34 + vi.mock('../../../src/lib/onboarding-gate.js', () => ({ 35 + checkOnboardingComplete: (...args: unknown[]) => checkOnboardingCompleteFn(...args) as unknown, 36 + })) 37 + 38 // Import routes AFTER mocking 39 import { reactionRoutes } from '../../../src/routes/reactions.js' 40 ··· 233 }) 234 235 it('creates a reaction on a topic and returns 201', async () => { 236 // 1. Community settings query -> reactionSet includes "like" 237 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like', 'heart'] }]) 238 // 2. Subject existence check -> topic found ··· 276 }) 277 278 it('creates a reaction on a reply and returns 201', async () => { 279 // 1. Community settings 280 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 281 // 2. Subject existence check -> reply found ··· 307 isTrackedFn.mockResolvedValue(false) 308 trackRepoFn.mockResolvedValue(undefined) 309 310 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 311 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 312 insertChain.returning.mockResolvedValueOnce([sampleReactionRow()]) ··· 396 }) 397 398 it("returns 400 when reaction type is not in community's reaction set", async () => { 399 // Community only allows "like" 400 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 401 ··· 414 }) 415 416 it("uses default reaction set ['like'] when no settings exist", async () => { 417 // No settings row found 418 selectChain.where.mockResolvedValueOnce([]) 419 // Subject exists ··· 435 }) 436 437 it('returns 404 when subject does not exist', async () => { 438 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 439 // Subject not found 440 selectChain.where.mockResolvedValueOnce([]) ··· 454 }) 455 456 it('returns 404 when subject URI has unknown collection', async () => { 457 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 458 // Unknown collection -> subjectExists stays false 459 ··· 472 }) 473 474 it('returns 409 when duplicate reaction (unique constraint)', async () => { 475 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 476 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 477 // onConflictDoNothing -> returning() returns empty array ··· 492 }) 493 494 it('returns 502 when PDS write fails', async () => { 495 selectChain.where.mockResolvedValueOnce([{ reactionSet: ['like'] }]) 496 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 497 createRecordFn.mockRejectedValueOnce(new Error('PDS unreachable'))
+11 -14
tests/unit/routes/replies.test.ts
··· 66 isNewAccount as isNewAccountMock, 67 } from '../../../src/lib/anti-spam.js' 68 69 // Import routes AFTER mocking 70 import { replyRoutes } from '../../../src/routes/replies.js' 71 ··· 387 it('creates a threaded reply (with parentUri) and returns 201', async () => { 388 // First select: look up topic 389 selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) 390 - // Onboarding gate: no mandatory fields 391 - selectChain.where.mockResolvedValueOnce([]) 392 // Second select: look up parent reply 393 selectChain.where.mockResolvedValueOnce([ 394 sampleReplyRow({ ··· 1492 it('returns 403 when onboarding is incomplete', async () => { 1493 // Topic lookup 1494 selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) 1495 - // Onboarding: mandatory fields exist 1496 - selectChain.where.mockResolvedValueOnce([ 1497 - { 1498 - id: 'field1', 1499 - label: 'Accept Rules', 1500 - fieldType: 'checkbox', 1501 - isMandatory: true, 1502 - communityDid: 'did:plc:community123', 1503 - }, 1504 - ]) 1505 - // Onboarding: user responses (none) 1506 - selectChain.where.mockResolvedValueOnce([]) 1507 1508 const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) 1509 const response = await app.inject({
··· 66 isNewAccount as isNewAccountMock, 67 } from '../../../src/lib/anti-spam.js' 68 69 + // Mock onboarding gate (tested separately in onboarding-gate.test.ts) 70 + const checkOnboardingCompleteFn = vi.fn().mockResolvedValue({ complete: true, missingFields: [] }) 71 + vi.mock('../../../src/lib/onboarding-gate.js', () => ({ 72 + checkOnboardingComplete: (...args: unknown[]) => checkOnboardingCompleteFn(...args) as unknown, 73 + })) 74 + 75 // Import routes AFTER mocking 76 import { replyRoutes } from '../../../src/routes/replies.js' 77 ··· 393 it('creates a threaded reply (with parentUri) and returns 201', async () => { 394 // First select: look up topic 395 selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) 396 // Second select: look up parent reply 397 selectChain.where.mockResolvedValueOnce([ 398 sampleReplyRow({ ··· 1496 it('returns 403 when onboarding is incomplete', async () => { 1497 // Topic lookup 1498 selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) 1499 + // Override onboarding gate to return incomplete 1500 + checkOnboardingCompleteFn.mockResolvedValueOnce({ 1501 + complete: false, 1502 + missingFields: [{ id: 'field1', label: 'Accept Rules', fieldType: 'checkbox' }], 1503 + }) 1504 1505 const encodedTopicUri = encodeURIComponent(TEST_TOPIC_URI) 1506 const response = await app.inject({
+5 -2
tests/unit/routes/topics-replies-integration.test.ts
··· 59 runAntiSpamChecks: vi.fn().mockResolvedValue({ held: false, reasons: [] }), 60 })) 61 62 // Import routes AFTER mocking 63 import { topicRoutes } from '../../../src/routes/topics.js' 64 import { replyRoutes } from '../../../src/routes/replies.js' ··· 637 638 // Topic lookup succeeds 639 selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) 640 - // Onboarding gate: no mandatory fields 641 - selectChain.where.mockResolvedValueOnce([]) 642 // Parent reply lookup succeeds 643 selectChain.where.mockResolvedValueOnce([ 644 sampleReplyRow({
··· 59 runAntiSpamChecks: vi.fn().mockResolvedValue({ held: false, reasons: [] }), 60 })) 61 62 + // Mock onboarding gate (tested separately in onboarding-gate.test.ts) 63 + vi.mock('../../../src/lib/onboarding-gate.js', () => ({ 64 + checkOnboardingComplete: vi.fn().mockResolvedValue({ complete: true, missingFields: [] }), 65 + })) 66 + 67 // Import routes AFTER mocking 68 import { topicRoutes } from '../../../src/routes/topics.js' 69 import { replyRoutes } from '../../../src/routes/replies.js' ··· 642 643 // Topic lookup succeeds 644 selectChain.where.mockResolvedValueOnce([sampleTopicRow()]) 645 // Parent reply lookup succeeds 646 selectChain.where.mockResolvedValueOnce([ 647 sampleReplyRow({
+6 -18
tests/unit/routes/votes.test.ts
··· 29 }), 30 })) 31 32 // Import routes AFTER mocking 33 import { voteRoutes } from '../../../src/routes/votes.js' 34 ··· 222 }) 223 224 it('creates a vote on a topic and returns 201', async () => { 225 - // 0. Onboarding gate: no mandatory fields 226 - selectChain.where.mockResolvedValueOnce([]) 227 // 1. Subject existence check -> topic found 228 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 229 // 2. Insert returning ··· 265 }) 266 267 it('creates a vote on a reply and returns 201', async () => { 268 - // 0. Onboarding gate: no mandatory fields 269 - selectChain.where.mockResolvedValueOnce([]) 270 // 1. Subject existence check -> reply found 271 selectChain.where.mockResolvedValueOnce([{ uri: TEST_REPLY_URI }]) 272 // 2. Insert returning ··· 296 isTrackedFn.mockResolvedValue(false) 297 trackRepoFn.mockResolvedValue(undefined) 298 299 - // 0. Onboarding gate: no mandatory fields 300 - selectChain.where.mockResolvedValueOnce([]) 301 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 302 insertChain.returning.mockResolvedValueOnce([sampleVoteRow()]) 303 ··· 360 }) 361 362 it('returns 400 for invalid direction', async () => { 363 - // 0. Onboarding gate: no mandatory fields 364 - selectChain.where.mockResolvedValueOnce([]) 365 - 366 const response = await app.inject({ 367 method: 'POST', 368 url: '/api/votes', ··· 389 }) 390 391 it('returns 404 when subject does not exist', async () => { 392 - // 0. Onboarding gate: no mandatory fields 393 - selectChain.where.mockResolvedValueOnce([]) 394 // Subject not found 395 selectChain.where.mockResolvedValueOnce([]) 396 ··· 409 }) 410 411 it('returns 404 when subject URI has unknown collection', async () => { 412 - // 0. Onboarding gate: no mandatory fields 413 - selectChain.where.mockResolvedValueOnce([]) 414 - 415 const response = await app.inject({ 416 method: 'POST', 417 url: '/api/votes', ··· 427 }) 428 429 it('returns 409 when duplicate vote (unique constraint)', async () => { 430 - // 0. Onboarding gate: no mandatory fields 431 - selectChain.where.mockResolvedValueOnce([]) 432 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 433 // onConflictDoNothing -> returning() returns empty array 434 insertChain.returning.mockResolvedValueOnce([]) ··· 448 }) 449 450 it('returns 502 when PDS write fails', async () => { 451 - // 0. Onboarding gate: no mandatory fields 452 - selectChain.where.mockResolvedValueOnce([]) 453 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 454 createRecordFn.mockRejectedValueOnce(new Error('PDS unreachable')) 455
··· 29 }), 30 })) 31 32 + // Mock onboarding gate (tested separately in onboarding-gate.test.ts) 33 + const checkOnboardingCompleteFn = vi.fn().mockResolvedValue({ complete: true, missingFields: [] }) 34 + vi.mock('../../../src/lib/onboarding-gate.js', () => ({ 35 + checkOnboardingComplete: (...args: unknown[]) => checkOnboardingCompleteFn(...args) as unknown, 36 + })) 37 + 38 // Import routes AFTER mocking 39 import { voteRoutes } from '../../../src/routes/votes.js' 40 ··· 228 }) 229 230 it('creates a vote on a topic and returns 201', async () => { 231 // 1. Subject existence check -> topic found 232 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 233 // 2. Insert returning ··· 269 }) 270 271 it('creates a vote on a reply and returns 201', async () => { 272 // 1. Subject existence check -> reply found 273 selectChain.where.mockResolvedValueOnce([{ uri: TEST_REPLY_URI }]) 274 // 2. Insert returning ··· 298 isTrackedFn.mockResolvedValue(false) 299 trackRepoFn.mockResolvedValue(undefined) 300 301 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 302 insertChain.returning.mockResolvedValueOnce([sampleVoteRow()]) 303 ··· 360 }) 361 362 it('returns 400 for invalid direction', async () => { 363 const response = await app.inject({ 364 method: 'POST', 365 url: '/api/votes', ··· 386 }) 387 388 it('returns 404 when subject does not exist', async () => { 389 // Subject not found 390 selectChain.where.mockResolvedValueOnce([]) 391 ··· 404 }) 405 406 it('returns 404 when subject URI has unknown collection', async () => { 407 const response = await app.inject({ 408 method: 'POST', 409 url: '/api/votes', ··· 419 }) 420 421 it('returns 409 when duplicate vote (unique constraint)', async () => { 422 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 423 // onConflictDoNothing -> returning() returns empty array 424 insertChain.returning.mockResolvedValueOnce([]) ··· 438 }) 439 440 it('returns 502 when PDS write fails', async () => { 441 selectChain.where.mockResolvedValueOnce([{ uri: TEST_TOPIC_URI }]) 442 createRecordFn.mockRejectedValueOnce(new Error('PDS unreachable')) 443