feat(spots): reports reach the admin, duplicates are caught, contributors are told

1) Reports were a VOID: ReportDialog publishes NIP-56 (1984) but the only reader
   in the app was a count on marketplace cards, so an admin never saw one. Now
   useSpotReports batch-reads kind 1984 by '#e' (one query for every spot) and
   the review queue ranks reported spots ABOVE the heuristics with a red
   'REPORTED: ...' chip. useNotASpotReport publishes to getWriteRelays()
   (community relay + the reporter's), not the default pool — the admin reads
   from the community relay, so reports written only to personal relays would
   never arrive. Verified end-to-end: published a report from Node, saw it
   surface in the queue.

2) Duplicate guard in CreateSpotPage: on submit, existing spots within 60 m are
   listed with distances + links, with 'It's a different spot — publish anyway'.
   Targets the clone-spam failure mode (five near-identical pins).

3) Contributor feedback: useSkateSpot no longer nulls a hidden spot for its
   AUTHOR (it used to silently 404 for the person who made it), and
   SpotDetailPage shows an author-only banner for hidden/verified.

tsc/eslint clean, 101 tests, smoke PASS.
This commit is contained in:
SkateSpots 2026-09-28 09:45:36 +07:00
parent e44f8b81a7
commit e549aeee3a
8 changed files with 273 additions and 22 deletions

View file

