nyxcore-systems
8 min read

Shipping Chapter Auto-Save and Public Reports: My Latest Dev Expedition

Join me on a late-night dev expedition as I tackle two crucial features for nyxBook: seamless chapter auto-saving and robust public report sharing. We'll dive into the code, celebrate the wins, and learn from the inevitable 'pain log' moments.

Next.jstRPCPrismaPostgreSQLSoftware DevelopmentLessons LearnedWorkflow EngineFeature Development

Late nights in development often lead to the most satisfying breakthroughs. This past session was one of those. My mission: bring two critical features to life for nyxBook, a project I'm deeply invested in. The goals were clear:

  1. Automate chapter saving within nyxBook's workflow engine, making the user experience smoother.
  2. Enable public sharing of reports, expanding their utility beyond private access.

Both are now live, deployed, and verified. But as with any real-world development, the journey was less a straight line and more a winding path with a few unexpected detours.

Feature 1: The Invisible Hand of Auto-Save for nyxBook Chapters

Imagine spending hours crafting content, only to lose it due to a missed save. Unthinkable, right? That's the problem I aimed to solve with nyxBook's auto-save feature. Our workflow engine generates narrative and "aktenlage" (supporting documentation) content, which naturally forms chapters of a book. The user shouldn't have to think about saving; it should just happen.

The Implementation Details

The core idea was to hook into the existing runWorkflow() completion block. When a workflow finishes, it now triggers a new process:

  1. Content Extraction: I added two helper functions, extractChapterNumber() and extractStepContent(), to workflow-engine.ts. These precisely parse the output of a completed workflow step to identify the relevant chapter data.
  2. Persistent Storage: The persistChapterFromWorkflow() function is the workhorse here. It takes the extracted data and performs an upsert operation on our BookChapter model. This ensures that if a chapter already exists (e.g., if a workflow step was re-run), it gets updated; otherwise, a new chapter is created.
  3. User Feedback: To keep the user informed without interrupting their flow, I wired up an SSE (Server-Sent Events) notification. Upon successful save, the client receives a "Chapter N saved (narrative + aktenlage)" message, providing a subtle confirmation.

This was a relatively smooth ride, backed by 9 solid unit tests in tests/unit/persist-chapter.test.ts to ensure data integrity and correct parsing. The design doc (docs/plans/2026-03-08-save-to-chapter-design.md) kept me on track.

Feature 2: Sharing is Caring – Public Report Accessibility

Reports generated by nyxBook are powerful, but their impact is limited if they can't be easily shared. The goal was to enable users to generate a public link for any report, allowing anyone with the URL to view it (and eventually download a PDF).

The Architecture

This feature touched more parts of the system, requiring careful integration:

  1. Database Schema Update: The first step was fundamental: add an isPublic boolean field to the Report model in Prisma, defaulting to false.
    typescript
    // prisma/schema.prisma
    model Report {
      // ... other fields
      isPublic Boolean @default(false)
      // ...
    }
    
  2. tRPC Mutation for Toggling: A new togglePublic tRPC mutation was added to reports.ts. This allows the client to securely update the isPublic status of a report.
  3. Public Route Creation: The heart of the sharing mechanism is the new public route: src/app/(public)/r/[shortId]/page.tsx. This is a server component designed to fetch and render a report based on a short, 8-character ID prefix of its UUID.
  4. Public PDF Endpoint: To complement the public view, I created src/app/api/v1/reports/pdf/public/route.ts. This API endpoint allows public users to download a PDF version of the shared report.
  5. UI Integration: Share toggles (represented by a Link2 icon) were added in both the ReportGeneratorModal and the ReportsTab on the project page. Clicking it reveals the copyable public URL.
  6. Middleware Whitelisting: Crucially, the new /r/ route needed to be added to the public routes in src/middleware.ts to bypass authentication for public access.

This feature required a detailed plan (docs/plans/2026-03-08-public-report-sharing-design.md) and careful coordination across frontend, backend, and infrastructure.

The Crucible: Lessons from the "Pain Log"

No development session is complete without hitting a few snags. These "pain log" entries often turn into the most valuable lessons.

Lesson 1: When ORMs Fall Short – Raw SQL to the Rescue for UUIDs

The Problem: For the public report sharing, I wanted to use a short, 8-character prefix of the report's UUID (e.g., 9CFD8B12) in the URL /r/[shortId]. My initial thought was to use Prisma's findFirst with a startsWith filter on the UUID column:

typescript
// What I tried (and failed with)
prisma.report.findFirst({
  where: {
    id: { startsWith: shortId } // Prisma UuidFilter does not support startsWith
  }
});

Prisma's UuidFilter doesn't support startsWith directly on UUID columns, likely because UUIDs are stored as a specific type, not just text, and string operations aren't always directly translatable.

The Fix & Takeaway: When ORMs hit their limits, dropping down to raw SQL is often the most pragmatic solution. I used prisma.$queryRaw and PostgreSQL's string manipulation functions:

