Skip to content

feat(notes): phase 3 server-action scaffold#103

Open
remcostoeten wants to merge 1 commit intodaddyfrom
feature/p3-notes-server-actions
Open

feat(notes): phase 3 server-action scaffold#103
remcostoeten wants to merge 1 commit intodaddyfrom
feature/p3-notes-server-actions

Conversation

@remcostoeten
Copy link
Owner

@remcostoeten remcostoeten commented Feb 14, 2026

Summary

  • add notes server query layer with typed Drizzle queries (getNotes, getNoteById, getNoteTree, getChildren)
  • add notes server actions for get/create/update/delete/set-visibility with auth + zod validation
  • wire authenticated paths in use-notes-query mutations/query to action-style calls while preserving guest path
  • add initial notes test coverage for guest fallback vs authenticated adapter path
  • update dashboard progress for completed Phase 2 merge and Phase 3 notes scaffold tasks

Validation

  • /home/remco/.bun/bin/bun test tests/notes/server-actions-notes.test.ts tests/notes/tree-helpers.test.ts
  • attempted typecheck: /home/remco/.bun/bin/bunx tsc --noEmit (repo has existing unrelated TS errors)

Summary by Sourcery

Introduce authenticated notes server actions and Drizzle-backed query layer, and wire the notes hooks to use these actions for non-guest users while updating project audit documentation.

New Features:

  • Add a notes server query module providing Drizzle-backed helpers for listing notes, fetching by ID, building a root-only note tree, and loading children on demand.
  • Add authenticated server actions for notes CRUD operations and visibility updates, with input validation and public ID handling.
  • Extend notes hooks to support an authenticated server-action path alongside the existing guest/localStorage path.

Enhancements:

  • Implement lazy-loaded note tree queries that compute child counts per folder/note and return sorted items by pin status, update time, and name.
  • Update the audit dashboard to mark Phase 2 completion and Phase 3 notes server-action tasks as done with dated notes.

Tests:

  • Add initial server-action tests for notes and tree helper behavior to cover guest fallback versus authenticated paths.

@vercel
Copy link
Contributor

vercel bot commented Feb 14, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
skriuw Error Error Feb 14, 2026 5:58pm

@chatgpt-codex-connector
Copy link

You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard.

@sourcery-ai
Copy link
Contributor

sourcery-ai bot commented Feb 14, 2026

Reviewer's Guide

Adds a typed Drizzle-based server query layer and server actions for the notes domain, wires authenticated React Query hooks over to these actions while preserving the existing guest/localStorage behavior, introduces lazy-loaded note trees, and updates the project audit dashboard to mark the corresponding Phase 2/3 tasks as complete.

Sequence diagram for authenticated notes list fetch via server actions

sequenceDiagram
    actor User
    participant NotesPage
    participant UseNotesQueryHook as useNotesQuery
    participant ServerAction as getNotesAction
    participant Auth as requireAuth
    participant Queries as getNotes
    participant DB

    User->>NotesPage: open notes view
    NotesPage->>UseNotesQueryHook: useNotesQuery()
    UseNotesQueryHook->>UseNotesQueryHook: read userId
    alt authenticated_user
        UseNotesQueryHook->>ServerAction: getNotesAction()
        ServerAction->>Auth: requireAuth()
        Auth-->>ServerAction: AuthenticatedUser(id)
        ServerAction->>Queries: getNotes(userId)
        Queries->>DB: select notes where userId and deletedAt is null
        DB-->>Queries: Note[]
        Queries-->>ServerAction: Note[]
        ServerAction-->>UseNotesQueryHook: Note[]
        UseNotesQueryHook->>UseNotesQueryHook: filterActiveItems(Note[])
        UseNotesQueryHook-->>NotesPage: filtered Note[]
    else guest_user
        UseNotesQueryHook->>UseNotesQueryHook: readMany from localStorage
        UseNotesQueryHook-->>NotesPage: guest Note[]
    end
Loading

Sequence diagram for guest vs authenticated create note mutation

sequenceDiagram
    actor User
    participant NotesPage
    participant UseCreateNoteHook as useCreateNoteMutation
    participant CreateServerAction as createNoteAction
    participant Auth as requireAuth
    participant DB
    participant LocalStorage

    User->>NotesPage: create note
    NotesPage->>UseCreateNoteHook: mutate({name, content, ...})
    UseCreateNoteHook->>UseCreateNoteHook: read userId
    alt authenticated_user
        UseCreateNoteHook->>CreateServerAction: createNoteAction(input)
        CreateServerAction->>Auth: requireAuth()
        Auth-->>CreateServerAction: AuthenticatedUser(id)
        alt payload_type_folder
            CreateServerAction->>DB: insert into folders
            DB-->>CreateServerAction: Folder
            CreateServerAction-->>UseCreateNoteHook: Folder
        else payload_type_note
            CreateServerAction->>DB: insert into notes
            DB-->>CreateServerAction: Note
            CreateServerAction-->>UseCreateNoteHook: Note
        end
        UseCreateNoteHook-->>NotesPage: created Note or Folder
    else guest_user
        UseCreateNoteHook->>LocalStorage: create note in STORAGE_KEYS.NOTES
        LocalStorage-->>UseCreateNoteHook: Note
        UseCreateNoteHook-->>NotesPage: created Note
    end