@ -417,6 +417,27 @@ session with **no other memory of this project**. So:
is **admin-tooling only** — without it a hidden spot leaves the list and can
never be un-hidden. The review queue keeps actioned rows (badge + **Undo**)
so both verbiage and verdicts are reversible in the UI.
- **Reports reach the admin, duplicates are caught, contributors are told (2026-09-28):**
three gaps closed in one pass.
1. **Reports were a void.** `ReportDialog` publishes NIP-56 (1984) for spots/
events/tours/users/listings/comments, but the ONLY reader in the app was a
count on marketplace cards — an admin never saw a report. Now
**`useSpotReports`** batch-reads `kinds:[1984], '#e': [all spot ids]` in ONE
query (via `querySkateContent`), and the review queue ranks reported spots
ABOVE the heuristics (red `REPORTED: …` chip). `useNotASpotReport` now
publishes to **`getWriteRelays()`** (community relay + the reporter's) rather
than the default pool, because the admin reads from the community relay — the
same relay-parity rule as the newsletter and DMs. Verified end-to-end by
publishing a report from Node and seeing it surface in the queue.
2. **Duplicate guard** in `CreateSpotPage`: on submit, any existing spot within
**60 m** (geohash-decoded, ≤3 candidates) shows an inline warning listing them
with distances and links, plus "It's a different spot — publish anyway"
(`dupConfirmedRef` bypass). Designed for the clone-spam failure mode.
3. **Contributor feedback:** `useSkateSpot` no longer returns null for a hidden
spot when the viewer is its **author** — otherwise a moderated spot silently
404s for the person who made it. `SpotDetailPage` shows an author-only banner
("Hidden by a moderator" / "Verified by SkateSpots"). The verdicts are public
labels, so there is nothing secret being revealed.
- **Mints directory**: `/mints` page lists Cashu mints from the community
**MintRadar** project. MintRadar has **no CORS**, so it's fetched same-origin
at `/api/mints/*` — prod proxies via `public/_redirects` (`/api/mints/* →

View file

@ -15,6 +15,12 @@ v1.5.4
**Confirm** a spot as well as hide it (a staff-vouched spot gets a badge).
- Moderation is **reversible**: Hide and Confirm can both be undone, returning the
spot to neutral without hiding or vouching for it.
- **Reports now reach a human**: the review queue surfaces reported spots at the
very top, above the automated signals — so "not a skate spot" actually lands.
- **Adding a spot warns you if one already exists** within 60 m, and links you
straight to it instead of letting you create a duplicate.
- **If a moderator hides or vouches for your spot, you're told** — right there on
the spot page (your own spots stay visible to you either way).
🛍 App Shop — a new section (empty on purpose, with free printables)
- Free to print, no sats needed: a 55 × 86 mm "DO YOU EVEN SKATE BRO?" sticker

View file

@ -1,5 +1,6 @@
import { useMutation } from '@tanstack/react-query';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useNostr } from '@nostrify/react';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useToast } from '@/hooks/useToast';
import { MODERATION_LABEL_NAMESPACE } from '@/lib/types';
@ -18,7 +19,8 @@ import { MODERATION_LABEL_NAMESPACE } from '@/lib/types';
*/
export function useNotASpotReport() {
const { user } = useCurrentUser();
const { mutateAsync: publish, isPending: publishing } = useNostrPublish();
const { nostr } = useNostr();
const { getWriteRelays } = useSkateRelays();
const { toast } = useToast();
const mutation = useMutation({
@ -35,9 +37,10 @@ export function useNotASpotReport() {
}) => {
if (!user) throw new Error('Log in to report a spot.');
await publish({
const event = await user.signer.signEvent({
kind: 1984, // NIP-56 report
content: title ? `Not a skate spot: ${title}` : 'Not a skate spot',
created_at: Math.floor(Date.now() / 1000),
tags: [
['e', eventId, 'other'],
['p', pubkey],
@ -47,6 +50,18 @@ export function useNotASpotReport() {
['alt', 'SkateSpots: report — not a skate spot'],
],
});
// Publish to the COMMUNITY relay as well as the reporter's own relays.
// Admins read reports from the community relay, so a report written only
// to the reporter's personal relays would never reach them.
const results = await Promise.allSettled(
getWriteRelays().map((url) =>
nostr.relay(url).event(event, { signal: AbortSignal.timeout(8000) }),
),
);
if (results.every((r) => r.status === 'rejected')) {
throw new Error('Could not reach any relay — check your connection and try again.');
}
},
onSuccess: () => {
toast({
@ -66,7 +81,7 @@ export function useNotASpotReport() {
return {
reportNotASpot: mutation.mutateAsync,
isPending: mutation.isPending || publishing,
isPending: mutation.isPending,
alreadyReported: mutation.isSuccess,
};
}

View file

@ -1,6 +1,7 @@
import { useQuery } from '@tanstack/react-query';
import { SKATE_SPOT_KIND, parseSkateSpot, type SkateSpot } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAdminModeration, filterAdminHidden } from '@/hooks/useAdminModeration';
import { cacheSpots, getAllCachedSpots, isOffline } from '@/lib/offlineCache';
@ -73,13 +74,20 @@ export function useSkateSpots(limit = 100, options: { includeHidden?: boolean }
export function useSkateSpot(identifier: string, pubkey?: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const hidden = useAdminModeration();
const { user } = useCurrentUser();
return useQuery({
queryKey: ['skate-spot', identifier, pubkey, getQueryKeySuffix()],
select: (spot: SkateSpot | null) =>
spot && hidden.has({ coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`, id: spot.id })
? null
: spot,
select: (spot: SkateSpot | null) => {
if (!spot) return null;
if (!hidden.has({ coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`, id: spot.id })) {
return spot;
}
// A hidden spot stays reachable for its OWN AUTHOR — otherwise their spot
// silently 404s and they never learn why. Everyone else gets null (the map
// and lists filter it too). The detail page shows them what happened.
return user?.pubkey === spot.pubkey ? spot : null;
},
queryFn: async () => {
// If offline, try to get from cache first
if (isOffline() && pubkey) {

View file

@ -0,0 +1,68 @@
import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useSkateRelays } from '@/hooks/useSkateRelays';
/** NIP-56 report kind. */
export const REPORT_KIND = 1984;
export interface SpotReportCounts {
/** Every NIP-56 report referencing the spot. */
total: number;
/** Reports specifically tagged `not-a-skate-spot` (see useNotASpotReport). */
notASpot: number;
}
/**
* Batch-read NIP-56 reports for many spots in ONE query.
*
* Reports carry the target's event id in an `e` tag, so asking for
* `#e: [all spot ids]` is a single relay request — the same trick as the status
* and photo batch hooks.
*
* WHY THIS EXISTS: people could already report a spot, but nothing ever brought
* those reports to the admin — the only reader in the whole app was a count on
* marketplace cards. A report button whose output nobody sees is decoration, so
* the review queue now ranks reported spots to the top.
*/
export function useSpotReports(spots: Array<{ id: string }>) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const ids = useMemo(() => spots.map((s) => s.id).filter(Boolean), [spots]);
const query = useQuery({
queryKey: ['spot-reports', ids.slice().sort().join(','), getQueryKeySuffix()],
enabled: ids.length > 0,
staleTime: 60_000,
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
return querySkateContent(
[{ kinds: [REPORT_KIND], '#e': ids, limit: 1000 }],
{ signal },
);
},
});
const counts = useMemo(() => {
const map = new Map<string, SpotReportCounts>();
for (const ev of query.data ?? []) {
// A report can reference several targets; count each.
for (const [n, value] of ev.tags) {
if (n !== 'e' || !value) continue;
const entry = map.get(value) ?? { total: 0, notASpot: 0 };
entry.total += 1;
// Our own taxonomy marker, or the NIP-56 'other' reason from the dialog.
const isNotASpot =
ev.tags.some(([t, v]) => t === 't' && v === 'not-a-skate-spot') ||
ev.tags.some(([t, v]) => t === 'l' && v === 'not-a-skate-spot');
if (isNotASpot) entry.notASpot += 1;
map.set(value, entry);
}
}
return map;
}, [query.data]);
return {
isLoading: query.isLoading,
getCounts: (id: string): SpotReportCounts => counts.get(id) ?? { total: 0, notASpot: 0 },
};
}

View file

@ -22,6 +22,7 @@ import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useBatchTrustScores } from '@/hooks/useBatchTrustScores';
import { useBatchSpotStatus } from '@/hooks/useSpotStatus';
import { useBatchSpotPhotos } from '@/hooks/useSpotContributions';
import { useSpotReports } from '@/hooks/useSpotReports';
import { useAdminHide, useAdminVerify, useAdminClear } from '@/hooks/useAdminHide';
import { useAdminModeration } from '@/hooks/useAdminModeration';
import { useNotASpotReport } from '@/hooks/useNotASpotReport';
@ -69,6 +70,8 @@ export default function AdminReviewPage() {
);
const { getStatus } = useBatchSpotStatus(statusInput);
const { getCommunityPhotoCount } = useBatchSpotPhotos(statusInput);
// What people actually reported — the strongest signal we have.
const { getCounts: getReportCounts } = useSpotReports(spots ?? []);
const rows = useMemo(
() =>
@ -80,6 +83,15 @@ export default function AdminReviewPage() {
const verified = moderation.isVerified({ coord, id: spot.id });
const photos = getCommunityPhotoCount(spot.pubkey, spot.identifier);
const reporters = status?.reporterCount ?? 0;
const reports = getReportCounts(spot.id);
const flags = spotFlags(spot, {
mapperTrust: trustScores?.get(spot.pubkey),
communityStatusReports: reporters,
communityPhotos: photos,
});
// Human reports outrank heuristics: someone looked and objected.
if (reports.notASpot > 0) flags.unshift('reported: not a skate spot');
else if (reports.total > 0) flags.unshift(`reported ×${reports.total}`);
return {
spot,
coord,
@ -90,19 +102,20 @@ export default function AdminReviewPage() {
trust: trustScores?.get(spot.pubkey),
reporters,
photos,
flags: spotFlags(spot, {
mapperTrust: trustScores?.get(spot.pubkey),
communityStatusReports: reporters,
communityPhotos: photos,
}),
reports,
flags,
};
})
.filter((row) => row.actioned || row.flags.length > 0)
// Un-actioned rows first (most flags first); actioned rows sink below.
.sort((a, b) =>
a.actioned === b.actioned ? b.flags.length - a.flags.length : a.actioned ? 1 : -1,
),
[spots, moderation, trustScores, getStatus, getCommunityPhotoCount],
// Un-actioned first; within that, human reports before heuristics;
// actioned rows sink to the bottom (they are done, but undoable).
.sort((a, b) => {
if (a.actioned !== b.actioned) return a.actioned ? 1 : -1;
if (a.reports.notASpot !== b.reports.notASpot) return b.reports.notASpot - a.reports.notASpot;
if (a.reports.total !== b.reports.total) return b.reports.total - a.reports.total;
return b.flags.length - a.flags.length;
}),
[spots, moderation, trustScores, getStatus, getCommunityPhotoCount, getReportCounts],
);
useSeoMeta({ title: 'Review queue — SkateSpots' });
@ -230,7 +243,11 @@ export default function AdminReviewPage() {
{!hidden && !verified && flags.map((f) => (
<Badge
key={f}
className="border border-amber-500/40 bg-amber-500/15 text-[10px] font-bold uppercase tracking-wide text-amber-300"
className={
f.startsWith('reported')
? 'border border-red-500/50 bg-red-500/20 text-[10px] font-black uppercase tracking-wide text-red-300'
: 'border border-amber-500/40 bg-amber-500/15 text-[10px] font-bold uppercase tracking-wide text-amber-300'
}
>
{f}
</Badge>

View file

@ -1,4 +1,4 @@
import { useState, useEffect } from 'react';
import { useState, useEffect, useRef } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, useSearchParams } from 'react-router-dom';
import ngeohash from 'ngeohash';
@ -10,6 +10,9 @@ import { Label } from '@/components/ui/label';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import { calculateDistance } from '@/lib/sortOptions';
import type { SkateSpot } from '@/lib/types';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/language';
import { LoginArea } from '@/components/auth/LoginArea';
@ -59,6 +62,12 @@ export default function CreateSpotPage() {
const [shelter, setShelter] = useState<ShelterType>(DEFAULT_SHELTER);
const [contactInfo, setContactInfo] = useState('');
const [images, setImages] = useState<string[]>([]);
// Duplicate guard: warn when a spot already exists right where you're pinning
// one. This is the cheapest fix for clone spam (five near-identical pins for
// the same spot) — and it points the contributor at the existing pin instead.
const { data: existingSpots } = useSkateSpots(500);
const [dupWarning, setDupWarning] = useState<Array<{ spot: SkateSpot; km: number }> | null>(null);
const dupConfirmedRef = useRef(false);
const [isGettingLocation, setIsGettingLocation] = useState(false);
const [showCamera, setShowCamera] = useState(false);
// True while the Quick Snap photo is uploading in the background after landing
@ -209,8 +218,8 @@ export default function CreateSpotPage() {
setImages((prev) => prev.filter((img) => img !== url));
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
const handleSubmit = async (e?: React.FormEvent) => {
e?.preventDefault();
if (!title.trim()) {
toast({
@ -275,6 +284,29 @@ export default function CreateSpotPage() {
return;
}
// Look for an existing spot within 60 m and ask before creating a clone.
if (!dupConfirmedRef.current) {
const nearby = (existingSpots ?? [])
.map((s) => {
if (!s.geohash) return null;
const { latitude, longitude } = ngeohash.decode(s.geohash);
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
return { spot: s, km: calculateDistance(lat, lng, latitude, longitude) };
})
.filter((x): x is { spot: SkateSpot; km: number } => !!x && x.km < 0.06)
.sort((a, b) => a.km - b.km)
.slice(0, 3);
if (nearby.length > 0) {
setDupWarning(nearby);
toast({
title: 'That spot may already be on the map',
description: 'Check the pin below before adding a duplicate.',
});
return;
}
}
try {
// Generate geohash from coordinates (7 chars = ~153m precision)
const geohash = ngeohash.encode(lat, lng, 7);
@ -765,6 +797,53 @@ export default function CreateSpotPage() {
hasSpotType={!!selectedCategory}
/>
{dupWarning && (
<div className="rounded-2xl border-2 border-amber-500/40 bg-amber-500/10 p-4">
<p className="text-sm font-black uppercase tracking-wide text-amber-300">
This spot may already exist
</p>
<p className="mt-1 text-xs text-gray-300">
{dupWarning.length === 1 ? 'There is a spot' : 'There are spots'} within 60 m of
your pin. If it's the same place, add your photo or a status report there
instead of creating a duplicate.
</p>
<ul className="mt-2 space-y-1">
{dupWarning.map(({ spot, km }) => (
<li key={spot.id} className="truncate text-xs">
<Link
to={`/spot/${spot.pubkey}/${spot.identifier}`}
className="text-[#ccff00] hover:opacity-80"
>
{spot.title || 'Untitled spot'} · {Math.round(km * 1000)} m away
</Link>
</li>
))}
</ul>
<div className="mt-3 flex flex-wrap gap-2">
<Button
type="button"
variant="outline"
className="border-white/20"
onClick={() => {
dupConfirmedRef.current = true;
setDupWarning(null);
void handleSubmit();
}}
>
It's a different spot — publish anyway
</Button>
<Button
type="button"
variant="ghost"
className="text-gray-400"
onClick={() => setDupWarning(null)}
>
Cancel
</Button>
</div>
</div>
)}
{/* Submit */}
<Button
type="submit"

View file

@ -63,6 +63,7 @@ import {
ShieldCheck,
ShieldQuestion,
BadgeCheck,
ShieldAlert,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
import { ReportDialog } from '@/components/ReportDialog';
@ -178,6 +179,10 @@ export default function SpotDetailPage() {
// Staff verdict (NIP-32 `verified` label) — separate from the community
// signal: a human actually looked at this one.
const moderation = useAdminModeration();
const staffHidden = !!spot && moderation.has({
coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`,
id: spot.id,
});
const staffVerified = !!spot && moderation.isVerified({
coord: `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`,
id: spot.id,
@ -547,6 +552,38 @@ export default function SpotDetailPage() {
))}
</div>
{/* Author-facing moderation notice. The verdicts are public
labels, so there is nothing secret to hide — and without this
a contributor whose spot was hidden just sees it vanish. */}
{isOwner && staffHidden && (
<div className="mb-4 flex items-start gap-3 rounded-2xl border-2 border-amber-500/40 bg-amber-500/10 p-4">
<ShieldAlert className="mt-0.5 h-5 w-5 shrink-0 text-amber-400" />
<div className="min-w-0">
<p className="text-sm font-black uppercase tracking-wide text-amber-300">
Hidden by a moderator
</p>
<p className="mt-1 text-xs leading-relaxed text-gray-300">
This spot is not shown on the map or in search. You can still see it because
it's yours. Editing it will not un-hide it — it stays hidden until a moderator
retracts the verdict.
</p>
</div>
</div>
)}
{isOwner && staffVerified && (
<div className="mb-4 flex items-start gap-3 rounded-2xl border-2 border-[#ccff00]/30 bg-[#ccff00]/5 p-4">
<BadgeCheck className="mt-0.5 h-5 w-5 shrink-0 text-[#ccff00]" />
<div className="min-w-0">
<p className="text-sm font-black uppercase tracking-wide text-[#ccff00]">
Verified by SkateSpots
</p>
<p className="mt-1 text-xs leading-relaxed text-gray-300">
A moderator reviewed this spot and vouched for it. Nice one.
</p>
</div>
</div>
)}
{/* Community Contributions Section */}
{!isShop && (
<Card className="bg-black/90 border-2 border-blue-500/30 overflow-hidden">