Skip to content

feat: provide collection functionality - #128

Merged
rindrics merged 18 commits into
mainfrom
issue-85-collection
Feb 11, 2026
Merged

feat: provide collection functionality#128
rindrics merged 18 commits into
mainfrom
issue-85-collection

Conversation

@rindrics

@rindrics rindrics commented Feb 10, 2026

Copy link
Copy Markdown
Owner

Summary by CodeRabbit

  • New Features

    • Full collections system: create, edit, delete, and manage personal collections of books/sections/chapters.
    • Add-to-collection buttons and modals added across headers, content pages, and chapter lists.
    • Collection detail and public-collection detail modals, plus a public collections browsing page.
    • User menu entry linking to personal collections.
  • Chores

    • Firestore security rules extended to support private and public collections and access constraints.

Open with Devin

@qodo-free-for-open-source-projects

Copy link
Copy Markdown

Review Summary by Qodo

Add collection management system with public sharing support

✨ Enhancement

Grey Divider

Walkthroughs

Description
• Add complete collection management system with CRUD operations
• Implement public/private collection sharing with Firestore indexing
• Create collection UI components for browsing and managing collections
• Integrate collection buttons into content pages alongside favorites
• Add Firestore security rules for collection access control
Diagram
flowchart LR
  A["User Content<br/>Sections/Chapters"] -->|"Add to Collection"| B["Collection<br/>Management"]
  B -->|"Create/Update/Delete"| C["Firestore<br/>Collections"]
  C -->|"Sync Public"| D["Public Collections<br/>Index"]
  B -->|"Browse"| E["My Collections<br/>Page"]
  D -->|"View"| F["Public Collections<br/>Page"]
  G["Security Rules"] -->|"Enforce Access"| C
  G -->|"Allow Read"| D
Loading

Grey Divider

File Changes

1. src/lib/collections.ts ✨ Enhancement +555/-0

Collection CRUD library with public indexing

src/lib/collections.ts


2. src/types/collection.ts ✨ Enhancement +69/-0

Collection type definitions and interfaces

src/types/collection.ts


3. firestore.rules ⚙️ Configuration changes +23/-1

Add collection security rules

firestore.rules


View more (10)
4. src/app/collections/page.tsx ✨ Enhancement +276/-0

User collection management page

src/app/collections/page.tsx


5. src/app/collections/public/page.tsx ✨ Enhancement +100/-0

Public collection browsing page

src/app/collections/public/page.tsx


6. src/components/AddToCollectionButton.tsx ✨ Enhancement +58/-0

Button component for adding content to collections

src/components/AddToCollectionButton.tsx


7. src/components/AddToCollectionModal.tsx ✨ Enhancement +228/-0

Modal for selecting and managing collection membership

src/components/AddToCollectionModal.tsx


8. src/components/CreateCollectionModal.tsx ✨ Enhancement +133/-0

Modal for creating new collections

src/components/CreateCollectionModal.tsx


9. src/components/CollectionDetailModal.tsx ✨ Enhancement +190/-0

Modal displaying collection contents with removal

src/components/CollectionDetailModal.tsx


10. src/components/PublicCollectionDetailModal.tsx ✨ Enhancement +152/-0

Modal for viewing public collection details

src/components/PublicCollectionDetailModal.tsx


11. src/components/AuthButton.tsx ✨ Enhancement +7/-0

Add collections link to user menu

src/components/AuthButton.tsx


12. src/app/books/[bookId]/[sectionId]/page.tsx ✨ Enhancement +19/-4

Integrate collection button into section page

src/app/books/[bookId]/[sectionId]/page.tsx


13. src/components/ContentPageClient.tsx ✨ Enhancement +7/-1

Add collection button to content pages

src/components/ContentPageClient.tsx


Grey Divider

Qodo Logo

@coderabbitai

coderabbitai Bot commented Feb 10, 2026

Copy link
Copy Markdown

Warning

Rate limit exceeded

@Rindrics has exceeded the limit for the number of commits that can be reviewed per hour. Please wait 5 minutes and 52 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.

📝 Walkthrough

Walkthrough

Adds a full collections feature: Firestore security rules for collections and publicCollections, new collection types and Firestore-backed library, multiple client UI components and pages for creating/managing/public-browsing collections, and integration buttons in existing content views.

Changes

Cohort / File(s) Summary
Firestore rules
firestore.rules
Added /collections/{userId} with nested /items/{collectionId} and /contents/{contentId} (owner-only), and /publicCollections/{collectionId} (public read, owner write) with userId validation and explicit update/delete checks.
Types & collections backend
src/types/collection.ts, src/lib/collections.ts
New collection-related types and a Firestore-backed module with create/update/delete, add/remove content, summaries/contents retrieval, public-index sync, and public-collections accessors.
Pages: management & public
src/app/collections/page.tsx, src/app/collections/public/page.tsx
New Collections management page (client) with create/edit/delete/toggle-public flows and a Public Collections browsing page with detail modal.
Collection UI components
src/components/AddToCollectionButton.tsx, src/components/AddToCollectionModal.tsx, src/components/CreateCollectionModal.tsx, src/components/CollectionDetailModal.tsx, src/components/PublicCollectionDetailModal.tsx
New client components for adding content to collections, creating collections, and viewing/managing private & public collection details; include optimistic updates, event dispatching, and error handling.
Integrations / small UI changes
src/app/books/[bookId]/[sectionId]/page.tsx, src/components/ContentPageClient.tsx, src/components/AuthButton.tsx
Inserted AddToCollectionButton alongside FavoriteButton in section/chapter views and added a "マイコレクション" link to the user menu.
Content display helper
src/lib/contentDisplay.ts
New utilities to derive display title, preview, and href for hierarchical content IDs used by modals and lists.

Sequence Diagram(s)

sequenceDiagram
  autonumber
  participant User as User
  participant UI as Browser UI
  participant API as Collections API
  participant DB as Firestore
  User->>UI: Click "Add to Collection"
  UI->>API: getCollections(userId) & getCollectionsForContent(userId, contentId)
  API->>DB: Read user collections & membership
  DB-->>API: Collections data
  API-->>UI: Return lists (collections, included-in)
  User->>UI: Toggle membership or create collection
  UI->>API: addContentToCollection / removeContentFromCollection / createCollection
  API->>DB: Transactional add/remove, update counts, sync public index
  DB-->>API: Transaction result
  API-->>UI: Success/Failure
  UI->>User: Update UI and dispatch "collections-changed"
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~45 minutes

Possibly related PRs

Suggested labels

Review effort 4/5

Poem

🐰 I hopped through lines with tiny taps,
Buttons, modals, quiet maps.
Collections bloom where chapters meet,
Public gardens, private treats.
Hop — collect — share stories sweet.

