Skip to main content

Project Requirements Document

Overview

This document outlines the requirements for implementing full CRUD (Create, Read, Update, Delete) operations on a discussion forum feature. The Threads & Comments feature allows authenticated users to create, view, edit, and delete discussion threads and comments.

info

Implementation Status: CREATE and READ operations are already implemented. This PRD focuses on completing UPDATE and DELETE operations while documenting best practices and common pitfalls for all CRUD operations.

note

Note on Designs: No UI/UX designs are provided for this PRD. For the UPDATE and DELETE features, use your best judgement based on the existing implementation patterns in the codebase and the best practices documented in this PRD. Refer to the existing Create Thread and Add Comment modals as reference for form patterns.


Table of Contents

  1. Product Context
  2. Implementation Status
  3. User Stories
  4. Functional Requirements
  5. Non-Functional Requirements
  6. Data Model
  7. API Specification
  8. UI/UX Requirements for Update & Delete
  9. Best Practices & Common Footguns
  10. Acceptance Criteria

Product Context

Purpose

Enable users to engage in discussions through a threaded conversation system. Users can create discussion threads and comment on existing threads.

Target Users

  • Authenticated users of the platform
  • Users with valid email accounts who have completed the sign-in process

Success Metrics

  • Users can perform all CRUD operations on threads and comments
  • Data persists correctly across sessions
  • UI provides instant feedback on user actions
  • Error states are handled gracefully
  • Only authors can modify their own content

Implementation Status

OperationThreadCommentStatus
CreateImplemented
Read (List)Implemented
Read (Detail)N/AImplemented
UpdateTo Be Implemented
DeleteTo Be Implemented

User Stories

Thread Management

IDAs a...I want to...So that...Status
T1Authenticated userView a list of all threadsI can browse available discussions✅ Done
T2Authenticated userCreate a new thread with a title and contentI can start a new discussion✅ Done
T3Authenticated userView a single thread's detailsI can read the full content and see comments✅ Done
T4Authenticated userSee the comment count on each threadI can gauge engagement✅ Done
T5Authenticated userSee the author and timestamp of threadsI know who posted and when✅ Done
T6Thread authorEdit my thread's title and contentI can fix mistakes or update information🔲 TODO
T7Thread authorDelete my threadI can remove content I no longer want shared🔲 TODO

Comment Management

IDAs a...I want to...So that...Status
C1Authenticated userView all comments on a threadI can follow the discussion✅ Done
C2Authenticated userAdd a comment to a threadI can participate in the discussion✅ Done
C3Authenticated userSee the author and timestamp of commentsI know the context of each comment✅ Done
C4Authenticated userLoad more comments when availableI can see the full discussion history✅ Done
C5Comment authorEdit my comment's contentI can fix mistakes or clarify my points🔲 TODO
C6Comment authorDelete my commentI can remove content I no longer want🔲 TODO

Functional Requirements

FR-1: Authentication Gate

  • FR-1.1: All thread and comment endpoints MUST require authentication
  • FR-1.2: Unauthenticated users MUST be redirected to the sign-in page
  • FR-1.3: User session MUST be destroyed on unauthorized access attempts

FR-2: Thread Creation ✅ Implemented

  • FR-2.1: Users MUST provide a title (1-255 characters)
  • FR-2.2: Users MUST provide content (1-5000 characters)
  • FR-2.3: Thread MUST be associated with the creating user as author
  • FR-2.4: Thread MUST have auto-generated timestamps (createdAt, updatedAt)
  • FR-2.5: Thread MUST have a unique CUID identifier

FR-2B: Thread Update 🔲 TODO

  • FR-2B.1: Only the thread author MUST be able to edit their thread
  • FR-2B.2: Updated title MUST meet same validation as creation (1-255 characters)
  • FR-2B.3: Updated content MUST meet same validation as creation (1-5000 characters)
  • FR-2B.4: updatedAt timestamp MUST be automatically updated on edit
  • FR-2B.5: Non-authors attempting to edit MUST receive a FORBIDDEN error