Loading

Class diagram for notes server queries, actions, and hooks

classDiagram
    class Note {
        +string id
        +string type
        +string name
        +string content
        +string userId
        +string parentFolderId
        +string icon
        +string coverImage
        +string[] tags
        +boolean isPublic
        +string publicId
        +number publicViews
        +number pinned
        +number favorite
        +number createdAt
        +number updatedAt
        +number deletedAt
    }

    class Folder {
        +string id
        +string type
        +string name
        +string userId
        +string parentFolderId
        +number pinned
        +number createdAt
        +number updatedAt
        +number deletedAt
    }

    class NoteTreeItem {
        +string id
        +string type
        +string name
        +string userId
        +string parentFolderId
        +number pinned
        +number createdAt
        +number updatedAt
        +number childCount
    }

    class NotesServerQueries {
        +getNotes(userId string) Note[]
        +getNoteById(userId string, id string) Note
        +getNoteTree(userId string) NoteTreeItem[]
        +getChildren(userId string, parentId string) NoteTreeItem[]
        -getChildCountsByParent(userId string) Map~string, number~
        -toCountMap(rows ChildCountRow[]) Map~string, number~
        -sortTreeItems(items NoteTreeItem[]) NoteTreeItem[]
    }

    class ChildCountRow {
        +string parentId
        +number count
    }

    class NotesActionsGet {
        +getNotesAction(input GetNotesInput) Note[] NoteTreeItem[] Note
    }

    class GetNotesInput {
        +string id
        +string parentId
        +boolean tree
    }

    class NotesActionsCreate {
        +createNoteAction(input unknown) Note Folder
        -serializeNoteContent(content unknown) string
        -createPublicId() string
    }

    class NotesActionsUpdate {
        +updateNoteAction(input unknown) Note
        -serializeNoteContent(content unknown) string
        -createPublicId() string
    }

    class NotesActionsDelete {
        +deleteNoteAction(input unknown) DeleteNoteResult
    }

    class NotesActionsVisibility {
        +setVisibilityAction(input unknown) Note
        -createPublicId() string
    }

    class DeleteNoteResult {
        +string id
        +boolean success
    }

    class RequireAuth {
        +requireAuth() AuthenticatedUser
    }

    class AuthenticatedUser {
        +string id
    }

    class NotesHooks {
        +useNotesQuery()
        +useCreateNoteMutation()
        +useUpdateNoteMutation()
        +useDeleteMutation()
        +useSetNoteVisibilityMutation()
        -getNotesAction() Note[]
        -createNoteAction(name string, content any[], parentFolderId string, icon string, tags string[], coverImage string, favorite boolean, pinned boolean) Note
        -updateNoteAction(id string, content any[], name string, icon string, tags string[], coverImage string) Note
        -deleteNoteAction(id string) boolean
        -setVisibilityAction(id string, isPublic boolean) Note
    }

    NoteTreeItem <|-- Note
    NoteTreeItem <|-- Folder

    NotesServerQueries --> Note
    NotesServerQueries --> Folder
    NotesServerQueries --> NoteTreeItem
    NotesActionsGet --> NotesServerQueries
    NotesActionsCreate --> Note
    NotesActionsCreate --> Folder
    NotesActionsUpdate --> Note
    NotesActionsDelete --> Note
    NotesActionsVisibility --> Note

    RequireAuth --> AuthenticatedUser
    NotesActionsGet --> RequireAuth
    NotesActionsCreate --> RequireAuth
    NotesActionsUpdate --> RequireAuth
    NotesActionsDelete --> RequireAuth
    NotesActionsVisibility --> RequireAuth

    NotesHooks --> NotesActionsGet
    NotesHooks --> NotesActionsCreate
    NotesHooks --> NotesActionsUpdate
    NotesHooks --> NotesActionsDelete
    NotesHooks --> NotesActionsVisibility

    class LocalStoragePath {
        +readMany()
        +create()
        +update()
        +destroy()
    }

    NotesHooks --> LocalStoragePath
Loading

File-Level Changes

