Barazo AppView backend barazo.forum

feat(setup): seed default categories with subcategories and demo content (#141)

Community initialization now creates a default category tree with 4 root
categories (General, Development, Community, Feedback) and 7 subcategories
(Frontend, Backend, DevOps, Showcase, Events, Bug Reports, Feature Requests).

Also seeds 7 demo topics (one per leaf category) with one reply each,
using the admin's DID as author. Topics include realistic content so the
forum feels alive from the first visit.

authored by

Guido X Jansen and committed by
GitHub
f08e7fc6 92bd5230

+460 -5
+295
src/setup/service.ts
··· 4 4 import { communityOnboardingFields } from '../db/schema/onboarding-fields.js' 5 5 import { users } from '../db/schema/users.js' 6 6 import { pages } from '../db/schema/pages.js' 7 + import { categories } from '../db/schema/categories.js' 8 + import { topics } from '../db/schema/topics.js' 9 + import { replies } from '../db/schema/replies.js' 7 10 import type { Database } from '../db/index.js' 8 11 import { encrypt } from '../lib/encryption.js' 9 12 import type { Logger } from '../lib/logger.js' ··· 267 270 ] 268 271 await db.insert(pages).values(pageDefaults) 269 272 logger.info({ communityDid, pageCount: pageDefaults.length }, 'Default pages seeded') 273 + 274 + // Seed default categories with subcategories 275 + const catGeneral = `cat-${randomUUID()}` 276 + const catDev = `cat-${randomUUID()}` 277 + const catDevFrontend = `cat-${randomUUID()}` 278 + const catDevBackend = `cat-${randomUUID()}` 279 + const catDevDevops = `cat-${randomUUID()}` 280 + const catCommunity = `cat-${randomUUID()}` 281 + const catCommunityShowcase = `cat-${randomUUID()}` 282 + const catCommunityEvents = `cat-${randomUUID()}` 283 + const catFeedback = `cat-${randomUUID()}` 284 + const catFeedbackBugs = `cat-${randomUUID()}` 285 + const catFeedbackFeatures = `cat-${randomUUID()}` 286 + 287 + const categoryDefaults = [ 288 + // Root categories 289 + { 290 + id: catGeneral, 291 + slug: 'general', 292 + name: 'General', 293 + description: 'Open discussion on any topic.', 294 + parentId: null, 295 + sortOrder: 0, 296 + communityDid, 297 + maturityRating: 'safe' as const, 298 + createdAt: now, 299 + updatedAt: now, 300 + }, 301 + { 302 + id: catDev, 303 + slug: 'development', 304 + name: 'Development', 305 + description: 'Technical discussions about software development.', 306 + parentId: null, 307 + sortOrder: 1, 308 + communityDid, 309 + maturityRating: 'safe' as const, 310 + createdAt: now, 311 + updatedAt: now, 312 + }, 313 + { 314 + id: catCommunity, 315 + slug: 'community', 316 + name: 'Community', 317 + description: 'Community news, events, and member introductions.', 318 + parentId: null, 319 + sortOrder: 2, 320 + communityDid, 321 + maturityRating: 'safe' as const, 322 + createdAt: now, 323 + updatedAt: now, 324 + }, 325 + { 326 + id: catFeedback, 327 + slug: 'feedback', 328 + name: 'Feedback', 329 + description: 'Help us improve — report bugs and suggest features.', 330 + parentId: null, 331 + sortOrder: 3, 332 + communityDid, 333 + maturityRating: 'safe' as const, 334 + createdAt: now, 335 + updatedAt: now, 336 + }, 337 + // Subcategories: Development 338 + { 339 + id: catDevFrontend, 340 + slug: 'frontend', 341 + name: 'Frontend', 342 + description: 'UI frameworks, CSS, accessibility, and browser APIs.', 343 + parentId: catDev, 344 + sortOrder: 0, 345 + communityDid, 346 + maturityRating: 'safe' as const, 347 + createdAt: now, 348 + updatedAt: now, 349 + }, 350 + { 351 + id: catDevBackend, 352 + slug: 'backend', 353 + name: 'Backend', 354 + description: 'Servers, databases, APIs, and system design.', 355 + parentId: catDev, 356 + sortOrder: 1, 357 + communityDid, 358 + maturityRating: 'safe' as const, 359 + createdAt: now, 360 + updatedAt: now, 361 + }, 362 + { 363 + id: catDevDevops, 364 + slug: 'devops', 365 + name: 'DevOps', 366 + description: 'CI/CD, containers, infrastructure, and deployment.', 367 + parentId: catDev, 368 + sortOrder: 2, 369 + communityDid, 370 + maturityRating: 'safe' as const, 371 + createdAt: now, 372 + updatedAt: now, 373 + }, 374 + // Subcategories: Community 375 + { 376 + id: catCommunityShowcase, 377 + slug: 'showcase', 378 + name: 'Showcase', 379 + description: 'Share what you have built with the community.', 380 + parentId: catCommunity, 381 + sortOrder: 0, 382 + communityDid, 383 + maturityRating: 'safe' as const, 384 + createdAt: now, 385 + updatedAt: now, 386 + }, 387 + { 388 + id: catCommunityEvents, 389 + slug: 'events', 390 + name: 'Events', 391 + description: 'Meetups, conferences, and community happenings.', 392 + parentId: catCommunity, 393 + sortOrder: 1, 394 + communityDid, 395 + maturityRating: 'safe' as const, 396 + createdAt: now, 397 + updatedAt: now, 398 + }, 399 + // Subcategories: Feedback 400 + { 401 + id: catFeedbackBugs, 402 + slug: 'bugs', 403 + name: 'Bug Reports', 404 + description: 'Report issues so we can fix them.', 405 + parentId: catFeedback, 406 + sortOrder: 0, 407 + communityDid, 408 + maturityRating: 'safe' as const, 409 + createdAt: now, 410 + updatedAt: now, 411 + }, 412 + { 413 + id: catFeedbackFeatures, 414 + slug: 'feature-requests', 415 + name: 'Feature Requests', 416 + description: 'Suggest new features or improvements.', 417 + parentId: catFeedback, 418 + sortOrder: 1, 419 + communityDid, 420 + maturityRating: 'safe' as const, 421 + createdAt: now, 422 + updatedAt: now, 423 + }, 424 + ] 425 + 426 + await db.insert(categories).values(categoryDefaults) 427 + logger.info( 428 + { communityDid, categoryCount: categoryDefaults.length }, 429 + 'Default categories seeded' 430 + ) 431 + 432 + // Seed demo topics and replies so the forum feels alive on first visit. 433 + // Uses the admin's DID as author. URIs use a synthetic namespace to avoid 434 + // collisions with real AT Protocol records from the firehose. 435 + const demoTopics = [ 436 + { 437 + category: 'general', 438 + title: 'Welcome to the community!', 439 + content: 440 + 'This is a brand new forum powered by the AT Protocol. Your identity is portable, your data is yours, and the community is decentralized.\n\nFeel free to introduce yourself and start a conversation.', 441 + tags: ['welcome', 'introduction'], 442 + replyContent: 443 + 'Excited to be here! The AT Protocol integration is a great touch — portable identity is the future.', 444 + }, 445 + { 446 + category: 'frontend', 447 + title: 'What frontend framework are you using?', 448 + content: 449 + 'Curious what everyone is building with these days. React? Vue? Svelte? Something else entirely?\n\nBonus points if you can explain *why* you chose it over the alternatives.', 450 + tags: ['frontend', 'frameworks', 'discussion'], 451 + replyContent: 452 + 'SolidJS for new projects, React for anything with a large ecosystem requirement. The signals model in Solid feels like the future of reactivity.', 453 + }, 454 + { 455 + category: 'backend', 456 + title: 'Database migration strategies for zero-downtime deploys', 457 + content: 458 + 'We have been running into issues with schema migrations that lock tables during deployment. Has anyone implemented a reliable expand-and-contract pattern?\n\nLooking for practical advice, not just theory.', 459 + tags: ['database', 'migrations', 'deployment'], 460 + replyContent: 461 + 'The expand-contract pattern works well. Key insight: never rename columns in a single migration. Add the new column, backfill, switch reads, then drop the old one.', 462 + }, 463 + { 464 + category: 'devops', 465 + title: 'Docker Compose vs Kubernetes for small teams', 466 + content: 467 + 'Our team of 4 is debating whether to move from Docker Compose to Kubernetes. Current setup handles ~10k requests/day on a single VPS.\n\nIs K8s overkill at this scale? What would make you switch?', 468 + tags: ['docker', 'kubernetes', 'infrastructure'], 469 + replyContent: 470 + 'At 10k req/day, Compose is perfectly fine. We made the switch at ~500k req/day when we needed auto-scaling and rolling deploys across multiple nodes.', 471 + }, 472 + { 473 + category: 'showcase', 474 + title: 'Built a real-time markdown editor with AT Protocol sync', 475 + content: 476 + 'Just finished a side project: a markdown editor that syncs documents to your PDS as AT Protocol records. Edits propagate in real-time via the firehose.\n\nSource is on GitHub — feedback welcome!', 477 + tags: ['atproto', 'project', 'open-source'], 478 + replyContent: 479 + 'This is impressive. How do you handle conflict resolution when two clients edit the same document simultaneously?', 480 + }, 481 + { 482 + category: 'bugs', 483 + title: '[Example] How to write a good bug report', 484 + content: 485 + 'A good bug report includes:\n\n1. **What you expected** to happen\n2. **What actually happened** (screenshots help!)\n3. **Steps to reproduce** the issue\n4. **Environment details** — browser, OS, screen size\n\nThe more detail you provide, the faster we can fix it.', 486 + tags: ['meta', 'guide'], 487 + replyContent: 488 + 'Adding browser console output (F12 → Console tab) is also incredibly helpful for tracking down frontend issues.', 489 + }, 490 + { 491 + category: 'feature-requests', 492 + title: '[Example] Dark mode toggle in user preferences', 493 + content: 494 + 'It would be great to have a dark mode option in user settings. Currently the theme follows the system preference, but I would like to override it per-forum.\n\n**Use case:** I prefer dark mode at night but light mode during the day, and my system setting does not auto-switch.', 495 + tags: ['ux', 'accessibility', 'theming'], 496 + replyContent: 497 + 'Strong support for this. A three-way toggle (Light / Dark / System) is the standard pattern. Could even store the preference in the PDS for cross-forum portability.', 498 + }, 499 + ] 500 + 501 + const topicValues = demoTopics.map((t, i) => { 502 + const rkey = `seed${String(i + 1).padStart(3, '0')}` 503 + return { 504 + uri: `at://${did}/forum.barazo.topic.post/${rkey}`, 505 + rkey, 506 + authorDid: did, 507 + title: t.title, 508 + content: t.content, 509 + contentFormat: null, 510 + category: t.category, 511 + tags: t.tags, 512 + communityDid, 513 + cid: `bafyreiseed${String(i + 1).padStart(3, '0')}`, 514 + replyCount: 1, 515 + reactionCount: 0, 516 + voteCount: 0, 517 + lastActivityAt: now, 518 + createdAt: now, 519 + indexedAt: now, 520 + isLocked: false, 521 + isPinned: i === 0, 522 + isModDeleted: false, 523 + isAuthorDeleted: false, 524 + moderationStatus: 'approved' as const, 525 + trustStatus: 'trusted' as const, 526 + } 527 + }) 528 + 529 + await db.insert(topics).values(topicValues) 530 + 531 + const replyValues = demoTopics.map((t, i) => { 532 + const topicRkey = `seed${String(i + 1).padStart(3, '0')}` 533 + const topicUri = `at://${did}/forum.barazo.topic.post/${topicRkey}` 534 + const topicCid = `bafyreiseed${String(i + 1).padStart(3, '0')}` 535 + const replyRkey = `seedreply${String(i + 1).padStart(3, '0')}` 536 + return { 537 + uri: `at://${did}/forum.barazo.topic.reply/${replyRkey}`, 538 + rkey: replyRkey, 539 + authorDid: did, 540 + content: t.replyContent, 541 + contentFormat: null, 542 + rootUri: topicUri, 543 + rootCid: topicCid, 544 + parentUri: topicUri, 545 + parentCid: topicCid, 546 + communityDid, 547 + cid: `bafyreiseedreply${String(i + 1).padStart(3, '0')}`, 548 + reactionCount: 0, 549 + voteCount: 0, 550 + depth: 1, 551 + createdAt: now, 552 + indexedAt: now, 553 + isAuthorDeleted: false, 554 + isModDeleted: false, 555 + moderationStatus: 'approved' as const, 556 + trustStatus: 'trusted' as const, 557 + } 558 + }) 559 + 560 + await db.insert(replies).values(replyValues) 561 + logger.info( 562 + { communityDid, topicCount: topicValues.length, replyCount: replyValues.length }, 563 + 'Demo content seeded' 564 + ) 270 565 271 566 const finalName = row.communityName 272 567 logger.info({ did, communityName: finalName }, 'Community initialized')
+165 -5
tests/unit/setup/service.test.ts
··· 310 310 311 311 await service.initialize({ did: TEST_DID, communityDid: TEST_COMMUNITY_DID }) 312 312 313 - // The insert should be called three times: community settings, onboarding field, pages 314 - expect(mocks.insertFn).toHaveBeenCalledTimes(3) 313 + // insert is called 6 times: settings, onboarding, pages, categories, topics, replies 314 + expect(mocks.insertFn).toHaveBeenCalledTimes(6) 315 315 316 316 // The second insert's values call should contain the platform age field 317 317 const secondValuesCall = mocks.valuesFn.mock.calls[1]?.[0] as Record<string, unknown> ··· 487 487 did: TEST_DID, 488 488 }) 489 489 490 - // insert is called three times: community settings, onboarding field, pages 491 - expect(mocks.insertFn).toHaveBeenCalledTimes(3) 490 + // insert is called 6 times: settings, onboarding, pages, categories, topics, replies 491 + expect(mocks.insertFn).toHaveBeenCalledTimes(6) 492 492 }) 493 493 494 494 it('seeds exactly 4 default pages with correct slugs', async () => { ··· 496 496 { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 497 497 ]) 498 498 499 - // Capture the values passed to the third insert call (pages) 499 + // Capture the values passed to the pages insert call (pages have 'status' field) 500 500 let capturedPageValues: Array<{ slug: string; status: string; communityDid: string }> = [] 501 501 mocks.valuesFn.mockImplementation((vals: unknown) => { 502 502 if ( 503 503 Array.isArray(vals) && 504 504 vals.length > 0 && 505 + 'status' in (vals[0] as Record<string, unknown>) && 505 506 'slug' in (vals[0] as Record<string, unknown>) 506 507 ) { 507 508 capturedPageValues = vals as typeof capturedPageValues ··· 563 564 expect(seedLog[0]).toHaveProperty('communityDid', TEST_COMMUNITY_DID) 564 565 expect(seedLog[0]).toHaveProperty('pageCount', 4) 565 566 } 567 + }) 568 + }) 569 + 570 + // ========================================================================= 571 + // initialize (category and demo content seeding) 572 + // ========================================================================= 573 + 574 + describe('initialize() category and demo content seeding', () => { 575 + it('seeds categories with subcategories', async () => { 576 + mocks.returningFn.mockResolvedValueOnce([ 577 + { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 578 + ]) 579 + 580 + let capturedCategoryValues: Array<{ 581 + slug: string 582 + parentId: string | null 583 + communityDid: string 584 + }> = [] 585 + mocks.valuesFn.mockImplementation((vals: unknown) => { 586 + if ( 587 + Array.isArray(vals) && 588 + vals.length > 0 && 589 + 'maturityRating' in (vals[0] as Record<string, unknown>) && 590 + 'slug' in (vals[0] as Record<string, unknown>) 591 + ) { 592 + capturedCategoryValues = vals as typeof capturedCategoryValues 593 + } 594 + return { 595 + onConflictDoUpdate: mocks.onConflictDoUpdateFn, 596 + onConflictDoNothing: mocks.onConflictDoNothingFn, 597 + } 598 + }) 599 + 600 + await service.initialize({ 601 + communityDid: TEST_COMMUNITY_DID, 602 + did: TEST_DID, 603 + }) 604 + 605 + expect(capturedCategoryValues.length).toBeGreaterThan(4) 606 + 607 + // Root categories have null parentId 608 + const roots = capturedCategoryValues.filter((c) => c.parentId === null) 609 + expect(roots.length).toBeGreaterThanOrEqual(4) 610 + 611 + // Subcategories have non-null parentId 612 + const subs = capturedCategoryValues.filter((c) => c.parentId !== null) 613 + expect(subs.length).toBeGreaterThanOrEqual(3) 614 + 615 + // All categories belong to the correct community 616 + for (const cat of capturedCategoryValues) { 617 + expect(cat.communityDid).toBe(TEST_COMMUNITY_DID) 618 + } 619 + }) 620 + 621 + it('seeds demo topics across categories including subcategories', async () => { 622 + mocks.returningFn.mockResolvedValueOnce([ 623 + { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 624 + ]) 625 + 626 + let capturedTopicValues: Array<{ 627 + category: string 628 + title: string 629 + authorDid: string 630 + }> = [] 631 + mocks.valuesFn.mockImplementation((vals: unknown) => { 632 + if ( 633 + Array.isArray(vals) && 634 + vals.length > 0 && 635 + 'title' in (vals[0] as Record<string, unknown>) && 636 + 'category' in (vals[0] as Record<string, unknown>) 637 + ) { 638 + capturedTopicValues = vals as typeof capturedTopicValues 639 + } 640 + return { 641 + onConflictDoUpdate: mocks.onConflictDoUpdateFn, 642 + onConflictDoNothing: mocks.onConflictDoNothingFn, 643 + } 644 + }) 645 + 646 + await service.initialize({ 647 + communityDid: TEST_COMMUNITY_DID, 648 + did: TEST_DID, 649 + }) 650 + 651 + expect(capturedTopicValues.length).toBeGreaterThanOrEqual(5) 652 + 653 + // Topics should span both root and subcategories 654 + const topicCategories = new Set(capturedTopicValues.map((t) => t.category)) 655 + expect(topicCategories.has('general')).toBe(true) 656 + expect(topicCategories.has('frontend')).toBe(true) 657 + expect(topicCategories.has('backend')).toBe(true) 658 + 659 + // All topics use the admin DID as author 660 + for (const topic of capturedTopicValues) { 661 + expect(topic.authorDid).toBe(TEST_DID) 662 + } 663 + }) 664 + 665 + it('seeds demo replies for each topic', async () => { 666 + mocks.returningFn.mockResolvedValueOnce([ 667 + { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 668 + ]) 669 + 670 + let capturedReplyValues: Array<{ 671 + rootUri: string 672 + authorDid: string 673 + depth: number 674 + }> = [] 675 + mocks.valuesFn.mockImplementation((vals: unknown) => { 676 + if ( 677 + Array.isArray(vals) && 678 + vals.length > 0 && 679 + 'rootUri' in (vals[0] as Record<string, unknown>) && 680 + 'depth' in (vals[0] as Record<string, unknown>) 681 + ) { 682 + capturedReplyValues = vals as typeof capturedReplyValues 683 + } 684 + return { 685 + onConflictDoUpdate: mocks.onConflictDoUpdateFn, 686 + onConflictDoNothing: mocks.onConflictDoNothingFn, 687 + } 688 + }) 689 + 690 + await service.initialize({ 691 + communityDid: TEST_COMMUNITY_DID, 692 + did: TEST_DID, 693 + }) 694 + 695 + // One reply per topic 696 + expect(capturedReplyValues.length).toBeGreaterThanOrEqual(5) 697 + 698 + for (const reply of capturedReplyValues) { 699 + expect(reply.authorDid).toBe(TEST_DID) 700 + expect(reply.depth).toBe(1) 701 + } 702 + }) 703 + 704 + it('logs category and demo content seeding', async () => { 705 + mocks.returningFn.mockResolvedValueOnce([ 706 + { communityName: DEFAULT_COMMUNITY_NAME, communityDid: TEST_COMMUNITY_DID }, 707 + ]) 708 + 709 + await service.initialize({ 710 + communityDid: TEST_COMMUNITY_DID, 711 + did: TEST_DID, 712 + }) 713 + 714 + const infoFn = mockLogger.info as ReturnType<typeof vi.fn> 715 + const logCalls = infoFn.mock.calls as Array<[Record<string, unknown>, string]> 716 + 717 + const catLog = logCalls.find( 718 + ([_ctx, msg]) => typeof msg === 'string' && msg.includes('Default categories seeded') 719 + ) 720 + expect(catLog).toBeDefined() 721 + 722 + const contentLog = logCalls.find( 723 + ([_ctx, msg]) => typeof msg === 'string' && msg.includes('Demo content seeded') 724 + ) 725 + expect(contentLog).toBeDefined() 566 726 }) 567 727 }) 568 728