🚥 Pre-merge checks | ✅ 2 | ❌ 1
❌ Failed checks (1 warning)
Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 55.17% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (2 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title 'feat: provide collection functionality' directly aligns with the main objective of the PR, which implements comprehensive collection management features across the codebase.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
  • Commit unit tests in branch issue-85-collection

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.

@github-actions

github-actions Bot commented Feb 10, 2026

Copy link
Copy Markdown

Visit the preview URL for this PR (updated for commit d029015):

https://izuminokami-kanesada--pr128-issue-85-collection-9aqwvk12.web.app

(expires Wed, 18 Feb 2026 20:55:30 GMT)

🔥 via Firebase Hosting GitHub Action 🌎

Sign: 4c4412227845b968bcb4c8b6996048cdd07fd6de

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 9

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
src/components/ContentPageClient.tsx (1)

13-17: ⚠️ Potential issue | 🟡 Minor

Stale JSDoc — now also displays the collection button.

The comment says "Displaying favorite button" but the component now renders both FavoriteButton and AddToCollectionButton.

📝 Proposed fix
 /**
  * Client component for content page that handles:
  * - Recording access history
- * - Displaying favorite button
+ * - Displaying favorite button and collection button
  */
firestore.rules (1)

26-56: ⚠️ Potential issue | 🔴 Critical

Catch-all rule makes private collections publicly readable.

Firestore rules are additive (OR semantics). The match /{document=**} block at line 50 with allow read: if true applies to every document path, including /collections/{userId}/items/{collectionId}/contents/{contentId}. When multiple rules match a path, access is granted if ANY rule allows it. The catch-all unconditionally allows read on all paths, bypassing the owner-only read restrictions defined in the /collections/{userId} rules (lines 28-40). Any unauthenticated user can read another user's private collections.

This is a critical security issue for the new private collection feature. Additionally, the publicCollections update rule (line 46) only validates that the current owner matches the requestor but does not prevent mutation of the userId field itself—an owner could change this field to another user's ID in a single update.

Consider restructuring: either scope the catch-all to only genuinely public content paths (e.g., book/chapter reference data), or remove the wildcard and enumerate all public-facing collections explicitly.

🤖 Fix all issues with AI agents
In `@firestore.rules`:
- Around line 42-47: The update rule for match /publicCollections/{collectionId}
currently allows owners to update but doesn't prevent changing the userId;
modify the allow update condition to require request.auth != null,
resource.data.userId == request.auth.uid, and additionally enforce
request.resource.data.userId == resource.data.userId so the owner cannot mutate
the userId field (keep the existing delete rule unchanged).

In `@src/app/collections/public/page.tsx`:
- Around line 45-53: The breadcrumb currently links to the auth-gated route via
Link href="/collections" inside the page component's nav; change this so public
viewers aren't sent to a protected page—either replace the href="/collections"
with href="/" (top page) or conditionally render that Link only when the user is
authenticated (e.g., check your auth helper like getCurrentUser/useSession
inside the page component and render the "マイコレクション" Link only if authenticated).
Update the Link element and any surrounding nav markup accordingly so the
breadcrumb never points unauthenticated users to the protected /collections
route.

In `@src/components/AddToCollectionModal.tsx`:
- Around line 60-95: handleToggle currently swallows errors by only logging them
to console; update it to surface failures to the user by showing a brief toast
or inline error when addContentToCollection or removeContentFromCollection
rejects. Inside the catch block of handleToggle, call your existing
toast/notification helper (or set a local error state like setToggleError with
collectionId) to display a concise message (e.g., "Failed to add to collection"
/ "Failed to remove from collection") and ensure the UI rollbacks any optimistic
state change if you applied one; keep setTogglingId(null) in finally and still
emit window.dispatchEvent('collections-changed') only on success (move that line
into the try after the await calls) so events aren’t fired when the operation
fails.

In `@src/components/CollectionDetailModal.tsx`:
- Around line 14-51: Extract the duplicated helper functions getPreviewText and
getContentDisplayInfo into a new shared module (e.g., create
src/lib/contentDisplay.ts), export both functions with their current signatures
and any used types, and replace the copies in CollectionDetailModal.tsx and
PublicCollectionDetailModal.tsx with imports from that new module; ensure you
preserve calls to getContentById, getBookById, getSectionById and default
parameter (maxLength = 30), keep the returned shape (type/preview/title/href),
and update the component files to import { getPreviewText, getContentDisplayInfo
} from 'src/lib/contentDisplay' (or the correct relative path) removing the
local definitions.

In `@src/lib/collections.ts`:
- Around line 80-135: deleteCollection currently uses getDocs (contentsRef /
contentsSnapshot) outside the transaction so new content added after that read
but before commit can be orphaned; update deleteCollection to ensure eventual
consistency by (a) performing the transactional delete of the collection, parent
count update, and publicCollections delete as-is using runTransaction, and then
(b) after the transaction completes, perform a second cleanup pass that queries
contentsRef again and deletes any remaining documents (paginating/batching as
needed) or schedule a Cloud Function trigger to remove leftover contents;
reference symbols: deleteCollection, getDocs, contentsRef, contentsSnapshot,
runTransaction.
- Around line 274-276: The sort comparator calls .toMillis() directly on
Timestamp fields which can be null; change the comparator to safely handle null
by using optional chaining and a numeric fallback (e.g. use
(a.updatedAt?.toMillis() ?? 0) and (b.updatedAt?.toMillis() ?? 0)) or create a
small helper like safeMillis(ts) that returns ts?.toMillis() ?? 0, then use it
in the sort for summaries, the addedAt sort in getCollectionWithContents, and
the updatedAt sort in getPublicCollections so null timestamps won't throw.
- Around line 534-555: The function getPublicCollectionWithContents currently
trusts the publicCollections index; change it to verify the collection's actual
isPublic flag before returning: after obtaining userId (from publicDoc.data()),
fetch the real collection (via getCollectionWithContents or by reading the
collection doc directly), check that the returned CollectionWithContents (or
collection doc) has isPublic === true, and only return it when that guard
passes; if isPublic is false or missing, return null and log appropriately.
Ensure you reference getPublicCollectionWithContents and
getCollectionWithContents when locating where to add this verification.
- Around line 419-455: getCollectionsForContent is doing sequential
per-collection getDoc calls (after calling getCollections), causing O(N)
sequential reads; change it to build all content document references using
encodeContentId and doc for each collection and then parallelize the reads with
Promise.all over getDoc promises (or alternatively use batched getDocs if
available) and then iterate the results to push matching ContentCollectionInfo
objects; keep the existing try/catch and error logging but replace the for-await
loop with a Promise.all map that returns the same result shape referencing
getCollections, getDoc, encodeContentId, and ContentCollectionInfo.
- Around line 238-281: The N+1 read issue in getCollections: stop fetching each
contents subcollection to compute contentCount; instead add and maintain a
numeric contentCount field on the collection document and read that field in
getCollections. Update addContentToCollection and removeContentFromCollection to
atomically increment/decrement contentCount (use Firestore
FieldValue.increment(1) / increment(-1)) when a content is added/removed and
update updatedAt there as well; then change getCollections to read
data.contentCount (defaulting to 0 if missing) instead of issuing getDocs for
the contents subcollection. Ensure types (CollectionSummary.contentCount) accept
the number and handle missing/undefined values safely.
🧹 Nitpick comments (9)
src/components/AddToCollectionModal.tsx (2)

97-127: Unguarded getCollections call outside try/catch.

Line 102 calls getCollections outside the try/catch block. Although getCollections internally catches errors and returns [], if the implementation ever changes to throw, this would result in an unhandled promise rejection. Wrap the full sequence for consistency:

🛡️ Proposed fix
   const handleCollectionCreated = async (newCollectionId: string) => {
     setIsCreating(false);
     if (!user) return;
 
-    // コレクション一覧を再読み込み
-    const cols = await getCollections(user.uid);
-    setCollections(cols);
-
-    // 新しいコレクションにコンテンツを追加
     try {
+      // コレクション一覧を再読み込み
+      const cols = await getCollections(user.uid);
+      setCollections(cols);
+
+      // 新しいコレクションにコンテンツを追加
       await addContentToCollection(

132-227: Modal lacks Escape key and backdrop-click dismissal.

The modal overlay (bg-black/50) doesn't respond to clicks, and there's no keyboard listener for the Escape key. Other modals in this codebase (e.g., AuthButton) support Escape to close. Consider adding an onKeyDown handler for Escape and an onClick on the backdrop for parity and accessibility.

src/app/collections/public/page.tsx (1)

1-1: Public page is fully client-rendered — no metadata for SEO.

This page is marked 'use client', so it cannot export generateMetadata. Since public collections are meant to be discoverable, consider making this a server component (or using a server component wrapper) to provide metadata such as <title> and <meta name="description"> for search engines and social sharing.

src/app/collections/page.tsx (3)

40-59: Global custom events for state synchronization is fragile.

Using window.dispatchEvent(new CustomEvent('collections-changed')) as a cross-component communication mechanism bypasses React's data flow. This works but is harder to debug, test, and maintain than alternatives like a shared context/store or lifting state up with callback props.

Not a blocker for this PR, but consider migrating to a lightweight state management approach (e.g., a React context with a refresh trigger, or a simple Zustand store) to improve testability and traceability.


101-109: Modal overlay does not close on backdrop click or Escape key.

Both CreateCollectionModal and CollectionDetailModal render a full-screen overlay (.fixed.inset-0) but there's no handler to close them when clicking outside the dialog or pressing Escape. This is a common accessibility and UX expectation for modal dialogs.

Consider adding onClick={onClose} on the backdrop and stopping propagation on the inner dialog, plus an onKeyDown Escape handler.


261-266: onCreated callback discards the collectionId.

The CreateCollectionModal passes collectionId to onCreated, but the handler () => setIsCreating(false) ignores it. This is not a bug, but if you later want to auto-navigate to the newly created collection (e.g., open the detail modal), you'd need it.

src/lib/collections.ts (3)

336-375: addContentToCollection and removeContentFromCollection are not transactional.

The setDoc/deleteDoc for the content document, the updateDoc for the collection's updatedAt, and the syncPublicCollectionIndex call are three separate non-atomic operations. If the process fails midway (e.g., after writing the content doc but before updating updatedAt), the collection metadata becomes stale.

Consider wrapping the content write and collection update in a transaction, especially if you adopt the contentCount field suggested above — an inconsistent count would be more visible to users than a stale updatedAt.


21-21: encodeContentId / decodeContentId imported from favorites.ts.

These are general-purpose utilities that happen to live in a feature-specific module. Consider extracting them to a shared utility file (e.g., src/lib/utils.ts or src/lib/contentId.ts) so the dependency between collections and favorites is removed.


140-187: updateCollection read-then-write is not atomic.

The getDoc + updateDoc + conditional public index sync are separate operations. A concurrent updateCollection call could interleave, causing the public index to become inconsistent (e.g., two toggle-public calls could both read isPublic=false and both create the public index). The impact is limited since the same user is unlikely to trigger concurrent updates, but wrapping the read and write in a transaction would be more robust.

Comment thread firestore.rules
Comment thread src/app/collections/public/page.tsx
Comment thread src/components/AddToCollectionModal.tsx
Comment on lines +14 to +51
function getPreviewText(contentId: string, maxLength = 30): string {
const content = getContentById(contentId);
if (!content || content.segments.length === 0) {
return '';
}
const text = content.segments.map((s) => s.text.original).join('');
if (text.length <= maxLength) {
return text;
}
return `${text.slice(0, maxLength)}…`;
}

function getContentDisplayInfo(contentId: string) {
const parts = contentId.split('/');
const bookId = parts[0];
const sectionId = parts[1];
const chapterId = parts[2];

const book = getBookById(bookId);
const section = getSectionById(bookId, sectionId);

if (chapterId) {
const content = getContentById(contentId);
return {
type: 'chapter' as const,
title: `${book?.name || bookId} ${section?.name || sectionId} ${content?.chapter || chapterId}`,
preview: getPreviewText(contentId),
href: `/books/${contentId}`,
};
} else {
return {
type: 'section' as const,
title: `${book?.name || bookId} ${section?.name || sectionId}`,
preview: section ? `${section.totalChapters}章` : '',
href: `/books/${bookId}/${sectionId}`,
};
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🛠️ Refactor suggestion | 🟠 Major

getPreviewText and getContentDisplayInfo are duplicated across modals.

These two helper functions are identical in both CollectionDetailModal.tsx and PublicCollectionDetailModal.tsx. Extract them into a shared utility module (e.g., src/lib/contentDisplay.ts) to avoid divergence and reduce maintenance burden.

#!/bin/bash
# Verify the duplication between the two files
echo "=== CollectionDetailModal helpers ==="
rg -n 'function getPreviewText|function getContentDisplayInfo' --type=ts

echo ""
echo "=== Diff of the two helper blocks ==="
# Extract helper functions from both files and compare
fd 'CollectionDetailModal.tsx' --type f --exec head -51 {} \;
echo "---"
fd 'PublicCollectionDetailModal.tsx' --type f --exec head -47 {} \;
🤖 Prompt for AI Agents
In `@src/components/CollectionDetailModal.tsx` around lines 14 - 51, Extract the
duplicated helper functions getPreviewText and getContentDisplayInfo into a new
shared module (e.g., create src/lib/contentDisplay.ts), export both functions
with their current signatures and any used types, and replace the copies in
CollectionDetailModal.tsx and PublicCollectionDetailModal.tsx with imports from
that new module; ensure you preserve calls to getContentById, getBookById,
getSectionById and default parameter (maxLength = 30), keep the returned shape
(type/preview/title/href), and update the component files to import {
getPreviewText, getContentDisplayInfo } from 'src/lib/contentDisplay' (or the
correct relative path) removing the local definitions.

Comment thread src/lib/collections.ts
Comment thread src/lib/collections.ts
Comment thread src/lib/collections.ts
Comment thread src/lib/collections.ts
Comment thread src/lib/collections.ts
@qodo-free-for-open-source-projects

qodo-free-for-open-source-projects Bot commented Feb 10, 2026

Copy link
Copy Markdown

Code Review by Qodo

🐞 Bugs (3) 📘 Rule violations (3) 📎 Requirement gaps (3)

Grey Divider


Action required

1. No collection audit logs 📘 Rule violation ✓ Correctness
Description
• Collection create/update/delete are critical write/delete actions but the new implementation does
not record an audit trail (who did what, when, and the outcome). • Without structured audit events,
it’s not possible to reliably reconstruct user actions for security analysis or compliance
investigations.
Code

src/lib/collections.ts[R30-187]

+export async function createCollection(
+  userId: string,
+  name: string,
+  description?: string,
+  isPublic = false,
+): Promise<string> {
+  if (!db) throw new Error('Firestore is not initialized');
+
+  const firestore = db;
+
+  return runTransaction(firestore, async (transaction) => {
+    const parentRef = doc(firestore, 'collections', userId);
+    const parentDoc = await transaction.get(parentRef);
+
+    const currentCount = parentDoc.exists()
+      ? parentDoc.data().collectionCount || 0
+      : 0;
+
+    if (currentCount >= MAX_COLLECTIONS) {
+      throw new Error(`コレクションは最大${MAX_COLLECTIONS}個までです`);
+    }
+
+    const collectionRef = doc(
+      collection(firestore, 'collections', userId, 'items'),
+    );
+
+    transaction.set(
+      parentRef,
+      {
+        userId,
+        collectionCount: increment(1),
+      },
+      { merge: true },
+    );
+
+    transaction.set(collectionRef, {
+      name,
+      description: description || null,
+      isPublic,
+      createdAt: serverTimestamp(),
+      updatedAt: serverTimestamp(),
+    });
+
+    return collectionRef.id;
+  });
+}
+
+/**
+ * コレクション削除
+ */
+export async function deleteCollection(
+  userId: string,
+  collectionId: string,
+): Promise<void> {
+  if (!db) throw new Error('Firestore is not initialized');
+
+  const firestore = db;
+
+  // まずコレクションが公開かどうか確認
+  const collectionRef = doc(
+    firestore,
+    'collections',
+    userId,
+    'items',
+    collectionId,
+  );
+  const collectionDoc = await getDoc(collectionRef);
+
+  if (!collectionDoc.exists()) {
+    throw new Error('コレクションが見つかりません');
+  }
+
+  const wasPublic = collectionDoc.data().isPublic;
+
+  return runTransaction(firestore, async (transaction) => {
+    // コンテンツを全て削除
+    const contentsRef = collection(
+      firestore,
+      'collections',
+      userId,
+      'items',
+      collectionId,
+      'contents',
+    );
+    const contentsSnapshot = await getDocs(contentsRef);
+
+    for (const contentDoc of contentsSnapshot.docs) {
+      transaction.delete(contentDoc.ref);
+    }
+
+    // コレクションを削除
+    transaction.delete(collectionRef);
+
+    // カウントをデクリメント
+    const parentRef = doc(firestore, 'collections', userId);
+    transaction.update(parentRef, {
+      collectionCount: increment(-1),
+    });
+
+    // 公開コレクションだった場合、インデックスも削除
+    if (wasPublic) {
+      const publicRef = doc(firestore, 'publicCollections', collectionId);
+      transaction.delete(publicRef);
+    }
+  });
+}
+
+/**
+ * コレクション更新
+ */
+export async function updateCollection(
+  userId: string,
+  collectionId: string,
+  updates: {
+    name?: string;
+    description?: string;
+    isPublic?: boolean;
+  },
+): Promise<void> {
+  if (!db) throw new Error('Firestore is not initialized');
+
+  const firestore = db;
+
+  const collectionRef = doc(
+    firestore,
+    'collections',
+    userId,
+    'items',
+    collectionId,
+  );
+  const collectionDoc = await getDoc(collectionRef);
+
+  if (!collectionDoc.exists()) {
+    throw new Error('コレクションが見つかりません');
+  }
+
+  const currentData = collectionDoc.data();
+  const wasPublic = currentData.isPublic;
+  const willBePublic = updates.isPublic ?? wasPublic;
+
+  await updateDoc(collectionRef, {
+    ...updates,
+    updatedAt: serverTimestamp(),
+  });
+
+  // 公開状態の変更に応じてインデックスを更新
+  if (willBePublic && !wasPublic) {
+    // 非公開→公開: インデックスに追加
+    await syncPublicCollectionIndex(userId, collectionId);
+  } else if (!willBePublic && wasPublic) {
+    // 公開→非公開: インデックスから削除
+    const publicRef = doc(firestore, 'publicCollections', collectionId);
+    await deleteDoc(publicRef);
+  } else if (willBePublic) {
+    // 公開のまま更新: インデックスも更新
+    await syncPublicCollectionIndex(userId, collectionId);
+  }
+}
Evidence
PR Compliance ID 1 requires audit logs for critical actions with user ID, timestamp, action, and
outcome. The collection mutation functions perform Firestore writes/deletes but contain no audit
logging calls (only operational logic).

Rule 1: Generic: Comprehensive Audit Trails
src/lib/collections.ts[30-75]
src/lib/collections.ts[80-135]
src/lib/collections.ts[140-187]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Collection mutations (create/update/delete collections and add/remove items) do not emit audit trail events with required context (user, timestamp, action, outcome).
## Issue Context
Compliance requires reconstructable audit trails for critical actions. Current implementation performs writes/deletes without emitting audit records.
## Fix Focus Areas
- src/lib/collections.ts[30-187]
- src/lib/collections.ts[336-414]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


2. err.message shown to users 📘 Rule violation ⛨ Security
Description
• The create-collection UI displays err.message directly to the user, which can expose internal
implementation details (e.g., initialization/config errors or backend messages). • This violates
secure error handling expectations for user-facing messages and can leak sensitive system context.
Code

src/components/CreateCollectionModal.tsx[R37-41]

+    } catch (err) {
+      console.error('Failed to create collection:', err);
+      setError(
+        err instanceof Error ? err.message : 'コレクションの作成に失敗しました',
+      );
Evidence
PR Compliance ID 4 requires generic user-facing errors and keeping detailed error info in secure
logs. The code explicitly sets UI error state from err.message, which can include internal
details.

Rule 4: Generic: Secure Error Handling
src/components/CreateCollectionModal.tsx[37-41]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The UI exposes raw exception messages via `err.message`.
## Issue Context
User-facing errors must be generic to avoid leaking internal details; detailed diagnostics should be logged securely.
## Fix Focus Areas
- src/components/CreateCollectionModal.tsx[37-41]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


3. Public collections not viewable 📎 Requirement gap ✓ Correctness
Description
• Public collection detail loading reads from /collections/{userId}/items/{collectionId}, but
Firestore rules allow reads there only for the owner (request.auth.uid == userId). • As a result,
other users (and anonymous users) cannot actually view the contents of a public collection, so the
public/private setting is not reflected in access.
Code

src/lib/collections.ts[R534-551]

+export async function getPublicCollectionWithContents(
+  collectionId: string,
+): Promise<CollectionWithContents | null> {
+  if (!db) return null;
+
+  try {
+    // まず公開インデックスから情報を取得
+    const publicRef = doc(db, 'publicCollections', collectionId);
+    const publicDoc = await getDoc(publicRef);
+
+    if (!publicDoc.exists()) return null;
+
+    const publicData = publicDoc.data();
+    const userId = publicData.userId;
+
+    // 実際のコレクションデータを取得
+    return getCollectionWithContents(userId, collectionId);
+  } catch (error) {
Evidence
PR Compliance ID 8/16 require that public collections are actually viewable by other users and that
isPublic affects viewing permissions. The current rules block reads of other users’ collections,
while the public detail loader fetches contents via that blocked path.

コレクションの公開/非公開設定が可能
公開コレクション検索/閲覧ページが提供される
firestore.rules[27-38]
src/lib/collections.ts[534-551]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Public collection detail uses a path that is forbidden to non-owners by Firestore rules, so public collections cannot actually be viewed.
## Issue Context
The public index is readable, but the underlying collection contents remain private under `/collections/{userId}/...`.
## Fix Focus Areas
- firestore.rules[27-47]
- src/lib/collections.ts[534-551]
- src/components/PublicCollectionDetailModal.tsx[60-77]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


View more (3)
4. Missing userId/collectionId fields 📎 Requirement gap ✓ Correctness
Description
• The persisted Firestore documents do not store fields required by the spec: collection docs omit
userId, and collection item docs omit collectionId. • Even if these can be inferred from the
document path, the compliance requirement explicitly calls out these fields as part of the data
model, and missing fields can break integrations/queries that rely on them.
Code

src/lib/collections.ts[R56-71]

+    transaction.set(
+      parentRef,
+      {
+        userId,
+        collectionCount: increment(1),
+      },
+      { merge: true },
+    );
+
+    transaction.set(collectionRef, {
+      name,
+      description: description || null,
+      isPublic,
+      createdAt: serverTimestamp(),
+      updatedAt: serverTimestamp(),
+    });
Evidence
PR Compliance ID 9 requires the Collection and CollectionItem models to retain specific fields. The
write payloads for collections and contents omit userId on the collection document and omit
collectionId on the content document.

データモデルが Collection / CollectionItem 仕様を満たす
src/lib/collections.ts[65-71]
src/lib/collections.ts[355-360]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Firestore persistence does not include required spec fields (`userId` on Collection docs and `collectionId` on CollectionItem docs).
## Issue Context
The compliance spec requires these fields to be held by the data model, not only inferred from paths.
## Fix Focus Areas
- src/lib/collections.ts[40-71]
- src/lib/collections.ts[355-360]
- src/types/collection.ts[17-35]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


✅ 5. Orphan content docs possible 📘 Rule violation ⛯ Reliability
Description
addContentToCollection() writes the content document before verifying that the target collection
exists. • If collectionId is invalid/deleted, this can create orphaned subcollection documents and
inconsistent state, which is an unhandled edge case.
Code

src/lib/collections.ts[R336-369]

+export async function addContentToCollection(
+  userId: string,
+  collectionId: string,
+  contentId: string,
+  contentType: ContentType,
+): Promise<void> {
+  if (!db) throw new Error('Firestore is not initialized');
+
+  const encodedId = encodeContentId(contentId);
+  const contentRef = doc(
+    db,
+    'collections',
+    userId,
+    'items',
+    collectionId,
+    'contents',
+    encodedId,
+  );
+
+  await setDoc(contentRef, {
+    contentId,
+    contentType,
+    addedAt: serverTimestamp(),
+  });
+
+  // コレクションの更新日時を更新
+  const collectionRef = doc(db, 'collections', userId, 'items', collectionId);
+  const collectionDoc = await getDoc(collectionRef);
+
+  if (collectionDoc.exists()) {
+    await updateDoc(collectionRef, {
+      updatedAt: serverTimestamp(),
+    });
+
Evidence
PR Compliance ID 3 requires explicit handling of edge cases and validation. The function performs a
write to the contents subcollection first, then only conditionally updates the parent collection
if it exists, leaving orphan writes possible.

Rule 3: Generic: Robust Error Handling and Edge Case Management
src/lib/collections.ts[336-369]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`addContentToCollection()` can create orphan `contents` documents when the parent collection does not exist.
## Issue Context
Edge cases like invalid/deleted collection IDs must be explicitly handled to avoid inconsistent state.
## Fix Focus Areas
- src/lib/collections.ts[336-374]
- src/lib/collections.ts[380-414]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


6. Catch-all read bypass 🐞 Bug ⛨ Security
Description
• The new /collections/{userId} rules intend to restrict reads/writes to the owning user, but the
existing catch-all match /{document=**} still grants allow read: if true. • Firestore allows are
effectively OR’ed across matching rules, so this makes collections (and other user data) publicly
readable despite the new restrictions. • This is a critical privacy/security issue and should be
fixed before shipping collections.
Code

firestore.rules[R27-51]

+    // Collections: users can only read/write their own collections
+    match /collections/{userId} {
+      allow read, write: if request.auth != null && request.auth.uid == userId;
+
+      // Collection items subcollection
+      match /items/{collectionId} {
+        allow read, write: if request.auth != null && request.auth.uid == userId;
+
+        // Contents within a collection
+        match /contents/{contentId} {
+          allow read, write: if request.auth != null && request.auth.uid == userId;
+        }
+      }
+    }
+
+    // Public collections: anyone can read, only owner can write
+    match /publicCollections/{collectionId} {
+      allow read: if true;
+      allow create: if request.auth != null && request.resource.data.userId == request.auth.uid;
+      allow update, delete: if request.auth != null && resource.data.userId == request.auth.uid;
+    }
+
// Legacy admin-only rules for other collections
match /{document=**} {
 // Anyone can read (public content)
Evidence
The ruleset simultaneously defines owner-only reads for /collections/{userId} and an unconditional
global read rule. The global rule will permit reads regardless of the owner-only restriction,
undermining the privacy model of collections.

firestore.rules[27-56]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The Firestore rules contain a global wildcard rule that allows any read (`allow read: if true`). This defeats the intent of the new `/collections/{userId}` owner-only rules, making private collections publicly readable.
## Issue Context
Firestore rules are not “first match wins”; permissive rules on any matching `match` path can grant access. A wildcard read rule at `/{document=**}` will match every document and can therefore bypass more restrictive rules.
## Fix Focus Areas
- firestore.rules[27-56]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools



Remediation recommended

7. Collections APIs not implemented 📎 Requirement gap ✓ Correctness
Description
• The compliance checklist requires REST endpoints for collections (user collections CRUD, items
operations, and public collections), but the implementation uses client-side Firestore access via
src/lib/collections.ts instead of providing the specified /api/... routes. • If these endpoints
are expected for integration/contract reasons, consumers cannot call the documented APIs and the
feature will be non-compliant with the required interface.
Code

src/lib/collections.ts[R1-12]

+import {
+  collection,
+  deleteDoc,
+  doc,
+  getDoc,
+  getDocs,
+  increment,
+  runTransaction,
+  serverTimestamp,
+  setDoc,
+  updateDoc,
+} from 'firebase/firestore';
Evidence
PR Compliance IDs 10–13 define explicit API endpoints. The PR implements collections operations by
importing and calling Firebase Firestore SDK directly from the app, which indicates the required
HTTP API surface is not being provided by this change.

自分のコレクション一覧取得 API が提供される(GET /api/users/{userId}/collections)
コレクション CRUD API が提供される(POST/PUT/DELETE /api/users/{userId}/collections)
コレクション内アイテム取得/追加/削除 API が提供される
公開コレクション一覧 API が提供される(GET /api/collections/public)
src/lib/collections.ts[1-12]
src/app/collections/page.tsx[9-13]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
The required REST API endpoints for collections are not implemented as `/api/...` routes; the PR uses client-side Firestore operations instead.
## Issue Context
The compliance checklist explicitly lists required endpoints for collections CRUD, items operations, and public collections listing.
## Fix Focus Areas
- src/app/collections/page.tsx[9-13]
- src/lib/collections.ts[1-555]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


✅ 8. Delete transaction brittle 🐞 Bug ⛯ Reliability
Description
deleteCollection decrements collectionCount using transaction.update(parentRef, ...) without
ensuring the parent doc exists. • If the parent doc is missing (corrupt/legacy/manual deletion), the
delete will fail, leaving the collection undeleted and the UI in a broken state. • It also deletes
every content doc inside a single transaction; very large collections can exceed Firestore
transaction limits and fail the operation.
Code

src/lib/collections.ts[R104-133]

+  return runTransaction(firestore, async (transaction) => {
+    // コンテンツを全て削除
+    const contentsRef = collection(
+      firestore,
+      'collections',
+      userId,
+      'items',
+      collectionId,
+      'contents',
+    );
+    const contentsSnapshot = await getDocs(contentsRef);
+
+    for (const contentDoc of contentsSnapshot.docs) {
+      transaction.delete(contentDoc.ref);
+    }
+
+    // コレクションを削除
+    transaction.delete(collectionRef);
+
+    // カウントをデクリメント
+    const parentRef = doc(firestore, 'collections', userId);
+    transaction.update(parentRef, {
+      collectionCount: increment(-1),
+    });
+
+    // 公開コレクションだった場合、インデックスも削除
+    if (wasPublic) {
+      const publicRef = doc(firestore, 'publicCollections', collectionId);
+      transaction.delete(publicRef);
+    }
Evidence
The delete path uses transaction.update on a parent doc without a preceding transaction read or
fallback set, which can throw if the doc is absent. It also loops over all content docs, performing
an unbounded number of transactional deletes.

src/lib/collections.ts[104-133]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
`deleteCollection` can fail if the parent `/collections/{userId}` doc does not exist (because `transaction.update` requires existence). It also attempts to delete all content docs inside one transaction.
## Issue Context
Users can accumulate many items in a collection; deleting them all transactionally is fragile. Also, parent docs can be missing due to legacy data or accidental deletion.
## Fix Focus Areas
- src/lib/collections.ts[80-135]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


9. N+1 Firestore reads 🐞 Bug ➹ Performance
Description
getCollections performs a getDocs(contents) for each collection to compute contentCount,
producing an N+1 read pattern. • getCollectionsForContent calls getCollections and then does a
per-collection getDoc to check membership, compounding reads; this is invoked in the
Add-to-collection modal. • With MAX_COLLECTIONS = 255, this can quickly become hundreds of reads
per interaction, harming performance and increasing Firestore costs.
Code

src/lib/collections.ts[R238-276]

+export async function getCollections(
+  userId: string,
+): Promise<CollectionSummary[]> {
+  if (!db) return [];
+
+  try {
+    const collectionsRef = collection(db, 'collections', userId, 'items');
+    const snapshot = await getDocs(collectionsRef);
+
+    const summaries: CollectionSummary[] = [];
+
+    for (const docSnap of snapshot.docs) {
+      const data = docSnap.data();
+
+      // コンテンツ数を取得
+      const contentsRef = collection(
+        db,
+        'collections',
+        userId,
+        'items',
+        docSnap.id,
+        'contents',
+      );
+      const contentsSnapshot = await getDocs(contentsRef);
+
+      summaries.push({
+        id: docSnap.id,
+        name: data.name,
+        description: data.description || undefined,
+        isPublic: data.isPublic || false,
+        contentCount: contentsSnapshot.size,
+        createdAt: data.createdAt,
+        updatedAt: data.updatedAt,
+      });
+    }
+
+    return summaries.sort(
+      (a, b) => b.updatedAt.toMillis() - a.updatedAt.toMillis(),
+    );
Evidence
The code explicitly loops over collections and issues extra Firestore reads per collection (contents
subcollection reads and membership doc reads). The UI modal calls both getCollections and
getCollectionsForContent, triggering the expensive pattern during common user flows.

src/lib/collections.ts[24-24]
src/lib/collections.ts[238-276]
src/lib/collections.ts[419-448]
src/components/AddToCollectionModal.tsx[36-48]

Agent prompt
The issue below was found during a code review. Follow the provided context and guidance below and implement a solution

## Issue description
Collections listing and membership checks use N+1 Firestore reads (and worse in `getCollectionsForContent`), which will scale poorly and increase costs.
## Issue Context
`MAX_COLLECTIONS` allows up to 255 collections; current implementation potentially performs hundreds of reads when opening the Add-to-collection modal.
## Fix Focus Areas
- src/lib/collections.ts[238-281]
- src/lib/collections.ts[336-414]
- src/lib/collections.ts[419-455]
- src/components/AddToCollectionModal.tsx[36-58]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools


Grey Divider

ⓘ The new review experience is currently in Beta. Learn more

Grey Divider

Qodo Logo

Comment thread src/lib/collections.ts
Comment on lines +30 to +187
export async function createCollection(
userId: string,
name: string,
description?: string,
isPublic = false,
): Promise<string> {
if (!db) throw new Error('Firestore is not initialized');

const firestore = db;

return runTransaction(firestore, async (transaction) => {
const parentRef = doc(firestore, 'collections', userId);
const parentDoc = await transaction.get(parentRef);

const currentCount = parentDoc.exists()
? parentDoc.data().collectionCount || 0
: 0;

if (currentCount >= MAX_COLLECTIONS) {
throw new Error(`コレクションは最大${MAX_COLLECTIONS}個までです`);
}

const collectionRef = doc(
collection(firestore, 'collections', userId, 'items'),
);

transaction.set(
parentRef,
{
userId,
collectionCount: increment(1),
},
{ merge: true },
);

transaction.set(collectionRef, {
name,
description: description || null,
isPublic,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
});

return collectionRef.id;
});
}

/**
* コレクション削除
*/
export async function deleteCollection(
userId: string,
collectionId: string,
): Promise<void> {
if (!db) throw new Error('Firestore is not initialized');

const firestore = db;

// まずコレクションが公開かどうか確認
const collectionRef = doc(
firestore,
'collections',
userId,
'items',
collectionId,
);
const collectionDoc = await getDoc(collectionRef);

if (!collectionDoc.exists()) {
throw new Error('コレクションが見つかりません');
}

const wasPublic = collectionDoc.data().isPublic;

return runTransaction(firestore, async (transaction) => {
// コンテンツを全て削除
const contentsRef = collection(
firestore,
'collections',
userId,
'items',
collectionId,
'contents',
);
const contentsSnapshot = await getDocs(contentsRef);

for (const contentDoc of contentsSnapshot.docs) {
transaction.delete(contentDoc.ref);
}

// コレクションを削除
transaction.delete(collectionRef);

// カウントをデクリメント
const parentRef = doc(firestore, 'collections', userId);
transaction.update(parentRef, {
collectionCount: increment(-1),
});

// 公開コレクションだった場合、インデックスも削除
if (wasPublic) {
const publicRef = doc(firestore, 'publicCollections', collectionId);
transaction.delete(publicRef);
}
});
}

/**
* コレクション更新
*/
export async function updateCollection(
userId: string,
collectionId: string,
updates: {
name?: string;
description?: string;
isPublic?: boolean;
},
): Promise<void> {
if (!db) throw new Error('Firestore is not initialized');

const firestore = db;

const collectionRef = doc(
firestore,
'collections',
userId,
'items',
collectionId,
);
const collectionDoc = await getDoc(collectionRef);

if (!collectionDoc.exists()) {
throw new Error('コレクションが見つかりません');
}

const currentData = collectionDoc.data();
const wasPublic = currentData.isPublic;
const willBePublic = updates.isPublic ?? wasPublic;

await updateDoc(collectionRef, {
...updates,
updatedAt: serverTimestamp(),
});

// 公開状態の変更に応じてインデックスを更新
if (willBePublic && !wasPublic) {
// 非公開→公開: インデックスに追加
await syncPublicCollectionIndex(userId, collectionId);
} else if (!willBePublic && wasPublic) {
// 公開→非公開: インデックスから削除
const publicRef = doc(firestore, 'publicCollections', collectionId);
await deleteDoc(publicRef);
} else if (willBePublic) {
// 公開のまま更新: インデックスも更新
await syncPublicCollectionIndex(userId, collectionId);
}
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

1. No collection audit logs 📘 Rule violation ✧ Quality

• Collection create/update/delete are critical write/delete actions but the new implementation does
  not record an audit trail (who did what, when, and the outcome).
• Without structured audit events, it’s not possible to reliably reconstruct user actions for
  security analysis or compliance investigations.
Agent Prompt
## Issue description
Collection mutations (create/update/delete collections and add/remove items) do not emit audit trail events with required context (user, timestamp, action, outcome).

## Issue Context
Compliance requires reconstructable audit trails for critical actions. Current implementation performs writes/deletes without emitting audit records.

## Fix Focus Areas
- src/lib/collections.ts[30-187]
- src/lib/collections.ts[336-414]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment on lines +37 to +41
} catch (err) {
console.error('Failed to create collection:', err);
setError(
err instanceof Error ? err.message : 'コレクションの作成に失敗しました',
);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

2. err.message shown to users 📘 Rule violation ⛨ Security

• The create-collection UI displays err.message directly to the user, which can expose internal
  implementation details (e.g., initialization/config errors or backend messages).
• This violates secure error handling expectations for user-facing messages and can leak sensitive
  system context.
Agent Prompt
## Issue description
The UI exposes raw exception messages via `err.message`.

## Issue Context
User-facing errors must be generic to avoid leaking internal details; detailed diagnostics should be logged securely.

## Fix Focus Areas
- src/components/CreateCollectionModal.tsx[37-41]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/lib/collections.ts
Comment on lines +534 to +551
export async function getPublicCollectionWithContents(
collectionId: string,
): Promise<CollectionWithContents | null> {
if (!db) return null;

try {
// まず公開インデックスから情報を取得
const publicRef = doc(db, 'publicCollections', collectionId);
const publicDoc = await getDoc(publicRef);

if (!publicDoc.exists()) return null;

const publicData = publicDoc.data();
const userId = publicData.userId;

// 実際のコレクションデータを取得
return getCollectionWithContents(userId, collectionId);
} catch (error) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

3. Public collections not viewable 📎 Requirement gap ✓ Correctness

• Public collection detail loading reads from /collections/{userId}/items/{collectionId}, but
  Firestore rules allow reads there only for the owner (request.auth.uid == userId).
• As a result, other users (and anonymous users) cannot actually view the contents of a public
  collection, so the public/private setting is not reflected in access.
Agent Prompt
## Issue description
Public collection detail uses a path that is forbidden to non-owners by Firestore rules, so public collections cannot actually be viewed.

## Issue Context
The public index is readable, but the underlying collection contents remain private under `/collections/{userId}/...`.

## Fix Focus Areas
- firestore.rules[27-47]
- src/lib/collections.ts[534-551]
- src/components/PublicCollectionDetailModal.tsx[60-77]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/lib/collections.ts
Comment on lines +56 to +71
transaction.set(
parentRef,
{
userId,
collectionCount: increment(1),
},
{ merge: true },
);

transaction.set(collectionRef, {
name,
description: description || null,
isPublic,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

4. Missing userid/collectionid fields 📎 Requirement gap ✓ Correctness

• The persisted Firestore documents do not store fields required by the spec: collection docs omit
  userId, and collection item docs omit collectionId.
• Even if these can be inferred from the document path, the compliance requirement explicitly calls
  out these fields as part of the data model, and missing fields can break integrations/queries that
  rely on them.
Agent Prompt
## Issue description
Firestore persistence does not include required spec fields (`userId` on Collection docs and `collectionId` on CollectionItem docs).

## Issue Context
The compliance spec requires these fields to be held by the data model, not only inferred from paths.

## Fix Focus Areas
- src/lib/collections.ts[40-71]
- src/lib/collections.ts[355-360]
- src/types/collection.ts[17-35]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread src/lib/collections.ts
Comment on lines +336 to +369
export async function addContentToCollection(
userId: string,
collectionId: string,
contentId: string,
contentType: ContentType,
): Promise<void> {
if (!db) throw new Error('Firestore is not initialized');

const encodedId = encodeContentId(contentId);
const contentRef = doc(
db,
'collections',
userId,
'items',
collectionId,
'contents',
encodedId,
);

await setDoc(contentRef, {
contentId,
contentType,
addedAt: serverTimestamp(),
});

// コレクションの更新日時を更新
const collectionRef = doc(db, 'collections', userId, 'items', collectionId);
const collectionDoc = await getDoc(collectionRef);

if (collectionDoc.exists()) {
await updateDoc(collectionRef, {
updatedAt: serverTimestamp(),
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

5. Orphan content docs possible 📘 Rule violation ⛯ Reliability

addContentToCollection() writes the content document before verifying that the target collection
  exists.
• If collectionId is invalid/deleted, this can create orphaned subcollection documents and
  inconsistent state, which is an unhandled edge case.
Agent Prompt
## Issue description
`addContentToCollection()` can create orphan `contents` documents when the parent collection does not exist.

## Issue Context
Edge cases like invalid/deleted collection IDs must be explicitly handled to avoid inconsistent state.

## Fix Focus Areas
- src/lib/collections.ts[336-374]
- src/lib/collections.ts[380-414]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

Comment thread firestore.rules
Comment on lines +27 to 51
// Collections: users can only read/write their own collections
match /collections/{userId} {
allow read, write: if request.auth != null && request.auth.uid == userId;

// Collection items subcollection
match /items/{collectionId} {
allow read, write: if request.auth != null && request.auth.uid == userId;

// Contents within a collection
match /contents/{contentId} {
allow read, write: if request.auth != null && request.auth.uid == userId;
}
}
}

// Public collections: anyone can read, only owner can write
match /publicCollections/{collectionId} {
allow read: if true;
allow create: if request.auth != null && request.resource.data.userId == request.auth.uid;
allow update, delete: if request.auth != null && resource.data.userId == request.auth.uid;
}

// Legacy admin-only rules for other collections
match /{document=**} {
// Anyone can read (public content)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Action required

6. Catch-all read bypass 🐞 Bug ⛨ Security

• The new /collections/{userId} rules intend to restrict reads/writes to the owning user, but the
  existing catch-all match /{document=**} still grants allow read: if true.
• Firestore allows are effectively OR’ed across matching rules, so this makes collections (and other
  user data) publicly readable despite the new restrictions.
• This is a critical privacy/security issue and should be fixed before shipping collections.
Agent Prompt
## Issue description
The Firestore rules contain a global wildcard rule that allows any read (`allow read: if true`). This defeats the intent of the new `/collections/{userId}` owner-only rules, making private collections publicly readable.

## Issue Context
Firestore rules are not “first match wins”; permissive rules on any matching `match` path can grant access. A wildcard read rule at `/{document=**}` will match every document and can therefore bypass more restrictive rules.

## Fix Focus Areas
- firestore.rules[27-56]

ⓘ Copy this prompt and use it to remediate the issue with your preferred AI generation tools

@devin-ai-integration devin-ai-integration Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Devin Review found 1 potential issue.

View 6 additional findings in Devin Review.

Open in Devin Review

Comment thread src/lib/collections.ts
Comment on lines +65 to +74
transaction.set(collectionRef, {
name,
description: description || null,
isPublic,
createdAt: serverTimestamp(),
updatedAt: serverTimestamp(),
});

return collectionRef.id;
});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🔴 createCollection with isPublic=true never creates the public index document

When a user creates a collection with the "公開する" checkbox enabled, the createCollection function stores isPublic: true in the collection document but never calls syncPublicCollectionIndex to create the corresponding publicCollections/{collectionId} document.

Root Cause and Impact

The createCollection function (src/lib/collections.ts:30-75) accepts an isPublic parameter and writes it to the Firestore document at line 68, but has no logic to create a public index entry. Compare this to updateCollection (src/lib/collections.ts:176-186) which correctly handles public index sync:

// updateCollection handles it:
if (willBePublic && !wasPublic) {
  await syncPublicCollectionIndex(userId, collectionId);
}

But createCollection has no such call after the transaction.

The CreateCollectionModal component (src/components/CreateCollectionModal.tsx:29-34) passes isPublic directly to createCollection. As a result:

  1. The collection appears with a "公開" badge in the owner's list (since isPublic: true is stored).
  2. But getPublicCollections() (src/lib/collections.ts:507-528) queries the publicCollections Firestore collection, which has no entry for this collection.
  3. The collection never appears on the public collections page for other users.

Impact: Creating a public collection is silently broken. The owner sees it marked as public, but it's invisible to everyone else. The only workaround is toggling public off then on again via updateCollection.

Prompt for agents
In src/lib/collections.ts, after the runTransaction completes in createCollection (around line 74), add logic to sync the public collection index if isPublic is true. Since syncPublicCollectionIndex needs to read the just-created document, add the call after the transaction returns:

After line 74 (the closing of runTransaction), before the function's closing brace, add:

  const newId = await runTransaction(...);

  if (isPublic) {
    await syncPublicCollectionIndex(userId, newId);
  }

  return newId;

This requires restructuring the return slightly so the transaction result is captured in a variable first, then the sync is performed, then the ID is returned.
Open in Devin Review

Was this helpful? React with 👍 or 👎 to provide feedback.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🤖 Fix all issues with AI agents
In `@src/components/AddToCollectionModal.tsx`:
- Around line 104-136: handleCollectionCreated currently calls getCollections
outside the try/catch so a rejection can crash the component; move the
getCollections(user.uid) call into the existing try block (alongside
addContentToCollection) or wrap it in its own try/catch, ensure errors set
setToggleError(...) and reset setIsCreating(false), and keep updating
setCollections(cols) only after a successful fetch; reference
handleCollectionCreated, getCollections, addContentToCollection, setCollections,
setToggleError, and setIsCreating when making the change.

In `@src/lib/collections.ts`:
- Around line 252-272: The code in syncPublicCollectionIndex is doing an
expensive getDocs on the contents subcollection to compute contentCount; instead
read the already-denormalized count from the collection document (the value
maintained by addContentToCollection and removeContentFromCollection) and use
that for contentCount when writing to the publicCollections doc; update the
logic in syncPublicCollectionIndex to reference data.contentCount (with a safe
fallback like 0 if missing) and remove the getDocs(contentsRef) call to
eliminate the subcollection read.
🧹 Nitpick comments (9)
firestore.rules (1)

50-57: Pre-existing catch-all rule grants public read access to private collections.

The match /{document=**} with allow read: if true matches all documents including /collections/{userId}/items/…. In Firestore, if any matching allow evaluates to true, the request is granted — so the owner-only restriction on collections (and on users/favorites) is effectively a no-op for reads.

This is not introduced by this PR (the pattern pre-dates it), but now that private collections with user-curated content are being stored, the exposure surface is larger. Consider scoping the catch-all to specific top-level collections that are truly public content, or removing the recursive wildcard.

src/lib/contentDisplay.ts (1)

29-53: Consider guarding against malformed contentId values.

If contentId has fewer than two /-separated parts, sectionId will be undefined, and getSectionById(bookId, undefined) may behave unexpectedly. A defensive early return or validation would improve robustness.

🛡️ Suggested guard
 export function getContentDisplayInfo(contentId: string): ContentDisplayInfo {
   const parts = contentId.split('/');
   const bookId = parts[0];
   const sectionId = parts[1];
   const chapterId = parts[2];
+
+  if (!bookId || !sectionId) {
+    return {
+      type: 'section',
+      title: contentId,
+      preview: '',
+      href: `/books/${contentId}`,
+    };
+  }
 
   const book = getBookById(bookId);
src/components/AddToCollectionModal.tsx (1)

141-241: Modal does not close on Escape key or backdrop click.

The overlay (bg-black/50) doesn't have an onClick handler for closing on backdrop click, and there's no keydown listener for the Escape key. This is a common UX expectation for modals.

src/lib/collections.ts (4)

384-404: addContentToCollection is not atomic — setDoc and updateDoc are independent operations.

If setDoc at line 384 succeeds but updateDoc at line 395 fails (or vice versa), contentCount drifts out of sync with the actual contents. Similarly, syncPublicCollectionIndex can fail independently. Consider wrapping at least the content write and collection update in a transaction or a batched write.


428-444: Same non-atomicity concern applies to removeContentFromCollection.

deleteDoc and updateDoc(increment(-1)) are not atomic. A failure after delete but before decrement leaves contentCount off by one.


85-140: TOCTOU: wasPublic is read outside the transaction, potentially stale by commit time.

The isPublic flag is read at line 107 before the transaction starts at line 110. If a concurrent updateCollection toggles isPublic between the read and the transaction commit, the public index may not be correctly updated (e.g., the collection becomes public but the public index entry gets deleted, or vice versa).

Moving the initial read into the transaction would close this window:

♻️ Sketch
-  const collectionDoc = await getDoc(collectionRef);
-
-  if (!collectionDoc.exists()) {
-    throw new Error('コレクションが見つかりません');
-  }
-
-  const wasPublic = collectionDoc.data().isPublic;
-
   await runTransaction(firestore, async (transaction) => {
+    const collectionDoc = await transaction.get(collectionRef);
+    if (!collectionDoc.exists()) {
+      throw new Error('コレクションが見つかりません');
+    }
+    const wasPublic = collectionDoc.data().isPublic;
+
     // コンテンツを全て削除

540-565: getPublicCollections fetches all documents without pagination.

If the number of public collections grows, this will hit Firestore read limits and increase latency and cost. Consider adding pagination (e.g., limit + startAfter cursor) or at minimum an upper-bound limit.

src/components/CollectionDetailModal.tsx (2)

45-59: alert() for error notification is functional but inconsistent with AddToCollectionModal.

AddToCollectionModal uses inline error state (toggleError) for a better UX, while this component uses alert() (line 55). Consider aligning the error handling pattern across modals for consistency.


61-149: Same as AddToCollectionModal: modal lacks Escape key and backdrop-click dismiss behavior.

Both modals share this gap. If you address it, consider extracting a shared ModalWrapper component to avoid duplicating the event listener logic.

Comment thread src/components/AddToCollectionModal.tsx
Comment thread src/lib/collections.ts
@rindrics
rindrics force-pushed the issue-85-collection branch from 9a878ec to c96cc60 Compare February 11, 2026 20:30

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
firestore.rules (1)

26-57: ⚠️ Potential issue | 🔴 Critical

Critical: Catch-all rule allows public read to all collections, including private user data.

Firestore evaluates overlapping match paths with OR semantics—if any matching rule grants access, the operation is allowed. The catch-all rule at line 51 (match /{document=**} with allow read: if true) will match every document path, including:

  • /users/{userId}/{document=**} (intended owner-only)
  • /favorites/{userId}/items/{contentId} (intended owner-only)
  • /collections/{userId}/items/{collectionId}/contents/{contentId} (intended owner-only)

This bypasses all three owner-only read restrictions—any user can read any other user's private data (users, favorites, and collections). Only /comments and /publicCollections are unaffected since they're already publicly readable.

The issue is structural: you cannot make a broad catch-all "public" and then restrict it elsewhere with narrower rules. Either remove the catch-all entirely (if no truly legacy collections exist), or explicitly list which top-level collections should have public read + admin write access, like:

-    // Legacy admin-only rules for other collections
-    match /{document=**} {
-      allow read: if true;
-      allow write: if request.auth != null && request.auth.token.admin == true;
-    }
+    // Legacy collections: public read + admin write only
+    match /announcements/{document=**} {
+      allow read: if true;
+      allow write: if request.auth != null && request.auth.token.admin == true;
+    }

(Replace announcements with actual legacy collection names if they exist.)

🤖 Fix all issues with AI agents
In `@src/lib/collections.ts`:
- Around line 354-394: The content count can drift because
addContentToCollection uses setDoc (an upsert) and then unconditionally
increments contentCount, and removeContentFromCollection deletes and
unconditionally decrements; change both functions to perform the content
write/delete and the collection counter update atomically by using a Firestore
transaction (runTransaction) on contentRef and collectionRef: read the content
document inside the transaction, if adding only call set if it doesn't exist and
increment(1) only when it was absent; if removing only call delete and
increment(-1) when the document existed; keep references to
encodedId/contentRef/collectionRef and preserve updatedAt/serverTimestamp()
updates inside the same transaction and still call
syncPublicCollectionIndex(userId, collectionId) after a successful transaction
when the collection is public.
🧹 Nitpick comments (6)
src/components/AddToCollectionModal.tsx (3)

37-59: No user-facing feedback when initial data load fails.

If getCollections or getCollectionsForContent throws, the error is logged to the console (Line 52) but the user sees either an empty list or no indication that something went wrong. Consider setting an error state here so the UI can display a retry prompt or error message.

💡 Suggested change
+  const [loadError, setLoadError] = useState(false);
+
   useEffect(() => {
     if (!user) return;
 
     const currentUser = user;
 
     async function loadData() {
       setIsLoading(true);
+      setLoadError(false);
       try {
         const [cols, included] = await Promise.all([
           getCollections(currentUser.uid),
           getCollectionsForContent(currentUser.uid, contentId),
         ]);
         setCollections(cols);
         setIncludedIn(included);
       } catch (error) {
         console.error('Failed to load collections:', error);
+        setLoadError(true);
       } finally {
         setIsLoading(false);
       }
     }
 
     loadData();
   }, [user, contentId]);

Then render an error/retry UI when loadError is true.


145-244: Modal lacks Escape-key dismissal and click-outside-to-close.

Neither this modal nor CreateCollectionModal handle Escape key or backdrop clicks to close. This is a common accessibility expectation for modals (WAI-ARIA dialog pattern). A single shared handler or a reusable modal wrapper would address both.


132-139: Redundant setIsCreating(false) in catch block.

setIsCreating(false) is already called at Line 105 before the try block, so the call at Line 138 is a no-op.

src/lib/collections.ts (3)

85-152: Non-transactional pre-read introduces a TOCTOU window in deleteCollection.

The collection document is read at Line 101 (outside the transaction) to capture wasPublic, then the transaction at Line 110 proceeds assuming that value is still valid. A concurrent updateCollection toggling isPublic between these two points could leave a stale publicCollections index document (or skip deleting one that should be removed). Moving the read inside the transaction (using transaction.get) would close this gap.

This is unlikely in a single-user-owned collection flow, but worth noting.


180-227: updateCollection read-then-write is not transactional — public index sync could be inconsistent.

The getDoc at Line 200 and updateDoc at Line 210 are separate operations. A concurrent updateCollection call (e.g., from two browser tabs) could produce conflicting public-index state since both reads see the same wasPublic and both attempt to sync/delete the index independently. Wrapping the read + update + index sync in a transaction would prevent this.

Low risk in a single-user flow, but flagging for awareness.


304-349: contentCount is omitted from the returned CollectionWithContents object.

The Collection interface includes an optional contentCount field, and data.contentCount is available on the Firestore document, but it's not included in the returned object at Lines 333-344. Consumers of getCollectionWithContents won't see contentCount even though they can access it on the type.

💡 Suggested fix
     return {
       id: collectionDoc.id,
       userId,
       name: data.name,
       description: data.description || undefined,
       isPublic: data.isPublic || false,
+      contentCount: data.contentCount ?? 0,
       createdAt: data.createdAt,
       updatedAt: data.updatedAt,
       contents: contents.sort(

Comment thread src/lib/collections.ts

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

Actionable comments posted: 1

🤖 Fix all issues with AI agents
In `@src/lib/collections.ts`:
- Around line 180-227: The updateCollection flow has a TOCTOU on isPublic; wrap
the read+write in a Firestore transaction (use runTransaction inside
updateCollection) so you read the document with transaction.get(collectionRef),
compute wasPublic and willBePublic inside the transaction, and perform the
update via transaction.update(collectionRef, { ...updates, updatedAt:
serverTimestamp() }); then perform the corresponding public-index mutation
inside the same transaction (use transaction.set on the publicCollections doc to
add/update or transaction.delete to remove) instead of calling
syncPublicCollectionIndex after the transactional update; alternatively, change
syncPublicCollectionIndex to perform its work within the same transaction and
call it from inside the transaction using the transactional reference
(collectionRef/publicRef) to ensure atomicity.
🧹 Nitpick comments (2)
src/lib/collections.ts (2)

154-175: Non-null assertion on firestore parameter that may be null by type.

typeof db includes null (since db can be null before initialization). The firestore! on Line 164 is safe at runtime because the caller already guards db, but the type doesn't encode that guarantee.

♻️ Tighten the parameter type
-async function cleanupOrphanedContents(
-  firestore: typeof db,
+async function cleanupOrphanedContents(
+  firestore: NonNullable<typeof db>,
   contentsRef: ReturnType<typeof collection>,
 ): Promise<void> {
   let queryObj = query(contentsRef, limit(100));
 
   while (true) {
     const snapshot = await getDocs(queryObj);
     if (snapshot.empty) break;
 
-    const batch = writeBatch(firestore!);
+    const batch = writeBatch(firestore);

560-573: Minor: inconsistent nullish handling for contentCount.

Line 566 uses || 0 for contentCount while Lines 258 and 286 use ?? 0. With ||, a contentCount of 0 would also fall through (though to the same 0 result, so it's functionally identical). Use ?? 0 consistently to match the rest of the file and avoid the semantic ambiguity.

♻️ Consistent nullish coalescing
-        contentCount: d.data().contentCount || 0,
+        contentCount: d.data().contentCount ?? 0,

Comment thread src/lib/collections.ts
@rindrics
rindrics merged commit 0354ef7 into main Feb 11, 2026
6 checks passed
@rindrics
rindrics deleted the issue-85-collection branch February 11, 2026 20:57
@rindrics rindrics mentioned this pull request Feb 11, 2026
This was referenced Feb 12, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant