From 355a4b1b3200a46cab1b9be0c37c07dd8892d40c Mon Sep 17 00:00:00 2001 From: Mike Johnston Date: Wed, 18 Mar 2026 17:54:02 -0400 Subject: [PATCH] pw change, leaderboard, lotto, related ht, admin ban, add, and display changes --- README.md | 119 ++++++++++ prisma/schema.prisma | 41 +++- src/app/admin/stocks/AdminAddHashtag.tsx | 127 +++++++++++ src/app/admin/stocks/AdminStockActions.tsx | 15 +- src/app/admin/stocks/page.tsx | 30 ++- src/app/api/admin/stocks/[hashtagId]/route.ts | 3 + src/app/api/admin/stocks/route.ts | 94 ++++++++ src/app/api/auth/register/route.ts | 3 +- src/app/api/lottery/pick/route.ts | 101 +++++++++ src/app/api/research/route.ts | 6 + src/app/api/user/me/route.ts | 72 +++++- src/app/api/user/password/route.ts | 51 +++++ src/app/auth/signup/page.tsx | 2 +- src/app/hashtag/[tag]/page.tsx | 29 ++- src/app/leaderboard/page.tsx | 170 ++++++++++++++ src/app/lottery/page.tsx | 212 ++++++++++++++++++ src/app/page.tsx | 48 ++-- .../[username]/AccountSettingsForm.tsx | 142 ++++++++++++ .../profile/[username]/ChangePasswordForm.tsx | 117 ++++++++++ src/app/profile/[username]/page.tsx | 19 +- src/components/Navbar.tsx | 10 +- src/lib/lottery.ts | 12 + src/lib/mastodon.ts | 34 ++- src/middleware.ts | 1 + src/worker/index.ts | 29 ++- 25 files changed, 1442 insertions(+), 45 deletions(-) create mode 100644 src/app/admin/stocks/AdminAddHashtag.tsx create mode 100644 src/app/api/admin/stocks/route.ts create mode 100644 src/app/api/lottery/pick/route.ts create mode 100644 src/app/api/user/password/route.ts create mode 100644 src/app/leaderboard/page.tsx create mode 100644 src/app/lottery/page.tsx create mode 100644 src/app/profile/[username]/AccountSettingsForm.tsx create mode 100644 src/app/profile/[username]/ChangePasswordForm.tsx create mode 100644 src/lib/lottery.ts diff --git a/README.md b/README.md index 8a364aa..1389120 100644 --- a/README.md +++ b/README.md @@ -320,3 +320,122 @@ npm run db:migrate # Create and apply a migration npm run db:seed # Seed the admin user npm run db:studio # Open Prisma Studio (GUI) ``` + +--- + +## Feature Roadmap + +The items below are planned improvements roughly ordered by user value. They are good candidates for the next development phase. + +--- + +### 1. Username Display Names & Password Changes on Profile + +**Problem:** Usernames are forced lowercase on registration (some users want "JohnDoe" not "johndoe"). Users also have no self-service way to change their password. + +**Plan:** +- Add a `displayUsername` field to `User` — stores the original mixed-case version typed at signup. Lookups/URLs continue using the lowercase `username` for uniqueness and consistency. +- Profile settings page (`/profile/[username]/settings` or a tab on the existing profile) with: + - **Change display name** — updates `displayUsername` only; the canonical `username` remains lowercase and immutable (avoids breaking existing links/positions). + - **Change username** — updates both `username` and `displayUsername`; requires confirming no conflicts; all foreign key relations in Prisma use the DB `id` so no cascade issues. + - **Change password** — classic current-password + new-password + confirm form; server endpoint `PATCH /api/user/me` validating current hash before updating. +- All places that show a username (Navbar, profile page, leaderboard, trade history) should render `displayUsername` if set, falling back to `username`. + +--- + +### 2. Leaderboard + +**Problem:** No public way to see who the top players are or browse other users' portfolios. + +**Plan:** +- New page `/leaderboard` showing top N players sorted by **net worth** (balance + sum of position value at current prices). +- Table columns: rank, avatar/username link → their public profile, net worth, total trades, biggest single position. +- Public profiles — any user can view `/profile/[username]`; sensitive data (exact balance) optionally hidden from non-owners/non-admins. +- Navbar: show **Leaderboard** link when signed in (replacing or supplementing the current unauthenticated-only links). +- Consider a separate "all-time best trade" leaderboard tab for engagement. + +--- + +### 3. Lucky Dip Lottery + +**Problem:** Players who go broke have no recovery mechanic; there is no daily engagement hook beyond trading. + +**Plan:** +- New page `/lottery`. +- Once per day (tracked by a `lastLotteryAt` timestamp on `User`), a player who has **$0 or less** can reveal one hidden box from a grid (e.g. 5 × 5 = 25 boxes). +- One box is the winner (selected server-side at draw time, stored encrypted or revealed only on pick to prevent client-side cheating). +- **Prizes** (configurable): e.g. one box pays $100, a few pay $10, the rest pay $0. +- API: `POST /api/lottery/pick` — validates cooldown, selects winner server-side, awards balance, returns outcome. +- Consider allowing players with any balance to play for a small stake (e.g. pay $5 to play, win up to $200) to make it useful beyond the broke scenario. +- Future: admin-configurable prize pool and grid size. + +--- + +### 4. Home Page Nav Changes (Signed-In vs Signed-Out) + +**Problem:** Home page shows the same call-to-action links regardless of auth state. + +**Plan:** +- When **signed out**: show Sign Up / Sign In CTAs as currently. +- When **signed in**: replace or supplement with quick links to **Leaderboard**, **Lottery**, and the user's own profile. +- Navbar already hides/shows some items; extend that logic to the hero section of the home page. + +--- + +### 5. Related Hashtags + +**Problem:** No discovery path from one hashtag to related ones. + +**Plan:** +- During price update jobs the worker already fetches up to 200 posts. Parse each post's `tags` array (present in the Mastodon API response) to collect co-occurring hashtags. +- Store co-occurrence counts in a new `RelatedHashtag` table: `(hashtagId, relatedTag, coOccurrences)` — upsert on each price update, incrementing counts. +- On the hashtag detail page, show the top 5 most co-occurring tags as "Related" chips that link to their own pages. +- Only surface related tags that are themselves active (or researchable) — avoid surfacing banned/inactive noise. +- Worker change: `MastodonPost` type already has `content`; add `tags: { name: string }[]` to the interface and parse it in `mastodon.ts`. + +--- + +### 6. Admin: Price Audit Log + +**Problem:** Admins have no visibility into the math behind a given price update. + +**Plan:** +- The `PriceHistory` table already stores `postsPerHour` alongside `price`. Surface this in two places: + 1. **Hashtag detail page** — show the most recent `postsPerHour` reading and the formula result as a tooltip or sub-line under the current price (e.g. _"$1.49 — 5.96 posts/hr at last update"_). + 2. **Admin stocks page** — add a "History" drawer/modal per hashtag showing the last N `PriceHistory` rows as a table: `recordedAt | postsPerHour | price | Δ price` so admins can see the full audit trail. +- No schema changes needed — `postsPerHour` is already stored. + +--- + +### 7. Admin: Add Hashtag Manually + +**Problem:** Admins cannot bypass the research-point system to seed the exchange with interesting hashtags. + +**Plan:** +- "Add hashtag" button on `/admin/stocks` that opens a modal with a tag input. +- API: `POST /api/admin/stocks` — normalizes tag, queries Mastodon immediately (same as research flow), upserts hashtag, queues an initial price-update job. Returns error if Mastodon returns nothing. +- Optionally allow admin to force-add with a manual starting price even if Mastodon has no results (useful for bootstrapping/testing). + +--- + +### 8. Admin: Ban / Block Hashtags + +**Problem:** No way to prevent abusive or NSFW hashtags from being researched into the exchange. + +**Plan:** +- Add `isBanned: Boolean @default(false)` field to the `Hashtag` model. +- Admin UI: "Ban" button on the stocks page that sets `isBanned = true` and `isActive = false`. Banned hashtags still exist in the DB for record-keeping but are not researchable. +- Research API (`/api/research`): check `isBanned` on any existing matching hashtag — if banned return a `403` with a user-friendly message (e.g. _"This hashtag is not available on HashEx."_) without spending a research point. +- New pre-ban blocklist: a static list (or admin-editable table) of tags that are auto-banned on first research attempt, before any Mastodon query. +- Banned tags should still appear in the admin stocks list with a distinct visual indicator and an "Unban" toggle. + +--- + +### Other Ideas / Nice-to-Haves + +- **Hedge funds**: group of players pool money into a shared portfolio, one designated fund manager places trades. +- **Email integration**: SMTP-based password reset and optional trade confirmation emails. +- **Multi-instance support**: let users choose which Mastodon instance to pull data from per-hashtag, or aggregate across instances. +- **Mobile-optimised trade panel**: the current layout works but a dedicated bottom-sheet on mobile would improve UX. +- **Price alerts**: users subscribe to a hashtag at a threshold price; a notification appears in the UI (or email if integrated) when it crosses that level. +- **Dark/light theme toggle**: currently dark-only. diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 22fa37d..1c777fe 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -8,14 +8,16 @@ datasource db { } model User { - id String @id @default(cuid()) - username String @unique - passwordHash String - balance Float @default(2000) - researchPoints Int @default(1) - isAdmin Boolean @default(false) - createdAt DateTime @default(now()) - updatedAt DateTime @updatedAt + id String @id @default(cuid()) + username String @unique // lowercase, used for URLs/lookups + displayUsername String? // original casing chosen by user + passwordHash String + balance Float @default(2000) + researchPoints Int @default(1) + isAdmin Boolean @default(false) + lastLotteryAt DateTime? // tracks daily lottery cooldown + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt positions Position[] trades Trade[] @@ -40,18 +42,35 @@ model Hashtag { displayTag String // original case as entered currentPrice Float @default(0.25) isActive Boolean @default(true) + isBanned Boolean @default(false) // Consecutive zero-result count; after 3 failed updates the hashtag auto-deactivates zeroCount Int @default(0) lastUpdated DateTime @default(now()) createdAt DateTime @default(now()) - priceHistory PriceHistory[] - positions Position[] - trades Trade[] + priceHistory PriceHistory[] + positions Position[] + trades Trade[] + relatedFrom RelatedHashtag[] @relation("RelatedFrom") + relatedTo RelatedHashtag[] @relation("RelatedTo") @@index([isActive, lastUpdated]) } +model RelatedHashtag { + id String @id @default(cuid()) + hashtagId String + hashtag Hashtag @relation("RelatedFrom", fields: [hashtagId], references: [id], onDelete: Cascade) + relatedTag String // lowercase tag name (may not yet be in Hashtag table) + relatedId String? // set if the related hashtag exists in the DB + related Hashtag? @relation("RelatedTo", fields: [relatedId], references: [id], onDelete: SetNull) + coOccurrences Int @default(1) + updatedAt DateTime @updatedAt + + @@unique([hashtagId, relatedTag]) + @@index([hashtagId, coOccurrences]) +} + model PriceHistory { id String @id @default(cuid()) hashtagId String diff --git a/src/app/admin/stocks/AdminAddHashtag.tsx b/src/app/admin/stocks/AdminAddHashtag.tsx new file mode 100644 index 0000000..64bb10e --- /dev/null +++ b/src/app/admin/stocks/AdminAddHashtag.tsx @@ -0,0 +1,127 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { Plus, Loader2 } from 'lucide-react' + +export function AdminAddHashtag() { + const router = useRouter() + const [open, setOpen] = useState(false) + const [tag, setTag] = useState('') + const [forcePrice, setForcePrice] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [message, setMessage] = useState('') + + async function handleAdd() { + setLoading(true) + setError('') + setMessage('') + + const body: { tag: string; forcePrice?: number } = { tag } + if (forcePrice && parseFloat(forcePrice) > 0) { + body.forcePrice = parseFloat(forcePrice) + } + + const res = await fetch('/api/admin/stocks', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }) + const data = await res.json() + setLoading(false) + + if (!res.ok) { + setError(data.error ?? 'Failed to add hashtag.') + } else { + setMessage(data.alreadyActive ? `#${tag} is already active.` : `#${tag} added successfully!`) + setTag('') + setForcePrice('') + router.refresh() + setTimeout(() => { setOpen(false); setMessage('') }, 1500) + } + } + + return ( + <> + + + {open && ( +
setOpen(false)} + > +
e.stopPropagation()} + > +

Add hashtag

+

+ Queries Mastodon and sets the price automatically. Provide an override price if you + want to add a tag with no recent activity. +

+ +
+
+ + setTag(e.target.value)} + placeholder="photography" + className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+
+ + setForcePrice(e.target.value)} + placeholder="e.g. 1.00" + className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+
+ + {error && ( +

+ {error} +

+ )} + {message && ( +

{message}

+ )} + +
+ + +
+
+
+ )} + + ) +} diff --git a/src/app/admin/stocks/AdminStockActions.tsx b/src/app/admin/stocks/AdminStockActions.tsx index 570debd..77511dd 100644 --- a/src/app/admin/stocks/AdminStockActions.tsx +++ b/src/app/admin/stocks/AdminStockActions.tsx @@ -9,6 +9,7 @@ interface HashtagData { displayTag: string currentPrice: number isActive: boolean + isBanned: boolean zeroCount: number } @@ -17,6 +18,7 @@ export function AdminStockActions({ hashtag }: { hashtag: HashtagData }) { const [open, setOpen] = useState(false) const [price, setPrice] = useState(String(hashtag.currentPrice)) const [isActive, setIsActive] = useState(hashtag.isActive) + const [isBanned, setIsBanned] = useState(hashtag.isBanned) const [loading, setLoading] = useState(false) const [error, setError] = useState('') @@ -26,7 +28,7 @@ export function AdminStockActions({ hashtag }: { hashtag: HashtagData }) { const res = await fetch(`/api/admin/stocks/${hashtag.id}`, { method: 'PATCH', headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ currentPrice: parseFloat(price), isActive }), + body: JSON.stringify({ currentPrice: parseFloat(price), isActive, isBanned }), }) const data = await res.json() setLoading(false) @@ -90,6 +92,17 @@ export function AdminStockActions({ hashtag }: { hashtag: HashtagData }) { +
+ setIsBanned(e.target.checked)} + className="rounded" + /> + +
+ + ) + })} + + + {canPlay && !result && ( +

Pick any box to play

+ )} + + ) +} diff --git a/src/app/page.tsx b/src/app/page.tsx index f092308..100f4a0 100644 --- a/src/app/page.tsx +++ b/src/app/page.tsx @@ -1,4 +1,6 @@ import { prisma } from '@/lib/prisma' +import { getServerSession } from 'next-auth' +import { authOptions } from '@/lib/auth' import { HashtagCard } from '@/components/HashtagCard' import { TrendingUp, Users, Hash } from 'lucide-react' import Link from 'next/link' @@ -36,7 +38,8 @@ async function getStats() { } export default async function HomePage() { - const { userCount, hashtagCount, tradeCount, topHashtags, recentTrades } = await getStats() + const [session, { userCount, hashtagCount, tradeCount, topHashtags, recentTrades }] = + await Promise.all([getServerSession(authOptions), getStats()]) return (
@@ -51,18 +54,37 @@ export default async function HomePage() { Research a tag to unlock it, then buy long or short.

- - Get started - - - Sign in - + {session ? ( + <> + + Leaderboard + + + Lucky Dip + + + ) : ( + <> + + Get started + + + Sign in + + + )}
diff --git a/src/app/profile/[username]/AccountSettingsForm.tsx b/src/app/profile/[username]/AccountSettingsForm.tsx new file mode 100644 index 0000000..0ef4f66 --- /dev/null +++ b/src/app/profile/[username]/AccountSettingsForm.tsx @@ -0,0 +1,142 @@ +'use client' + +import { useState } from 'react' +import { useRouter } from 'next/navigation' +import { UserCog, Loader2, CheckCircle2 } from 'lucide-react' + +interface Props { + currentUsername: string + currentDisplayUsername: string | null +} + +export default function AccountSettingsForm({ currentUsername, currentDisplayUsername }: Props) { + const router = useRouter() + const [open, setOpen] = useState(false) + + const [displayUsername, setDisplayUsername] = useState(currentDisplayUsername ?? currentUsername) + const [username, setUsername] = useState(currentUsername) + + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState('') + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError('') + setSuccess('') + + const updates: { displayUsername?: string; username?: string } = {} + + if (displayUsername.trim() !== (currentDisplayUsername ?? currentUsername)) { + updates.displayUsername = displayUsername.trim() + } + + const newUsername = username.toLowerCase().trim() + if (newUsername !== currentUsername) { + updates.username = newUsername + } + + if (Object.keys(updates).length === 0) { + setError('No changes to save.') + return + } + + setLoading(true) + try { + const res = await fetch('/api/user/me', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(updates), + }) + const data = await res.json() + if (!res.ok) { + setError(data.error ?? 'Something went wrong.') + } else { + setSuccess('Settings saved!') + // If username changed, navigate to new profile URL + if (updates.username) { + router.push(`/profile/${data.username}`) + } else { + router.refresh() + } + setTimeout(() => setOpen(false), 1200) + } + } catch { + setError('Network error. Please try again.') + } finally { + setLoading(false) + } + } + + return ( +
+ + + {open && ( +
+
+ + setDisplayUsername(e.target.value)} + minLength={3} + maxLength={20} + pattern="^[a-zA-Z0-9_]{3,20}$" + title="3–20 characters: letters, numbers, underscores" + required + className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+ +
+ + setUsername(e.target.value)} + minLength={3} + maxLength={20} + pattern="^[a-zA-Z0-9_]{3,20}$" + title="3–20 characters: letters, numbers, underscores" + required + className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +

+ Will be stored as lowercase. Profile URL: /profile/{username.toLowerCase()} +

+
+ + {error &&

{error}

} + {success && ( +

+ {success} +

+ )} + + +
+ )} +
+ ) +} diff --git a/src/app/profile/[username]/ChangePasswordForm.tsx b/src/app/profile/[username]/ChangePasswordForm.tsx new file mode 100644 index 0000000..5bf8c71 --- /dev/null +++ b/src/app/profile/[username]/ChangePasswordForm.tsx @@ -0,0 +1,117 @@ +'use client' + +import { useState } from 'react' +import { KeyRound, Loader2, CheckCircle2 } from 'lucide-react' + +export default function ChangePasswordForm() { + const [open, setOpen] = useState(false) + const [current, setCurrent] = useState('') + const [next, setNext] = useState('') + const [confirm, setConfirm] = useState('') + const [loading, setLoading] = useState(false) + const [error, setError] = useState('') + const [success, setSuccess] = useState(false) + + async function handleSubmit(e: React.FormEvent) { + e.preventDefault() + setError('') + setSuccess(false) + + if (next !== confirm) { + setError('New passwords do not match.') + return + } + + setLoading(true) + try { + const res = await fetch('/api/user/password', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ currentPassword: current, newPassword: next }), + }) + const data = await res.json() + if (!res.ok) { + setError(data.error ?? 'Something went wrong.') + } else { + setSuccess(true) + setCurrent('') + setNext('') + setConfirm('') + setTimeout(() => setOpen(false), 1800) + } + } catch { + setError('Network error. Please try again.') + } finally { + setLoading(false) + } + } + + return ( +
+ + + {open && ( +
+
+ + setCurrent(e.target.value)} + required + autoComplete="current-password" + className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+
+ + setNext(e.target.value)} + required + minLength={8} + autoComplete="new-password" + className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+
+ + setConfirm(e.target.value)} + required + minLength={8} + autoComplete="new-password" + className="w-full bg-surface border border-surface-border rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500" + /> +
+ + {error &&

{error}

} + {success && ( +

+ Password updated! +

+ )} + + +
+ )} +
+ ) +} diff --git a/src/app/profile/[username]/page.tsx b/src/app/profile/[username]/page.tsx index 070850e..022b512 100644 --- a/src/app/profile/[username]/page.tsx +++ b/src/app/profile/[username]/page.tsx @@ -5,6 +5,8 @@ import { notFound } from 'next/navigation' import { formatCurrency, formatNumber, pnlColor, formatPnl } from '@/lib/utils' import Link from 'next/link' import { TrendingUp, TrendingDown, Coins } from 'lucide-react' +import ChangePasswordForm from './ChangePasswordForm' +import AccountSettingsForm from './AccountSettingsForm' export const dynamic = 'force-dynamic' @@ -21,6 +23,7 @@ export default async function ProfilePage({ params }: Props) { select: { id: true, username: true, + displayUsername: true, balance: true, researchPoints: true, createdAt: true, @@ -66,7 +69,10 @@ export default async function ProfilePage({ params }: Props) { {/* Header */}
-

{user.username}

+

{user.displayUsername ?? user.username}

+ {user.displayUsername && user.displayUsername.toLowerCase() !== user.username && ( +

@{user.username}

+ )} {isOwn && (

{user.researchPoints} research point{user.researchPoints !== 1 ? 's' : ''} available @@ -95,6 +101,17 @@ export default async function ProfilePage({ params }: Props) { />

+ {/* Account settings — only shown to the profile owner */} + {isOwn && ( + <> + + + + )} + {/* Positions */} {user.positions.length > 0 && (
diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 58b04c0..87fa42e 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -2,7 +2,7 @@ import Link from 'next/link' import { useSession, signOut } from 'next-auth/react' -import { TrendingUp, Search, User, LogOut, Shield } from 'lucide-react' +import { TrendingUp, Search, User, LogOut, Shield, Trophy } from 'lucide-react' import { useState } from 'react' import { useRouter } from 'next/navigation' import { formatCurrency } from '@/lib/utils' @@ -53,6 +53,14 @@ export function Navbar() { {/* Balance chip */} + + + + {session.user.isAdmin && ( = { + 0: 200, // jackpot + 1: 50, + 2: 50, + 3: 10, + 4: 10, + 5: 10, +} diff --git a/src/lib/mastodon.ts b/src/lib/mastodon.ts index c9fec29..ccef9c7 100644 --- a/src/lib/mastodon.ts +++ b/src/lib/mastodon.ts @@ -2,6 +2,7 @@ export interface MastodonPost { id: string created_at: string content: string + tags?: { name: string }[] } interface TimelineResult { @@ -49,6 +50,17 @@ async function fetchPage(tag: string, maxId?: string): Promise { * (e.g., #happynewyear at midnight) up to MAX_PAGES_PER_HASHTAG pages. */ export async function getPostsPerHour(tag: string): Promise { + const { postsPerHour } = await getPostsData(tag) + return postsPerHour +} + +/** + * Returns posts-per-hour AND a sorted list of co-occurring tag names + * (lowercased, excluding the queried tag itself). + */ +export async function getPostsData( + tag: string, +): Promise<{ postsPerHour: number; relatedTags: string[] }> { const maxPages = parseInt(process.env.MAX_PAGES_PER_HASHTAG ?? '5', 10) let allPosts: MastodonPost[] = [] @@ -71,7 +83,7 @@ export async function getPostsPerHour(tag: string): Promise { maxId = nextMaxId } - if (allPosts.length === 0) return 0 + if (allPosts.length === 0) return { postsPerHour: 0, relatedTags: [] } const times = allPosts.map((p) => new Date(p.created_at).getTime()) const newestMs = Math.max(...times) @@ -79,6 +91,24 @@ export async function getPostsPerHour(tag: string): Promise { // Minimum 1-minute span to handle flood scenario (all same timestamp) const spanHours = Math.max((newestMs - oldestMs) / (1000 * 60 * 60), 1 / 60) + const postsPerHour = allPosts.length / spanHours - return allPosts.length / spanHours + // Count co-occurring tags + const counts = new Map() + const lowerTag = tag.toLowerCase() + for (const post of allPosts) { + for (const t of post.tags ?? []) { + const name = t.name.toLowerCase() + if (name !== lowerTag && name.length >= 2 && name.length <= 100) { + counts.set(name, (counts.get(name) ?? 0) + 1) + } + } + } + // Return top 10 by count + const relatedTags = [...counts.entries()] + .sort((a, b) => b[1] - a[1]) + .slice(0, 10) + .map(([name]) => name) + + return { postsPerHour, relatedTags } } diff --git a/src/middleware.ts b/src/middleware.ts index 2b2465f..15786fe 100644 --- a/src/middleware.ts +++ b/src/middleware.ts @@ -8,5 +8,6 @@ export const config = { '/api/research/:path*', '/api/user/:path*', '/api/admin/:path*', + '/api/lottery/:path*', ], } diff --git a/src/worker/index.ts b/src/worker/index.ts index 03b2723..419957b 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -12,7 +12,7 @@ import { Worker, Queue } from 'bullmq' import { PrismaClient } from '@prisma/client' -import { getPostsPerHour } from '../lib/mastodon' +import { getPostsData } from '../lib/mastodon' import { calcPrice, dailyResearchPoints } from '../lib/pricing' // ── Connection options ──────────────────────────────────────────────────────── @@ -61,8 +61,9 @@ const priceWorker = new Worker( console.log(`[price] updating #${tag}`) let postsPerHour = 0 + let relatedTags: string[] = [] try { - postsPerHour = await getPostsPerHour(tag) + ;({ postsPerHour, relatedTags } = await getPostsData(tag)) } catch (err) { console.error(`[price] mastodon error for #${tag}:`, err) throw err // BullMQ will retry @@ -107,6 +108,30 @@ const priceWorker = new Worker( }), ]) + // Upsert related hashtag co-occurrences (outside transaction — non-critical) + if (relatedTags.length > 0) { + for (const relTag of relatedTags) { + // Try to resolve to an existing hashtag id + const relHashtag = await prisma.hashtag.findUnique({ + where: { tag: relTag }, + select: { id: true }, + }) + await prisma.relatedHashtag.upsert({ + where: { hashtagId_relatedTag: { hashtagId, relatedTag: relTag } }, + create: { + hashtagId, + relatedTag: relTag, + relatedId: relHashtag?.id ?? null, + coOccurrences: 1, + }, + update: { + coOccurrences: { increment: 1 }, + relatedId: relHashtag?.id ?? undefined, + }, + }) + } + } + console.log(`[price] #${tag} → $${newPrice.toFixed(2)} (${postsPerHour.toFixed(1)} posts/hr)`) // Honour the configured rate limit by sleeping after each job