typescript
// The working solution
const report = await prisma.$queryRaw<Report[]>`
  SELECT * FROM "Report"
  WHERE REPLACE(id::text, '-', '') LIKE ${prefix + "%"}
  LIMIT 1;
`;
// Note: `id::text` casts the UUID to text, and `REPLACE` removes hyphens
// to ensure a clean prefix match against the 8-char shortId.

Takeaway: Don't be afraid to use raw SQL when your ORM doesn't offer the specific functionality you need, especially for database-specific optimizations or type handling. Understand your database's capabilities beyond the ORM's abstraction.

Lesson 2: The Ghost of Unpushed Commits – Git Discipline

The Problem: I made some local commits, felt ready to deploy, and ran my SSH deploy script. The server's git pull fetched nothing. My changes weren't there.

The Cause & Takeaway: This was a classic "oops." I forgot to git push origin main after committing locally. The commits only existed on my local machine, not on the remote repository the server was pulling from.

Takeaway: Always, always git push your changes to the remote repository before attempting to deploy from it. Make it a muscle memory, especially in solo development or small teams where you might be both the developer and the deployer.

Lesson 3: The Peril of prisma db push on Production – Schema Migration Caution

The Problem: To add the isPublic column, I initially tried npx prisma@5.22.0 db push on the production server. It immediately warned me it wanted to drop an embedding vector column with 711 non-null values. Panic!

The Cause & Takeaway: prisma db push is designed for rapid prototyping and development environments. It attempts to synchronize the database schema with your Prisma schema by making destructive changes if necessary (like dropping columns it thinks are no longer needed). This is extremely dangerous on production with existing data.

The Fix: I backed off immediately and opted for a direct SQL ALTER TABLE command, which is much safer for simple additions:

sql
ALTER TABLE reports ADD COLUMN IF NOT EXISTS "isPublic" BOOLEAN NOT NULL DEFAULT false;

Takeaway: Never use prisma db push on a production database with real data. For production schema changes, use prisma migrate deploy (after generating migrations in a dev environment) or carefully crafted raw SQL ALTER TABLE statements. Understand the difference between db push and migrate.

Lesson 4: The Middleware's Misdirection – Public Route Oversight

The Problem: After deploying the public reports, I tried accessing https://nyxcore.cloud/r/9CFD8B12. Instead of seeing the report, I was redirected to the login page (HTTP 307).

The Cause & Takeaway: My src/middleware.ts had a list of public routes that bypassed authentication. I had remembered to add my /b/ (book) routes previously, but completely forgot to add /r/ for reports. The middleware saw /r/ and, not recognizing it as public, enforced authentication.

The Fix: A simple addition to the public route check in src/middleware.ts:

typescript
// src/middleware.ts
// ...
const isReportRoute = nextUrl.pathname.startsWith("/r/");
// ...
if (isPublicRoute || isReportRoute) {
  return NextResponse.next();
}
// ...

Takeaway: When adding new public-facing routes that bypass authentication, always double-check your middleware or routing configuration. Thoroughly test authentication bypass for all new public endpoints.

Wrapping Up & What's Next

Despite the bumps, both features are now live and performing as expected. The auto-save brings a much-needed layer of reliability to content generation, and public report sharing opens up new avenues for collaboration and dissemination.

I've verified the public report page at https://nyxcore.cloud/r/9CFD8B12 (using test report 9cfd8b12).

My immediate next steps involve:

  1. Thoroughly testing the UI elements for the share toggle.
  2. Verifying PDF download from the public report page.
  3. Considering rate limiting for public report and PDF routes to prevent abuse.
  4. Adding Open Graph (OG) meta tags to public report pages for better social media sharing.

This session was a fantastic reminder that even well-planned features can present unexpected challenges. Embracing these challenges, understanding their root causes, and documenting the solutions is what truly hones our craft as developers. Until next time!

json
{
  "thingsDone": [
    "nyxBook auto-save chapter on workflow completion",
    "Public report sharing via /r/[shortId]"
  ],
  "pains": [
    "Prisma UuidFilter does not support startsWith, requiring raw SQL",
    "Forgot to git push before deploying, leading to stale code on server",
    "Attempted prisma db push on production, risking data loss, requiring manual SQL for schema update",
    "Public /r/ route redirected to login due to missing middleware configuration"
  ],
  "successes": [
    "Successfully implemented workflow engine hook for auto-save",
    "Created robust public sharing infrastructure with new model field, tRPC mutation, and public routes",
    "Learned valuable lessons about Prisma limitations, Git workflow, production migrations, and middleware configuration",
    "Both features deployed to production and verified working"
  ],
  "techStack": [
    "Next.js",
    "tRPC",
    "Prisma",
    "PostgreSQL",
    "TypeScript",
    "SSE (Server-Sent Events)",
    "Git",
    "SSH"
  ]
}