Change Details Files
Introduce Drizzle-based server query layer for notes, including lazy-loaded tree support.
  • Add NoteTreeItem type and helper utilities for child counting and sorting.
  • Implement getNotes and getNoteById to return non-deleted user-scoped notes ordered by pin and recency.
  • Implement getNoteTree to fetch only root-level folders/notes with aggregated childCount for lazy loading.
  • Implement getChildren to fetch child folders/notes for a given parent, reusing shared child count logic.
apps/web/features/notes/server/queries.ts
Add authenticated server actions for notes CRUD and visibility, with validation and public-sharing behavior.
  • Implement createNoteAction with CreateNoteSchema validation, folder/note branching, content serialization, and automatic publicId assignment for public notes.
  • Implement updateNoteAction with UpdateNoteSchema validation, conditional field updates, pin/favorite/public state handling, and publicId generation when making a note public.
  • Implement deleteNoteAction as a soft-delete server action with zod validation and user scoping.
  • Implement setVisibilityAction to toggle isPublic with zod validation, ensuring a publicId exists whenever a note is made public.
  • Implement getNotesAction as an authenticated router over getNotes/getNoteById/getNoteTree/getChildren based on input flags.
apps/web/features/notes/actions/create-note.ts
apps/web/features/notes/actions/update-note.ts
apps/web/features/notes/actions/delete-note.ts
apps/web/features/notes/actions/set-visibility.ts
apps/web/features/notes/actions/get-notes.ts
Wire notes React Query hooks to use server actions for authenticated users while preserving guest/localStorage behavior.
  • Introduce temporary wrapper functions in use-notes-query.ts that adapt existing client-side helpers to the new server action shapes.
  • Update useNotesQuery to call getNotesAction for authenticated users while keeping the existing readMany-based path for guests, still filtering active items.
  • Update useCreateNoteMutation, useUpdateNoteMutation, useDeleteMutation, and useSetNoteVisibilityMutation to call the corresponding *Action for authenticated users and fall back to local CRUD or fetch for guests.
  • Remove server-side activity tracking from the update path when using the localStorage adapter, keeping behavior simple for guests.
apps/web/features/notes/hooks/use-notes-query.ts
Mark Phase 2 completion and Phase 3 notes server-action scaffold tasks as done in the audit dashboard.
  • Mark Phase 2 branch/test/merge tasks as done with completion notes including dates and branch/PR metadata.
  • Mark Phase 3 notes server query/actions and hook-wiring tasks as done, clarifying that changes only affect authenticated paths and that lazy tree loading and soft-delete behavior are in place.
docs/audit-dashboard.html

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Feb 14, 2026

Warning

Rate limit exceeded

@remcostoeten has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 3 minutes and 28 seconds before requesting another review.

⌛ How to resolve this issue?

After the wait time has elapsed, a review can be triggered using the @coderabbitai review command as a PR comment. Alternatively, push new commits to this PR.

We recommend that you space out your commits to avoid hitting the rate limit.

🚦 How do rate limits work?

CodeRabbit enforces hourly rate limits for each developer per organization.

Our paid plans have higher rate limits than the trial, open-source and free plans. In all cases, we re-allow further reviews after a brief timeout.

Please see our FAQ for further information.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch feature/p3-notes-server-actions

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey - I've found 2 issues, and left some high level feedback:

  • The temporary wrappers in use-notes-query.ts (getNotesAction, createNoteAction, etc.) shadow the new server actions with the same names and rely on any casts; consider renaming these interim helpers or exporting and wiring the real server actions now to avoid confusion and type holes.
  • Both create-note.ts and update-note.ts (and partially set-visibility.ts) duplicate serializeNoteContent and createPublicId/publicId assignment logic; extracting these into a shared notes utility would reduce the risk of divergence between create/update/visibility behaviors.
  • The getNotesAction server action returns a broad union (Note[] | NoteTreeItem[] | Note | null) keyed off optional flags; you may want to split this into more focused actions (e.g., getNote, getNoteTree, getChildren) or introduce a discriminated result shape to keep call sites more type-safe and intention-revealing.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- The temporary wrappers in `use-notes-query.ts` (`getNotesAction`, `createNoteAction`, etc.) shadow the new server actions with the same names and rely on `any` casts; consider renaming these interim helpers or exporting and wiring the real server actions now to avoid confusion and type holes.
- Both `create-note.ts` and `update-note.ts` (and partially `set-visibility.ts`) duplicate `serializeNoteContent` and `createPublicId`/publicId assignment logic; extracting these into a shared notes utility would reduce the risk of divergence between create/update/visibility behaviors.
- The `getNotesAction` server action returns a broad union (`Note[] | NoteTreeItem[] | Note | null`) keyed off optional flags; you may want to split this into more focused actions (e.g., `getNote`, `getNoteTree`, `getChildren`) or introduce a discriminated result shape to keep call sites more type-safe and intention-revealing.

## Individual Comments

