feat(notes): phase 3 server-action scaffold#103
Conversation
|
The latest updates on your projects. Learn more about Vercel for GitHub.
|
|
You have reached your Codex usage limits for code reviews. You can see your limits in the Codex usage dashboard. |
Reviewer's GuideAdds 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 actionssequenceDiagram
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
Sequence diagram for guest vs authenticated create note mutationsequenceDiagram
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
Class diagram for notes server queries, actions, and hooksclassDiagram
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
File-Level Changes
Tips and commandsInteracting with Sourcery
Customizing Your ExperienceAccess your dashboard to:
Getting Help
|
|
Warning Rate limit exceeded
⌛ How to resolve this issue?After the wait time has elapsed, a review can be triggered using the 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)
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. Comment |
There was a problem hiding this comment.
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 onanycasts; consider renaming these interim helpers or exporting and wiring the real server actions now to avoid confusion and type holes. - Both
create-note.tsandupdate-note.ts(and partiallyset-visibility.ts) duplicateserializeNoteContentandcreatePublicId/publicId assignment logic; extracting these into a shared notes utility would reduce the risk of divergence between create/update/visibility behaviors. - The
getNotesActionserver 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>Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.
| if (payload.pinned !== undefined) { | ||
| updates.pinned = payload.pinned ? 1 : 0 | ||
| updates.pinnedAt = payload.pinned ? (payload.pinnedAt ?? now) : null | ||
| } | ||
|
|
||
| if (payload.pinnedAt !== undefined) { |
There was a problem hiding this comment.
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).
| type DeleteNoteResult = { | ||
| id: string | ||
| success: true | ||
| } | ||
|
|
||
| export async function deleteNoteAction(input: unknown): Promise<DeleteNoteResult> { |
There was a problem hiding this comment.
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 successIf 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).
Summary
Validation
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:
Enhancements:
Tests: