Skip to content

Feat/likes#41

Merged
BretRen merged 8 commits into
mainfrom
feat/likes
Apr 21, 2026
Merged

Feat/likes#41
BretRen merged 8 commits into
mainfrom
feat/likes

Conversation

@BretRen
Copy link
Copy Markdown
Member

@BretRen BretRen commented Apr 21, 2026

Summary by CodeRabbit

Release Notes

New Features

  • Users can now like and unlike items on the explore page with a single click
  • Each item card displays a heart icon that indicates your current like status
  • Simply click the heart icon to toggle your like for any item
  • All likes are automatically saved and persist across your sessions

@BretRen BretRen linked an issue Apr 21, 2026 that may be closed by this pull request
@coderabbitai
Copy link
Copy Markdown

coderabbitai Bot commented Apr 21, 2026

📝 Walkthrough

Walkthrough

A like/unlike toggle feature is introduced across the backend and frontend. The API endpoint validates the target type, searches for existing likes, and creates or deletes records accordingly. Four database migrations define the likes collection schema and refine its fields and access rules. The frontend explore page fetches user likes on load and provides a UI button to toggle likes with live state updates.

Changes

Cohort / File(s) Summary
Backend API Endpoint
main.go
Added authenticated POST endpoint for toggling likes with type/id validation, lookup of existing likes in the collection, and conditional create/delete operations. Middleware validation updated to use core.RequestEvent-scoped error helpers instead of apis.* constructors.
Database Migrations
migrations/1776715443_created_likes.go, migrations/1776715655_updated_likes.go, migrations/1776715948_updated_likes.go, migrations/1776728364_updated_likes.go
Defined likes collection with user relation, target_id, and target_collection fields. Successive migrations added indexes, corrected target_collection enum values (cps, characters), and set empty list/view access rules.
Frontend Like Support
website/src/routes/explore/+page.svelte
Added likedTargetIds state, reloadLikes() initializer to fetch user's likes, and toggleLike() handler to POST toggle requests. Replaced anchor navigation with div+goto, added like button with conditional heart icon rendering, and updated card layout structure.

Sequence Diagram

sequenceDiagram
    participant User as User (Browser)
    participant Client as Client Component
    participant API as Backend API
    participant DB as Database

    User->>Client: Click like button
    Client->>API: POST /api/{type}/{id}/toggle-like
    API->>DB: Query likes collection for user+target_id
    alt Like exists
        DB-->>API: Return existing like record
        API->>DB: Delete like record
        DB-->>API: Confirm deletion
        API-->>Client: {like: false, record}
    else Like does not exist
        DB-->>API: No record found
        API->>DB: Insert new like record
        DB-->>API: Return saved record
        API-->>Client: {like: true, record}
    end
    Client->>Client: Update likedTargetIds state
    Client->>Client: Re-render heart icon
    Client-->>User: Display updated like state
Loading

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~25 minutes

Possibly related issues

  • feat: Like #39: Implements the complete like/unlike feature with API endpoint, database collection, and frontend toggle support that directly addresses the feature requirement.

Possibly related PRs

  • Feat/view cps&chars #17: Modifies the same explore page component (website/src/routes/explore/+page.svelte), altering card navigation and rendering behavior that could intersect with like button placement and styling.

Poem

🐰✨ A heart that toggles with delight,
Users click to mark their might,
Likes persist through database's sight,
From frontend charm to backend tight,
Love recorded, pure and bright! 💕

🚥 Pre-merge checks | ✅ 1 | ❌ 2

❌ Failed checks (1 warning, 1 inconclusive)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 28.57% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
Title check ❓ Inconclusive The title 'Feat/likes' is vague and uses a generic feature prefix convention without describing the actual functionality being added. It does not clearly convey what the like feature does or its primary purpose. Consider using a more descriptive title such as 'Add user like toggle functionality with database schema' or 'Implement like/unlike API endpoint and UI' to clearly communicate the main changes.
✅ Passed checks (1 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.

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

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/likes

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

❤️ Share

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

Copy link
Copy Markdown

@gemini-code-assist gemini-code-assist Bot left a comment

Choose a reason for hiding this comment

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

Code Review

This pull request implements a 'like' functionality for the 'cps' and 'characters' collections, including a new backend endpoint for toggling likes, database migrations for the 'likes' collection, and frontend updates to the explore page. The review feedback highlights a logic bug where the collection type was missing from the like-lookup filter and identifies a potential security risk in using string formatting for database queries. Other suggestions focus on improving performance by updating local frontend state instead of re-fetching data, using the request-scoped app instance in handlers, and hardening the database schema by making the target collection field required.

Comment thread main.go
Comment thread main.go
Comment thread migrations/1776715443_created_likes.go
Comment thread website/src/routes/explore/+page.svelte
Comment thread website/src/routes/explore/+page.svelte
Copy link
Copy Markdown

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 5

🧹 Nitpick comments (1)
website/src/routes/explore/+page.svelte (1)

13-24: Run likes loading in the client lifecycle and await refreshes.

Line 24 fires an unhandled async request during component initialization, and Line 40 starts a refresh without awaiting or catching failures. Move the initial load into onMount and await the post-toggle refresh so errors are handled deterministically.

♻️ Proposed fix
 	import { goto } from '$app/navigation';
+	import { onMount } from 'svelte';
 	import pb from '$lib/pocketbase';
@@
 	const reloadLikes = async () => {
-
 		if (!pb.authStore.record) return;
 
-		const likes = await pb.collection('likes').getFullList({
-			filter: `user = "${pb.authStore.record?.id}"`
-		})
+		const likes = await pb.collection('likes').getFullList({
+			filter: `user = "${pb.authStore.record.id}"`
+		});
 
 		likedTargetIds = new Set(likes.map(l => l.target_id));
 	};
 
-	reloadLikes();
+	onMount(() => {
+		reloadLikes().catch(console.error);
+	});
@@
-		reloadLikes();
+		await reloadLikes();
 	}

Also applies to: 40-40

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@website/src/routes/explore/`+page.svelte around lines 13 - 24, Move the
initial call to reloadLikes() into the Svelte client lifecycle by importing
onMount and invoking await reloadLikes() inside onMount so the async request
runs in the client lifecycle; ensure reloadLikes remains async (it already
awaits pb.collection('likes').getFullList) and sets likedTargetIds = new
Set(...). Also update any post-toggle refresh calls that call reloadLikes (the
place that currently triggers a refresh at Line 40) to await reloadLikes() and
wrap those awaits in try/catch so failures are caught and logged instead of
creating unhandled rejections.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@main.go`:
- Around line 52-99: Validate that the referenced target exists before creating
a like: after verifying requestCollection and before building record :=
core.NewRecord(collection), query the target collection (use
app.FindCollectionByNameOrId or app.FindFirstRecordByFilter with
requestCollection and targetId) to ensure a record with that ID exists; if the
query returns sql.ErrNoRows return a 400 validation error (similar shape to the
existing validation.Error for "type"), and if the query returns any other error
return an InternalServerError; only proceed to call core.NewRecord and app.Save
when the target record is confirmed present.
- Around line 63-65: The filter used in app.FindFirstRecordByFilter for the
"likes" collection omits target_collection and interpolates targetId directly,
which can match the wrong like (e.g., CP vs character) and is unsafe; update the
filter passed to FindFirstRecordByFilter to include "target_collection =
'<collectionName>'" along with user and target_id (matching the unique index)
and switch to a parameterized lookup API or binding mechanism to pass targetId
(and collectionName) as parameters rather than using fmt.Sprintf string
interpolation; locate the call to app.FindFirstRecordByFilter (variables userId,
targetId, collection "likes") and modify it to include target_collection and use
the app/client method that accepts parameters or placeholders.

In `@migrations/1776728364_updated_likes.go`:
- Around line 18-21: The current json.Unmarshal that sets collection.listRule
and collection.viewRule to empty strings leaves likes public; update that JSON
so the "listRule" and "viewRule" enforce authenticated-owner access (e.g.,
require request.auth.id equals the like owner field) instead of empty strings.
Locate the json.Unmarshal(...) assignment to the collection variable in
migrations/1776728364_updated_likes.go and replace the empty string values for
"listRule" and "viewRule" with rules that check for an authenticated user and
match the like record's owner id (use the same owner field name used elsewhere
in the likes collection).

In `@website/src/routes/explore/`+page.svelte:
- Around line 120-131: Update the like button to expose state to assistive tech
by using the liked state from likedTargetIds for the button attributes: set
aria-pressed to likedTargetIds.has(item.id) and change aria-label and title to
either "Unlike" when likedTargetIds.has(item.id) is true or "Like" when false;
keep the existing toggleLike(item) call in the click handler and ensure the same
item.id and likedTargetIds symbols are used to determine state for these
attributes.
- Around line 91-124: The like button is still allowing keyboard activation to
bubble up to the parent card (which has the onkeydown handler that triggers
navigation); update the button that calls toggleLike(item) to stop keyboard
event propagation as well—add an on:keydown/onkeydown handler on the same button
element to call e.stopPropagation() for Enter and Space (or for all key events)
so keyboard activation toggles the like without triggering the parent card's
onkeydown, and keep the existing click handler that stops propagation; reference
the button element and the toggleLike function as the places to change.

---

Nitpick comments:
In `@website/src/routes/explore/`+page.svelte:
- Around line 13-24: Move the initial call to reloadLikes() into the Svelte
client lifecycle by importing onMount and invoking await reloadLikes() inside
onMount so the async request runs in the client lifecycle; ensure reloadLikes
remains async (it already awaits pb.collection('likes').getFullList) and sets
likedTargetIds = new Set(...). Also update any post-toggle refresh calls that
call reloadLikes (the place that currently triggers a refresh at Line 40) to
await reloadLikes() and wrap those awaits in try/catch so failures are caught
and logged instead of creating unhandled rejections.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro

Run ID: 3719528d-f284-4ef5-b813-3f816e82405f

📥 Commits

Reviewing files that changed from the base of the PR and between 57f7739 and bdf6489.

📒 Files selected for processing (6)
  • main.go
  • migrations/1776715443_created_likes.go
  • migrations/1776715655_updated_likes.go
  • migrations/1776715948_updated_likes.go
  • migrations/1776728364_updated_likes.go
  • website/src/routes/explore/+page.svelte

Comment thread main.go
Comment on lines +52 to +99
if requestCollection != "cps" && requestCollection != "characters" {
return e.Error(400, "Invalid request data", map[string]validation.Error{
"type": validation.NewError("validation_collection_type", "Invalid collection type."),
})
}

collection, err := app.FindCollectionByNameOrId("likes")
if err != nil {
return e.InternalServerError("Internal Server Error", map[string]any{"message": "Cannot find collection", "err": err})
}

existingRecord, err := app.FindFirstRecordByFilter("likes",
fmt.Sprintf("user = '%s' && target_id = '%s'", userId, targetId),
)

if err != nil {
if !errors.Is(err, sql.ErrNoRows) {
return e.InternalServerError("Internal Server Error", map[string]any{
"message": "Cannot find record",
"err": err,
})
}
}

if existingRecord != nil {

err = app.Delete(existingRecord)
if err != nil {
return e.InternalServerError("Internal Server Error", map[string]any{
"message": "Cannot delete record",
"err": err,
})
}

return e.JSON(200, map[string]any{
"message": "Success",
"like": false,
"data": existingRecord,
})
}

record := core.NewRecord(collection)

record.Set("target_id", targetId)
record.Set("target_collection", requestCollection)
record.Set("user", e.Auth.Id)

err = app.Save(record)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, let's find and examine the main.go file
find . -name "main.go" -type f | head -5

Repository: pdnode-team/cp

Length of output: 67


🏁 Script executed:

# Get the file structure to understand the repository
git ls-files | head -20

Repository: pdnode-team/cp

Length of output: 623


🏁 Script executed:

# Read the relevant section of main.go
cat -n main.go | sed -n '50,105p'

Repository: pdnode-team/cp

Length of output: 2016


🏁 Script executed:

# Search for FindRecordById and FindFirstRecordByFilter method definitions
rg "FindRecordById|FindFirstRecordByFilter" -A 5

Repository: pdnode-team/cp

Length of output: 303


🏁 Script executed:

# Search for FindRecordById method definition
rg "func.*FindRecordById" -A 3

Repository: pdnode-team/cp

Length of output: 40


🏁 Script executed:

# Also check if there's a method to get records by ID
rg "func.*\(.*\).*FindRecord" | head -20

Repository: pdnode-team/cp

Length of output: 40


🏁 Script executed:

# Check if this is a PocketBase application
rg "pocketbase" -i

Repository: pdnode-team/cp

Length of output: 5683


🏁 Script executed:

# Search for method definitions on app variable
rg "app\.\w+\(" main.go | head -20

Repository: pdnode-team/cp

Length of output: 803


🏁 Script executed:

# Look at imports to understand the framework
head -30 main.go

Repository: pdnode-team/cp

Length of output: 627


🏁 Script executed:

# Get more context of the endpoint handler
cat -n main.go | sed -n '35,110p'

Repository: pdnode-team/cp

Length of output: 2820


🏁 Script executed:

# Check if FindRecordById is used anywhere in the codebase
rg "FindRecordById|GetRecordById"

Repository: pdnode-team/cp

Length of output: 40


🏁 Script executed:

# Check the go.mod to see the exact PocketBase version
cat go.mod

Repository: pdnode-team/cp

Length of output: 1607


🏁 Script executed:

# Search for how records are fetched in the codebase - look at other patterns
rg "app\.\w+Record" main.go config.go

Repository: pdnode-team/cp

Length of output: 541


🏁 Script executed:

# Check PocketBase official documentation for available methods
# Let's search for any other record-fetching patterns in the codebase
rg "app\." main.go config.go | grep -i record

Repository: pdnode-team/cp

Length of output: 620


🏁 Script executed:

# Let's check config.go to see how records are typically accessed
cat config.go

Repository: pdnode-team/cp

Length of output: 3959


🏁 Script executed:

# Search for any method that gets a record by ID in the entire codebase
rg "Record\(" | head -20

Repository: pdnode-team/cp

Length of output: 105


🏁 Script executed:

# Check if there are any examples of fetching records in migrations or elsewhere
rg "FindRecord|GetRecord" --type go

Repository: pdnode-team/cp

Length of output: 40


🌐 Web query:

PocketBase 0.36.9 Go SDK FindRecordById method

💡 Result:

There is no official Go SDK for PocketBase client-side API interactions (only JS and Dart SDKs are official). Community-maintained Go SDKs exist, such as github.com/habibrosyad/pocketbase-go-sdk, but they do not have a method named FindRecordById. PocketBase core (github.com/pocketbase/pocketbase v0.36.9) has app.FindRecordById(collectionName, recordId) *core.Record, used server-side when extending PocketBase with Go. This method retrieves a single record by ID from the specified collection, returning nil and sql.ErrNoRows if not found. Example usage (server-side): record, err := app.FindRecordById("articles", "RECORD_ID") if err != nil { // handle error (e.g., sql.ErrNoRows if not found) } In community client SDKs like habibrosyad/pocketbase-go-sdk, use client.One(collectionName, recordId) map[string]any to fetch a single record by ID as a raw map, or client.OneTo(collectionName, recordId, &target) to unmarshal into a struct. Example (client-side with community SDK): record, err := client.One("posts_public", "record_id") if err != nil { log.Fatal(err) } log.Print(record["field"])

Citations:


🏁 Script executed:

# Let's also check if there's a Find method with different patterns
rg "func.*Find.*Record" --type go | head -20

Repository: pdnode-team/cp

Length of output: 40


🏁 Script executed:

# Verify e.BadRequestError method exists in the codebase or is available in pocketbase
rg "BadRequestError|e\.Error" main.go | head -10

Repository: pdnode-team/cp

Length of output: 288


Validate that the liked target actually exists.

The endpoint validates only the collection name, then saves arbitrary target_id values without checking if the target record exists. This allows creating likes for nonexistent records.

🛡️ Proposed validation
 			if requestCollection != "cps" && requestCollection != "characters" {
 				return e.Error(400, "Invalid request data", map[string]validation.Error{
 					"type": validation.NewError("validation_collection_type", "Invalid collection type."),
 				})
 			}
+
+			if _, err := app.FindRecordById(requestCollection, targetId); err != nil {
+				if errors.Is(err, sql.ErrNoRows) {
+					return e.BadRequestError("Invalid request data", map[string]validation.Error{
+						"id": validation.NewError("validation_target_not_found", "Target record does not exist."),
+					})
+				}
+				return e.InternalServerError("Internal Server Error", nil)
+			}
 
 			collection, err := app.FindCollectionByNameOrId("likes")
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@main.go` around lines 52 - 99, Validate that the referenced target exists
before creating a like: after verifying requestCollection and before building
record := core.NewRecord(collection), query the target collection (use
app.FindCollectionByNameOrId or app.FindFirstRecordByFilter with
requestCollection and targetId) to ensure a record with that ID exists; if the
query returns sql.ErrNoRows return a 400 validation error (similar shape to the
existing validation.Error for "type"), and if the query returns any other error
return an InternalServerError; only proceed to call core.NewRecord and app.Save
when the target record is confirmed present.

Comment thread main.go
Comment on lines +63 to +65
existingRecord, err := app.FindFirstRecordByFilter("likes",
fmt.Sprintf("user = '%s' && target_id = '%s'", userId, targetId),
)
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

# First, locate and examine main.go to understand the context around lines 63-65
fd -t f "main.go" | head -5

Repository: pdnode-team/cp

Length of output: 65


🏁 Script executed:

# Get context around the flagged lines to understand the full implementation
head -120 main.go | tail -70

Repository: pdnode-team/cp

Length of output: 1909


🏁 Script executed:

# Check if there's a schema or migration file that defines the likes collection structure
fd -t f "migrations" | grep -E "\.(go|sql)$" | head -10

Repository: pdnode-team/cp

Length of output: 40


🏁 Script executed:

# Search for "likes" collection references to understand its schema
rg "likes" -A 3 -B 1 | head -50

Repository: pdnode-team/cp

Length of output: 2874


🏁 Script executed:

# Search for other uses of FindFirstRecordByFilter to understand the API
rg "FindFirstRecordByFilter" -B 2 -A 2

Repository: pdnode-team/cp

Length of output: 241


🏁 Script executed:

# Search for dbx.Params usage to see if this pattern is used elsewhere
rg "dbx\.Params" -B 2 -A 2

Repository: pdnode-team/cp

Length of output: 40


🏁 Script executed:

# Check imports to understand what dbx package is
head -30 main.go | grep -E "^import|dbx"

Repository: pdnode-team/cp

Length of output: 95


🏁 Script executed:

# Search for how filters are used elsewhere in the codebase
rg "FindFirstRecordByFilter\|FindRecordById\|GetList" -A 3 | head -40

Repository: pdnode-team/cp

Length of output: 40


🏁 Script executed:

# Look at PocketBase examples or tests to understand the filter API
rg "fmt\.Sprintf.*&&\|fmt\.Sprintf.*\|\|" -B 1 -A 1 | head -30

Repository: pdnode-team/cp

Length of output: 40


🏁 Script executed:

# Check if there's any documentation or comments about the filter syntax
rg "filter|Filter" --type go -B 1 -A 1 | grep -E "(//|/\*)" | head -20

Repository: pdnode-team/cp

Length of output: 40


🏁 Script executed:

# Check if there are other filter usages in main.go or if dbx is imported
rg "dbx" main.go

Repository: pdnode-team/cp

Length of output: 235


🏁 Script executed:

# Look for all filter strings to understand the pattern
rg "\&\&" main.go

Repository: pdnode-team/cp

Length of output: 287


🏁 Script executed:

# Check PocketBase documentation via web search or examine if there are type definitions
grep -r "FindFirstRecordByFilter" . 2>/dev/null | head -5

Repository: pdnode-team/cp

Length of output: 130


🏁 Script executed:

# Search for how dbx is actually used in query patterns
rg "Where\(dbx" -B 2 -A 3

Repository: pdnode-team/cp

Length of output: 263


🏁 Script executed:

# Check if there's a go.mod to understand PocketBase version
cat go.mod | grep pocketbase

Repository: pdnode-team/cp

Length of output: 134


🏁 Script executed:

# Search for the actual signature of FindFirstRecordByFilter or related methods
rg "func.*FindFirstRecord|func.*GetOne|func.*FindRecord" --type go -A 2

Repository: pdnode-team/cp

Length of output: 40


🏁 Script executed:

# Look for any imports or examples that show how to use filters safely with PocketBase
rg "RecordFilter|Filter\(" main.go -B 2 -A 2

Repository: pdnode-team/cp

Length of output: 201


🏁 Script executed:

# Check if there's usage of filter with parameters elsewhere
rg "filter.*:|\{:.*\}" -B 1 -A 1

Repository: pdnode-team/cp

Length of output: 3638


🏁 Script executed:

# Check PocketBase filter documentation by looking for examples in the codebase
rg "FindFirstRecordByFilter|FindRecordsByFilter" -B 5 -A 5 | head -60

Repository: pdnode-team/cp

Length of output: 551


🏁 Script executed:

# Look for how PocketBase filters are documented in comments or test files
find . -name "*.go" -type f -exec grep -l "filter" {} \; | xargs grep -l "user\|target" | head -3

Repository: pdnode-team/cp

Length of output: 40


🏁 Script executed:

# Search web documentation for PocketBase FindFirstRecordByFilter API
# to verify if it supports parameterized queries

Repository: pdnode-team/cp

Length of output: 40


🌐 Web query:

PocketBase FindFirstRecordByFilter parameterized queries dbx syntax v0.36

💡 Result:

In PocketBase v0.36 (latest as of 2026-04-21, with releases up to v0.36.8), the FindFirstRecordByFilter method supports parameterized queries using dbx syntax for safe binding of untrusted user input. Syntax (JSVM/Go): app.FindFirstRecordByFilter("posts", "slug = {:slug} && status = 'public'", dbx.Params{"slug": "test"}) - First argument: collection name or model. - Second: filter string with {:paramName} placeholders. - Last (variadic): dbx.Params{key: value} map(s) for binding parameters, preventing SQL injection. This works in both Go backend code and JSVM hooks/routes. Returns sql.ErrNoRows if no match. No breaking changes in v0.36 affecting this; query optimizations were added but syntax remains the same. Alternative for complex queries: use app.RecordQuery(collection).AndWhere(dbx.HashExp{"field": value}) or dbx.NewExp. Example: record := core.NewRecord(collection) $app.dao.recordQuery("posts") .andWhere(dbx.NewExp("slug = {:slug}", dbx.Params{"slug": slug})) .limit(1) .one(record)

Citations:


Add target_collection to the filter to prevent targeting the wrong like record.

The query at line 64 omits target_collection, allowing a CP and character with the same ID to be confused. A user who likes CP with id="X" will have that like deleted instead when attempting to like a character with id="X". Since target_collection is part of the unique index, it must be included in the lookup. Additionally, use parameterized queries instead of string interpolation for targetId from the URL path.

🐛 Proposed fix
 			existingRecord, err := app.FindFirstRecordByFilter("likes",
-				fmt.Sprintf("user = '%s' && target_id = '%s'", userId, targetId),
+				"user = {:userId} && target_id = {:targetId} && target_collection = {:targetCollection}",
+				dbx.Params{
+					"userId":           userId,
+					"targetId":         targetId,
+					"targetCollection": requestCollection,
+				},
 			)
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@main.go` around lines 63 - 65, The filter used in app.FindFirstRecordByFilter
for the "likes" collection omits target_collection and interpolates targetId
directly, which can match the wrong like (e.g., CP vs character) and is unsafe;
update the filter passed to FindFirstRecordByFilter to include
"target_collection = '<collectionName>'" along with user and target_id (matching
the unique index) and switch to a parameterized lookup API or binding mechanism
to pass targetId (and collectionName) as parameters rather than using
fmt.Sprintf string interpolation; locate the call to app.FindFirstRecordByFilter
(variables userId, targetId, collection "likes") and modify it to include
target_collection and use the app/client method that accepts parameters or
placeholders.

Comment on lines +18 to +21
if err := json.Unmarshal([]byte(`{
"listRule": "",
"viewRule": ""
}`), &collection); err != nil {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🏁 Script executed:

cd /tmp && find . -name "*migrations*" -type d 2>/dev/null | head -5

Repository: pdnode-team/cp

Length of output: 40


🏁 Script executed:

# Check if the likes migration file exists
find . -name "1776728364_updated_likes.go" 2>/dev/null

Repository: pdnode-team/cp

Length of output: 98


🏁 Script executed:

# Also search for the file more broadly
find . -name "*likes*" -type f 2>/dev/null | grep -E "\.(go|json)$"

Repository: pdnode-team/cp

Length of output: 221


🏁 Script executed:

# Check the repo structure
git ls-files | head -20

Repository: pdnode-team/cp

Length of output: 623


🏁 Script executed:

# Read the full migration file to understand the context
cat -n ./migrations/1776728364_updated_likes.go

Repository: pdnode-team/cp

Length of output: 1165


🏁 Script executed:

# Also check the initial creation of likes to see if access was always public
cat -n ./migrations/1776715443_created_likes.go

Repository: pdnode-team/cp

Length of output: 3469


🏁 Script executed:

# Check other migration files to see the pattern for collections
cat -n ./migrations/1776131344_created_cps.go | head -40

Repository: pdnode-team/cp

Length of output: 1250


🏁 Script executed:

# Check if there's documentation about access rules
find . -name "*.md" -o -name "*.txt" | xargs grep -l "likes\|viewRule\|listRule" 2>/dev/null

Repository: pdnode-team/cp

Length of output: 42


🏁 Script executed:

# Look for any schema documentation or comments in main config
cat -n ./config.go | head -50

Repository: pdnode-team/cp

Length of output: 1335


🏁 Script executed:

# Check other migrations to see access patterns in other collections
rg "listRule\|viewRule" migrations/ -A 2 -B 2 | head -80

Repository: pdnode-team/cp

Length of output: 40


🏁 Script executed:

# Look for any explicit justification or comments about public likes
git log --oneline migrations/1776728364_updated_likes.go 2>/dev/null | head -5

Repository: pdnode-team/cp

Length of output: 88


🏁 Script executed:

# Check if there are any other recent migrations to understand intent
ls -la migrations/ | tail -20

Repository: pdnode-team/cp

Length of output: 1581


🏁 Script executed:

# Check the intermediate migrations to see the progression of access rules
cat -n ./migrations/1776715655_updated_likes.go

Repository: pdnode-team/cp

Length of output: 1755


🏁 Script executed:

# Check the next intermediate migration
cat -n ./migrations/1776715948_updated_likes.go

Repository: pdnode-team/cp

Length of output: 1672


🏁 Script executed:

# Check the users migration to see if there's documentation about why viewRule was made public
cat -n ./migrations/1776571426_updated_users.go

Repository: pdnode-team/cp

Length of output: 1132


🏁 Script executed:

# Check if there's a frontend or documentation explaining public likes access
find . -name "*.js" -o -name "*.ts" -o -name "*.md" | xargs grep -l "likes" 2>/dev/null | head -5

Repository: pdnode-team/cp

Length of output: 40


🏁 Script executed:

# Check git history to see if there's a commit message explaining the change to public likes
git log --all --oneline -- migrations/1776728364_updated_likes.go 2>/dev/null

Repository: pdnode-team/cp

Length of output: 88


🏁 Script executed:

# Check the full git log for the likes migrations to understand the progression
git log --all --oneline -- migrations/*likes* 2>/dev/null

Repository: pdnode-team/cp

Length of output: 88


🏁 Script executed:

# Look for any README or documentation about the app's architecture
cat -n ./README.md 2>/dev/null | head -50

Repository: pdnode-team/cp

Length of output: 40


Restrict likes to authenticated owner only.

Setting both listRule and viewRule to empty strings exposes all likes records—including the mappings between users and their liked targets—to unauthenticated clients. Unlike the public user directory, exposure of individual user preferences poses a privacy concern. If the UI only requires displaying the current user's likes, restrict reads to authenticated owner access.

🔒 Proposed rule tightening
-			"listRule": "",
-			"viewRule": ""
+			"listRule": "@request.auth.id != \"\" && user = `@request.auth.id`",
+			"viewRule": "@request.auth.id != \"\" && user = `@request.auth.id`"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
if err := json.Unmarshal([]byte(`{
"listRule": "",
"viewRule": ""
}`), &collection); err != nil {
if err := json.Unmarshal([]byte(`{
"listRule": "@request.auth.id != \"\" && user = `@request.auth.id`",
"viewRule": "@request.auth.id != \"\" && user = `@request.auth.id`"
}`), &collection); err != nil {
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@migrations/1776728364_updated_likes.go` around lines 18 - 21, The current
json.Unmarshal that sets collection.listRule and collection.viewRule to empty
strings leaves likes public; update that JSON so the "listRule" and "viewRule"
enforce authenticated-owner access (e.g., require request.auth.id equals the
like owner field) instead of empty strings. Locate the json.Unmarshal(...)
assignment to the collection variable in migrations/1776728364_updated_likes.go
and replace the empty string values for "listRule" and "viewRule" with rules
that check for an authenticated user and match the like record's owner id (use
the same owner field name used elsewhere in the likes collection).

Comment on lines +91 to +124
<div
role="link"
tabindex="0"
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && goto(`/${mode}/${item.id}`)}
aria-label={`View ${item.name}'s Details'`}
class="card cursor-pointer border border-base-200 bg-base-100 shadow-xl transition-all hover:bg-base-300 active:scale-95"
onclick={() => goto(`/${mode}/${item.id}`)}
>
<figure class="h-48 bg-base-300">
{#if item.images[0]}
<img
src={pb.files.getURL(item, item.images[0], { thumb: '400x300' })}
alt={item.name}
class="h-full w-full object-cover"
/>
src={pb.files.getURL(item, item.images[0], { thumb: '400x300' })}
alt={item.name}
class="h-full w-full object-cover"
/>
{:else}
<p>{item.name}</p>
<p>{item.name}</p>
{/if}

</figure>
<div class="card-body p-5">
<h2 class="card-title">
{item.name}
<div class="badge {mode === 'cps' ? 'badge-secondary' : 'badge-accent'}">
{mode === 'cps' ? 'CP' : 'Character'}
</div>
</h2>
{#if mode === 'cps'}
<div class="flex gap-1">
{#each item.expand?.characters || [] as c}
<span class="badge badge-ghost text-xs">{c.name}</span>
{/each}
</div>
{/if}
<p class="line-clamp-2 text-sm opacity-70">{item.description}</p>
<div class="card-body flex-row items-center justify-between p-5">
<div class="flex-1">
<h2 class="card-title">
{item.name}
<div class="badge {mode === 'cps' ? 'badge-secondary' : 'badge-accent'}">
{mode === 'cps' ? 'CP' : 'Character'}
</div>
</h2>
</div>

<button
onclick={(e) => {
e.stopPropagation();
toggleLike(item);
}}
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Stop keyboard events from the like button from navigating the card.

The parent card handles Enter/Space on Line 94, but the button only stops click propagation. When the like button has focus, keyboard activation can bubble to the card and navigate instead of just toggling the like.

♿ Proposed fix
 						<button
+							onkeydown={(e) => {
+								if (e.key === 'Enter' || e.key === ' ') {
+									e.stopPropagation();
+								}
+							}}
 							onclick={(e) => {
 								e.stopPropagation();
 								toggleLike(item);
 							}}
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<div
role="link"
tabindex="0"
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && goto(`/${mode}/${item.id}`)}
aria-label={`View ${item.name}'s Details'`}
class="card cursor-pointer border border-base-200 bg-base-100 shadow-xl transition-all hover:bg-base-300 active:scale-95"
onclick={() => goto(`/${mode}/${item.id}`)}
>
<figure class="h-48 bg-base-300">
{#if item.images[0]}
<img
src={pb.files.getURL(item, item.images[0], { thumb: '400x300' })}
alt={item.name}
class="h-full w-full object-cover"
/>
src={pb.files.getURL(item, item.images[0], { thumb: '400x300' })}
alt={item.name}
class="h-full w-full object-cover"
/>
{:else}
<p>{item.name}</p>
<p>{item.name}</p>
{/if}
</figure>
<div class="card-body p-5">
<h2 class="card-title">
{item.name}
<div class="badge {mode === 'cps' ? 'badge-secondary' : 'badge-accent'}">
{mode === 'cps' ? 'CP' : 'Character'}
</div>
</h2>
{#if mode === 'cps'}
<div class="flex gap-1">
{#each item.expand?.characters || [] as c}
<span class="badge badge-ghost text-xs">{c.name}</span>
{/each}
</div>
{/if}
<p class="line-clamp-2 text-sm opacity-70">{item.description}</p>
<div class="card-body flex-row items-center justify-between p-5">
<div class="flex-1">
<h2 class="card-title">
{item.name}
<div class="badge {mode === 'cps' ? 'badge-secondary' : 'badge-accent'}">
{mode === 'cps' ? 'CP' : 'Character'}
</div>
</h2>
</div>
<button
onclick={(e) => {
e.stopPropagation();
toggleLike(item);
}}
<div
role="link"
tabindex="0"
onkeydown={(e) => (e.key === 'Enter' || e.key === ' ') && goto(`/${mode}/${item.id}`)}
aria-label={`View ${item.name}'s Details'`}
class="card cursor-pointer border border-base-200 bg-base-100 shadow-xl transition-all hover:bg-base-300 active:scale-95"
onclick={() => goto(`/${mode}/${item.id}`)}
>
<figure class="h-48 bg-base-300">
{`#if` item.images[0]}
<img
src={pb.files.getURL(item, item.images[0], { thumb: '400x300' })}
alt={item.name}
class="h-full w-full object-cover"
/>
{:else}
<p>{item.name}</p>
{/if}
</figure>
<div class="card-body flex-row items-center justify-between p-5">
<div class="flex-1">
<h2 class="card-title">
{item.name}
<div class="badge {mode === 'cps' ? 'badge-secondary' : 'badge-accent'}">
{mode === 'cps' ? 'CP' : 'Character'}
</div>
</h2>
</div>
<button
onkeydown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.stopPropagation();
}
}}
onclick={(e) => {
e.stopPropagation();
toggleLike(item);
}}
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@website/src/routes/explore/`+page.svelte around lines 91 - 124, The like
button is still allowing keyboard activation to bubble up to the parent card
(which has the onkeydown handler that triggers navigation); update the button
that calls toggleLike(item) to stop keyboard event propagation as well—add an
on:keydown/onkeydown handler on the same button element to call
e.stopPropagation() for Enter and Space (or for all key events) so keyboard
activation toggles the like without triggering the parent card's onkeydown, and
keep the existing click handler that stops propagation; reference the button
element and the toggleLike function as the places to change.

Comment on lines +120 to +131
<button
onclick={(e) => {
e.stopPropagation();
toggleLike(item);
}}
aria-label="like"
title="like"
class="btn btn-circle btn-ghost"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 {likedTargetIds.has(item.id) ? 'fill-current' : ''} text-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.

⚠️ Potential issue | 🟡 Minor

Expose the like toggle state to assistive tech.

The filled heart is visual-only. Add aria-pressed and switch the label/title between Like and Unlike so screen-reader users can tell the current state.

♿ Proposed fix
 						<button
@@
-							aria-label="like"
-							title="like"
+							aria-pressed={likedTargetIds.has(item.id)}
+							aria-label={likedTargetIds.has(item.id) ? 'Unlike' : 'Like'}
+							title={likedTargetIds.has(item.id) ? 'Unlike' : 'Like'}
 							class="btn btn-circle btn-ghost"
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
<button
onclick={(e) => {
e.stopPropagation();
toggleLike(item);
}}
aria-label="like"
title="like"
class="btn btn-circle btn-ghost"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 {likedTargetIds.has(item.id) ? 'fill-current' : ''} text-error"
<button
onclick={(e) => {
e.stopPropagation();
toggleLike(item);
}}
aria-pressed={likedTargetIds.has(item.id)}
aria-label={likedTargetIds.has(item.id) ? 'Unlike' : 'Like'}
title={likedTargetIds.has(item.id) ? 'Unlike' : 'Like'}
class="btn btn-circle btn-ghost"
>
<svg
xmlns="http://www.w3.org/2000/svg"
class="h-6 w-6 {likedTargetIds.has(item.id) ? 'fill-current' : ''} text-error"
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@website/src/routes/explore/`+page.svelte around lines 120 - 131, Update the
like button to expose state to assistive tech by using the liked state from
likedTargetIds for the button attributes: set aria-pressed to
likedTargetIds.has(item.id) and change aria-label and title to either "Unlike"
when likedTargetIds.has(item.id) is true or "Like" when false; keep the existing
toggleLike(item) call in the click handler and ensure the same item.id and
likedTargetIds symbols are used to determine state for these attributes.

@BretRen BretRen merged commit 0145170 into main Apr 21, 2026
1 check passed
@BretRen BretRen deleted the feat/likes branch April 21, 2026 00:32
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

feat: Like

1 participant