FR-2C: Thread Deletion 🔲 TODO

  • FR-2C.1: Only the thread author MUST be able to delete their thread
  • FR-2C.2: Deletion SHOULD be a soft delete (set deletedAt timestamp)
  • FR-2C.3: Soft-deleted threads MUST NOT appear in thread listings
  • FR-2C.4: Soft-deleted threads MUST return NOT_FOUND when accessed directly
  • FR-2C.5: Deletion MUST show a confirmation dialog before proceeding
  • FR-2C.6: Associated comments SHOULD remain in database but be inaccessible

FR-3: Thread Listing

  • FR-3.1: Threads MUST be displayed in reverse chronological order (newest first)
  • FR-3.2: Listing MUST support pagination with configurable page size (1-100 items)
  • FR-3.3: Each thread card MUST display: title, content, author (name or email), last updated time, comment count
  • FR-3.4: Empty state MUST be shown when no threads exist

FR-4: Thread Detail View

  • FR-4.1: Thread detail page MUST display full thread content
  • FR-4.2: Invalid thread IDs MUST show a "Not Found" page
  • FR-4.3: Thread detail MUST include navigation back to the list

FR-5: Comment Creation ✅ Implemented

  • FR-5.1: Users MUST provide content (1-5000 characters)
  • FR-5.2: Comment MUST be associated with:
    • The creating user as author
    • The parent thread
  • FR-5.3: Comment MUST have auto-generated timestamps

FR-5B: Comment Update 🔲 TODO

  • FR-5B.1: Only the comment author MUST be able to edit their comment
  • FR-5B.2: Updated content MUST meet same validation as creation (1-5000 characters)
  • FR-5B.3: updatedAt timestamp MUST be automatically updated on edit
  • FR-5B.4: Non-authors attempting to edit MUST receive a FORBIDDEN error
  • FR-5B.5: Edited comments SHOULD display an "edited" indicator

FR-5C: Comment Deletion 🔲 TODO

  • FR-5C.1: Only the comment author MUST be able to delete their comment
  • FR-5C.2: Deletion SHOULD be a soft delete (set deletedAt timestamp)
  • FR-5C.3: Soft-deleted comments MUST NOT appear in comment listings
  • FR-5C.4: Thread's comment count MUST be updated to exclude soft-deleted comments
  • FR-5C.5: Deletion SHOULD show a confirmation dialog before proceeding

FR-6: Comment Listing

  • FR-6.1: Comments MUST be displayed in reverse chronological order within a thread
  • FR-6.2: Listing MUST support pagination
  • FR-6.3: Each comment MUST display: content, author (name or email), creation time
  • FR-6.4: Empty state MUST be shown when no comments exist

FR-7: Input Validation

  • FR-7.1: All inputs MUST be validated on both client and server
  • FR-7.2: Validation errors MUST display user-friendly messages
  • FR-7.3: ID parameters MUST be validated as CUIDs

Non-Functional Requirements

NFR-1: Performance

  • NFR-1.1: Thread list SHOULD load within 2 seconds
  • NFR-1.2: Server-side prefetching SHOULD be used for initial page loads
  • NFR-1.3: Optimistic updates SHOULD be used for comment creation to provide instant feedback
  • NFR-1.4: Cache invalidation SHOULD minimize unnecessary network requests

NFR-2: User Experience

  • NFR-2.1: Forms SHOULD provide inline validation feedback
  • NFR-2.2: Loading states SHOULD be displayed during async operations
  • NFR-2.3: Success/error toasts SHOULD confirm action completion
  • NFR-2.4: Modal forms SHOULD reset and close on successful submission

NFR-3: Reliability

  • NFR-3.1: Failed mutations SHOULD display error messages to users
  • NFR-3.2: Duplicate comments from optimistic updates MUST be deduplicated in the UI
  • NFR-3.3: Data integrity MUST be maintained through cascading deletes

NFR-4: Security

  • NFR-4.1: All CRUD operations MUST require authentication
  • NFR-4.2: Author ID MUST be derived from server session, not client input
  • NFR-4.3: Input MUST be validated to prevent injection attacks

Data Model

Thread Entity