### Comment 1
<location> `apps/web/features/notes/actions/update-note.ts:44-49` </location>
<code_context>
+	if (payload.coverImage !== undefined) updates.coverImage = payload.coverImage
+	if (payload.tags !== undefined) updates.tags = payload.tags
+
+	if (payload.pinned !== undefined) {
+		updates.pinned = payload.pinned ? 1 : 0
+		updates.pinnedAt = payload.pinned ? (payload.pinnedAt ?? now) : null
+	}
+
+	if (payload.pinnedAt !== undefined) {
+		updates.pinnedAt = payload.pinnedAt
+	}
</code_context>

<issue_to_address>
**issue (bug_risk):** Pinned and pinnedAt can get into an inconsistent state when both are provided.

Because `pinnedAt` is set first in the `payload.pinned !== undefined` block and then may be overwritten in the `payload.pinnedAt !== undefined` block, a payload like `{ pinned: false, pinnedAt: ts }` can result in `pinned = 0` with a non-null `pinnedAt`. Please handle these together so the two fields stay consistent (e.g., only apply `pinnedAt` when `pinned` is true, or always null `pinnedAt` when `pinned` is false).
</issue_to_address>

### Comment 2
<location> `apps/web/features/notes/actions/delete-note.ts:7-12` </location>
<code_context>
+import { and, eq, getDatabase, isNull, notes } from '@skriuw/db'
+import { z } from 'zod'
+
+type DeleteNoteResult = {
+	id: string
+	success: true
+}
+
+export async function deleteNoteAction(input: unknown): Promise<DeleteNoteResult> {
+	const user = await requireAuth()
+	const parsed = z
</code_context>

<issue_to_address>
**suggestion (bug_risk):** The delete-note server action returns an object where the client-side mutation expects a boolean.

`useDeleteMutation` (via `deleteNoteAction`) currently expects a `boolean` and throws on falsy, but this action returns `{ id, success: true }`. Once wired up, that mismatch will break the caller. Either change this to `Promise<boolean>` (dropping `id`) or keep the object shape and add a client-side adapter that converts it to a boolean so the hook’s API stays consistent.

Suggested implementation:

```typescript
import { and, eq, getDatabase, isNull, notes } from '@skriuw/db'
import { z } from 'zod'

export async function deleteNoteAction(input: unknown): Promise<boolean> {

```

```typescript
	const deleted = await db
		.update(notes)
		.set({ deletedAt: now, updatedAt: now })
		.where(and(eq(notes.id, parsed.id), eq(notes.userId, user.id), isNull(notes.deletedAt)))
		.returning()

	const success = deleted.length > 0
	return success

```

If `useDeleteMutation` or any other caller was already updated to expect the `{ id, success }` shape, those call sites should be reverted to expect a `boolean` again (or wrapped in an adapter that converts the boolean to whatever the hook API needs).
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +44 to +49
if (payload.pinned !== undefined) {
updates.pinned = payload.pinned ? 1 : 0
updates.pinnedAt = payload.pinned ? (payload.pinnedAt ?? now) : null
}

if (payload.pinnedAt !== undefined) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Pinned and pinnedAt can get into an inconsistent state when both are provided.

Because pinnedAt is set first in the payload.pinned !== undefined block and then may be overwritten in the payload.pinnedAt !== undefined block, a payload like { pinned: false, pinnedAt: ts } can result in pinned = 0 with a non-null pinnedAt. Please handle these together so the two fields stay consistent (e.g., only apply pinnedAt when pinned is true, or always null pinnedAt when pinned is false).

Comment on lines +7 to +12
type DeleteNoteResult = {
id: string
success: true
}

export async function deleteNoteAction(input: unknown): Promise<DeleteNoteResult> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

suggestion (bug_risk): The delete-note server action returns an object where the client-side mutation expects a boolean.

useDeleteMutation (via deleteNoteAction) currently expects a boolean and throws on falsy, but this action returns { id, success: true }. Once wired up, that mismatch will break the caller. Either change this to Promise<boolean> (dropping id) or keep the object shape and add a client-side adapter that converts it to a boolean so the hook’s API stays consistent.

Suggested implementation:

import { and, eq, getDatabase, isNull, notes } from '@skriuw/db'
import { z } from 'zod'

export async function deleteNoteAction(input: unknown): Promise<boolean> {
	const deleted = await db
		.update(notes)
		.set({ deletedAt: now, updatedAt: now })
		.where(and(eq(notes.id, parsed.id), eq(notes.userId, user.id), isNull(notes.deletedAt)))
		.returning()

	const success = deleted.length > 0
	return success

If useDeleteMutation or any other caller was already updated to expect the { id, success } shape, those call sites should be reverted to expect a boolean again (or wrapped in an adapter that converts the boolean to whatever the hook API needs).

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant