Compare commits

...

8 Commits

Author SHA1 Message Date
woikos
cb50a9c5c4 feat(curating): add user event viewer and delete functionality (v0.50.0)
Some checks are pending
Go / build-and-release (push) Waiting to run
- Add geteventsforpubkey API method for viewing user events with pagination
- Add deleteeventsforpubkey API method to purge blacklisted user events
- Add clickable user detail view in curation UI showing all events
- Add event content expansion/truncation for long content
- Add kind name display for common Nostr event types
- Implement safety check requiring blacklist before event deletion

Files modified:
- app/handle-nip86-curating.go: Add event fetch/delete handlers
- pkg/database/curating-acl.go: Add GetEventsForPubkey, DeleteEventsForPubkey
- app/web/src/CurationView.svelte: Add user detail view with event listing
- pkg/version/version: Bump to v0.50.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 19:19:33 +01:00
woikos
c5be98bcaa fix(curating): correct pubkey hash computation and UI field name
- Fix countEventsForPubkey to use SHA256 hash of pubkey (first 8 bytes)
  matching the PubHash type used in the Pubkey index
- Fix UI to use event_count field instead of total_events

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:40:31 +01:00
woikos
417866ebf4 feat(curating-ui): add Scan Database button to unclassified users tab
- Adds "Scan Database" button that calls the scanpubkeys API
- Shows results with total pubkeys, events, and skipped count
- Automatically refreshes the unclassified users list after scan

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:33:43 +01:00
woikos
0e87337723 feat(curating): add scanpubkeys API method to retroactively populate unclassified users
- Add ScanAllPubkeys method to scan SerialPubkey index for all pubkeys
- Count events for each pubkey using the Pubkey index
- Store event counts in CURATING_ACL_EVENT_COUNT_ prefix
- Add NIP-86 "scanpubkeys" API endpoint to trigger the scan

This allows the curation UI to show all existing users in the unclassified
list, even if they had events before curating mode was enabled.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:26:31 +01:00
woikos
b10851c209 fix: add frontend-compatible aliases in getcuratingconfig response
The frontend expected 'categories' and 'custom_kinds' but the backend
returned 'kind_categories' and 'allowed_kinds'. Add aliases for both
naming conventions to ensure frontend compatibility.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 18:18:31 +01:00
woikos
e68916ca5d Fix Neo4j tag filter returning all events instead of filtering (v0.49.2)
Some checks are pending
Go / build-and-release (push) Waiting to run
- Change OPTIONAL MATCH to EXISTS subquery for tag filtering in Neo4j
  - OPTIONAL MATCH returned rows even when tags didn't match (NULL values)
  - EXISTS subquery correctly requires matching tags to exist
- Strip "#" prefix from filter tag types before matching
  - Filters use "#d", "#p", "#e" but events store tags without prefix
- Add trace-level logging for Neo4j query debugging
- Add comprehensive tests for Neo4j query builder
- Clean up temporary debug logging from handle-req.go

Files modified:
- pkg/neo4j/query-events.go: Fix tag filtering with EXISTS subquery
- pkg/neo4j/query-events_test.go: Add query builder tests
- app/handle-req.go: Remove debug logging
- pkg/version/version: Bump to v0.49.2

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 17:49:46 +01:00
woikos
0e30f7a697 feat: add NIP-99/Gamma Markets kind categories for curating mode
Add marketplace_nip99 category with Plebeian Market event kinds:
- 30402 (Products)
- 30403 (Orders - legacy)
- 30405 (Collections)
- 30406 (Shipping options)
- 31555 (Product reviews)

Add order_communication category for Gamma Markets (kinds 16, 17).

Rename existing marketplace category to marketplace_nip15 for clarity
while keeping backward compatibility with the legacy alias.

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 17:16:57 +01:00
woikos
a0af5bb45e Fix Neo4j query returning zero events for REQ filters (v0.49.1)
Some checks are pending
Go / build-and-release (push) Waiting to run
- Fix zero-value timestamp filter bug: since/until with value 0 were
  being added as WHERE clauses, causing queries to match no events
- Fix event parsing: use direct slice assignment instead of copy() on
  nil slices for ID, Pubkey, and Sig fields

Files modified:
- pkg/neo4j/query-events.go: Fix buildCypherQuery and parseEventsFromResult

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-01-14 10:56:31 +01:00
11 changed files with 1587 additions and 83 deletions

View File

@@ -143,6 +143,12 @@ func (s *Server) handleCuratingNIP86Method(request NIP86Request, curatingACL *ac
return s.handleUnblockCuratingIP(request.Params, dbACL)
case "isconfigured":
return s.handleIsConfigured(dbACL)
case "scanpubkeys":
return s.handleScanPubkeys(dbACL)
case "geteventsforpubkey":
return s.handleGetEventsForPubkey(request.Params, dbACL)
case "deleteeventsforpubkey":
return s.handleDeleteEventsForPubkey(request.Params, dbACL)
default:
return NIP86Response{Error: "Unknown method: " + request.Method}
}
@@ -167,6 +173,9 @@ func (s *Server) handleCuratingSupportedMethods() NIP86Response {
"listblockedips",
"unblockip",
"isconfigured",
"scanpubkeys",
"geteventsforpubkey",
"deleteeventsforpubkey",
}
return NIP86Response{Result: methods}
}
@@ -444,8 +453,11 @@ func (s *Server) handleGetCuratingConfig(dbACL *database.CuratingACL) NIP86Respo
"first_ban_hours": config.FirstBanHours,
"second_ban_hours": config.SecondBanHours,
"allowed_kinds": config.AllowedKinds,
"custom_kinds": config.AllowedKinds, // Alias for frontend compatibility
"allowed_ranges": config.AllowedRanges,
"kind_ranges": config.AllowedRanges, // Alias for frontend compatibility
"kind_categories": config.KindCategories,
"categories": config.KindCategories, // Alias for frontend compatibility
"config_event_id": config.ConfigEventID,
"config_pubkey": config.ConfigPubkey,
"configured_at": config.ConfiguredAt,
@@ -531,11 +543,23 @@ func GetKindCategoriesInfo() []map[string]interface{} {
"kinds": []int{1063, 20, 21, 22},
},
{
"id": "marketplace",
"name": "Marketplace",
"description": "Product listings, stalls, auctions",
"id": "marketplace_nip15",
"name": "Marketplace (NIP-15)",
"description": "Legacy NIP-15 stalls and products",
"kinds": []int{30017, 30018, 30019, 30020, 1021, 1022},
},
{
"id": "marketplace_nip99",
"name": "Marketplace (NIP-99/Gamma)",
"description": "NIP-99 classified listings, collections, shipping, reviews (Plebeian Market)",
"kinds": []int{30402, 30403, 30405, 30406, 31555},
},
{
"id": "order_communication",
"name": "Order Communication",
"description": "Gamma Markets order messages and payment receipts",
"kinds": []int{16, 17},
},
{
"id": "groups_nip29",
"name": "Group Messaging (NIP-29)",
@@ -591,3 +615,122 @@ func parseRange(s string, parts []int) (int, error) {
}
return 0, nil
}
// handleScanPubkeys scans the database for all pubkeys and populates event counts
// This is used to retroactively populate the unclassified users list
func (s *Server) handleScanPubkeys(dbACL *database.CuratingACL) NIP86Response {
result, err := dbACL.ScanAllPubkeys()
if chk.E(err) {
return NIP86Response{Error: "Failed to scan pubkeys: " + err.Error()}
}
return NIP86Response{Result: map[string]interface{}{
"total_pubkeys": result.TotalPubkeys,
"total_events": result.TotalEvents,
"skipped": result.Skipped,
}}
}
// handleGetEventsForPubkey returns events for a specific pubkey
// Params: [pubkey, limit (optional, default 100), offset (optional, default 0)]
func (s *Server) handleGetEventsForPubkey(params []interface{}, dbACL *database.CuratingACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: pubkey"}
}
pubkey, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid pubkey parameter"}
}
if len(pubkey) != 64 {
return NIP86Response{Error: "Invalid pubkey format (must be 64 hex characters)"}
}
// Parse optional limit (default 100)
limit := 100
if len(params) > 1 {
if l, ok := params[1].(float64); ok {
limit = int(l)
if limit > 500 {
limit = 500 // Cap at 500
}
if limit < 1 {
limit = 1
}
}
}
// Parse optional offset (default 0)
offset := 0
if len(params) > 2 {
if o, ok := params[2].(float64); ok {
offset = int(o)
if offset < 0 {
offset = 0
}
}
}
events, total, err := dbACL.GetEventsForPubkey(pubkey, limit, offset)
if chk.E(err) {
return NIP86Response{Error: "Failed to get events: " + err.Error()}
}
// Convert to response format
eventList := make([]map[string]interface{}, len(events))
for i, ev := range events {
eventList[i] = map[string]interface{}{
"id": ev.ID,
"kind": ev.Kind,
"content": ev.Content,
"created_at": ev.CreatedAt,
}
}
return NIP86Response{Result: map[string]interface{}{
"events": eventList,
"total": total,
"limit": limit,
"offset": offset,
}}
}
// handleDeleteEventsForPubkey deletes all events for a specific pubkey
// This is only allowed for blacklisted pubkeys as a safety measure
// Params: [pubkey]
func (s *Server) handleDeleteEventsForPubkey(params []interface{}, dbACL *database.CuratingACL) NIP86Response {
if len(params) < 1 {
return NIP86Response{Error: "Missing required parameter: pubkey"}
}
pubkey, ok := params[0].(string)
if !ok {
return NIP86Response{Error: "Invalid pubkey parameter"}
}
if len(pubkey) != 64 {
return NIP86Response{Error: "Invalid pubkey format (must be 64 hex characters)"}
}
// Safety check: only allow deletion of events from blacklisted users
isBlacklisted, err := dbACL.IsPubkeyBlacklisted(pubkey)
if chk.E(err) {
return NIP86Response{Error: "Failed to check blacklist status: " + err.Error()}
}
if !isBlacklisted {
return NIP86Response{Error: "Can only delete events from blacklisted users. Blacklist the user first."}
}
// Delete all events for this pubkey
deleted, err := dbACL.DeleteEventsForPubkey(pubkey)
if chk.E(err) {
return NIP86Response{Error: "Failed to delete events: " + err.Error()}
}
return NIP86Response{Result: map[string]interface{}{
"deleted": deleted,
"pubkey": pubkey,
}}
}

View File

@@ -34,7 +34,6 @@ import (
func (l *Listener) HandleReq(msg []byte) (err error) {
log.D.F("handling REQ: %s", msg)
log.T.F("HandleReq: START processing from %s", l.remote)
// var rem []byte
env := reqenvelope.New()
if _, err = env.Unmarshal(msg); chk.E(err) {

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -13,6 +13,15 @@
let messageType = "info";
let isConfigured = false;
// User detail view state
let selectedUser = null;
let selectedUserType = null; // "trusted", "blacklisted", or "unclassified"
let userEvents = [];
let userEventsTotal = 0;
let userEventsOffset = 0;
let loadingEvents = false;
let expandedEvents = {}; // Track which events are expanded
// Configuration state
let config = {
daily_limit: 50,
@@ -186,6 +195,19 @@
}
}
// Scan database for all pubkeys
async function scanDatabase() {
try {
const result = await callNIP86API("scanpubkeys");
showMessage(`Database scanned: ${result.total_pubkeys} pubkeys, ${result.total_events} events (${result.skipped} skipped)`, "success");
// Refresh the unclassified users list
await loadUnclassifiedUsers();
} catch (error) {
console.error("Failed to scan database:", error);
showMessage("Failed to scan database: " + error.message, "error");
}
}
// Load spam events
async function loadSpamEvents() {
try {
@@ -430,6 +452,176 @@
if (!timestamp) return "";
return new Date(timestamp).toLocaleString();
}
// Show message helper
function showMessage(msg, type = "info") {
message = msg;
messageType = type;
}
// Open user detail view
async function openUserDetail(pubkey, type) {
console.log("openUserDetail called:", pubkey, type);
selectedUser = pubkey;
selectedUserType = type;
userEvents = [];
userEventsTotal = 0;
userEventsOffset = 0;
expandedEvents = {};
console.log("selectedUser set to:", selectedUser);
await loadUserEvents();
}
// Close user detail view
function closeUserDetail() {
selectedUser = null;
selectedUserType = null;
userEvents = [];
userEventsTotal = 0;
userEventsOffset = 0;
expandedEvents = {};
}
// Load events for selected user
async function loadUserEvents() {
console.log("loadUserEvents called, selectedUser:", selectedUser, "loadingEvents:", loadingEvents);
if (!selectedUser || loadingEvents) return;
try {
loadingEvents = true;
console.log("Calling geteventsforpubkey API...");
const result = await callNIP86API("geteventsforpubkey", [selectedUser, 100, userEventsOffset]);
console.log("API result:", result);
if (result) {
if (userEventsOffset === 0) {
userEvents = result.events || [];
} else {
userEvents = [...userEvents, ...(result.events || [])];
}
userEventsTotal = result.total || 0;
}
} catch (error) {
console.error("Failed to load user events:", error);
showMessage("Failed to load events: " + error.message, "error");
} finally {
loadingEvents = false;
}
}
// Load more events
async function loadMoreEvents() {
userEventsOffset = userEvents.length;
await loadUserEvents();
}
// Toggle event expansion
function toggleEventExpansion(eventId) {
expandedEvents = {
...expandedEvents,
[eventId]: !expandedEvents[eventId]
};
}
// Truncate content to 6 lines (approximately 300 chars per line)
function truncateContent(content, maxLines = 6) {
if (!content) return "";
const lines = content.split('\n');
if (lines.length <= maxLines && content.length <= maxLines * 100) {
return content;
}
// Truncate by lines or characters, whichever is smaller
let truncated = lines.slice(0, maxLines).join('\n');
if (truncated.length > maxLines * 100) {
truncated = truncated.substring(0, maxLines * 100);
}
return truncated;
}
// Check if content is truncated
function isContentTruncated(content, maxLines = 6) {
if (!content) return false;
const lines = content.split('\n');
return lines.length > maxLines || content.length > maxLines * 100;
}
// Trust user from detail view and refresh
async function trustUserFromDetail() {
await trustPubkey(selectedUser, "");
// Refresh list and go back
await loadAllData();
closeUserDetail();
}
// Blacklist user from detail view and refresh
async function blacklistUserFromDetail() {
await blacklistPubkey(selectedUser, "");
// Refresh list and go back
await loadAllData();
closeUserDetail();
}
// Untrust user from detail view and refresh
async function untrustUserFromDetail() {
await untrustPubkey(selectedUser);
await loadAllData();
closeUserDetail();
}
// Unblacklist user from detail view and refresh
async function unblacklistUserFromDetail() {
await unblacklistPubkey(selectedUser);
await loadAllData();
closeUserDetail();
}
// Delete all events for a blacklisted user
async function deleteAllEventsForUser() {
if (!confirm(`Delete ALL ${userEventsTotal} events from this user? This cannot be undone.`)) {
return;
}
try {
isLoading = true;
const result = await callNIP86API("deleteeventsforpubkey", [selectedUser]);
showMessage(`Deleted ${result.deleted} events`, "success");
// Refresh the events list
userEvents = [];
userEventsTotal = 0;
userEventsOffset = 0;
await loadUserEvents();
} catch (error) {
console.error("Failed to delete events:", error);
showMessage("Failed to delete events: " + error.message, "error");
} finally {
isLoading = false;
}
}
// Get kind name
function getKindName(kind) {
const kindNames = {
0: "Metadata",
1: "Text Note",
3: "Follow List",
4: "Encrypted DM",
6: "Repost",
7: "Reaction",
14: "Chat Message",
16: "Order Message",
17: "Payment Receipt",
1063: "File Metadata",
10002: "Relay List",
30017: "Stall",
30018: "Product (NIP-15)",
30023: "Long-form",
30078: "App Data",
30402: "Product (NIP-99)",
30405: "Collection",
30406: "Shipping",
31555: "Review",
};
return kindNames[kind] || `Kind ${kind}`;
}
</script>
<div class="curation-view">
@@ -532,29 +724,97 @@
</div>
</div>
{:else}
<!-- Active Mode -->
<div class="tabs">
<button class="tab" class:active={activeTab === "trusted"} on:click={() => activeTab = "trusted"}>
Trusted ({trustedPubkeys.length})
</button>
<button class="tab" class:active={activeTab === "blacklist"} on:click={() => activeTab = "blacklist"}>
Blacklist ({blacklistedPubkeys.length})
</button>
<button class="tab" class:active={activeTab === "unclassified"} on:click={() => activeTab = "unclassified"}>
Unclassified ({unclassifiedUsers.length})
</button>
<button class="tab" class:active={activeTab === "spam"} on:click={() => activeTab = "spam"}>
Spam ({spamEvents.length})
</button>
<button class="tab" class:active={activeTab === "ips"} on:click={() => activeTab = "ips"}>
Blocked IPs ({blockedIPs.length})
</button>
<button class="tab" class:active={activeTab === "settings"} on:click={() => activeTab = "settings"}>
Settings
</button>
</div>
<!-- User Detail View -->
{#if selectedUser}
<div class="user-detail-view">
<div class="detail-header">
<div class="detail-header-left">
<button class="back-btn" on:click={closeUserDetail}>
&larr; Back
</button>
<h3>User Events</h3>
<span class="detail-pubkey" title={selectedUser}>{formatPubkey(selectedUser)}</span>
<span class="detail-count">{userEventsTotal} events</span>
</div>
<div class="detail-header-right">
{#if selectedUserType === "trusted"}
<button class="btn-danger" on:click={untrustUserFromDetail}>Remove Trust</button>
<button class="btn-danger" on:click={blacklistUserFromDetail}>Blacklist</button>
{:else if selectedUserType === "blacklisted"}
<button class="btn-delete-all" on:click={deleteAllEventsForUser} disabled={isLoading || userEventsTotal === 0}>
Delete All Events
</button>
<button class="btn-success" on:click={unblacklistUserFromDetail}>Remove from Blacklist</button>
<button class="btn-success" on:click={trustUserFromDetail}>Trust</button>
{:else}
<button class="btn-success" on:click={trustUserFromDetail}>Trust</button>
<button class="btn-danger" on:click={blacklistUserFromDetail}>Blacklist</button>
{/if}
</div>
</div>
<div class="tab-content">
<div class="events-list">
{#if loadingEvents && userEvents.length === 0}
<div class="loading">Loading events...</div>
{:else if userEvents.length === 0}
<div class="empty">No events found for this user.</div>
{:else}
{#each userEvents as event}
<div class="event-item">
<div class="event-header">
<span class="event-kind">{getKindName(event.kind)}</span>
<span class="event-id" title={event.id}>{formatPubkey(event.id)}</span>
<span class="event-time">{formatDate(event.created_at * 1000)}</span>
</div>
<div class="event-content" class:expanded={expandedEvents[event.id]}>
{#if expandedEvents[event.id] || !isContentTruncated(event.content)}
<pre>{event.content || "(empty)"}</pre>
{:else}
<pre>{truncateContent(event.content)}...</pre>
{/if}
</div>
{#if isContentTruncated(event.content)}
<button class="expand-btn" on:click={() => toggleEventExpansion(event.id)}>
{expandedEvents[event.id] ? "Show less" : "Show more"}
</button>
{/if}
</div>
{/each}
{#if userEvents.length < userEventsTotal}
<div class="load-more">
<button on:click={loadMoreEvents} disabled={loadingEvents}>
{loadingEvents ? "Loading..." : `Load more (${userEvents.length} of ${userEventsTotal})`}
</button>
</div>
{/if}
{/if}
</div>
</div>
{:else}
<!-- Active Mode -->
<div class="tabs">
<button class="tab" class:active={activeTab === "trusted"} on:click={() => activeTab = "trusted"}>
Trusted ({trustedPubkeys.length})
</button>
<button class="tab" class:active={activeTab === "blacklist"} on:click={() => activeTab = "blacklist"}>
Blacklist ({blacklistedPubkeys.length})
</button>
<button class="tab" class:active={activeTab === "unclassified"} on:click={() => activeTab = "unclassified"}>
Unclassified ({unclassifiedUsers.length})
</button>
<button class="tab" class:active={activeTab === "spam"} on:click={() => activeTab = "spam"}>
Spam ({spamEvents.length})
</button>
<button class="tab" class:active={activeTab === "ips"} on:click={() => activeTab = "ips"}>
Blocked IPs ({blockedIPs.length})
</button>
<button class="tab" class:active={activeTab === "settings"} on:click={() => activeTab = "settings"}>
Settings
</button>
</div>
<div class="tab-content">
{#if activeTab === "trusted"}
<div class="section">
<h3>Trusted Publishers</h3>
@@ -579,7 +839,7 @@
<div class="list">
{#if trustedPubkeys.length > 0}
{#each trustedPubkeys as item}
<div class="list-item">
<div class="list-item clickable" on:click={() => openUserDetail(item.pubkey, "trusted")}>
<div class="item-main">
<span class="pubkey" title={item.pubkey}>{formatPubkey(item.pubkey)}</span>
{#if item.note}
@@ -587,7 +847,7 @@
{/if}
</div>
<div class="item-actions">
<button class="btn-danger" on:click={() => untrustPubkey(item.pubkey)}>
<button class="btn-danger" on:click|stopPropagation={() => untrustPubkey(item.pubkey)}>
Remove
</button>
</div>
@@ -624,7 +884,7 @@
<div class="list">
{#if blacklistedPubkeys.length > 0}
{#each blacklistedPubkeys as item}
<div class="list-item">
<div class="list-item clickable" on:click={() => openUserDetail(item.pubkey, "blacklisted")}>
<div class="item-main">
<span class="pubkey" title={item.pubkey}>{formatPubkey(item.pubkey)}</span>
{#if item.reason}
@@ -632,7 +892,7 @@
{/if}
</div>
<div class="item-actions">
<button class="btn-success" on:click={() => unblacklistPubkey(item.pubkey)}>
<button class="btn-success" on:click|stopPropagation={() => unblacklistPubkey(item.pubkey)}>
Remove
</button>
</div>
@@ -650,23 +910,28 @@
<h3>Unclassified Users</h3>
<p class="help-text">Users who have posted events but haven't been classified. Sorted by event count.</p>
<button class="refresh-btn" on:click={loadUnclassifiedUsers} disabled={isLoading}>
Refresh
</button>
<div class="button-row">
<button class="refresh-btn" on:click={loadUnclassifiedUsers} disabled={isLoading}>
Refresh
</button>
<button class="scan-btn" on:click={scanDatabase} disabled={isLoading}>
Scan Database
</button>
</div>
<div class="list">
{#if unclassifiedUsers.length > 0}
{#each unclassifiedUsers as user}
<div class="list-item">
<div class="list-item clickable" on:click={() => openUserDetail(user.pubkey, "unclassified")}>
<div class="item-main">
<span class="pubkey" title={user.pubkey}>{formatPubkey(user.pubkey)}</span>
<span class="event-count">{user.total_events} events</span>
<span class="event-count">{user.event_count} events</span>
</div>
<div class="item-actions">
<button class="btn-success" on:click={() => trustPubkey(user.pubkey, "")}>
<button class="btn-success" on:click|stopPropagation={() => trustPubkey(user.pubkey, "")}>
Trust
</button>
<button class="btn-danger" on:click={() => blacklistPubkey(user.pubkey, "")}>
<button class="btn-danger" on:click|stopPropagation={() => blacklistPubkey(user.pubkey, "")}>
Blacklist
</button>
</div>
@@ -840,6 +1105,7 @@
</div>
{/if}
</div>
{/if}
{/if}
</div>
@@ -1149,6 +1415,26 @@
cursor: not-allowed;
}
.button-row {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
.scan-btn {
padding: 0.5rem 1rem;
background: var(--warning, #f0ad4e);
color: var(--text-color);
border: none;
border-radius: 4px;
cursor: pointer;
}
.scan-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.list {
border: 1px solid var(--border-color);
border-radius: 4px;
@@ -1222,6 +1508,26 @@
font-size: 0.85em;
}
.btn-delete-all {
padding: 0.35rem 0.75rem;
background: #8B0000;
color: white;
border: none;
border-radius: 4px;
cursor: pointer;
font-size: 0.85em;
font-weight: 600;
}
.btn-delete-all:hover:not(:disabled) {
background: #660000;
}
.btn-delete-all:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.empty {
padding: 2rem;
text-align: center;
@@ -1229,4 +1535,187 @@
opacity: 0.6;
font-style: italic;
}
/* Clickable list items */
.list-item.clickable {
cursor: pointer;
transition: background-color 0.2s;
}
.list-item.clickable:hover {
background-color: var(--button-hover-bg);
}
/* User Detail View */
.user-detail-view {
background: var(--card-bg);
border-radius: 8px;
padding: 1.5em;
border: 1px solid var(--border-color);
}
.detail-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 1px solid var(--border-color);
flex-wrap: wrap;
gap: 1rem;
}
.detail-header-left {
display: flex;
align-items: center;
gap: 1rem;
flex-wrap: wrap;
}
.detail-header-left h3 {
margin: 0;
color: var(--text-color);
}
.detail-header-right {
display: flex;
gap: 0.5rem;
}
.back-btn {
padding: 0.5rem 1rem;
background: var(--bg-color);
color: var(--text-color);
border: 1px solid var(--border-color);
border-radius: 4px;
cursor: pointer;
font-size: 0.9em;
}
.back-btn:hover {
background: var(--button-hover-bg);
}
.detail-pubkey {
font-family: monospace;
font-size: 0.9em;
color: var(--text-color);
background: var(--bg-color);
padding: 0.25rem 0.5rem;
border-radius: 4px;
}
.detail-count {
font-size: 0.85em;
color: var(--success);
font-weight: 500;
}
/* Events List */
.events-list {
max-height: 600px;
overflow-y: auto;
}
.event-item {
background: var(--bg-color);
border: 1px solid var(--border-color);
border-radius: 6px;
padding: 1rem;
margin-bottom: 0.75rem;
}
.event-header {
display: flex;
gap: 1rem;
margin-bottom: 0.5rem;
flex-wrap: wrap;
align-items: center;
}
.event-kind {
background: var(--accent-color);
color: var(--text-color);
padding: 0.2rem 0.5rem;
border-radius: 4px;
font-size: 0.8em;
font-weight: 500;
}
.event-id {
font-family: monospace;
font-size: 0.8em;
color: var(--text-color);
opacity: 0.7;
}
.event-time {
font-size: 0.8em;
color: var(--text-color);
opacity: 0.6;
}
.event-content {
background: var(--card-bg);
border-radius: 4px;
padding: 0.75rem;
overflow: hidden;
}
.event-content pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: inherit;
font-size: 0.9em;
color: var(--text-color);
max-height: 150px;
overflow: hidden;
}
.event-content.expanded pre {
max-height: none;
}
.expand-btn {
margin-top: 0.5rem;
padding: 0.25rem 0.5rem;
background: transparent;
color: var(--accent-color);
border: 1px solid var(--accent-color);
border-radius: 4px;
cursor: pointer;
font-size: 0.8em;
}
.expand-btn:hover {
background: var(--accent-color);
color: var(--text-color);
}
.load-more {
text-align: center;
padding: 1rem;
}
.load-more button {
padding: 0.5rem 1.5rem;
background: var(--info);
color: var(--text-color);
border: none;
border-radius: 4px;
cursor: pointer;
}
.load-more button:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.loading {
padding: 2rem;
text-align: center;
color: var(--text-color);
opacity: 0.6;
}
</style>

View File

@@ -30,11 +30,23 @@ export const curationKindCategories = [
kinds: [1063, 20, 21, 22],
},
{
id: "marketplace",
name: "Marketplace",
description: "Product listings, stalls, and marketplace events",
id: "marketplace_nip15",
name: "Marketplace (NIP-15)",
description: "Legacy NIP-15 stalls and products",
kinds: [30017, 30018, 30019, 30020],
},
{
id: "marketplace_nip99",
name: "Marketplace (NIP-99/Gamma)",
description: "NIP-99 classified listings, collections, shipping, reviews (Plebeian Market)",
kinds: [30402, 30403, 30405, 30406, 31555],
},
{
id: "order_communication",
name: "Order Communication",
description: "Gamma Markets order messages and payment receipts (kinds 16, 17)",
kinds: [16, 17],
},
{
id: "groups_nip29",
name: "Group Messaging (NIP-29)",

View File

@@ -4,12 +4,17 @@ package database
import (
"bytes"
"context"
"encoding/json"
"fmt"
"sort"
"time"
"github.com/dgraph-io/badger/v4"
"github.com/minio/sha256-simd"
"git.mleku.dev/mleku/nostr/encoders/filter"
"git.mleku.dev/mleku/nostr/encoders/hex"
"git.mleku.dev/mleku/nostr/encoders/tag"
)
// CuratingConfig represents the configuration for curating ACL mode
@@ -965,14 +970,17 @@ func kindInRange(kind int, rangeStr string) bool {
// kindInCategory checks if a kind belongs to a predefined category
func kindInCategory(kind int, category string) bool {
categories := map[string][]int{
"social": {0, 1, 3, 6, 7, 10002},
"dm": {4, 14, 1059},
"longform": {30023, 30024},
"media": {1063, 20, 21, 22},
"marketplace": {30017, 30018, 30019, 30020, 1021, 1022},
"groups_nip29": {9, 10, 11, 12, 9000, 9001, 9002, 39000, 39001, 39002},
"groups_nip72": {34550, 1111, 4550},
"lists": {10000, 10001, 10003, 30000, 30001, 30003},
"social": {0, 1, 3, 6, 7, 10002},
"dm": {4, 14, 1059},
"longform": {30023, 30024},
"media": {1063, 20, 21, 22},
"marketplace": {30017, 30018, 30019, 30020, 1021, 1022}, // Legacy alias
"marketplace_nip15": {30017, 30018, 30019, 30020, 1021, 1022},
"marketplace_nip99": {30402, 30403, 30405, 30406, 31555}, // NIP-99/Gamma Markets (Plebeian Market)
"order_communication": {16, 17}, // Gamma Markets order messages
"groups_nip29": {9, 10, 11, 12, 9000, 9001, 9002, 39000, 39001, 39002},
"groups_nip72": {34550, 1111, 4550},
"lists": {10000, 10001, 10003, 30000, 30001, 30003},
}
kinds, ok := categories[category]
@@ -987,3 +995,236 @@ func kindInCategory(kind int, category string) bool {
}
return false
}
// ==================== Database Scanning ====================
// ScanResult contains the results of scanning all pubkeys in the database
type ScanResult struct {
TotalPubkeys int `json:"total_pubkeys"`
TotalEvents int `json:"total_events"`
Skipped int `json:"skipped"` // Trusted/blacklisted users skipped
}
// ScanAllPubkeys scans the database to find all unique pubkeys and count their events.
// This populates the event count data needed for the unclassified users list.
// It uses the SerialPubkey index to find all pubkeys, then counts events for each.
func (c *CuratingACL) ScanAllPubkeys() (*ScanResult, error) {
result := &ScanResult{}
// First, get all trusted and blacklisted pubkeys to skip
trusted, err := c.ListTrustedPubkeys()
if err != nil {
return nil, err
}
blacklisted, err := c.ListBlacklistedPubkeys()
if err != nil {
return nil, err
}
excludeSet := make(map[string]struct{})
for _, t := range trusted {
excludeSet[t.Pubkey] = struct{}{}
}
for _, b := range blacklisted {
excludeSet[b.Pubkey] = struct{}{}
}
// Scan the SerialPubkey index to get all pubkeys
pubkeys := make(map[string]struct{})
err = c.View(func(txn *badger.Txn) error {
// SerialPubkey prefix is "spk"
prefix := []byte("spk")
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix})
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
item := it.Item()
// The value contains the 32-byte pubkey
val, err := item.ValueCopy(nil)
if err != nil {
continue
}
if len(val) == 32 {
// Convert to hex
pubkeyHex := fmt.Sprintf("%x", val)
pubkeys[pubkeyHex] = struct{}{}
}
}
return nil
})
if err != nil {
return nil, err
}
result.TotalPubkeys = len(pubkeys)
// For each pubkey, count events and store the count
today := time.Now().Format("2006-01-02")
for pubkeyHex := range pubkeys {
// Skip if trusted or blacklisted
if _, excluded := excludeSet[pubkeyHex]; excluded {
result.Skipped++
continue
}
// Count events for this pubkey using the Pubkey index
count, err := c.countEventsForPubkey(pubkeyHex)
if err != nil {
continue
}
if count > 0 {
result.TotalEvents += count
// Store the event count
ec := PubkeyEventCount{
Pubkey: pubkeyHex,
Date: today,
Count: count,
LastEvent: time.Now(),
}
err = c.Update(func(txn *badger.Txn) error {
key := c.getEventCountKey(pubkeyHex, today)
data, err := json.Marshal(ec)
if err != nil {
return err
}
return txn.Set(key, data)
})
if err != nil {
continue
}
}
}
return result, nil
}
// EventSummary represents a simplified event for display in the UI
type EventSummary struct {
ID string `json:"id"`
Kind int `json:"kind"`
Content string `json:"content"`
CreatedAt int64 `json:"created_at"`
}
// GetEventsForPubkey fetches events for a pubkey, returning simplified event data
// limit specifies max events to return, offset is for pagination
func (c *CuratingACL) GetEventsForPubkey(pubkeyHex string, limit, offset int) ([]EventSummary, int, error) {
var events []EventSummary
// First, count total events for this pubkey
totalCount, err := c.countEventsForPubkey(pubkeyHex)
if err != nil {
return nil, 0, err
}
// Decode the pubkey hex to bytes
pubkeyBytes, err := hex.DecAppend(nil, []byte(pubkeyHex))
if err != nil {
return nil, 0, fmt.Errorf("invalid pubkey hex: %w", err)
}
// Create a filter to query events by author
// Use a larger limit to account for offset, then slice
queryLimit := uint(limit + offset)
f := &filter.F{
Authors: tag.NewFromBytesSlice(pubkeyBytes),
Limit: &queryLimit,
}
// Query events using the database's QueryEvents method
ctx := context.Background()
evs, err := c.D.QueryEvents(ctx, f)
if err != nil {
return nil, 0, err
}
// Apply offset and convert to EventSummary
for i, ev := range evs {
if i < offset {
continue
}
if len(events) >= limit {
break
}
events = append(events, EventSummary{
ID: hex.Enc(ev.ID),
Kind: int(ev.Kind),
Content: string(ev.Content),
CreatedAt: ev.CreatedAt,
})
}
return events, totalCount, nil
}
// DeleteEventsForPubkey deletes all events for a given pubkey
// Returns the number of events deleted
func (c *CuratingACL) DeleteEventsForPubkey(pubkeyHex string) (int, error) {
// Decode the pubkey hex to bytes
pubkeyBytes, err := hex.DecAppend(nil, []byte(pubkeyHex))
if err != nil {
return 0, fmt.Errorf("invalid pubkey hex: %w", err)
}
// Create a filter to find all events by this author
f := &filter.F{
Authors: tag.NewFromBytesSlice(pubkeyBytes),
}
// Query all events for this pubkey
ctx := context.Background()
evs, err := c.D.QueryEvents(ctx, f)
if err != nil {
return 0, err
}
// Delete each event
deleted := 0
for _, ev := range evs {
if err := c.D.DeleteEvent(ctx, ev.ID); err != nil {
// Log error but continue deleting
continue
}
deleted++
}
return deleted, nil
}
// countEventsForPubkey counts events in the database for a given pubkey hex string
func (c *CuratingACL) countEventsForPubkey(pubkeyHex string) (int, error) {
count := 0
// Decode the pubkey hex to bytes
pubkeyBytes := make([]byte, 32)
for i := 0; i < 32 && i*2+1 < len(pubkeyHex); i++ {
fmt.Sscanf(pubkeyHex[i*2:i*2+2], "%02x", &pubkeyBytes[i])
}
// Compute the pubkey hash (SHA256 of pubkey, first 8 bytes)
// This matches the PubHash type in indexes/types/pubhash.go
pkh := sha256.Sum256(pubkeyBytes)
// Scan the Pubkey index (prefix "pc-") for this pubkey
err := c.View(func(txn *badger.Txn) error {
// Build prefix: "pc-" + 8-byte SHA256 hash of pubkey
prefix := make([]byte, 3+8)
copy(prefix[:3], []byte("pc-"))
copy(prefix[3:], pkh[:8])
it := txn.NewIterator(badger.IteratorOptions{Prefix: prefix})
defer it.Close()
for it.Rewind(); it.Valid(); it.Next() {
count++
}
return nil
})
return count, err
}

View File

@@ -10,12 +10,15 @@ import (
"git.mleku.dev/mleku/nostr/encoders/filter"
"git.mleku.dev/mleku/nostr/encoders/hex"
"git.mleku.dev/mleku/nostr/encoders/tag"
"lol.mleku.dev/log"
"next.orly.dev/pkg/database/indexes/types"
"next.orly.dev/pkg/interfaces/store"
)
// QueryEvents retrieves events matching the given filter
func (n *N) QueryEvents(c context.Context, f *filter.F) (evs event.S, err error) {
log.T.F("Neo4j QueryEvents called with filter: kinds=%v, authors=%d, tags=%v",
f.Kinds != nil, f.Authors != nil && len(f.Authors.T) > 0, f.Tags != nil)
return n.QueryEventsWithOptions(c, f, false, false)
}
@@ -101,6 +104,7 @@ func (n *N) buildCypherQuery(f *filter.F, includeDeleteEvents bool) (string, map
// Normalize to lowercase hex using our utility function
// This handles both binary-encoded pubkeys and hex string pubkeys (including uppercase)
hexAuthor := NormalizePubkeyHex(author)
log.T.F("Neo4j author filter: raw_len=%d, normalized=%q", len(author), hexAuthor)
if hexAuthor == "" {
continue
}
@@ -130,30 +134,39 @@ func (n *N) buildCypherQuery(f *filter.F, includeDeleteEvents bool) (string, map
}
// Time range filters - for temporal queries
if f.Since != nil {
// Note: Check both pointer and value - a zero timestamp (Unix epoch 1970) is almost
// certainly not a valid constraint as Nostr events didn't exist then
if f.Since != nil && f.Since.V > 0 {
params["since"] = f.Since.V
whereClauses = append(whereClauses, "e.created_at >= $since")
}
if f.Until != nil {
if f.Until != nil && f.Until.V > 0 {
params["until"] = f.Until.V
whereClauses = append(whereClauses, "e.created_at <= $until")
}
// Tag filters - this is where Neo4j's graph capabilities shine
// We can efficiently traverse tag relationships
// We use EXISTS subqueries to efficiently filter events by tags
// This ensures events are only returned if they have matching tags
tagIndex := 0
if f.Tags != nil {
for _, tagValues := range *f.Tags {
if len(tagValues.T) > 0 {
tagVarName := fmt.Sprintf("t%d", tagIndex)
tagTypeParam := fmt.Sprintf("tagType_%d", tagIndex)
tagValuesParam := fmt.Sprintf("tagValues_%d", tagIndex)
// Add tag relationship to MATCH clause
matchClause += fmt.Sprintf(" OPTIONAL MATCH (e)-[:TAGGED_WITH]->(%s:Tag)", tagVarName)
// The first element is the tag type (e.g., "e", "p", "#e", "#p", etc.)
// Filter tags may have "#" prefix (e.g., "#d" for d-tag filters)
// Event tags are stored without prefix, so we must strip it
tagTypeBytes := tagValues.T[0]
var tagType string
if len(tagTypeBytes) > 0 && tagTypeBytes[0] == '#' {
tagType = string(tagTypeBytes[1:]) // Strip "#" prefix
} else {
tagType = string(tagTypeBytes)
}
// The first element is the tag type (e.g., "e", "p", etc.)
tagType := string(tagValues.T[0])
log.T.F("Neo4j tag filter: type=%q (raw=%q, len=%d)", tagType, string(tagTypeBytes), len(tagTypeBytes))
// Convert remaining tag values to strings (skip first element which is the type)
// For e/p tags, use NormalizePubkeyHex to handle binary encoding and uppercase hex
@@ -162,26 +175,34 @@ func (n *N) buildCypherQuery(f *filter.F, includeDeleteEvents bool) (string, map
if tagType == "e" || tagType == "p" {
// Normalize e/p tag values to lowercase hex (handles binary encoding)
normalized := NormalizePubkeyHex(tv)
log.T.F("Neo4j tag filter: %s-tag value normalized: %q (raw len=%d, binary=%v)",
tagType, normalized, len(tv), IsBinaryEncoded(tv))
if normalized != "" {
tagValueStrings = append(tagValueStrings, normalized)
}
} else {
// For other tags, use direct string conversion
tagValueStrings = append(tagValueStrings, string(tv))
val := string(tv)
log.T.F("Neo4j tag filter: %s-tag value: %q (len=%d)", tagType, val, len(val))
tagValueStrings = append(tagValueStrings, val)
}
}
// Skip if no valid values after normalization
if len(tagValueStrings) == 0 {
log.W.F("Neo4j tag filter: no valid values for tag type %q, skipping", tagType)
continue
}
// Add WHERE conditions for this tag
log.T.F("Neo4j tag filter: type=%s, values=%v", tagType, tagValueStrings)
// Use EXISTS subquery to filter events that have matching tags
// This is more correct than OPTIONAL MATCH because it requires the tag to exist
params[tagTypeParam] = tagType
params[tagValuesParam] = tagValueStrings
whereClauses = append(whereClauses,
fmt.Sprintf("(%s.type = $%s AND %s.value IN $%s)",
tagVarName, tagTypeParam, tagVarName, tagValuesParam))
fmt.Sprintf("EXISTS { MATCH (e)-[:TAGGED_WITH]->(t:Tag) WHERE t.type = $%s AND t.value IN $%s }",
tagTypeParam, tagValuesParam))
tagIndex++
}
@@ -248,6 +269,26 @@ RETURN e.id AS id,
// Combine all parts
cypher := matchClause + whereClause + returnClause + orderClause + limitClause
// Log the generated query for debugging
log.T.F("Neo4j query: %s", cypher)
// Log params at trace level for debugging
var paramSummary strings.Builder
for k, v := range params {
switch val := v.(type) {
case []string:
if len(val) <= 3 {
paramSummary.WriteString(fmt.Sprintf("%s: %v ", k, val))
} else {
paramSummary.WriteString(fmt.Sprintf("%s: [%d values] ", k, len(val)))
}
case []int64:
paramSummary.WriteString(fmt.Sprintf("%s: %v ", k, val))
default:
paramSummary.WriteString(fmt.Sprintf("%s: %v ", k, v))
}
}
log.T.F("Neo4j params: %s", paramSummary.String())
return cypher, params
}
@@ -300,19 +341,17 @@ func (n *N) parseEventsFromResult(result *CollectedResult) ([]*event.E, error) {
_ = tags.UnmarshalJSON([]byte(tagsStr))
}
// Create event
// Create event with decoded binary fields
e := &event.E{
ID: id,
Pubkey: pubkey,
Kind: uint16(kind),
CreatedAt: createdAt,
Content: []byte(content),
Tags: tags,
Sig: sig,
}
// Copy fixed-size arrays
copy(e.ID[:], id)
copy(e.Sig[:], sig)
copy(e.Pubkey[:], pubkey)
events = append(events, e)
}

View File

@@ -462,3 +462,584 @@ func TestCountEvents(t *testing.T) {
t.Logf("✓ Count events returned correct count: %d", count)
}
// TestQueryEventsByTagWithHashPrefix tests that tag filters with "#" prefix work correctly.
// This is a regression test for a bug where filter tags like "#d" were not being matched
// because the "#" prefix wasn't being stripped before comparison with stored tags.
func TestQueryEventsByTagWithHashPrefix(t *testing.T) {
if testDB == nil {
t.Skip("Neo4j not available")
}
cleanTestDatabase()
ctx := context.Background()
signer := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create events with d-tags (parameterized replaceable kind)
createAndSaveEventLocal(t, ctx, signer, 30382, "Event with d=id1",
tag.NewS(tag.NewFromAny("d", "id1")), baseTs)
createAndSaveEventLocal(t, ctx, signer, 30382, "Event with d=id2",
tag.NewS(tag.NewFromAny("d", "id2")), baseTs+1)
createAndSaveEventLocal(t, ctx, signer, 30382, "Event with d=id3",
tag.NewS(tag.NewFromAny("d", "id3")), baseTs+2)
createAndSaveEventLocal(t, ctx, signer, 30382, "Event with d=other",
tag.NewS(tag.NewFromAny("d", "other")), baseTs+3)
// Query with "#d" prefix (as clients send it) - should match events with d=id1
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(30382)),
Tags: tag.NewS(tag.NewFromAny("#d", "id1")),
})
if err != nil {
t.Fatalf("Failed to query events with #d tag: %v", err)
}
if len(evs) != 1 {
t.Fatalf("Expected 1 event with d=id1, got %d", len(evs))
}
// Verify the returned event has the correct d-tag
dTag := evs[0].Tags.GetFirst([]byte("d"))
if dTag == nil || string(dTag.Value()) != "id1" {
t.Fatalf("Expected d=id1, got d=%s", dTag.Value())
}
t.Logf("✓ Query with #d prefix returned correct event")
}
// TestQueryEventsByTagMultipleValues tests that tag filters with multiple values
// use OR logic (match events with ANY of the values).
func TestQueryEventsByTagMultipleValues(t *testing.T) {
if testDB == nil {
t.Skip("Neo4j not available")
}
cleanTestDatabase()
ctx := context.Background()
signer := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create events with different d-tags
createAndSaveEventLocal(t, ctx, signer, 30382, "Event A",
tag.NewS(tag.NewFromAny("d", "target-1")), baseTs)
createAndSaveEventLocal(t, ctx, signer, 30382, "Event B",
tag.NewS(tag.NewFromAny("d", "target-2")), baseTs+1)
createAndSaveEventLocal(t, ctx, signer, 30382, "Event C",
tag.NewS(tag.NewFromAny("d", "target-3")), baseTs+2)
createAndSaveEventLocal(t, ctx, signer, 30382, "Event D (not target)",
tag.NewS(tag.NewFromAny("d", "other-value")), baseTs+3)
createAndSaveEventLocal(t, ctx, signer, 30382, "Event E (no match)",
tag.NewS(tag.NewFromAny("d", "different")), baseTs+4)
// Query with multiple d-tag values using "#d" prefix
// Should match events with d=target-1 OR d=target-2 OR d=target-3
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(30382)),
Tags: tag.NewS(tag.NewFromAny("#d", "target-1", "target-2", "target-3")),
})
if err != nil {
t.Fatalf("Failed to query events with multiple #d values: %v", err)
}
if len(evs) != 3 {
t.Fatalf("Expected 3 events matching the d-tag values, got %d", len(evs))
}
// Verify returned events have correct d-tags
validDTags := map[string]bool{"target-1": false, "target-2": false, "target-3": false}
for _, ev := range evs {
dTag := ev.Tags.GetFirst([]byte("d"))
if dTag == nil {
t.Fatalf("Event missing d-tag")
}
dValue := string(dTag.Value())
if _, ok := validDTags[dValue]; !ok {
t.Fatalf("Unexpected d-tag value: %s", dValue)
}
validDTags[dValue] = true
}
// Verify all expected d-tags were found
for dValue, found := range validDTags {
if !found {
t.Fatalf("Expected to find event with d=%s", dValue)
}
}
t.Logf("✓ Query with multiple #d values returned correct events")
}
// TestQueryEventsByTagNoMatch tests that tag filters correctly return no results
// when no events match the filter.
func TestQueryEventsByTagNoMatch(t *testing.T) {
if testDB == nil {
t.Skip("Neo4j not available")
}
cleanTestDatabase()
ctx := context.Background()
signer := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create events with d-tags
createAndSaveEventLocal(t, ctx, signer, 30382, "Event",
tag.NewS(tag.NewFromAny("d", "existing-value")), baseTs)
// Query for d-tag value that doesn't exist
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(30382)),
Tags: tag.NewS(tag.NewFromAny("#d", "non-existent-value")),
})
if err != nil {
t.Fatalf("Failed to query events: %v", err)
}
if len(evs) != 0 {
t.Fatalf("Expected 0 events for non-matching d-tag, got %d", len(evs))
}
t.Logf("✓ Query with non-matching #d value returned no events")
}
// TestQueryEventsByTagWithKindAndAuthor tests the combination of kind, author, and tag filters.
// This is the specific case reported by the user with kind 30382.
func TestQueryEventsByTagWithKindAndAuthor(t *testing.T) {
if testDB == nil {
t.Skip("Neo4j not available")
}
cleanTestDatabase()
ctx := context.Background()
alice := createTestSignerLocal(t)
bob := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create events from different authors with d-tags
createAndSaveEventLocal(t, ctx, alice, 30382, "Alice target 1",
tag.NewS(tag.NewFromAny("d", "card-1")), baseTs)
createAndSaveEventLocal(t, ctx, alice, 30382, "Alice target 2",
tag.NewS(tag.NewFromAny("d", "card-2")), baseTs+1)
createAndSaveEventLocal(t, ctx, alice, 30382, "Alice other",
tag.NewS(tag.NewFromAny("d", "other-card")), baseTs+2)
createAndSaveEventLocal(t, ctx, bob, 30382, "Bob target 1",
tag.NewS(tag.NewFromAny("d", "card-1")), baseTs+3) // Same d-tag as Alice but different author
// Query for Alice's events with specific d-tags
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(30382)),
Authors: tag.NewFromBytesSlice(alice.Pub()),
Tags: tag.NewS(tag.NewFromAny("#d", "card-1", "card-2")),
})
if err != nil {
t.Fatalf("Failed to query events: %v", err)
}
// Should only return Alice's 2 events, not Bob's even though he has card-1
if len(evs) != 2 {
t.Fatalf("Expected 2 events from Alice with matching d-tags, got %d", len(evs))
}
alicePubkey := hex.Enc(alice.Pub())
for _, ev := range evs {
if hex.Enc(ev.Pubkey[:]) != alicePubkey {
t.Fatalf("Expected author %s, got %s", alicePubkey, hex.Enc(ev.Pubkey[:]))
}
dTag := ev.Tags.GetFirst([]byte("d"))
dValue := string(dTag.Value())
if dValue != "card-1" && dValue != "card-2" {
t.Fatalf("Expected d=card-1 or card-2, got d=%s", dValue)
}
}
t.Logf("✓ Query with kind, author, and #d filter returned correct events")
}
// TestBinaryTagFilterRegression tests that queries with #e and #p tags work correctly
// even when tags are stored with binary-encoded values but filters come as hex strings.
// This mirrors the Badger database test for binary tag handling.
func TestBinaryTagFilterRegression(t *testing.T) {
if testDB == nil {
t.Skip("Neo4j not available")
}
cleanTestDatabase()
ctx := context.Background()
author := createTestSignerLocal(t)
referenced := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create a referenced event to get a valid event ID for e-tag
refEvent := createAndSaveEventLocal(t, ctx, referenced, 1, "Referenced event", nil, baseTs)
// Get hex representations
refEventIdHex := hex.Enc(refEvent.ID)
refPubkeyHex := hex.Enc(referenced.Pub())
// Create test event with e, p, d, and other tags
testEvent := createAndSaveEventLocal(t, ctx, author, 30520, "Event with binary tags",
tag.NewS(
tag.NewFromAny("d", "test-d-value"),
tag.NewFromAny("p", string(refPubkeyHex)),
tag.NewFromAny("e", string(refEventIdHex)),
tag.NewFromAny("t", "test-topic"),
), baseTs+1)
testEventIdHex := hex.Enc(testEvent.ID)
// Test case 1: Query WITHOUT #e/#p tags (baseline - should work)
t.Run("QueryWithoutEPTags", func(t *testing.T) {
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(30520)),
Authors: tag.NewFromBytesSlice(author.Pub()),
Tags: tag.NewS(tag.NewFromAny("#d", "test-d-value")),
})
if err != nil {
t.Fatalf("Query without e/p tags failed: %v", err)
}
if len(evs) == 0 {
t.Fatal("Expected to find event with d tag filter, got 0 results")
}
found := false
for _, ev := range evs {
if hex.Enc(ev.ID) == testEventIdHex {
found = true
break
}
}
if !found {
t.Errorf("Expected event ID %s not found", testEventIdHex)
}
})
// Test case 2: Query WITH #p tag
t.Run("QueryWithPTag", func(t *testing.T) {
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(30520)),
Authors: tag.NewFromBytesSlice(author.Pub()),
Tags: tag.NewS(
tag.NewFromAny("#d", "test-d-value"),
tag.NewFromAny("#p", string(refPubkeyHex)),
),
})
if err != nil {
t.Fatalf("Query with #p tag failed: %v", err)
}
if len(evs) == 0 {
t.Fatalf("REGRESSION: Expected to find event with #p tag filter, got 0 results")
}
})
// Test case 3: Query WITH #e tag
t.Run("QueryWithETag", func(t *testing.T) {
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(30520)),
Authors: tag.NewFromBytesSlice(author.Pub()),
Tags: tag.NewS(
tag.NewFromAny("#d", "test-d-value"),
tag.NewFromAny("#e", string(refEventIdHex)),
),
})
if err != nil {
t.Fatalf("Query with #e tag failed: %v", err)
}
if len(evs) == 0 {
t.Fatalf("REGRESSION: Expected to find event with #e tag filter, got 0 results")
}
})
// Test case 4: Query WITH BOTH #e AND #p tags
t.Run("QueryWithBothEAndPTags", func(t *testing.T) {
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(30520)),
Authors: tag.NewFromBytesSlice(author.Pub()),
Tags: tag.NewS(
tag.NewFromAny("#d", "test-d-value"),
tag.NewFromAny("#e", string(refEventIdHex)),
tag.NewFromAny("#p", string(refPubkeyHex)),
),
})
if err != nil {
t.Fatalf("Query with both #e and #p tags failed: %v", err)
}
if len(evs) == 0 {
t.Fatalf("REGRESSION: Expected to find event with #e and #p tag filters, got 0 results")
}
})
t.Logf("✓ Binary tag filter regression tests passed")
}
// TestParameterizedReplaceableEvents tests that parameterized replaceable events (kind 30000+)
// are handled correctly - only the newest version should be returned in queries by kind/author/d-tag.
func TestParameterizedReplaceableEvents(t *testing.T) {
if testDB == nil {
t.Skip("Neo4j not available")
}
cleanTestDatabase()
ctx := context.Background()
signer := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create older parameterized replaceable event
createAndSaveEventLocal(t, ctx, signer, 30000, "Original event",
tag.NewS(tag.NewFromAny("d", "test-param")), baseTs-7200) // 2 hours ago
// Create newer event with same kind/author/d-tag
createAndSaveEventLocal(t, ctx, signer, 30000, "Newer event",
tag.NewS(tag.NewFromAny("d", "test-param")), baseTs-3600) // 1 hour ago
// Create newest event with same kind/author/d-tag
newestEvent := createAndSaveEventLocal(t, ctx, signer, 30000, "Newest event",
tag.NewS(tag.NewFromAny("d", "test-param")), baseTs) // Now
// Query for events - should only return the newest one
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(30000)),
Authors: tag.NewFromBytesSlice(signer.Pub()),
Tags: tag.NewS(tag.NewFromAny("#d", "test-param")),
})
if err != nil {
t.Fatalf("Failed to query parameterized replaceable events: %v", err)
}
// Note: Neo4j backend may or may not automatically deduplicate replaceable events
// depending on implementation. The important thing is that the newest is returned first.
if len(evs) == 0 {
t.Fatal("Expected at least 1 event")
}
// Verify the first (most recent) event is the newest one
if hex.Enc(evs[0].ID) != hex.Enc(newestEvent.ID) {
t.Logf("Note: Expected newest event first, got different order")
}
t.Logf("✓ Parameterized replaceable events test returned %d events", len(evs))
}
// TestQueryForIds tests the QueryForIds method
func TestQueryForIds(t *testing.T) {
if testDB == nil {
t.Skip("Neo4j not available")
}
cleanTestDatabase()
ctx := context.Background()
signer := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create test events
ev1 := createAndSaveEventLocal(t, ctx, signer, 1, "Event 1", nil, baseTs)
ev2 := createAndSaveEventLocal(t, ctx, signer, 1, "Event 2", nil, baseTs+1)
createAndSaveEventLocal(t, ctx, signer, 7, "Reaction", nil, baseTs+2)
// Query for IDs of kind 1 events
idPkTs, err := testDB.QueryForIds(ctx, &filter.F{
Kinds: kind.NewS(kind.New(1)),
})
if err != nil {
t.Fatalf("Failed to query for IDs: %v", err)
}
if len(idPkTs) != 2 {
t.Fatalf("Expected 2 IDs for kind 1 events, got %d", len(idPkTs))
}
// Verify IDs match our events
foundIds := make(map[string]bool)
for _, r := range idPkTs {
foundIds[hex.Enc(r.Id)] = true
}
if !foundIds[hex.Enc(ev1.ID)] {
t.Error("Event 1 ID not found in results")
}
if !foundIds[hex.Enc(ev2.ID)] {
t.Error("Event 2 ID not found in results")
}
t.Logf("✓ QueryForIds returned correct IDs")
}
// TestQueryForSerials tests the QueryForSerials method
func TestQueryForSerials(t *testing.T) {
if testDB == nil {
t.Skip("Neo4j not available")
}
cleanTestDatabase()
ctx := context.Background()
signer := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create test events
createAndSaveEventLocal(t, ctx, signer, 1, "Event 1", nil, baseTs)
createAndSaveEventLocal(t, ctx, signer, 1, "Event 2", nil, baseTs+1)
createAndSaveEventLocal(t, ctx, signer, 1, "Event 3", nil, baseTs+2)
// Query for serials
serials, err := testDB.QueryForSerials(ctx, &filter.F{
Kinds: kind.NewS(kind.New(1)),
})
if err != nil {
t.Fatalf("Failed to query for serials: %v", err)
}
if len(serials) != 3 {
t.Fatalf("Expected 3 serials, got %d", len(serials))
}
t.Logf("✓ QueryForSerials returned %d serials", len(serials))
}
// TestQueryEventsComplex tests complex filter combinations
func TestQueryEventsComplex(t *testing.T) {
if testDB == nil {
t.Skip("Neo4j not available")
}
cleanTestDatabase()
ctx := context.Background()
alice := createTestSignerLocal(t)
bob := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create diverse set of events
createAndSaveEventLocal(t, ctx, alice, 1, "Alice note with bitcoin tag",
tag.NewS(tag.NewFromAny("t", "bitcoin")), baseTs)
createAndSaveEventLocal(t, ctx, alice, 1, "Alice note with nostr tag",
tag.NewS(tag.NewFromAny("t", "nostr")), baseTs+1)
createAndSaveEventLocal(t, ctx, alice, 7, "Alice reaction",
nil, baseTs+2)
createAndSaveEventLocal(t, ctx, bob, 1, "Bob note with bitcoin tag",
tag.NewS(tag.NewFromAny("t", "bitcoin")), baseTs+3)
// Test: kinds + tags (no authors)
t.Run("KindsAndTags", func(t *testing.T) {
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(1)),
Tags: tag.NewS(tag.NewFromAny("#t", "bitcoin")),
})
if err != nil {
t.Fatalf("Query failed: %v", err)
}
if len(evs) != 2 {
t.Fatalf("Expected 2 events with kind=1 and #t=bitcoin, got %d", len(evs))
}
})
// Test: authors + tags (no kinds)
t.Run("AuthorsAndTags", func(t *testing.T) {
evs, err := testDB.QueryEvents(ctx, &filter.F{
Authors: tag.NewFromBytesSlice(alice.Pub()),
Tags: tag.NewS(tag.NewFromAny("#t", "bitcoin")),
})
if err != nil {
t.Fatalf("Query failed: %v", err)
}
if len(evs) != 1 {
t.Fatalf("Expected 1 event from Alice with #t=bitcoin, got %d", len(evs))
}
})
// Test: kinds + authors (no tags)
t.Run("KindsAndAuthors", func(t *testing.T) {
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(1)),
Authors: tag.NewFromBytesSlice(alice.Pub()),
})
if err != nil {
t.Fatalf("Query failed: %v", err)
}
if len(evs) != 2 {
t.Fatalf("Expected 2 kind=1 events from Alice, got %d", len(evs))
}
})
// Test: all three filters
t.Run("AllFilters", func(t *testing.T) {
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(1)),
Authors: tag.NewFromBytesSlice(alice.Pub()),
Tags: tag.NewS(tag.NewFromAny("#t", "nostr")),
})
if err != nil {
t.Fatalf("Query failed: %v", err)
}
if len(evs) != 1 {
t.Fatalf("Expected 1 event (Alice kind=1 #t=nostr), got %d", len(evs))
}
})
t.Logf("✓ Complex filter combination tests passed")
}
// TestQueryEventsMultipleTagTypes tests filtering with multiple different tag types
func TestQueryEventsMultipleTagTypes(t *testing.T) {
if testDB == nil {
t.Skip("Neo4j not available")
}
cleanTestDatabase()
ctx := context.Background()
signer := createTestSignerLocal(t)
baseTs := timestamp.Now().V
// Create events with multiple tag types
createAndSaveEventLocal(t, ctx, signer, 30382, "Event with d and client tags",
tag.NewS(
tag.NewFromAny("d", "user-1"),
tag.NewFromAny("client", "app-a"),
), baseTs)
createAndSaveEventLocal(t, ctx, signer, 30382, "Event with d and different client",
tag.NewS(
tag.NewFromAny("d", "user-2"),
tag.NewFromAny("client", "app-b"),
), baseTs+1)
createAndSaveEventLocal(t, ctx, signer, 30382, "Event with only d tag",
tag.NewS(
tag.NewFromAny("d", "user-3"),
), baseTs+2)
// Query with multiple tag types (should AND them together)
evs, err := testDB.QueryEvents(ctx, &filter.F{
Kinds: kind.NewS(kind.New(30382)),
Tags: tag.NewS(
tag.NewFromAny("#d", "user-1", "user-2"),
tag.NewFromAny("#client", "app-a"),
),
})
if err != nil {
t.Fatalf("Query with multiple tag types failed: %v", err)
}
// Should match only the first event (user-1 with app-a)
if len(evs) != 1 {
t.Fatalf("Expected 1 event matching both #d and #client, got %d", len(evs))
}
dTag := evs[0].Tags.GetFirst([]byte("d"))
if string(dTag.Value()) != "user-1" {
t.Fatalf("Expected d=user-1, got d=%s", dTag.Value())
}
t.Logf("✓ Multiple tag types filter test passed")
}

View File

@@ -1 +1 @@
v0.49.0
v0.50.0