FieldTypeConstraintsDescription
idStringPrimary Key, CUIDUnique identifier
titleStringRequired, 1-255 charsThread title
contentStringRequired, 1-5000 charsThread body content
createdAtDateTimeAuto-generatedCreation timestamp
updatedAtDateTimeAuto-updatedLast modification timestamp
authorIdStringForeign Key → UserCreator's user ID
deletedAtDateTime?NullableSoft delete timestamp

Comment Entity

FieldTypeConstraintsDescription
idIntPrimary Key, Auto-incrementUnique identifier
contentStringRequired, 1-5000 charsComment body content
createdAtDateTimeAuto-generatedCreation timestamp
updatedAtDateTimeAuto-updatedLast modification timestamp
authorIdStringForeign Key → UserCreator's user ID
threadIdStringForeign Key → ThreadParent thread ID
deletedAtDateTime?NullableSoft delete timestamp

Relationships

Cascade Behavior

  • Deleting a User cascades to all their Threads and Comments
  • Deleting a Thread cascades to all its Comments

API Specification

Thread Endpoints

GET /api/trpc/thread.getAll

Retrieve paginated list of threads.

Input:

{
limit?: number // 1-100, default: 5
cursor?: number // Page number, default: 1
}

Output:

{
threads: Array<{
id: string
title: string
content: string
updatedAt: Date
author: { name: string | null, email: string }
_count: { comments: number }
}>
nextCursor?: number
}

GET /api/trpc/thread.getById

Retrieve a single thread by ID.

Input:

{
id: string; // CUID
}

Output:

{
id: string
title: string
content: string
updatedAt: Date
author: { name: string | null, email: string }
_count: { comments: number }
} | null

POST /api/trpc/thread.create

Create a new thread.

Input:

{
title: string; // 1-255 characters
content: string; // 1-5000 characters
}

Output:

{
id: string;
title: string;
content: string;
authorId: string;
}

PUT /api/trpc/thread.update 🔲 TODO

Update an existing thread. Only the author can update.

Input:

{
id: string; // CUID - thread to update
title: string; // 1-255 characters
content: string; // 1-5000 characters
}

Output:

{
id: string;
title: string;
content: string;
updatedAt: Date;
}

Errors:

  • NOT_FOUND - Thread does not exist
  • FORBIDDEN - User is not the author

DELETE /api/trpc/thread.delete 🔲 TODO

Soft delete a thread. Only the author can delete.

Input:

{
id: string; // CUID - thread to delete
}

Output:

{
success: boolean;
}

Errors:

  • NOT_FOUND - Thread does not exist or already deleted
  • FORBIDDEN - User is not the author

Comment Endpoints

GET /api/trpc/comment.getCommentsByThreadId

Retrieve paginated comments for a thread.

Input:

{
threadId: string // CUID
limit?: number // 1-100, default: 5
cursor?: number // Page number
}

Output:

{
comments: Array<{
id: number
content: string
createdAt: Date
author: { id: string, name: string | null, email: string }
}>
nextCursor?: number
}

POST /api/trpc/comment.create

Create a new comment on a thread.

Input:

{
threadId: string; // CUID
content: string; // 1-5000 characters
}

Output:

{
id: number
content: string
createdAt: Date
author: { id: string, name: string | null, email: string }
}

PUT /api/trpc/comment.update 🔲 TODO

Update an existing comment. Only the author can update.

Input:

{
id: number; // Comment ID to update
content: string; // 1-5000 characters
}

Output:

{
id: number;
content: string;
updatedAt: Date;
}

Errors:

  • NOT_FOUND - Comment does not exist
  • FORBIDDEN - User is not the author

DELETE /api/trpc/comment.delete 🔲 TODO

Soft delete a comment. Only the author can delete.

Input:

{
id: number; // Comment ID to delete
}

Output:

{
success: boolean;
threadId: string; // For cache invalidation on client
}

Errors:

  • NOT_FOUND - Comment does not exist or already deleted
  • FORBIDDEN - User is not the author

UI/UX Requirements for Update & Delete

The CREATE and READ UI components are already implemented. This section focuses only on the UPDATE and DELETE features to be built.

Thread Update UI

  • Edit button/icon visible only to thread author on thread detail page
  • Edit form (modal or inline) pre-populated with existing title and content
  • Same field validation as create form
  • Cancel and Save buttons
  • Loading state on Save button during mutation
  • Success toast and UI update on completion

Thread Delete UI

  • Delete button/icon visible only to thread author
  • Confirmation dialog with clear warning message
  • Loading state during deletion
  • Redirect to dashboard after successful deletion
  • Success toast confirming deletion

Comment Update UI

  • Edit button/icon visible only to comment author
  • Inline edit or modal form pre-populated with existing content
  • Same field validation as create form
  • Cancel and Save buttons
  • "Edited" indicator shown on updated comments
  • Loading state during mutation

Comment Delete UI

  • Delete button/icon visible only to comment author
  • Confirmation dialog (can be simpler than thread delete)
  • Immediate removal from list on success
  • Thread comment count updated
  • Success toast confirming deletion

Best Practices & Common Footguns

This section documents best practices for implementing CRUD operations and highlights common mistakes to avoid.


1. Input Validation

Best Practices

  • Share validation schemas between client and server using Zod
  • Provide user-friendly error messages in schema definitions
  • Validate at multiple layers: form → tRPC input → database constraints
// ✅ Good: Reusable schema with clear error messages
export const createThreadInputSchema = z.object({
title: z
.string()
.min(1, { message: "Title is required" })
.max(255, { message: "Title must be at most 255 characters" }),
content: z
.string()
.min(1, { message: "Content is required" })
.max(5000, { message: "Content must be at most 5000 characters" }),
});

// ✅ Good: Reuse existing schema for update
export const updateThreadInputSchema = createThreadInputSchema.extend({
id: z.cuid(),
});

⚠️ Common Footguns

FootgunProblemSolution
Different client/server validationData can pass client but fail serverUse shared Zod schemas
Missing ID validationInvalid IDs reach databaseUse z.cuid() for CUID fields
Generic error messagesPoor UX, users don't know what's wrongAdd { message: '...' } to validators
Not validating on updateEdit forms can submit invalid dataReuse/extend creation schemas

2. Authorization (Ownership Checks)

Best Practices

  • Always check ownership server-side before UPDATE/DELETE operations
  • Use FORBIDDEN (403) for authorization failures, NOT_FOUND (404) for missing resources
  • Never trust client-provided authorId - derive from session
// ✅ Good: Server-side ownership check
update: protectedProcedure
.input(updateThreadInputSchema)
.mutation(async ({ input, ctx }) => {
// First, fetch the resource to check ownership
const thread = await db.thread.findUnique({
where: { id: input.id },
select: { authorId: true },
});

if (!thread) {
throw new TRPCError({ code: "NOT_FOUND" });
}

// Check ownership BEFORE any mutation
if (thread.authorId !== ctx.session.userId) {
throw new TRPCError({ code: "FORBIDDEN" });
}

// Safe to update
return db.thread.update({
where: { id: input.id },
data: { title: input.title, content: input.content },
});
});

⚠️ Common Footguns

FootgunProblemSolution
Client-side only auth checksUsers can bypass via API callsAlways verify server-side
Using client-provided authorIdUsers can impersonate othersDerive authorId from ctx.session.userId
Checking auth after mutationRace conditions, data leaksCheck ownership BEFORE any database write
Wrong error code (404 vs 403)Security info leak about resource existenceUse 403 for auth failures, 404 for missing

3. Cache Invalidation

Best Practices

Choose the right strategy based on the operation:

OperationStrategyReason
CreateinvalidateQueriesNew item position depends on sorting
UpdatesetQueryData + selective invalidateUpdate in place, refresh affected lists
DeletesetQueryData to remove + invalidate countsRemove immediately, refresh related data
// ✅ Good: Optimistic update for comment creation
const createCommentMutation = useMutation(
trpc.comment.create.mutationOptions({
onSuccess: (newComment) => {
// 1. Optimistically add to comments list
queryClient.setQueryData(
trpc.comment.getCommentsByThreadId.infiniteQueryKey({ threadId }),
(oldData) => {
if (!oldData) return oldData;
const updatedPages = oldData.pages.map((page, index) => {
if (index === 0) {
return { ...page, comments: [newComment, ...page.comments] };
}
return page;
});
return { ...oldData, pages: updatedPages };
}
);

// 2. Update related count
queryClient.setQueryData(
trpc.thread.getById.queryKey({ id: threadId }),
(old) =>
old ? { ...old, _count: { comments: old._count.comments + 1 } } : old
);

// 3. Invalidate list where recalculation is complex
void queryClient.invalidateQueries({
queryKey: trpc.thread.getAll.infiniteQueryKey(),
});
},
})
);

⚠️ Common Footguns

FootgunProblemSolution
Invalidating everythingUnnecessary refetches, poor UXBe selective with query keys
Forgetting related queriesStale counts, inconsistent UIUpdate thread comment counts on comment changes
Not handling optimistic update duplicatesDuplicate items in listsDeduplicate in UI with useMemo and Set
Over-using optimistic updatesComplex rollback logic for failuresUse invalidateQueries for simpler cases

4. Soft Deletes

Best Practices

  • Filter by deletedAt: null in all read queries
  • Set deletedAt timestamp instead of actually deleting
  • Update related counts to exclude soft-deleted items
  • Consider showing "deleted" state instead of hiding entirely (for audit trails)
// ✅ Good: Soft delete implementation
delete: protectedProcedure
.input(z.object({ id: z.cuid() }))
.mutation(async ({ input, ctx }) => {
const thread = await db.thread.findUnique({
where: { id: input.id, deletedAt: null }, // Only find non-deleted
select: { authorId: true },
})

if (!thread) {
throw new TRPCError({ code: 'NOT_FOUND' })
}

if (thread.authorId !== ctx.session.userId) {
throw new TRPCError({ code: 'FORBIDDEN' })
}

// Soft delete: set timestamp instead of actual delete
await db.thread.update({
where: { id: input.id },
data: { deletedAt: new Date() },
})

return { success: true }
})

// ✅ Good: Filter out soft-deleted in reads
getAll: protectedProcedure.query(async () => {
return db.thread.findMany({
where: { deletedAt: null }, // Exclude soft-deleted
orderBy: { createdAt: 'desc' },
})
})

⚠️ Common Footguns

FootgunProblemSolution
Forgetting deletedAt: null filterDeleted items still appearAdd to all read queries
Hard deleting by mistakeData loss, broken referencesUse soft delete pattern
Not updating countsComment count includes deletedRecount or decrement on delete
Cascade deleting childrenComments gone when thread soft-deletedKeep comments, filter by thread.deletedAt

5. Form Handling for Edit Operations

Best Practices

  • Pre-populate forms with existing data for edit operations
  • Use the same validation schema as create (or extend it)
  • Disable submit until changes are made (optional UX improvement)
  • Handle concurrent edit conflicts gracefully
// ✅ Good: Edit form with pre-populated data
export const EditThreadModal = ({ thread }: { thread: Thread }) => {
const {
control,
handleSubmit,
formState: { isDirty },
} = useForm({
resolver: zodResolver(updateThreadInputSchema),
defaultValues: {
id: thread.id,
title: thread.title,
content: thread.content,
},
});

return (
<form onSubmit={handleSubmit(onSubmit)}>
{/* Form fields */}
<Button type="submit" isDisabled={!isDirty}>
Save Changes
</Button>
</form>
);
};

⚠️ Common Footguns

FootgunProblemSolution
Empty defaultValues on editForm appears emptyPass existing data as defaultValues
Different validation for editInconsistent rulesExtend create schema with ID field
Not checking if data changedUnnecessary API callsUse isDirty from form state
Not handling 404 on editEditing deleted resourceCheck if resource exists before showing form

6. Error Handling

Best Practices

  • Use appropriate tRPC error codes: UNAUTHORIZED, FORBIDDEN, NOT_FOUND, BAD_REQUEST
  • Handle errors at multiple levels: global, route, form
  • Show actionable error messages to users
  • Log errors server-side for debugging
// ✅ Good: Layered error handling

// Layer 1: Global mutation error handler (query-client.ts)
mutations: {
onError: (error) => {
if (isTRPCClientError(error)) {
const code = error.data?.code
if (code === 'FORBIDDEN') {
toast.error('You do not have permission to perform this action.')
return
}
if (code === 'NOT_FOUND') {
toast.error('This item no longer exists.')
return
}
}
toast.error('Something went wrong. Please try again.')
},
}

