nyxcore-systems
6 min read

Smart Notes, Smarter Actions: Elevating Project Management with Granular AI Enrichment & Grouped Insights

Dive into how we empowered users with fine-grained control over AI note enrichment and brought clarity to action points by grouping them by their source notes. A journey through state management, type safety, and database security.

AINoteTakingProjectManagementTypeScriptNext.jsPrismatRPCFrontendBackendDevelopmentWorkflow

In the fast-paced world of software development, turning raw ideas into actionable tasks is a critical bottleneck. Our project management platform aims to streamline this by leveraging AI to enrich notes and then converting those insights into actionable items. Recently, we embarked on a mission to make this process even more powerful, customizable, and intuitive.

Our latest development sprint focused on two key enhancements:

  1. Granular AI Enrichment: Giving users the power to choose their AI provider, model, and even a specific persona for note enrichment.
  2. Organized Action Points: Bringing clarity to project tasks by grouping action points directly under their source notes.

This post will walk you through the features we built, the challenges we faced, and the valuable lessons we learned along the way.

What We Built: Empowering Smart Notes and Smarter Actions

Our core goal was to move beyond a "one-size-fits-all" AI experience and to provide a more structured view of derived action points.

1. Granular AI Enrichment: Your AI, Your Way

Previously, our AI note enrichment was a bit of a black box. While effective, it lacked the flexibility many users desired. Now, we've opened up the controls:

  • Provider & Model Selection: Users can now specify which LLM provider (e.g., OpenAI, Claude, Ollama) and even which specific model they want to use for enriching a particular note. This is crucial for balancing cost, performance, and specific AI capabilities.
    • On the backend, our enrichNoteWithWisdom() service in src/server/services/note-enrichment.ts was extended to accept providerName, modelOverride, and personaId.
    • Input validation for the provider is handled robustly via z.enum(LLM_PROVIDERS) in our tRPC enrich procedure, ensuring only supported providers are accepted.
    • We also added a crucial guard for missing FAST_MODELS entries (e.g., for self-hosted Ollama instances), throwing a clear error if a configured model isn't found.
  • Persona-Driven Insights: Imagine enriching a note about a user interview from the perspective of a "Product Manager" or a "Marketing Strategist." With persona selection, users can now guide the AI's output to better suit their needs.
    • A significant security enhancement here was scoping persona queries to the user's tenant or built-in personas, preventing accidental exposure or misuse.
  • Intuitive UI for Control: All these settings are exposed in a sleek, per-note enrichment panel within the NotesTab. This involved building a Map<noteId, EnrichSettings> to manage the state of enrichment preferences for each individual note, ensuring that changing settings for one note doesn't inadvertently affect others.

2. Action Points, Grouped by Source Note

As projects grow, the list of action points can become overwhelming. Without context, it's hard to remember why a particular action was generated. Our solution: group action points by their originating note.

  • Database Foundation: We extended our prisma/schema.prisma by adding a sourceNoteId foreign key (nullable UUID) to the ActionPoint model, linking it back to the ProjectNote. We also added an actionPoints ActionPoint[] back-relation on ProjectNote for easy querying.
  • Backend Integration: The applyEnrichment procedure now correctly passes the sourceNoteId when creating new action points. Crucially, we added tenant and project ownership checks before any updates to ensure data integrity and security.
  • Smart Grouping UI: On the frontend, we developed a new ActionPointGroups component. This component intelligently groups action points by their sourceNote, displaying them under collapsible headers. To keep the UI snappy, we memoized the grouping logic, preventing unnecessary re-calculations. We also took care to fix React key placement on .map() call sites for optimal performance and stability.

Challenges & Lessons Learned

No development sprint is without its hurdles. These challenges, however, provided valuable learning opportunities that strengthened our architecture and development practices.

1. The Peril of Shared State: Per-Note Settings vs. Global Chaos

  • Initial Approach: Our first thought for enrichment settings was to use a shared state variable that would apply across all notes.
  • The Problem: During code review, it became immediately apparent that this would lead to a poor user experience. Changing the AI model for one note would unexpectedly change it for all notes currently open or being viewed, leading to confusion and potential data inconsistencies.
  • The Fix: We pivoted to a Map<noteId, EnrichSettings> data structure to manage settings. Each note now maintains its own independent enrichment configuration, accessed and updated via getEnrichSettings() and patchEnrichSettings() functions.
  • Lesson Learned: In dynamic UIs with multiple instances of similar components, carefully consider the scope of your state. Global state is tempting for simplicity but can quickly lead to unintended side effects. Granular, component-specific, or ID-keyed state is often the more robust solution for complex interactions.

2. Tightening Type Safety with Zod: From Arbitrary Strings to Enforced Providers

  • Initial Approach: When defining the input for our tRPC enrich procedure, we initially used z.string() for the providerName field.
  • The Problem: While technically validating the type, z.string() allows any string. This meant a malicious or mistaken user could send providerName: "not-a-real-provider" and potentially cause an unhandled error or expose internal logic. It also made type inference less precise on the frontend.
  • The Fix: We tightened the validation to z.enum(LLM_PROVIDERS), where LLM_PROVIDERS is a constant array of our supported AI provider names. This not only restricts input to valid options but also allowed us to type EnrichSettings.provider as LLMProviderName, giving us compile-time safety across the stack.
  • Lesson Learned: Zod's enum validator is incredibly powerful for enforcing strict input constraints against a known set of values. It's a crucial tool for building robust, type-safe APIs that prevent invalid data from reaching your business logic, enhancing both security and developer experience.

3. Ensuring Data Ownership in Prisma: Beyond Simple where Clauses

  • Initial Approach: When updating project-related entities like ActionPoint or ProjectNote, we initially tried to use a compound where clause in Prisma's update method, like where: { id: actionPointId, tenantId: userTenantId, projectId: userProjectId }.
  • The Problem: Prisma's update method typically requires the where clause to target fields marked with @id or @@unique in your schema. Our tenantId and projectId fields were not unique identifiers on their own (as they can appear on many records). This meant our compound where for ownership checks wouldn't work directly with update.
  • The Fix: We implemented a two-step process. First, we perform a findFirst query with the compound where clause (id, tenantId, projectId) to explicitly verify that the requesting user owns the record. If the record is found, then we proceed with a standard update using only the @id field.
  • Lesson Learned: Always explicitly verify data ownership and permissions before performing sensitive write operations. While ORMs like Prisma are powerful, they don't implicitly handle all security concerns. A separate, explicit ownership check is a critical safeguard against unauthorized data modification, especially in multi-tenant applications.

Immediate Next Steps

With the core features deployed, our immediate focus shifts to ensuring everything works perfectly and planning for future refinements:

  1. End-to-End Verification: A thorough end-to-end test is essential: expand a note, configure enrichment settings, trigger enrichment, apply the generated action points, and finally, verify that they are correctly grouped in the Actions tab.
  2. Component Extraction: The NoteEnrichmentPanel is currently integrated directly into the NotesTab. As the page grows, we'll consider extracting it into a standalone component for better modularity and maintainability.
  3. Local Dev Environment: The .claude/ directory remains untracked, which is by design for local settings.

Conclusion

This sprint has significantly enhanced our platform's intelligence and usability. By giving users fine-grained control over AI enrichment and bringing much-needed structure to action points, we're empowering them to manage projects with unprecedented clarity and efficiency. The journey also reinforced key principles around state management, type safety, and database security – invaluable lessons that will guide our future development. We're excited to see how these features help our users turn insights into impactful actions!