// Layer 2: Specific mutation handler
const deleteMutation = useMutation(
trpc.thread.delete.mutationOptions({
onError: (error) => {
// Handle specific cases differently if needed
if (error.data?.code === 'FORBIDDEN') {
toast.error('You can only delete your own threads.')
}
},
onSuccess: () => {
toast.success('Thread deleted successfully')
router.push('/dashboard')
},
}),
)

⚠️ Common Footguns

FootgunProblemSolution
Swallowing errors silentlyUsers don't know action failedAlways show error feedback
Exposing internal error detailsSecurity riskUse generic messages for unexpected errors
Not handling network errorsApp appears frozenHandle offline/timeout cases
Inconsistent error UXConfusing experienceCentralize error handling in QueryClient

7. UI State Management for CRUD

Best Practices

  • Show loading states during mutations
  • Disable forms during submission to prevent double-submits
  • Confirm destructive actions with dialogs
  • Provide undo options where possible
// ✅ Good: Comprehensive UI state handling
const DeleteThreadButton = ({ threadId }: { threadId: string }) => {
const [showConfirm, setShowConfirm] = useState(false);

const deleteMutation = useMutation(
trpc.thread.delete.mutationOptions({
onSuccess: () => {
toast.success("Thread deleted");
router.push("/dashboard");
},
})
);

return (
<>
<Button
color="danger"
onPress={() => setShowConfirm(true)}
isDisabled={deleteMutation.isPending}
>
Delete
</Button>

<ConfirmDialog
isOpen={showConfirm}
onClose={() => setShowConfirm(false)}
onConfirm={() => deleteMutation.mutate({ id: threadId })}
title="Delete Thread?"
message="This action cannot be undone."
isPending={deleteMutation.isPending}
/>
</>
);
};

⚠️ Common Footguns

FootgunProblemSolution
No loading indicatorUsers click multiple timesUse isPending state on buttons
No confirmation for deleteAccidental data lossAlways confirm destructive actions
Allowing submit during pendingDuplicate submissionsDisable form/button when isPending
Not redirecting after deleteUser stuck on deleted resource pageNavigate away on success

Architecture Summary


Acceptance Criteria

Thread CRUD

Create ✅ Implemented

  • User can create a thread with title and content
  • Validation errors shown inline
  • Success toast and redirect to new thread
  • Thread list invalidated after creation

Read ✅ Implemented

  • User can view paginated list of threads
  • User can view individual thread details
  • Thread displays author, timestamp, and comment count
  • Empty state shown when no threads exist
  • 404 page shown for invalid thread IDs

Update 🔲 TODO

  • Edit button visible only to thread author
  • Edit form pre-populated with existing data
  • Same validation as create
  • Success toast on update
  • Thread detail and list caches updated

Delete 🔲 TODO

  • Delete button visible only to thread author
  • Confirmation dialog before deletion
  • Soft delete (sets deletedAt)
  • Redirect to dashboard after deletion
  • Thread removed from all lists

Comment CRUD

Create ✅ Implemented

  • User can create a comment on a thread
  • Comment appears instantly (optimistic update)
  • Thread comment count updated
  • Success toast on creation

Read ✅ Implemented

  • User can view paginated comments on a thread
  • Comment displays author and timestamp
  • Empty state shown when no comments exist
  • Duplicates from optimistic updates are handled

Update 🔲 TODO

  • Edit button visible only to comment author
  • Inline edit or modal form
  • Same validation as create
  • "Edited" indicator shown after update
  • Comment list updated in place

Delete 🔲 TODO

  • Delete button visible only to comment author
  • Confirmation before deletion
  • Soft delete (sets deletedAt)
  • Comment removed from list
  • Thread comment count decremented

Security Checklist

  • All endpoints require authentication
  • Author ID derived from server session (not client)
  • Input validation on both client and server
  • Ownership verified server-side for UPDATE/DELETE
  • FORBIDDEN error for unauthorized edit/delete attempts
  • Soft-deleted items filtered from all queries