diff --git a/.github/workflows/publish-npm-package.yml b/.github/workflows/publish-npm-package.yml index e9792db0..a0104172 100644 --- a/.github/workflows/publish-npm-package.yml +++ b/.github/workflows/publish-npm-package.yml @@ -7,6 +7,7 @@ on: paths: - "data/**" - "packages/alphland-dapps/**" + workflow_dispatch: jobs: publish: @@ -24,9 +25,12 @@ jobs: - name: Setup Node.js uses: actions/setup-node@v4 with: - node-version: "18" + node-version: "22" registry-url: "https://registry.npmjs.org" + - name: Update npm + run: npm install -g npm@latest + - name: Check if package version changed id: version-check run: | diff --git a/.husky/pre-push b/.husky/pre-push new file mode 100755 index 00000000..6b3d2bd1 --- /dev/null +++ b/.husky/pre-push @@ -0,0 +1,38 @@ +#!/bin/sh + +# Load nvm +export NVM_DIR="$HOME/.nvm" +[ -s "$NVM_DIR/nvm.sh" ] && \. "$NVM_DIR/nvm.sh" + +# Skip the test suite when the push only touches dApp content +# (data/*.json entries and public/dapps/** images) — e.g. a teammate +# submitting or removing a dApp listing, with no code changes. +zero="0000000000000000000000000000000000000000" +run_tests=0 + +while read -r local_ref local_sha remote_ref remote_sha; do + [ -z "$local_sha" ] && continue + [ "$local_sha" = "$zero" ] && continue # deleting a branch, nothing to check + + if [ "$remote_sha" = "$zero" ]; then + base=$(git merge-base "$local_sha" origin/develop 2>/dev/null) + [ -z "$base" ] && base="$remote_sha" + else + base="$remote_sha" + fi + + changed=$(git diff --name-only "$base" "$local_sha" 2>/dev/null) + [ -z "$changed" ] && continue + + non_dapp_changes=$(printf '%s\n' "$changed" | grep -vE '^(data/[^/]+\.json|public/dapps/.*)$') + if [ -n "$non_dapp_changes" ]; then + run_tests=1 + fi +done + +if [ "$run_tests" = "0" ]; then + echo "pre-push: only dApp content changed (data/*.json, public/dapps/**) — skipping test suite" + exit 0 +fi + +npm run test:ci diff --git a/.lintstagedrc b/.lintstagedrc index 7b1e1174..ec8d1291 100644 --- a/.lintstagedrc +++ b/.lintstagedrc @@ -4,5 +4,8 @@ ], "**/*.+(ts|tsx|js|jsx|css|json)": [ "prettier --write" + ], + "src/features/bounty/**/*.+(ts|tsx)": [ + "vitest related --run" ] } diff --git a/data/alephium-market-cap-ranking.json b/data/alephium-market-cap-ranking.json new file mode 100644 index 00000000..7799b7b5 --- /dev/null +++ b/data/alephium-market-cap-ranking.json @@ -0,0 +1,38 @@ +{ + "name": "Alephium Market Cap Ranking", + "description": "Alephium Market Cap Ranking is an analytics website that tracks Alephium’s market capitalization ranking over time. It visualizes historical ranking data to help users follow how Alephium’s position changes in the broader crypto market.", + "short_description": "Track Alephium market cap ranking over time", + "tags": [ + "Analytics" + ], + "verified": false, + "dotw": false, + "councils_choice": false, + "links": { + "website": "https://spikeblack-alph.github.io/Alephium-MC-overtime/", + "careers": "", + "twitter": "", + "telegram": "https://t.me/Spike_Black", + "discord": "", + "docs": "", + "github": "https://github.com/SpikeBlack-Alph/Alephium-MC-overtime", + "youtube": "", + "medium": "", + "mirror": "", + "linkedin": "" + }, + "twitterName": "", + "teamInfo": { + "contactEmail": "spikeblackalephium@gmail.com", + "founded": "2026-08-24T00:00:00.000Z", + "anonymous": false + }, + "media": { + "logoUrl": "/dapps/alephium-market-cap-ranking/alephium-market-cap-ranking-logo.webp", + "bannerUrl": "/dapps/alephium-market-cap-ranking/alephium-market-cap-ranking-banner.webp", + "previewUrl": "/dapps/alephium-market-cap-ranking/alephium-market-cap-ranking-preview.webp" + }, + "contracts": [], + "audits": [], + "tokens": [] +} diff --git a/data/alphscoundrel.json b/data/alphscoundrel.json new file mode 100644 index 00000000..254fe9d8 --- /dev/null +++ b/data/alphscoundrel.json @@ -0,0 +1,38 @@ +{ + "name": "AlphScoundrel", + "description": "AlphScoundrel is a deterministic dungeon card game built on Alephium. Players enter with 24 HP and work through a 44-card deck, fighting monsters, equipping weapons, banking potions, and maximizing a progress-based score. Guest practice runs stay local, while wallet-signed runs are transcript-verified before leaderboard submission; paid ALPH entries can follow a prize-eligible on-chain path.", + "short_description": "Scoundrel dungeon card game on Alephium", + "tags": [ + "Games" + ], + "verified": false, + "dotw": false, + "councils_choice": false, + "links": { + "website": "https://alphscoundrel.alehpium.org/", + "careers": "", + "twitter": "", + "telegram": "", + "discord": "", + "docs": "", + "github": "", + "youtube": "", + "medium": "", + "mirror": "", + "linkedin": "" + }, + "twitterName": "", + "teamInfo": { + "contactEmail": "", + "founded": "", + "anonymous": true + }, + "media": { + "logoUrl": "/dapps/alphscoundrel/alphscoundrel-logo.webp", + "bannerUrl": "/dapps/alphscoundrel/alphscoundrel-banner.webp", + "previewUrl": "/dapps/alphscoundrel/alphscoundrel-preview.webp" + }, + "contracts": [], + "audits": [], + "tokens": [] +} \ No newline at end of file diff --git a/data/alphtris.json b/data/alphtris.json new file mode 100644 index 00000000..2822a4e7 --- /dev/null +++ b/data/alphtris.json @@ -0,0 +1,38 @@ +{ + "name": "ALPHtris", + "description": "ALPHtris is a neon block-stacking arcade game on Alephium. Players connect an Alephium wallet, pay an ALPH entry fee, play a run, and submit scores on chain to compete on current-round, global, and recent leaderboards. It's Tetris. On Alephium.", + "short_description": "on-chain Alphtris (Tetris)", + "tags": [ + "Games" + ], + "verified": false, + "dotw": false, + "councils_choice": false, + "links": { + "website": "https://alphtris.alehpium.org/", + "careers": "", + "twitter": "", + "telegram": "", + "discord": "", + "docs": "", + "github": "", + "youtube": "", + "medium": "", + "mirror": "", + "linkedin": "" + }, + "twitterName": "", + "teamInfo": { + "contactEmail": "", + "founded": "", + "anonymous": true + }, + "media": { + "logoUrl": "/dapps/alphtris/alphtris-logo.webp", + "bannerUrl": "/dapps/alphtris/alphtris-banner.webp", + "previewUrl": "/dapps/alphtris/alphtris-preview.webp" + }, + "contracts": [], + "audits": [], + "tokens": [] +} \ No newline at end of file diff --git a/public/dapps/alephium-market-cap-ranking/alephium-market-cap-ranking-banner.webp b/public/dapps/alephium-market-cap-ranking/alephium-market-cap-ranking-banner.webp new file mode 100644 index 00000000..f8305983 Binary files /dev/null and b/public/dapps/alephium-market-cap-ranking/alephium-market-cap-ranking-banner.webp differ diff --git a/public/dapps/alephium-market-cap-ranking/alephium-market-cap-ranking-logo.webp b/public/dapps/alephium-market-cap-ranking/alephium-market-cap-ranking-logo.webp new file mode 100644 index 00000000..11aeab6c Binary files /dev/null and b/public/dapps/alephium-market-cap-ranking/alephium-market-cap-ranking-logo.webp differ diff --git a/public/dapps/alephium-market-cap-ranking/alephium-market-cap-ranking-preview.webp b/public/dapps/alephium-market-cap-ranking/alephium-market-cap-ranking-preview.webp new file mode 100644 index 00000000..9479ba22 Binary files /dev/null and b/public/dapps/alephium-market-cap-ranking/alephium-market-cap-ranking-preview.webp differ diff --git a/public/dapps/alphscoundrel/alphscoundrel-banner.webp b/public/dapps/alphscoundrel/alphscoundrel-banner.webp new file mode 100644 index 00000000..9af2bc35 Binary files /dev/null and b/public/dapps/alphscoundrel/alphscoundrel-banner.webp differ diff --git a/public/dapps/alphscoundrel/alphscoundrel-logo.webp b/public/dapps/alphscoundrel/alphscoundrel-logo.webp new file mode 100644 index 00000000..e3dfd0a8 Binary files /dev/null and b/public/dapps/alphscoundrel/alphscoundrel-logo.webp differ diff --git a/public/dapps/alphscoundrel/alphscoundrel-preview.webp b/public/dapps/alphscoundrel/alphscoundrel-preview.webp new file mode 100644 index 00000000..032e5cf8 Binary files /dev/null and b/public/dapps/alphscoundrel/alphscoundrel-preview.webp differ diff --git a/public/dapps/alphtris/alphtris-banner.webp b/public/dapps/alphtris/alphtris-banner.webp new file mode 100644 index 00000000..138e22f2 Binary files /dev/null and b/public/dapps/alphtris/alphtris-banner.webp differ diff --git a/public/dapps/alphtris/alphtris-logo.webp b/public/dapps/alphtris/alphtris-logo.webp new file mode 100644 index 00000000..dccca5a5 Binary files /dev/null and b/public/dapps/alphtris/alphtris-logo.webp differ diff --git a/public/dapps/alphtris/alphtris-preview.webp b/public/dapps/alphtris/alphtris-preview.webp new file mode 100644 index 00000000..438cae6c Binary files /dev/null and b/public/dapps/alphtris/alphtris-preview.webp differ diff --git a/public/submit/index.html b/public/submit/index.html index a7d83686..d46690e0 100644 --- a/public/submit/index.html +++ b/public/submit/index.html @@ -387,7 +387,7 @@

Submit Your dApp to Alphland

- +
dApp name is required
diff --git a/src/features/bounty/components/BookmarksSection.tsx b/src/features/bounty/components/BookmarksSection.tsx index 7e322545..7fe8f8d9 100644 --- a/src/features/bounty/components/BookmarksSection.tsx +++ b/src/features/bounty/components/BookmarksSection.tsx @@ -27,6 +27,7 @@ interface BookmarkItem { status: string; end_date: string; sponsor_name: string; + sponsor_slug?: string; sponsor_logo_url: string | null; } @@ -208,7 +209,7 @@ export function BookmarksSection({ by{" "} {bookmark.sponsor_id && bookmark.sponsor_name ? (

{sponsorId && company ? ( - + e.stopPropagation()} diff --git a/src/features/bounty/components/CommentSection.tsx b/src/features/bounty/components/CommentSection.tsx index 425892b9..ca0a1b36 100644 --- a/src/features/bounty/components/CommentSection.tsx +++ b/src/features/bounty/components/CommentSection.tsx @@ -1,6 +1,5 @@ "use client"; -import { notificationService } from "../services/notificationService"; import { apiClient, BountyComment } from "@/lib/api-client"; import { containsProfanity } from "@/lib/profanity-filter"; import Link from "next/link"; @@ -115,12 +114,6 @@ function SingleComment({ // Notify comment owner (if not liking own comment) if (comment.user_id !== currentUserId) { - await notificationService.notifyCommentLike( - comment.user_id, - bountyId, - bountyTitle, - currentUsername, - ); } } } catch (error) { @@ -392,47 +385,9 @@ export function CommentSection({ parent_comment_id: replyingTo || undefined, }); - // Send notifications - if (replyingTo) { - // Find the parent comment to get its author - const findComment = ( - comments: CommentWithReplies[], - id: string, - ): CommentWithReplies | null => { - for (const c of comments) { - if (c.id === id) return c; - if (c.replies) { - const found = findComment(c.replies, id); - if (found) return found; - } - } - return null; - }; - const parentComment = findComment(comments, replyingTo); - if (parentComment && parentComment.user_id !== currentUserId) { - await notificationService.notifyCommentReply( - parentComment.user_id, - bountyId, - bountyTitle, - currentUsername, - ); - } - } else if (sponsorUserId && sponsorUserId !== currentUserId) { - // Notify sponsor of new comment (if not muted) - const shouldNotify = await notificationService.shouldNotify( - sponsorUserId, - bountyId, - "comments", - ); - if (shouldNotify) { - await notificationService.notifyNewComment( - sponsorUserId, - bountyId, - bountyTitle, - currentUsername, - ); - } - } + // Notifications are written by the worker when the comment is created -- + // it knows the parent author and the sponsor, and it honours the bounty + // mute for replies too, which this path never did. setNewComment(""); setReplyingTo(null); diff --git a/src/features/bounty/components/RewardInput.tsx b/src/features/bounty/components/RewardInput.tsx new file mode 100644 index 00000000..e1ef4e82 --- /dev/null +++ b/src/features/bounty/components/RewardInput.tsx @@ -0,0 +1,129 @@ +import { DollarSign } from "lucide-react"; +import { formatAlph, formatUsd, useAlphPrice } from "../hooks/useAlphPrice"; + +export type Denomination = "alph" | "usd"; + +interface RewardInputProps { + denomination: Denomination; + /** The single number the sponsor types — ALPH or USD per `denomination`. */ + amount: string; + onDenominationChange: (d: Denomination) => void; + onAmountChange: (value: string) => void; + disabled?: boolean; +} + +const inputClass = + "w-full pl-14 pr-4 py-3 bg-smoked-white dark:bg-light-black border " + + "border-border-grey dark:border-dark-charcoal rounded-lg text-black " + + "dark:text-white font-barlow focus:outline-none focus:ring-2 focus:ring-orange"; + +const labelClass = + "block text-sm font-semibold text-light-charcoal dark:text-lightgrey " + + "font-barlow mb-2"; + +/** + * Reward amount + which currency the sponsor is fixing it in. + * + * Exactly one number is ever entered. The other side is shown underneath as + * an approximation and is never submitted — the server derives and stores it + * from its own rate. This replaces the old form's hand-typed "USD Reference" + * field, which was the source of every unverifiable USD figure in the DB. + */ +export default function RewardInput({ + denomination, + amount, + onDenominationChange, + onAmountChange, + disabled, +}: RewardInputProps) { + const { price, loading } = useAlphPrice(); + + const numeric = parseFloat(amount); + const hasAmount = Number.isFinite(numeric) && numeric > 0; + + const conversion = () => { + if (!hasAmount) return null; + if (loading) return "Loading exchange rate…"; + if (!price) { + return denomination === "usd" + ? "Exchange rate unavailable — the ALPH amount will be set once it returns." + : "Exchange rate unavailable — the USD value will be filled in shortly."; + } + return denomination === "alph" + ? `≈ ${formatUsd(numeric * price.usd)}` + : `≈ ${formatAlph(numeric / price.usd)} at today's rate — recalculated ` + + `at the rate on the day you pay out.`; + }; + + const hint = conversion(); + + return ( + <> +

+ +
+ {( + [ + ["alph", "Fixed in ALPH"], + ["usd", "Fixed in USD"], + ] as const + ).map(([value, label]) => ( + + ))} +
+

+ {denomination === "alph" + ? "You pay exactly this many ALPH. Its USD value moves with the market." + : "You guarantee this much USD value. The ALPH you pay moves with the market."} +

+
+ +
+ +
+ {denomination === "alph" ? ( + + ALPH + + ) : ( + + )} + onAmountChange(e.target.value)} + required + min="0" + step="0.01" + disabled={disabled} + placeholder={denomination === "alph" ? "e.g., 500" : "0.00"} + className={inputClass} + /> +
+ {hint && ( +

+ {hint} +

+ )} +
+ + ); +} diff --git a/src/features/bounty/components/SubmissionModal.tsx b/src/features/bounty/components/SubmissionModal.tsx index 37ae015d..725a47ab 100644 --- a/src/features/bounty/components/SubmissionModal.tsx +++ b/src/features/bounty/components/SubmissionModal.tsx @@ -4,8 +4,7 @@ import { useState } from "react"; import { X } from "lucide-react"; import Link from "next/link"; import { apiClient } from "@/lib/api-client"; -import { notificationService } from "../services/notificationService"; -import { normalizeUrl } from "../utils/validators"; +import { isValidUrl, normalizeUrl } from "../utils/validators"; interface SubmissionModalProps { isOpen: boolean; @@ -65,10 +64,8 @@ export function SubmissionModal({ } // Validate URL format - try { - new URL(formData.submission_url); - } catch { - setError("Please provide a valid URL"); + if (!isValidUrl(formData.submission_url)) { + setError("Please provide a valid http(s) URL"); return; } @@ -87,16 +84,8 @@ export function SubmissionModal({ description: `**${formData.title}**\n\n${descriptionWithNotes}`, }); - // Notify sponsor if available - if (sponsorUserId) { - await notificationService.notifyNewSubmission( - sponsorUserId, - bountyId, - bountyTitle, - username, - result.submission?.id, - ); - } + // The sponsor's notification is written by the worker when the + // submission is created. // Reset form setFormData({ diff --git a/src/features/bounty/components/SubmissionReviewModal.tsx b/src/features/bounty/components/SubmissionReviewModal.tsx index cbfadd70..6a10fed3 100644 --- a/src/features/bounty/components/SubmissionReviewModal.tsx +++ b/src/features/bounty/components/SubmissionReviewModal.tsx @@ -1,6 +1,7 @@ "use client"; import React, { useState, useEffect } from "react"; +import { submissionTitle } from "../utils"; import { X, ExternalLink, @@ -11,7 +12,6 @@ import { } from "lucide-react"; import Link from "next/link"; import { apiClient, BountySubmission } from "@/lib/api-client"; -import { notificationService } from "../services/notificationService"; import { Bounty, TieredReward } from "../types/bounty.types"; import { generateTieredRewards } from "../utils/rewardCalculator"; @@ -134,30 +134,31 @@ export function SubmissionReviewModal({ setIsSubmitting(true); try { - // Prepare reviewer notes with reward info - let finalReviewerNotes = form.reviewerNotes.trim(); - if (form.reviewAction === "approved") { - const tierInfo = - bounty.reward_type === "tiered" && form.selectedTier - ? `Tier ${form.selectedTier} placement. ` - : ""; - // For ALPH bounties use the stored USD reference; for USD bounties use the bounty amount. - // Avoid toLocaleString to keep the number parseable by the earnings regex. - const usdBountyValue = - bounty.reward.token === "ALPH" - ? bounty.reward.usd_equivalent - : getTokenAmount().amount; - const rewardInfo = `${tierInfo}Reward: ${form.rewardAmount} ALPH (for ${usdBountyValue} USD bounty)`; - finalReviewerNotes = finalReviewerNotes - ? `${finalReviewerNotes}\n\n${rewardInfo}` - : rewardInfo; - } + // Notes are the sponsor's words only. Placement and payout go in their + // own fields so nothing has to be parsed back out of prose. + const finalReviewerNotes = form.reviewerNotes.trim(); // Update submission status await apiClient.updateSubmission(submission.id, { status: form.reviewAction, reviewer_notes: finalReviewerNotes || undefined, transaction_hash: form.transactionHash.trim() || undefined, + ...(form.reviewAction === "approved" + ? { + winner_position: + bounty.reward_type === "tiered" + ? (form.selectedTier ?? undefined) + : 1, + reward_amount: parseFloat(form.rewardAmount) || 0, + reward_currency: "ALPH", + // Frozen at settlement: for an ALPH-priced bounty this is the + // stored USD value, for a USD-priced one the amount itself. + reward_usd: + bounty.reward.token === "ALPH" + ? bounty.reward.usd_equivalent + : getTokenAmount().amount, + } + : {}), }); // Close bounty if requested and this is the last spot @@ -184,23 +185,9 @@ export function SubmissionReviewModal({ } } - // Send notification to submitter - if (form.reviewAction === "approved") { - await notificationService.notifySubmissionApproved( - submission.user_id, - submission.bounty_id, - bounty.title, - parseFloat(form.rewardAmount), - bounty.reward?.token || "ALPH", - ); - } else if (form.reviewAction === "rejected") { - await notificationService.notifySubmissionRejected( - submission.user_id, - submission.bounty_id, - bounty.title, - form.reviewerNotes, - ); - } + // The submitter's notification is written by the worker inside the same + // request that records the review, so it survives this tab closing -- + // and covers revision_requested, which was never handled here. // Show success state then reset form setSuccessState({ show: true, action: form.reviewAction }); @@ -316,21 +303,6 @@ export function SubmissionReviewModal({ ); } - const extractTitle = (description: string | null): string => { - if (!description) return "Submission"; - - // Try to extract title from markdown bold syntax - const titleMatch = description.match(/^\*\*(.+?)\*\*/); - if (titleMatch) { - return titleMatch[1]; - } - - // Fallback to first line - const firstLine = description.split("\n")[0]; - if (!firstLine) return "Submission"; - return firstLine.substring(0, 50) + (firstLine.length > 50 ? "..." : ""); - }; - const extractDescription = (description: string | null): string => { if (!description) return ""; @@ -340,26 +312,12 @@ export function SubmissionReviewModal({ return text.trim(); }; - // Parse tier placement from reviewer_notes - // Format: "Tier X placement. Reward: Y ALPH (...)" - const parseTierFromNotes = (notes: string | null): number | null => { - if (!notes) return null; - const match = notes.match(/Tier (\d+) placement/); - return match ? parseInt(match[1]) : null; - }; - - // Parse ALPH reward amount from reviewer_notes - // Format: "Reward: Y ALPH (...)" - const parseAlphRewardFromNotes = (notes: string | null): string | null => { - if (!notes) return null; - const match = notes.match(/Reward:\s*([\d.]+)\s*ALPH/); - return match ? `${match[1]} ALPH` : null; - }; - - const tierPosition = parseTierFromNotes(submission.reviewer_notes ?? null); - const alphReward = parseAlphRewardFromNotes( - submission.reviewer_notes ?? null, - ); + // Read from the columns rather than re-parsing the note. + const tierPosition = submission.winner_position ?? null; + const alphReward = + submission.reward_amount != null + ? `${submission.reward_amount} ALPH` + : null; const tierLabel = (pos: number) => pos === 1 @@ -405,7 +363,7 @@ export function SubmissionReviewModal({ {/* Submission Info */}

- {extractTitle(submission.description)} + {submissionTitle(submission.description)}

{submission.description && ( diff --git a/src/features/bounty/components/SubmissionsSection.tsx b/src/features/bounty/components/SubmissionsSection.tsx index e06af0e5..e2db9ec6 100644 --- a/src/features/bounty/components/SubmissionsSection.tsx +++ b/src/features/bounty/components/SubmissionsSection.tsx @@ -1,6 +1,7 @@ "use client"; import { useState, useEffect } from "react"; +import { submissionTitle } from "../utils"; import { apiClient, BountySubmission } from "@/lib/api-client"; import { Clock, @@ -161,20 +162,6 @@ export function SubmissionsSection({ userId }: SubmissionsSectionProps) { }); }; - const extractTitle = (description: string | null): string => { - if (!description) return "Submission"; - - // Try to extract title from markdown bold syntax - const titleMatch = description.match(/^\*\*(.+?)\*\*/); - if (titleMatch) { - return titleMatch[1]; - } - - // Fallback to first line - const firstLine = description.split("\n")[0]; - return firstLine.substring(0, 50) + (firstLine.length > 50 ? "..." : ""); - }; - const extractNotes = (description: string | null): string | null => { if (!description) return null; const notesMatch = description.match(/Notes:\n([\s\S]+)$/); @@ -242,7 +229,7 @@ export function SubmissionsSection({ userId }: SubmissionsSectionProps) {

- {extractTitle(submission.description)} + {submissionTitle(submission.description)}

diff --git a/src/features/bounty/hooks/useAlphPrice.ts b/src/features/bounty/hooks/useAlphPrice.ts new file mode 100644 index 00000000..02c1d7c0 --- /dev/null +++ b/src/features/bounty/hooks/useAlphPrice.ts @@ -0,0 +1,57 @@ +import { useEffect, useState } from "react"; + +export interface AlphPrice { + usd: number; + fetched_at: number; + age_seconds: number; +} + +/** + * The ALPH/USD rate the backend last recorded (refreshed daily at 08:00 + * Europe/Berlin). Used only to show the approximate other side of a reward — + * never to compute a value that gets stored, which the server does itself so + * the client cannot influence what a bounty is worth. + * + * `price` stays null while loading and if the feed is unavailable; callers + * must render without the conversion rather than showing a wrong number. + */ +export function useAlphPrice() { + const [price, setPrice] = useState(null); + const [loading, setLoading] = useState(true); + + useEffect(() => { + let cancelled = false; + + fetch("/api/price/alph") + .then((r) => (r.ok ? r.json() : null)) + .then((data) => { + if (cancelled) return; + setPrice( + data && typeof data.usd === "number" && data.usd > 0 ? data : null, + ); + }) + .catch(() => { + if (!cancelled) setPrice(null); + }) + .finally(() => { + if (!cancelled) setLoading(false); + }); + + return () => { + cancelled = true; + }; + }, []); + + return { price, loading }; +} + +const usdFmt = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 2, +}); + +const alphFmt = new Intl.NumberFormat("en-US", { maximumFractionDigits: 2 }); + +export const formatUsd = (n: number) => usdFmt.format(n); +export const formatAlph = (n: number) => `${alphFmt.format(n)} ALPH`; diff --git a/src/features/bounty/index.ts b/src/features/bounty/index.ts index ef8d398d..f2e350b8 100644 --- a/src/features/bounty/index.ts +++ b/src/features/bounty/index.ts @@ -9,9 +9,6 @@ export * from "./components"; // Hooks export * from "./hooks"; -// Services -export * from "./services"; - // Types export * from "./types"; diff --git a/src/features/bounty/pages/BountyDetail.tsx b/src/features/bounty/pages/BountyDetail.tsx index d0964fd2..61c71a80 100644 --- a/src/features/bounty/pages/BountyDetail.tsx +++ b/src/features/bounty/pages/BountyDetail.tsx @@ -223,7 +223,7 @@ export default function BountyDetail({ bounty }: BountyDetailProps) {
{bounty.sponsor_id && bounty.sponsor_name ? ( by{" "} @@ -649,7 +649,7 @@ export async function getServerSideProps(context: any) { reward: { amount: parseFloat(bountyData.reward_amount) || 0, token: bountyData.reward_currency || "ALPH", - usd_equivalent: parseFloat(bountyData.reward_usd_value) || 0, + usd_equivalent: Number(bountyData.reward_usd) || 0, }, reward_type: bountyData.reward_type || "fixed", tier_count: bountyData.tier_count || 5, diff --git a/src/features/bounty/pages/BountyList.tsx b/src/features/bounty/pages/BountyList.tsx index e62736aa..8f689d00 100644 --- a/src/features/bounty/pages/BountyList.tsx +++ b/src/features/bounty/pages/BountyList.tsx @@ -567,6 +567,7 @@ export default function BountyList() { logo={bounty.sponsor_logo_url || "💼"} title={bounty.title} company={bounty.sponsor_name || "Sponsor"} + companySlug={bounty.sponsor_slug} reward={`${bounty.reward_amount?.toLocaleString() || "0"} ${ bounty.reward_currency || "ALPH" }`} diff --git a/src/features/bounty/pages/EditBounty.tsx b/src/features/bounty/pages/EditBounty.tsx index d875c584..adab9b3a 100644 --- a/src/features/bounty/pages/EditBounty.tsx +++ b/src/features/bounty/pages/EditBounty.tsx @@ -2,6 +2,9 @@ import { useState, useEffect } from "react"; import { useRouter } from "next/router"; import { useSession } from "@/lib/auth-client"; import Layout from "@/components/Layout"; +import RewardInput, { + type Denomination, +} from "@/features/bounty/components/RewardInput"; import { Plus, X, @@ -12,14 +15,16 @@ import { } from "lucide-react"; import Modal from "@/components/Modal/Modal"; import { toast } from "react-toastify"; +import { generateTieredRewards } from "../utils/rewardCalculator"; interface BountyFormData { title: string; description: string; category: string; reward_amount: string; - reward_currency: string; - reward_usd_value: string; + denomination: Denomination; + reward_type: "fixed" | "tiered"; + tier_count: number; status: string; start_date: string; end_date: string; @@ -51,8 +56,9 @@ export default function EditBounty() { description: "", category: "Content", reward_amount: "", - reward_currency: "USD", - reward_usd_value: "", + denomination: "alph", + reward_type: "fixed", + tier_count: 5, status: "open", start_date: "", end_date: "", @@ -118,13 +124,17 @@ export default function EditBounty() { title: b.title || "", description: b.description || "", category: b.category || "Content", + // Edit the side the sponsor actually fixed: target_usd for a + // USD-denominated bounty, reward_amount (ALPH) otherwise. Showing + // the derived side would let a save silently re-peg the promise. + denomination: b.denomination === "usd" ? "usd" : "alph", reward_amount: - b.reward?.amount?.toString() || b.reward_amount?.toString() || "", - reward_currency: b.reward?.token || b.reward_currency || "USD", - reward_usd_value: - b.reward?.usd_equivalent?.toString() || - b.reward_usd_value?.toString() || - "", + (b.denomination === "usd" + ? b.target_usd + : (b.reward?.amount ?? b.reward_amount) + )?.toString() || "", + reward_type: b.reward_type === "tiered" ? "tiered" : "fixed", + tier_count: b.tier_count || 5, status: b.status || "open", start_date: startDate, end_date: endDate, @@ -165,11 +175,13 @@ export default function EditBounty() { title: formData.title, description: formData.description, category: formData.category, - reward_amount: parseFloat(formData.reward_amount), - reward_currency: formData.reward_currency, - reward_usd_value: formData.reward_usd_value - ? parseFloat(formData.reward_usd_value) - : 0, + denomination: formData.denomination, + ...(formData.denomination === "usd" + ? { target_usd: parseFloat(formData.reward_amount) || 0 } + : { reward_amount: parseFloat(formData.reward_amount) || 0 }), + reward_type: formData.reward_type, + tier_count: + formData.reward_type === "tiered" ? formData.tier_count : null, status: formData.status, start_date: formData.start_date || null, end_date: formData.end_date || null, @@ -378,81 +390,126 @@ export default function EditBounty() { Reward Information - {formData.reward_currency !== "ALPH" && ( -
-

- Note:{" "} - Rewards will be paid in ALPH, converted from USD at the - current exchange rate. -

-
- )} - - {formData.reward_currency === "ALPH" ? ( - <> -
- -
- - ALPH - + + setFormData((prev) => ({ ...prev, denomination: d })) + } + onAmountChange={(v) => + setFormData((prev) => ({ ...prev, reward_amount: v })) + } + /> + + {/* Reward Structure */} +
+ +
+ {( + [ + ["fixed", "Single reward"], + ["tiered", "Tiered rewards"], + ] as const + ).map(([value, label]) => ( +
-
-
- -
- - -
-

- Approximate USD equivalent for reference (used for stats - tracking) -

-
- - ) : ( -
-
+
+ + {/* Tiered Reward Configuration */} + {formData.reward_type === "tiered" && ( +
+ -
- - +
+ {[3, 5, 10].map((count) => ( + + ))}
+ + {/* Prize Distribution Preview */} + {formData.reward_amount && ( +
+

+ Prize Distribution +

+
+ {generateTieredRewards( + { + amount: parseFloat(formData.reward_amount) || 0, + token: + formData.denomination === "usd" ? "USD" : "ALPH", + usd_equivalent: 0, + }, + formData.tier_count, + ).map((tier) => ( +
+ + {tier.position === 1 + ? "1st Place" + : tier.position === 2 + ? "2nd Place" + : tier.position === 3 + ? "3rd Place" + : `${tier.position}th Place`} + + + {tier.amount.toLocaleString()} {tier.token} ( + {Math.round(tier.percentage * 100)}%) + +
+ ))} +
+
+ )}
)}
diff --git a/src/features/bounty/pages/EditSponsor.tsx b/src/features/bounty/pages/EditSponsor.tsx index 7b978a1f..4fdcc4bb 100644 --- a/src/features/bounty/pages/EditSponsor.tsx +++ b/src/features/bounty/pages/EditSponsor.tsx @@ -12,7 +12,6 @@ import { toast } from "react-toastify"; interface FormData { name: string; - username: string; description: string; entity_name: string; industry: string; @@ -63,7 +62,6 @@ export default function EditSponsorProfile() { const [userPersonalUsername, setUserPersonalUsername] = useState(""); const [formData, setFormData] = useState({ name: "", - username: "", description: "", entity_name: "", industry: "", @@ -102,7 +100,6 @@ export default function EditSponsorProfile() { setSponsorId(data.sponsor.id); setFormData({ name: data.sponsor.name || "", - username: data.sponsor.username || "", description: data.sponsor.description || "", entity_name: data.sponsor.entity_name || "", industry: data.sponsor.industry || "", diff --git a/src/features/bounty/pages/ManualCreateBounty.tsx b/src/features/bounty/pages/ManualCreateBounty.tsx index 8e254d4c..548f9159 100644 --- a/src/features/bounty/pages/ManualCreateBounty.tsx +++ b/src/features/bounty/pages/ManualCreateBounty.tsx @@ -4,6 +4,9 @@ import { useSession } from "@/lib/auth-client"; import Layout from "@/components/Layout"; import { Plus, X, Calendar, DollarSign, Copy } from "lucide-react"; import { containsProfanity } from "@/lib/profanity-filter"; +import RewardInput, { + type Denomination, +} from "@/features/bounty/components/RewardInput"; import { toast } from "react-toastify"; interface BountyFormData { @@ -15,8 +18,7 @@ interface BountyFormData { deliverables: string[]; skills: string[]; reward_amount: string; - reward_currency: string; - reward_usd_value: string; + denomination: Denomination; reward_type: "fixed" | "tiered"; tier_count: number; start_date: string; @@ -42,8 +44,7 @@ export default function ManualCreateBounty() { deliverables: [""], skills: [""], reward_amount: "", - reward_currency: "USD", - reward_usd_value: "", + denomination: "alph", reward_type: "fixed", tier_count: 5, start_date: new Date().toISOString().split("T")[0], @@ -122,9 +123,15 @@ export default function ManualCreateBounty() { requirements: parseJsonField(bounty.requirements), deliverables: parseJsonField(bounty.deliverables), skills: parseJsonField(bounty.skills), - reward_amount: bounty.reward_amount?.toString() || "", - reward_currency: bounty.reward_currency || "USD", - reward_usd_value: bounty.reward_usd_value?.toString() || "", + // Copy the side the source bounty fixed its promise on, so a USD + // bounty stays USD when duplicated rather than silently becoming a + // fixed ALPH amount at today's rate. + denomination: bounty.denomination === "usd" ? "usd" : "alph", + reward_amount: + (bounty.denomination === "usd" + ? bounty.target_usd + : bounty.reward_amount + )?.toString() || "", reward_type: bounty.reward_type || "fixed", tier_count: bounty.tier_count || 5, dapp_name: bounty.dapp_name || "", @@ -169,9 +176,15 @@ export default function ManualCreateBounty() { setFormData((prev) => ({ ...prev, [field]: newArray })); }; - const handleSubmit = async (e: React.FormEvent) => { - e.preventDefault(); - + /** + * Save the form. + * + * `publish: false` stores it as a draft — nobody but this sponsor can see + * it, and it can be finished later from the dashboard. The server treats a + * missing `is_published` as "publish", so this is the only place that has + * to know about the distinction. + */ + const saveBounty = async (publish: boolean) => { if ( containsProfanity(formData.title) || containsProfanity(formData.description) @@ -192,11 +205,15 @@ export default function ManualCreateBounty() { }, body: JSON.stringify({ ...formData, + is_published: publish, sponsor_id: sponsor.id, user_id: session?.user?.id, - reward_usd_value: formData.reward_usd_value - ? parseFloat(formData.reward_usd_value) - : 0, + // Send only the number the sponsor actually fixed. The server + // derives the other side from its own rate — the client never + // decides what a bounty is worth. + ...(formData.denomination === "usd" + ? { target_usd: parseFloat(formData.reward_amount) || 0 } + : { reward_amount: parseFloat(formData.reward_amount) || 0 }), requirements: formData.requirements.filter((r) => r.trim() !== ""), deliverables: formData.deliverables.filter((d) => d.trim() !== ""), skills: formData.skills.filter((s) => s.trim() !== ""), @@ -210,19 +227,37 @@ export default function ManualCreateBounty() { ); } - const data = await response.json(); + await response.json(); + toast.success(publish ? "Bounty published" : "Draft saved"); // Redirect to sponsor dashboard after successful creation router.push("/bounty/sponsor/dashboard"); } catch (error: any) { console.error("Error creating bounty:", error); toast.error( - error?.message || "Failed to create bounty. Please try again.", + error?.message || + `Failed to ${publish ? "publish" : "save"} bounty. Please try again.`, ); } finally { setLoading(false); } }; + const handleSubmit = (e: React.FormEvent) => { + e.preventDefault(); + void saveBounty(true); + }; + + /** + * `type="button"` so the browser's required-field validation is bypassed: + * a draft is unfinished by definition, and refusing to save one because the + * deadline is blank defeats the purpose. The server matches this — a draft + * needs only a title, and the full requirements are checked when it is + * published. + */ + const handleSaveDraft = () => { + void saveBounty(false); + }; + if (loadingSponsor || isPending) { return ( @@ -536,152 +571,61 @@ export default function ManualCreateBounty() { Reward Information - {/* Payment Notice — only for USD-denominated bounties */} - {formData.reward_currency !== "ALPH" && ( -
-

- Note:{" "} - Rewards will be paid in ALPH, converted from USD at the - current exchange rate. -

-
- )} - - {/* Reward Type Selection */} + {/* Denomination + amount. Which currency the sponsor fixes the + promise in is independent of how the pot is split, so the + two are now separate choices instead of three mixed radios + (Fixed USD / Fixed ALPH / Tiered USD). */} + + setFormData((prev) => ({ ...prev, denomination: d })) + } + onAmountChange={(v) => + setFormData((prev) => ({ ...prev, reward_amount: v })) + } + /> + + {/* Reward Structure */}
-
- - - -
-
- - {/* Reward Amount inputs — vary by currency mode */} - {formData.reward_currency === "ALPH" ? ( - <> -
- -
- - ALPH - +
+ {( + [ + ["fixed", "Single reward"], + ["tiered", "Tiered rewards"], + ] as const + ).map(([value, label]) => ( +
-
-
- -
- - -
-

- Approximate USD equivalent for reference (used for stats - tracking) -

-
- - ) : ( -
- -
- - -
+ ))}
- )} +

+ {formData.reward_type === "fixed" + ? "One winner takes the whole reward." + : "The reward is split across the top placements by a fixed percentage each."} +

+
{/* Tiered Reward Configuration */} {formData.reward_type === "tiered" && ( @@ -939,12 +883,21 @@ export default function ManualCreateBounty() { > Cancel +
diff --git a/src/features/bounty/pages/SponsorDashboard.tsx b/src/features/bounty/pages/SponsorDashboard.tsx index 467eceb6..6c6d5dea 100644 --- a/src/features/bounty/pages/SponsorDashboard.tsx +++ b/src/features/bounty/pages/SponsorDashboard.tsx @@ -3,6 +3,11 @@ import type { Sponsor } from "../types/sponsor.types"; import type { Submission } from "../types/submission.types"; import { SubmissionReviewModal } from "../components/SubmissionReviewModal"; import { BountySubmission } from "@/lib/api-client"; +import { submissionTitle } from "../utils"; +import { + getBountyDisplayStatus, + type BountyDisplayStatus, +} from "../utils/bountyStatus"; import { AlertTriangle, BarChart3, @@ -13,6 +18,7 @@ import { Mail, Plus, RefreshCw, + Send, TrendingUp, X, } from "lucide-react"; @@ -20,6 +26,7 @@ import Image from "next/image"; import Link from "next/link"; import { useRouter } from "next/router"; import { useState, useEffect, useCallback } from "react"; +import { toast } from "react-toastify"; import { useSession } from "@/lib/auth-client"; import Layout from "@/components/Layout"; @@ -48,7 +55,7 @@ export default function SponsorDashboard() { { id: string; name: string; - username: string | null; + slug: string | null; logo_url: string | null; is_verified: number; is_banned: number; @@ -115,25 +122,68 @@ export default function SponsorDashboard() { [router], ); + const [publishingId, setPublishingId] = useState(null); + + /** + * Take a draft live. + * + * The endpoint is a conditional UPDATE, so a double click cannot publish + * twice — the second request comes back 409 and is reported as "already + * published" rather than as a failure. A 400 means the draft is still + * missing required fields, and the server's message names them. + */ + const handlePublish = useCallback( + async (bounty: Bounty, e: React.MouseEvent) => { + e.stopPropagation(); + if (publishingId) return; + setPublishingId(bounty.id); + + try { + const res = await fetch(`/api/bounties/${bounty.id}/publish`, { + method: "POST", + }); + const data = await res.json().catch(() => ({})); + + if (!res.ok) { + throw new Error( + data?.error || + (res.status === 400 + ? "This draft is missing required fields." + : "Failed to publish."), + ); + } + + toast.success("Bounty published"); + setBounties((prev) => + prev.map((b) => + b.id === bounty.id + ? { + ...b, + is_published: 1, + published_at: data?.bounty?.published_at, + } + : b, + ), + ); + } catch (err: any) { + toast.error(err?.message || "Failed to publish."); + } finally { + setPublishingId(null); + } + }, + [publishingId], + ); + const viewSubmission = useCallback( (submission: any, bountyId: string, skipUrlUpdate = false) => { - // Convert Submission to BountySubmission format + // Field names now match the API, so the submission passes straight + // through. This used to be a rename table between the type's invented + // names and the real ones -- and `reviewed_at` read `completed_at`, + // which no endpoint sends, so the review date was always blank. const bountySubmission: any = { - id: submission.id, + ...submission, bounty_id: bountyId, - user_id: submission.user_id, submitted_by: submission.user_id, - submission_url: submission.submission_url || "", - description: submission.description || submission.title || null, - status: submission.status || "pending", - reviewer_notes: submission.reviewer_notes || null, - reviewed_by: null, - reviewed_at: submission.completed_at || null, - transaction_hash: submission.transaction_hash || null, - created_at: submission.submitted_at, - updated_at: submission.submitted_at, - user_username: submission.user_username || null, - user_name: submission.user_name || null, }; // Find the associated bounty @@ -180,41 +230,52 @@ export default function SponsorDashboard() { return dateObj.toLocaleDateString(); }; - // Compute display status for a bounty using end_date + submission review state - const getBountyDisplayStatus = ( - bounty: Bounty, - ): "open" | "closed" | "completed" => { - // DB status takes priority - if (bounty.status === "completed") return "completed"; - if (!bounty.end_date) return "open"; - const ts = Number(bounty.end_date); - const endMs = - !isNaN(ts) && ts < 10000000000 - ? ts * 1000 - : new Date(bounty.end_date).getTime(); - const isExpired = endMs < Date.now(); - if (!isExpired) return "open"; - const bountySubmissions = allSubmissions.filter( - (s) => s.bounty_id === bounty.id, + /** + * Display status for a bounty. + * + * Delegates to the shared derivation the worker also uses, so the dashboard + * and the rest of the site cannot disagree. The previous local version + * inferred everything from end_date plus submission review state, which + * predates the is_published / is_winners_announced columns and therefore + * had no way to show a draft at all. + * + * Payment counts come from the submissions already loaded here, which is + * what lets the dashboard distinguish "Payment Pending" from "Completed". + */ + const bountyStatusOf = (bounty: Bounty): BountyDisplayStatus => { + const mine = allSubmissions.filter((s) => s.bounty_id === bounty.id); + const winners = mine.filter((s) => (s as any).is_winner); + return getBountyDisplayStatus( + bounty, + { + winnerCount: winners.length, + paidCount: winners.filter((s) => (s as any).is_paid).length, + }, + Date.now(), ); - const allReviewed = - bountySubmissions.length === 0 || - bountySubmissions.every( - (s) => - (s.status as string) === "approved" || - (s.status as string) === "rejected", - ); - return allReviewed ? "completed" : "closed"; }; - const getStatusBadgeStyle = (status: "open" | "closed" | "completed") => { + /** + * Badge colours follow DESIGN_SYSTEM.md's semantic rules: + * green for accepting-submissions, red for negative/action-overdue, + * orange for the achievement-like end state, neutral for the merely + * informational ones. + */ + const getStatusBadgeStyle = (status: BountyDisplayStatus) => { switch (status) { - case "open": + case "In Progress": return "bg-accessible-green/20 text-accessible-green"; - case "closed": - return "bg-danger-red/10 text-danger-red"; - case "completed": - return "bg-accessible-green/10 text-light-charcoal dark:text-lightgrey"; + case "Completed": + return "bg-orange/10 text-orange"; + case "Payment Pending": + return "bg-red-500/10 text-red-500"; + case "Cancelled": + return "bg-red-500/10 text-red-500"; + case "Draft": + case "Unpublished": + case "In Review": + default: + return "bg-smoked-white dark:bg-light-black text-light-charcoal dark:text-lightgrey"; } }; @@ -246,7 +307,7 @@ export default function SponsorDashboard() { dashboardData.submissions || [] ).map((s: any) => ({ id: s.id, - title: s.title || "Submission", + title: submissionTitle(s.description), description: s.description || "", submission_url: s.submission_url, user_username: s.user_username || null, @@ -260,7 +321,6 @@ export default function SponsorDashboard() { status: s.status, reviewer_notes: s.reviewer_notes || null, transaction_hash: s.transaction_hash || null, - submitted_at: s.created_at, })); setAllSubmissions(transformedSubmissions); @@ -348,7 +408,7 @@ export default function SponsorDashboard() { dashboardData.submissions || [] ).map((s: any) => ({ id: s.id, - title: s.title || "Submission", + title: submissionTitle(s.description), description: s.description || "", submission_url: s.submission_url, user_username: s.user_username || null, @@ -362,7 +422,6 @@ export default function SponsorDashboard() { status: s.status, reviewer_notes: s.reviewer_notes || null, transaction_hash: s.transaction_hash || null, - submitted_at: s.created_at, })); // Count submissions per bounty @@ -389,11 +448,16 @@ export default function SponsorDashboard() { reward: { amount: parseFloat(b.reward_amount) || 0, token: b.reward_currency || "ALPH", - usd_equivalent: parseFloat(b.reward_usd_value) || 0, + usd_equivalent: Number(b.reward_usd) || 0, }, reward_type: b.reward_type || "fixed", tier_count: b.tier_count || null, category: b.category || "Development", + slug: b.slug ?? null, + is_published: b.is_published, + published_at: b.published_at ?? null, + is_winners_announced: b.is_winners_announced, + winners_announced_at: b.winners_announced_at ?? null, created_at: b.created_at, updated_at: b.updated_at, })); @@ -433,9 +497,7 @@ export default function SponsorDashboard() { (s) => !godSponsorSearch || s.name.toLowerCase().includes(godSponsorSearch.toLowerCase()) || - (s.username || "") - .toLowerCase() - .includes(godSponsorSearch.toLowerCase()), + (s.slug || "").toLowerCase().includes(godSponsorSearch.toLowerCase()), ); const switchToSponsor = async (sponsorId: string) => { setLoading(true); @@ -457,7 +519,7 @@ export default function SponsorDashboard() { const godSubmissions = (dashboardData.submissions || []).map( (s: any) => ({ id: s.id, - title: s.title || "Submission", + title: submissionTitle(s.description), description: s.description || "", submission_url: s.submission_url, user_username: s.user_username || null, @@ -471,7 +533,6 @@ export default function SponsorDashboard() { status: s.status, reviewer_notes: s.reviewer_notes || null, transaction_hash: s.transaction_hash || null, - submitted_at: s.created_at, }), ); const godSubmissionCountByBounty: Record = {}; @@ -542,9 +603,9 @@ export default function SponsorDashboard() {

{s.name}

- {s.username && ( -

- @{s.username} + {s.slug && ( +

+ /bounty/sponsor/{s.slug}

)}
@@ -911,11 +972,10 @@ export default function SponsorDashboard() {
{(() => { - const ds = - getBountyDisplayStatus(bounty); + const ds = bountyStatusOf(bounty); return ( {ds} @@ -927,6 +987,20 @@ export default function SponsorDashboard() {
+ {bountyStatusOf(bounty) === "Draft" || + bountyStatusOf(bounty) === "Unpublished" ? ( + + ) : null} {bounty.status !== "completed" && (
@@ -1223,10 +1297,10 @@ export default function SponsorDashboard() {
{(() => { - const ds = getBountyDisplayStatus(bounty); + const ds = bountyStatusOf(bounty); return ( {ds} @@ -1246,6 +1320,19 @@ export default function SponsorDashboard() { {bounty.reward.amount} {bounty.reward.token}
+ {bountyStatusOf(bounty) === "Draft" || + bountyStatusOf(bounty) === "Unpublished" ? ( + + ) : null} {bounty.status !== "completed" && (
diff --git a/src/features/bounty/pages/SponsorLanding.tsx b/src/features/bounty/pages/SponsorLanding.tsx index 1ca60b67..bed2fa4f 100644 --- a/src/features/bounty/pages/SponsorLanding.tsx +++ b/src/features/bounty/pages/SponsorLanding.tsx @@ -49,8 +49,8 @@ export default function SponsorLanding() { if (response.ok) { const data = await response.json(); setExistingSponsor(data.sponsor); - // If user is already a sponsor, redirect to dashboard - if (data.sponsor && !data.sponsor.is_banned) { + // If user is already a sponsor, or is a god user (superadmin), redirect to dashboard + if (data.is_god || (data.sponsor && !data.sponsor.is_banned)) { router.push("/bounty/sponsor/dashboard"); } } diff --git a/src/features/bounty/services/index.ts b/src/features/bounty/services/index.ts deleted file mode 100644 index 86a98d98..00000000 --- a/src/features/bounty/services/index.ts +++ /dev/null @@ -1,2 +0,0 @@ -// Bounty services exports -export * from "./notificationService"; diff --git a/src/features/bounty/services/notificationService.ts b/src/features/bounty/services/notificationService.ts deleted file mode 100644 index 11e8364c..00000000 --- a/src/features/bounty/services/notificationService.ts +++ /dev/null @@ -1,231 +0,0 @@ -/** - * Notification Service - * Handles creating notifications for various events in the bounty system - */ -import { apiClient } from "@/lib/api-client"; - -export type NotificationType = - | "submission_accepted" - | "submission_rejected" - | "bounty_completed" - | "comment_reply" - | "comment_like" - | "new_comment" - | "new_submission" - | "sponsor_approved" - | "sponsor_rejected" - | "general"; - -interface NotificationData { - user_id: string; - type: NotificationType; - title: string; - message: string; - link?: string; -} - -/** - * Create a notification - */ -async function createNotification(data: NotificationData) { - try { - console.log("Creating notification with data:", data); - await apiClient.createNotification(data); - } catch (error) { - console.error("Failed to create notification:", error); - console.error("Notification data was:", data); - } -} - -/** - * Notify sponsor when their application is approved - */ -export async function notifySponsorApproved( - userId: string, - sponsorName: string, -) { - await createNotification({ - user_id: userId, - type: "sponsor_approved", - title: "Sponsor Application Approved", - message: `Congratulations! Your sponsor application for "${sponsorName}" has been approved. You can now create bounties.`, - link: "/bounty/sponsor/dashboard", - }); -} - -/** - * Notify sponsor when their application is rejected - */ -export async function notifySponsorRejected( - userId: string, - sponsorName: string, - reason?: string, -) { - await createNotification({ - user_id: userId, - type: "sponsor_rejected", - title: "Sponsor Application Rejected", - message: reason - ? `Your sponsor application for "${sponsorName}" was rejected. Reason: ${reason}` - : `Your sponsor application for "${sponsorName}" was rejected. Please contact support for more information.`, - link: "/bounty/sponsor/create", - }); -} - -/** - * Notify user when their submission is approved - */ -export async function notifySubmissionApproved( - userId: string, - bountyId: string, - bountyTitle: string, - rewardAmount?: number, - rewardCurrency?: string, -) { - const rewardText = - rewardAmount && rewardCurrency - ? ` You've earned ${rewardAmount} ${rewardCurrency}!` - : ""; - await createNotification({ - user_id: userId, - type: "submission_accepted", - title: "Submission Approved", - message: `Your submission for "${bountyTitle}" has been approved!${rewardText}`, - link: `/bounty/${bountyId}`, - }); -} - -/** - * Notify user when their submission is rejected - */ -export async function notifySubmissionRejected( - userId: string, - bountyId: string, - bountyTitle: string, - feedback?: string, -) { - await createNotification({ - user_id: userId, - type: "submission_rejected", - title: "Submission Rejected", - message: feedback - ? `Your submission for "${bountyTitle}" was rejected. Feedback: ${feedback}` - : `Your submission for "${bountyTitle}" was rejected.`, - link: `/bounty/${bountyId}`, - }); -} - -/** - * Notify sponsor when their bounty receives a new submission - */ -export async function notifyNewSubmission( - sponsorUserId: string, - bountyId: string, - bountyTitle: string, - submitterUsername?: string, - submissionId?: string, -) { - const submitterText = submitterUsername ? ` from ${submitterUsername}` : ""; - const link = submissionId - ? `/bounty/sponsor/dashboard?submission=${submissionId}` - : "/bounty/sponsor/dashboard"; - await createNotification({ - user_id: sponsorUserId, - type: "new_submission", - title: "New Submission", - message: `Your bounty "${bountyTitle}" received a new submission${submitterText}.`, - link, - }); -} - -/** - * Notify user when someone replies to their comment - */ -export async function notifyCommentReply( - userId: string, - bountyId: string, - bountyTitle: string, - replierUsername?: string, -) { - const replierText = replierUsername - ? `${replierUsername} replied` - : "Someone replied"; - await createNotification({ - user_id: userId, - type: "comment_reply", - title: "New Reply", - message: `${replierText} to your comment on "${bountyTitle}".`, - link: `/bounty/${bountyId}`, - }); -} - -/** - * Notify user when someone likes their comment - */ -export async function notifyCommentLike( - userId: string, - bountyId: string, - bountyTitle: string, - likerUsername?: string, -) { - const likerText = likerUsername ? `${likerUsername} liked` : "Someone liked"; - await createNotification({ - user_id: userId, - type: "comment_like", - title: "Comment Liked", - message: `${likerText} your comment on "${bountyTitle}".`, - link: `/bounty/${bountyId}`, - }); -} - -/** - * Notify sponsor when someone comments on their bounty (if not muted) - */ -export async function notifyNewComment( - sponsorUserId: string, - bountyId: string, - bountyTitle: string, - commenterUsername?: string, -) { - const commenterText = commenterUsername - ? `${commenterUsername} commented` - : "Someone commented"; - await createNotification({ - user_id: sponsorUserId, - type: "new_comment", - title: "New Comment", - message: `${commenterText} on your bounty "${bountyTitle}".`, - link: `/bounty/${bountyId}`, - }); -} - -/** - * Check if notifications should be sent (respecting mute preferences) - */ -export async function shouldNotify( - userId: string, - bountyId: string, - type: "comments" | "submissions", -): Promise { - try { - // Check if user has muted notifications for this bounty - const { muted } = await apiClient.checkNotificationMute(bountyId, userId); - // If muted, don't send notifications (for any type) - return !muted; - } catch { - return true; // Default to sending notifications if check fails - } -} - -export const notificationService = { - createNotification, - notifySponsorApproved, - notifySponsorRejected, - notifySubmissionApproved, - notifySubmissionRejected, - notifyNewSubmission, - notifyCommentReply, - notifyCommentLike, - notifyNewComment, - shouldNotify, -}; diff --git a/src/features/bounty/types/bounty.types.ts b/src/features/bounty/types/bounty.types.ts index d93d4eda..20c7e002 100644 --- a/src/features/bounty/types/bounty.types.ts +++ b/src/features/bounty/types/bounty.types.ts @@ -36,7 +36,17 @@ export interface Bounty { difficulty?: string; dapp_name?: string; sponsor_name?: string; + /** Stored routing key for /bounty/sponsor/:slug — stable across renames. */ + sponsor_slug?: string; sponsor_logo_url?: string | null; + /** Readable routing key. Null for drafts whose title yields no slug. */ + slug?: string | null; + /** 0 while a draft; 1 once live. See getBountyDisplayStatus(). */ + is_published?: number; + /** Set the first time it was published, so a later unpublish is not a draft. */ + published_at?: number | null; + is_winners_announced?: number; + winners_announced_at?: number | null; created_at: string; updated_at: string; } diff --git a/src/features/bounty/types/sponsor.types.ts b/src/features/bounty/types/sponsor.types.ts index 58422432..5d63a768 100644 --- a/src/features/bounty/types/sponsor.types.ts +++ b/src/features/bounty/types/sponsor.types.ts @@ -7,8 +7,8 @@ export interface Sponsor { user_id: string; // Links to User table (one-to-zero-or-one relationship) // Organization Information - name: string; // Organization name - username: string; // Organization username (slug for URL) + name: string; // Organization name — display text only, never routing + slug: string; // Stored routing key for /bounty/sponsor/:slug (027) description: string; // Organization short bio entity_name: string; // Full legal entity name industry: string; diff --git a/src/features/bounty/types/submission.types.ts b/src/features/bounty/types/submission.types.ts index b3874875..8d5d813b 100644 --- a/src/features/bounty/types/submission.types.ts +++ b/src/features/bounty/types/submission.types.ts @@ -1,31 +1,54 @@ // Submission related types -export type SubmissionStatus = "submitted" | "approved" | "rejected"; +export type SubmissionStatus = + | "pending" + | "submitted" + | "approved" + | "rejected" + | "revision_requested"; +/** + * A submission as the API actually returns it. + * + * Field names match the `bounty_submissions` columns, plus the joined columns + * listed below. They previously did not: the type promised `submitted_at`, + * `completed_at`, `review_started_at`, `title` and `tweet_url`, none of which + * any endpoint sends. TypeScript cannot catch that, so each one was a silent + * `undefined` at runtime -- which is how the sponsor dashboard ended up + * rendering a blank review date for every submission. + * + * Keep this in step with the database: `submission-type-drift.test.ts` fails + * if a field is neither a real column nor a registered join alias. + */ export interface Submission { + // ── Columns on bounty_submissions ── id: string; bounty_id: string; user_id: string; - sponsor_id: string; - title: string; description: string; submission_url: string; - tweet_url?: string; status: SubmissionStatus; reviewer_notes?: string; + reviewed_by?: string | null; + reviewed_at?: number | null; transaction_hash?: string; - user_username: string; - user_avatar_url?: string; - user_wallet_address: string; - bounty_title: string; - submitted_at: string; - review_started_at?: string; - completed_at?: string; -} + created_at: number; + updated_at?: number; -export interface SubmissionFormData { - title: string; - description: string; - submission_url: string; - tweet_url?: string; + /** Structured review outcome — replaces parsing reviewer_notes. */ + is_winner?: number; + winner_position?: number | null; + reward_amount?: number | null; + reward_currency?: string | null; + reward_usd?: number | null; + is_paid?: number; + paid_at?: number | null; + label?: string; + + // ── Joined in by the API, not columns ── + sponsor_id?: string; + bounty_title?: string; + user_username?: string; + user_avatar_url?: string; + user_wallet_address?: string; } diff --git a/src/features/bounty/utils/bountyStatus.ts b/src/features/bounty/utils/bountyStatus.ts new file mode 100644 index 00000000..59ca394b --- /dev/null +++ b/src/features/bounty/utils/bountyStatus.ts @@ -0,0 +1,128 @@ +/** + * Derived bounty display status — shared by the frontend and the worker. + * + * Storage keeps orthogonal, indexable facts (`status`, `is_published`, + * `is_winners_announced`, `end_date`, winner and payment counts). What a + * viewer should be told is derived here, in one place, so that adding a + * display state costs no migration and the two sides can never disagree + * about what a bounty currently is. + * + * Deliberately dependency-free: this file is imported by the Cloudflare + * Worker as well as by React, so it must not pull in either environment. + */ + +export type BountyDisplayStatus = + | "Cancelled" + | "Draft" + | "Unpublished" + | "In Progress" + | "In Review" + | "Payment Pending" + | "Completed"; + +/** The subset of a bounty row the derivation actually reads. */ +export interface BountyStatusInput { + status?: string | null; + is_published?: number | boolean | null; + published_at?: number | null; + is_winners_announced?: number | boolean | null; + /** Unix seconds, or an ISO string — both appear in this codebase. */ + end_date?: number | string | null; +} + +/** Counts that decide whether an announced bounty is settled. */ +export interface BountySettlement { + winnerCount: number; + paidCount: number; +} + +const truthy = (v: number | boolean | null | undefined) => + v === true || v === 1; + +/** + * Normalise a deadline to unix milliseconds. + * + * bounties.end_date is INTEGER unix seconds in D1, but several client paths + * carry it as an ISO string. Returns null for anything unparseable, and + * callers treat "no deadline" as not-yet-passed rather than guessing. + */ +export function endDateMs( + end: number | string | null | undefined, +): number | null { + if (end === null || end === undefined || end === "") return null; + + if (typeof end === "number") { + if (!Number.isFinite(end)) return null; + // Values this small are seconds, not milliseconds. A bounty deadline in + // 1970 is not a thing anyone means. + return end < 1e11 ? end * 1000 : end; + } + + const numeric = Number(end); + if (Number.isFinite(numeric) && end.trim() !== "") { + return numeric < 1e11 ? numeric * 1000 : numeric; + } + + const parsed = Date.parse(end); + return Number.isFinite(parsed) ? parsed : null; +} + +/** True once the deadline is in the past. No deadline means never passed. */ +export function deadlinePassed( + end: number | string | null | undefined, + now: number = Date.now(), +): boolean { + const ms = endDateMs(end); + return ms !== null && now >= ms; +} + +/** + * What to show for this bounty. + * + * Order matters — the first matching rule wins: + * + * cancelled/deleted -> Cancelled (terminal, outranks everything) + * not published, never was -> Draft + * not published, was before -> Unpublished + * published, deadline ahead -> In Progress + * published, deadline passed -> In Review (awaiting the sponsor) + * announced, not all paid -> Payment Pending + * announced, all paid -> Completed + * + * `settlement` is optional: callers that have not counted winners and + * payments still get every state up to "Payment Pending", which is the honest + * answer when you do not know whether payment finished. + */ +export function getBountyDisplayStatus( + bounty: BountyStatusInput, + settlement?: BountySettlement, + now: number = Date.now(), +): BountyDisplayStatus { + if (bounty.status === "cancelled" || bounty.status === "deleted") { + return "Cancelled"; + } + + if (!truthy(bounty.is_published)) { + return bounty.published_at ? "Unpublished" : "Draft"; + } + + if (!truthy(bounty.is_winners_announced)) { + return deadlinePassed(bounty.end_date, now) ? "In Review" : "In Progress"; + } + + // Announced. Without counts we cannot claim it is finished, and claiming + // "Completed" wrongly is the worse error of the two. + if (!settlement) return "Payment Pending"; + + const { winnerCount, paidCount } = settlement; + if (winnerCount > 0 && paidCount >= winnerCount) return "Completed"; + return "Payment Pending"; +} + +/** Statuses that mean the bounty is visible and accepting submissions. */ +export function isAcceptingSubmissions( + bounty: BountyStatusInput, + now: number = Date.now(), +): boolean { + return getBountyDisplayStatus(bounty, undefined, now) === "In Progress"; +} diff --git a/src/features/bounty/utils/index.ts b/src/features/bounty/utils/index.ts index baceaab3..d27d8863 100644 --- a/src/features/bounty/utils/index.ts +++ b/src/features/bounty/utils/index.ts @@ -1,5 +1,6 @@ // Bounty utilities exports +export * from "./bountyStatus"; export * from "./rewardCalculator"; export * from "./timeFormatter"; export * from "./validators"; diff --git a/src/features/bounty/utils/validators.test.ts b/src/features/bounty/utils/validators.test.ts deleted file mode 100644 index 22a6b730..00000000 --- a/src/features/bounty/utils/validators.test.ts +++ /dev/null @@ -1,130 +0,0 @@ -import { describe, expect, it } from "vitest"; -import { - isValidUrl, - isValidWalletAddress, - normalizeUrl, - sponsorSlug, - validateSubmissionForm, -} from "./validators"; - -describe("sponsorSlug", () => { - it("lowercases and strips non-alphanumeric characters", () => { - expect(sponsorSlug("Linx Labs")).toBe("linxlabs"); - expect(sponsorSlug("BabyPoolTool")).toBe("babypooltool"); - expect(sponsorSlug("Aleph-ium 2.0!")).toBe("alephium20"); - }); -}); - -describe("isValidUrl", () => { - it("accepts valid URLs", () => { - expect(isValidUrl("https://example.com")).toBe(true); - expect(isValidUrl("http://localhost:3000/path?q=1")).toBe(true); - }); - - it("rejects invalid URLs", () => { - expect(isValidUrl("not-a-url")).toBe(false); - expect(isValidUrl("")).toBe(false); - expect(isValidUrl("ftp://")).toBe(false); - }); -}); - -describe("normalizeUrl", () => { - it("passes through URLs that already have a protocol", () => { - expect(normalizeUrl("https://example.com")).toBe("https://example.com"); - expect(normalizeUrl("http://example.com")).toBe("http://example.com"); - }); - - it("prepends https:// when protocol is missing", () => { - expect(normalizeUrl("example.com")).toBe("https://example.com"); - expect(normalizeUrl("www.example.com")).toBe("https://www.example.com"); - }); - - it("returns empty string unchanged", () => { - expect(normalizeUrl("")).toBe(""); - expect(normalizeUrl(" ")).toBe(""); - }); -}); - -describe("isValidWalletAddress", () => { - it("accepts a valid Ethereum-style address", () => { - expect( - isValidWalletAddress("0xAbCdEf1234567890AbCdEf1234567890AbCdEf12"), - ).toBe(true); - }); - - it("rejects empty or whitespace addresses", () => { - expect(isValidWalletAddress("")).toBe(false); - expect(isValidWalletAddress(" ")).toBe(false); - }); - - it("rejects addresses with wrong length", () => { - expect(isValidWalletAddress("0x123")).toBe(false); - }); - - it("rejects addresses without 0x prefix", () => { - expect( - isValidWalletAddress("AbCdEf1234567890AbCdEf1234567890AbCdEf12"), - ).toBe(false); - }); -}); - -describe("validateSubmissionForm", () => { - const validForm = { - title: "My Submission", - description: "A detailed description", - submission_url: "https://github.com/example/repo", - }; - - it("accepts a valid form", () => { - const result = validateSubmissionForm(validForm); - expect(result.valid).toBe(true); - expect(result.errors).toHaveLength(0); - }); - - it("requires title", () => { - const result = validateSubmissionForm({ ...validForm, title: "" }); - expect(result.valid).toBe(false); - expect(result.errors).toContain("Title is required"); - }); - - it("requires description", () => { - const result = validateSubmissionForm({ ...validForm, description: " " }); - expect(result.valid).toBe(false); - expect(result.errors).toContain("Description is required"); - }); - - it("requires a valid submission URL", () => { - const result = validateSubmissionForm({ - ...validForm, - submission_url: "not-a-url", - }); - expect(result.valid).toBe(false); - expect(result.errors).toContain("Valid submission URL is required"); - }); - - it("rejects an invalid optional tweet URL", () => { - const result = validateSubmissionForm({ - ...validForm, - tweet_url: "bad-url", - }); - expect(result.valid).toBe(false); - expect(result.errors).toContain("Tweet URL must be a valid URL"); - }); - - it("accepts a valid optional tweet URL", () => { - const result = validateSubmissionForm({ - ...validForm, - tweet_url: "https://x.com/user/status/123", - }); - expect(result.valid).toBe(true); - }); - - it("can accumulate multiple errors", () => { - const result = validateSubmissionForm({ - title: "", - description: "", - submission_url: "bad", - }); - expect(result.errors.length).toBeGreaterThanOrEqual(3); - }); -}); diff --git a/src/features/bounty/utils/validators.ts b/src/features/bounty/utils/validators.ts index 221bd1f8..f31db8e4 100644 --- a/src/features/bounty/utils/validators.ts +++ b/src/features/bounty/utils/validators.ts @@ -8,10 +8,46 @@ export function sponsorSlug(name: string): string { return name.toLowerCase().replace(/[^a-z0-9]/g, ""); } +/** Longest slug we will generate, before any uniqueness suffix. */ +export const BOUNTY_SLUG_MAX = 60; + +/** + * Generate a URL-friendly slug from a bounty title. + * + * "Create a YouTube Tutorial: How to Use Linx App" + * -> "create-a-youtube-tutorial-how-to-use-linx-app" + * + * Deliberately NOT sponsorSlug(). That one deletes every non-alphanumeric + * character, which suits a short org name ("Linx Labs" -> "linxlabs") but + * turns a sentence into "createayoutubetutorialhowtouselinxapp" — unreadable, + * and worthless for the search ranking that is the only reason to have slugs + * on bounties at all. Words are joined with hyphens instead. + * + * Truncation cuts on a word boundary so the tail is not a fragment, and the + * result can be empty (a title of only punctuation or non-Latin script), which + * callers must handle by falling back to the id rather than storing "". + */ +export function bountySlug(title: string): string { + const base = (title || "") + .toLowerCase() + .replace(/[^a-z0-9]+/g, "-") + .replace(/^-+|-+$/g, ""); + + if (base.length <= BOUNTY_SLUG_MAX) return base; + + const cut = base.slice(0, BOUNTY_SLUG_MAX); + const lastHyphen = cut.lastIndexOf("-"); + // Only honour the word boundary if it leaves a usable slug; a title whose + // first word is longer than the cap would otherwise slice to nothing. + return (lastHyphen > 20 ? cut.slice(0, lastHyphen) : cut).replace(/-+$/, ""); +} + export function isValidUrl(url: string): boolean { try { - new URL(url); - return true; + const parsed = new URL(url); + // Only http(s) links are safe to store and render as a clickable . + // Rejects javascript:, data:, vbscript:, etc. + return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { return false; } @@ -48,37 +84,27 @@ export function isValidWalletAddress(address: string): boolean { return ethAddressPattern.test(address.trim()); } -export function validateSubmissionForm(data: { - title: string; - description: string; - submission_url: string; - tweet_url?: string; - wallet_address?: string; -}): { valid: boolean; errors: string[] } { - const errors: string[] = []; - - if (!data.title || data.title.trim() === "") { - errors.push("Title is required"); - } - - if (!data.description || data.description.trim() === "") { - errors.push("Description is required"); - } - - if (!data.submission_url || !isValidUrl(data.submission_url)) { - errors.push("Valid submission URL is required"); - } - - if (data.tweet_url && !isValidUrl(data.tweet_url)) { - errors.push("Tweet URL must be a valid URL"); - } +/** + * The submission's title, read out of its description. + * + * There is no `title` column: SubmissionModal writes the title into the + * description as `**Title**\n\nbody`. The sponsor dashboard used to read a + * `title` field that no endpoint sends, so every submission there displayed + * the literal word "Submission". + * + * Falls back to the first line for submissions written before the modal + * added the bold wrapper, and only then to a placeholder. + */ +export function submissionTitle( + description: string | null | undefined, + fallback = "Submission", +): string { + if (!description) return fallback; - if (data.wallet_address && !isValidWalletAddress(data.wallet_address)) { - errors.push("Invalid wallet address format"); - } + const bold = description.match(/^\s*\*\*(.+?)\*\*/); + if (bold?.[1]?.trim()) return bold[1].trim(); - return { - valid: errors.length === 0, - errors, - }; + const firstLine = description.split("\n")[0]?.trim(); + if (!firstLine) return fallback; + return firstLine.length > 50 ? `${firstLine.substring(0, 50)}...` : firstLine; } diff --git a/src/lib/api-client.ts b/src/lib/api-client.ts index 0a4a528d..9e46a174 100644 --- a/src/lib/api-client.ts +++ b/src/lib/api-client.ts @@ -28,7 +28,6 @@ export interface Bounty { | "closed" | "deleted"; created_by: string; - assigned_to: string | null; tags: string | null; requirements: string | null; submission_url: string | null; @@ -36,9 +35,10 @@ export interface Bounty { end_date: string | null; created_at: string; updated_at: string; - completed_at: string | null; sponsor_id?: string; sponsor_name?: string; + /** Stored routing key for /bounty/sponsor/:slug — stable across renames. */ + sponsor_slug?: string; sponsor_logo_url?: string | null; sponsor_is_verified?: number; submission_count?: number; @@ -93,10 +93,8 @@ export interface BountySubmission { sponsor_name: string; sponsor_logo_url: string | null; user_id: string; - title: string; description: string; submission_url: string; - tweet_url: string | null; status: | "submitted" | "in_review" @@ -105,8 +103,16 @@ export interface BountySubmission { | "pending" | "revision_requested"; reviewer_notes: string | null; - review_started_at: number | null; - completed_at: number | null; + /** Structured review outcome — replaces parsing reviewer_notes. */ + is_winner?: number; + winner_position?: number | null; + reward_amount?: number | null; + reward_currency?: string | null; + reward_usd?: number | null; + is_paid?: number; + paid_at?: number | null; + label?: string; + reviewed_at: number | null; reward: { token: string; amount: number; @@ -132,6 +138,11 @@ export interface UpdateSubmissionInput { status: "approved" | "rejected" | "revision_requested"; reviewer_notes?: string; transaction_hash?: string; + /** Structured outcome, sent only when approving. */ + winner_position?: number; + reward_amount?: number; + reward_currency?: string; + reward_usd?: number; } export interface BountyComment { diff --git a/src/pages/[name].tsx b/src/pages/[name].tsx index 93c34639..03fea123 100644 --- a/src/pages/[name].tsx +++ b/src/pages/[name].tsx @@ -429,7 +429,13 @@ export const getStaticProps: GetStaticProps = async ( } const dappFile = path.join(process.cwd(), "data", `${name}.json`); - const content = await readFile(dappFile, "utf8"); + + let content: string; + try { + content = await readFile(dappFile, "utf8"); + } catch { + return { notFound: true }; + } const dappInfo: DappInfo = JSON.parse(content); diff --git a/src/types/database.ts b/src/types/database.ts deleted file mode 100644 index 24480328..00000000 --- a/src/types/database.ts +++ /dev/null @@ -1,399 +0,0 @@ -/** - * Database type definitions for Cloudflare D1 - * Updated schema after cleanup migration (16_schema_cleanup.sql) - * - * Table relationships: - * - user: Core auth table, connects to session, user_profiles, sponsors, bounty_submissions, bounty_comments, notifications - * - user_profiles: Extended user info, connects to user via user_id - * - sponsors: Company profiles, connects to user via user_id, to bounties - * - bounties: Bounty listings, connects to sponsors via sponsor_id - * - bounty_submissions: User submissions, connects to bounties, sponsors, user - * - bounty_comments: Comments on bounties, connects to bounties, user (liked_by field replaces comment_likes table) - * - notifications: User notifications, connects to user, bounties, bounty_submissions, bounty_comments - * - notification_mutes: Per-bounty notification settings, connects to user and bounties - */ - -// ========================================== -// Authentication Tables -// ========================================== - -/** - * Core user table - handles authentication and basic user info - * Connected to: session, user_profiles, sponsors (via user_id), bounty_comments, notifications - * Ban logic: If user.is_banned = 1, user cannot access ANY features (user or sponsor) - */ -export interface User { - id: string; - email: string; - emailVerified: number; // 0 or 1 (boolean) - name: string | null; - image: string | null; - is_banned: number; // 0 or 1 - banned users cannot use platform - is_sponsor: number; // 0 or 1 - is this user a sponsor - sponsor_id: string | null; // Reference to sponsors.id if is_sponsor = 1 - createdAt: number; // Unix timestamp (milliseconds) - Date.now() format - updatedAt: number; -} - -export interface Session { - id: string; - userId: string; - expiresAt: number; - ipAddress: string | null; - userAgent: string | null; - createdAt: number; - updatedAt: number; -} - -// REMOVED: Account table - no longer needed, auth handled by better-auth -// REMOVED: Verification table - moved to sponsors.is_verified boolean field - -// ========================================== -// Business Tables -// ========================================== - -/** - * Extended user profile (stored separately from auth.user) - */ -export interface UserProfile { - id: string; - user_id: string; - username: string | null; - first_name: string | null; - last_name: string | null; - full_name: string | null; - bio: string | null; - avatar_url: string | null; - wallet_address: string | null; - github_url: string | null; - twitter_url: string | null; - linkedin_url: string | null; - telegram_url: string | null; - website_url: string | null; - web3_interests: string[]; // JSON array in DB - work_experience: string | null; - location: string | null; - current_employer: string | null; - frontend_skills: string[]; - backend_skills: string[]; - blockchain_skills: string[]; - design_skills: string[]; - content_skills: string[]; - created_at: number; - updated_at: number; -} - -/** - * UserProfile as stored in database (JSON fields are strings) - */ -export interface DbUserProfile - extends Omit< - UserProfile, - | "web3_interests" - | "frontend_skills" - | "backend_skills" - | "blockchain_skills" - | "design_skills" - | "content_skills" - > { - web3_interests: string; // JSON string - frontend_skills: string; - backend_skills: string; - blockchain_skills: string; - design_skills: string; - content_skills: string; -} - -/** - * Sponsors table - companies/organizations creating bounties - * Connected to: user (via user_id), bounties (via sponsor_id) - * Ban logic: When sponsor.is_banned = 1, the associated user.is_banned is also set to 1 - * Note: approved_at, rejected_at, rejection_reason fields removed - all sponsors auto-approved - */ -export interface Sponsor { - id: string; - user_id: string; // Reference to user.id - name: string; - username: string | null; - description: string | null; - entity_name: string | null; - industry: string | null; - logo_url: string | null; - banner_url: string | null; - website: string | null; - twitter: string | null; - discord: string | null; - telegram: string | null; - wallet_address: string | null; - contact_first_name: string | null; - contact_last_name: string | null; - contact_username: string | null; - contact_telegram: string | null; - status: "approved" | "rejected"; // All new sponsors auto-approved (deprecated, always "approved") - is_verified: number; // 0 or 1 - verified badge (replaces verification table) - is_banned: number; // 0 or 1 - if banned, sponsor cannot create bounties - banned_at: number | null; - total_bounties_count: number; - total_projects_count: number; - total_reward_amount: number; - profile_photos: string[]; // JSON array in DB - created_at: number; - updated_at: number; -} - -export interface DbSponsor extends Omit { - profile_photos: string; // JSON string -} - -export type BountyCategory = "content" | "design" | "development" | "other"; -export type BountyStatus = "open" | "in_review" | "completed"; -export type DifficultyLevel = "beginner" | "intermediate" | "advanced"; - -export interface BountyReward { - token: string; // e.g., 'ALPH' - amount: number; - usd_equivalent: number; -} - -export interface RewardTier { - rank: number; - amount: number; - description?: string; -} - -export interface Bounty { - id: string; - sponsor_id: string; - title: string; - description: string | null; - category: BountyCategory | null; - status: BountyStatus | null; - requirements: string | null; - reward: BountyReward; // JSON in DB - submission_guidelines: string | null; - max_submissions: number; - current_submissions: number; - start_date: number; - end_date: number; - review_timeframe: number; - difficulty_level: DifficultyLevel | null; - estimated_hours: number | null; - tags: string[]; // JSON array in DB - is_featured: number; // 0 or 1 - is_tiered_reward: number; - reward_tiers: RewardTier[] | null; // JSON in DB - created_at: number; - updated_at: number; -} - -export interface DbBounty - extends Omit { - reward: string; // JSON string - tags: string; // JSON string - reward_tiers: string | null; // JSON string -} - -export type SubmissionStatus = - | "submitted" - | "in_review" - | "accepted" - | "rejected" - | "revision_requested"; - -export interface BountySubmission { - id: string; - bounty_id: string; - bounty_name: string; - sponsor_id: string; - sponsor_name: string; - sponsor_logo_url: string | null; - user_id: string; - title: string; - description: string; - submission_url: string; - tweet_url: string | null; - status: SubmissionStatus; - feedback: string | null; - review_started_at: number | null; - completed_at: number | null; - reward: BountyReward; // JSON in DB - user_username: string | null; - user_avatar_url: string | null; - user_full_name: string | null; - user_wallet_address: string | null; - transaction_hash: string | null; - created_at: number; - updated_at: number; -} - -export interface DbBountySubmission extends Omit { - reward: string; // JSON string -} - -/** - * Bounty comments table - discussions on bounties - * Connected to: bounties (via bounty_id), user (via user_id) - * Note: liked_by field replaces the comment_likes table - */ -export interface BountyComment { - id: string; - bounty_id: string; - user_id: string; - content: string; - parent_comment_id: string | null; - like_count: number; - liked_by: string[]; // JSON array of user IDs who liked this comment - deleted_at: number | null; // Soft delete timestamp - created_at: number; - updated_at: number; -} - -export interface DbBountyComment extends Omit { - liked_by: string; // JSON string -} - -// REMOVED: CommentLike table - moved to BountyComment.liked_by field (JSON array) - -/** - * Bounty overview table - stores global platform statistics - * Single row table with id = 1 - */ -export interface BountyOverview { - id: number; // Always 1 - total_value_usd: number; - total_value_alph: number; - list_number: number; // Total bounties listed - user_number: number; // Total users - sponsor_number: number; // Total sponsors - updated_at: number; -} - -/** - * Bookmarks table - users can bookmark bounties to save for later - * Connected to: user (via user_id), bounties (via bounty_id) - */ -export interface Bookmark { - id: string; - user_id: string; - bounty_id: string; - created_at: number; -} - -export interface ProofOfWork { - id: string; - user_id: string; - username: string; - title: string; - description: string; - skills: string[]; // JSON array in DB - link: string; - created_at: number; - updated_at: number; -} - -export interface DbProofOfWork extends Omit { - skills: string; // JSON string -} - -export type SkillCategory = - | "frontend" - | "backend" - | "blockchain" - | "design" - | "content" - | "other"; - -export interface Skill { - id: string; - name: string; - category: SkillCategory; - created_at: number; - updated_at: number; -} - -export type NotificationType = - | "submission_accepted" - | "submission_rejected" - | "bounty_completed" - | "comment_reply" - | "comment_like" - | "new_comment" - | "new_submission" - | "sponsor_approved" - | "sponsor_rejected" - | "sponsor_banned" - | "general"; - -/** - * Notifications table - user notifications - * Connected to: user (via user_id), bounties, bounty_submissions, bounty_comments - */ -export interface Notification { - id: string; - user_id: string; - type: NotificationType; - title: string; - message: string; - link: string | null; - related_bounty_id: string | null; - related_submission_id: string | null; - related_comment_id: string | null; - is_read: number; // 0 or 1 - read_at: number | null; - created_at: number; -} - -/** - * Notification mutes - simpler replacement for notification_preferences - * Connected to: user (via user_id), bounties (via bounty_id) - * If a record exists, notifications for that bounty are muted for that user - */ -export interface NotificationMute { - id: string; - user_id: string; - bounty_id: string; - created_at: number; -} - -// REMOVED: NotificationPreference table - replaced by NotificationMute table (simpler approach) - -// ========================================== -// Utility Types -// ========================================== - -/** - * Insert types (without auto-generated fields) - */ -export type InsertUser = Omit; -export type InsertUserProfile = Omit; -export type InsertBounty = Omit< - Bounty, - "created_at" | "updated_at" | "current_submissions" ->; -export type InsertBountySubmission = Omit< - BountySubmission, - "created_at" | "updated_at" ->; - -/** - * Update types (all fields optional except id) - */ -export type UpdateUser = Partial & { id: string }; -export type UpdateUserProfile = Partial & { id: string }; -export type UpdateBounty = Partial & { id: string }; -export type UpdateBountySubmission = Partial & { id: string }; - -/** - * Helper type for converting DB types to app types - */ -export type DbToApp = T extends DbUserProfile - ? UserProfile - : T extends DbBounty - ? Bounty - : T extends DbBountySubmission - ? BountySubmission - : T extends DbSponsor - ? Sponsor - : T extends DbProofOfWork - ? ProofOfWork - : T; diff --git a/src/worker/email.ts b/src/worker/email.ts index ca8de8d5..780447d8 100644 --- a/src/worker/email.ts +++ b/src/worker/email.ts @@ -3,20 +3,222 @@ import { Env } from "./index"; const BASE_URL = "https://alph.land"; +/** + * Which opt-out category each email type belongs to. + * + * A type that is absent from this map is transactional — account + * verification, password reset, sponsor approval — and is never suppressed. + * That is why the mapping is a lookup table rather than a field on the call: + * forgetting to pass a category must fail toward sending, not toward silence. + */ +const CATEGORY_BY_TYPE: Record = { + submission_approved: "submission_result", + submission_rejected: "submission_result", + revision_requested: "submission_result", + new_submission: "sponsor_activity", + submission_resubmitted: "sponsor_activity", + bounty_deadline: "deadline", + review_overdue: "deadline", + scout_invite: "scout_invite", + product: "product", +}; + +/** Categories a user may turn off, plus the catch-all. */ +export const EMAIL_CATEGORIES = [ + "submission_result", + "sponsor_activity", + "deadline", + "scout_invite", + "product", +] as const; + +export function categoryForType(type: string): string | null { + return CATEGORY_BY_TYPE[type] ?? null; +} + +/** + * Has this user opted out of this category? + * + * Matches the category itself or the 'all' catch-all. A database error is + * deliberately not swallowed into "unsubscribed" — see the caller. + */ +export async function isUnsubscribed( + env: Env, + userId: string, + category: string, +): Promise { + const row = (await env.DB.prepare( + `SELECT 1 AS hit FROM email_unsubscribes + WHERE user_id = ? AND category IN (?, 'all') LIMIT 1`, + ) + .bind(userId, category) + .first()) as { hit: number } | null; + + return !!row; +} + +/** + * Sign an unsubscribe link so it can only be used for its own (user, + * category) pair. + * + * A bare user id in the URL would let anyone unsubscribe anyone — the same + * hole the notification endpoints had. Returns null when INTERNAL_SECRET is + * unset, and the caller then omits the footer rather than emitting an + * unsigned link. + */ +export async function signUnsubscribe( + env: Env, + userId: string, + category: string, +): Promise { + if (!env.INTERNAL_SECRET) return null; + + // TextEncoder always returns a plain-ArrayBuffer-backed view; the lib type + // is wider than that, so narrow it for crypto.subtle. + const utf8 = (s: string) => new TextEncoder().encode(s).buffer as ArrayBuffer; + + const key = await crypto.subtle.importKey( + "raw", + utf8(env.INTERNAL_SECRET), + { name: "HMAC", hash: "SHA-256" }, + false, + ["sign"], + ); + const sig = await crypto.subtle.sign( + "HMAC", + key, + utf8(`${userId}:${category}`), + ); + + // base64url — the token travels in a query string and in mail headers. + const raw = new Uint8Array(sig); + let bin = ""; + for (let i = 0; i < raw.length; i++) bin += String.fromCharCode(raw[i]); + + return btoa(bin).replace(/\+/g, "-").replace(/\//g, "_").replace(/=+$/, ""); +} + +/** Constant-time compare, so a wrong token cannot be probed byte by byte. */ +export async function verifyUnsubscribe( + env: Env, + userId: string, + category: string, + token: string, +): Promise { + const expected = await signUnsubscribe(env, userId, category); + if (!expected || expected.length !== token.length) return false; + + let diff = 0; + for (let i = 0; i < expected.length; i++) { + diff |= expected.charCodeAt(i) ^ token.charCodeAt(i); + } + return diff === 0; +} + +/** + * Who and what an email is about, so the log row can be deduplicated later. + * + * `userId` is the *recipient*, not the person the mail is about — for a + * sponsor notification that is the sponsor's user id, not the submitter's. + * Both are optional: transactional mail (verification, password reset) has no + * useful key and is never deduplicated. + */ +export interface EmailContext { + userId?: string | null; + bountyId?: string | null; +} + +/** + * Has this exact email already gone out? + * + * For repeating jobs — the deadline cron fires hourly and must not re-send the + * same reminder every hour. Only counts successful sends, so a failed attempt + * is retried on the next run. + * + * Returns false when there is no key to match on: with no userId we cannot + * tell two recipients apart, and answering "already sent" would silently + * suppress everyone's mail. + */ +export async function alreadySent( + env: Env, + type: string, + ctx: EmailContext, +): Promise { + if (!ctx.userId) return false; + + const row = (await env.DB.prepare( + `SELECT 1 AS hit FROM email_logs + WHERE type = ? AND user_id = ? AND status = 'sent' + AND (? IS NULL OR bounty_id = ?) + LIMIT 1`, + ) + .bind(type, ctx.userId, ctx.bountyId ?? null, ctx.bountyId ?? null) + .first()) as { hit: number } | null; + + return !!row; +} + export async function sendAndLog( env: Env, to: string, subject: string, type: string, html: string, + ctx: EmailContext = {}, ): Promise { if (!env.RESEND_API_KEY) { console.error("[email] RESEND_API_KEY not set, skipping:", type); return; } + + // The opt-out check lives here and nowhere else. Putting it in the six + // notifyXxx() callers is how one of them ends up missing it — the same + // failure the notification-auth pass had to undo. + const category = categoryForType(type); + if (ctx.userId && category) { + let optedOut = false; + try { + optedOut = await isUnsubscribed(env, ctx.userId, category); + } catch (e) { + // Fail toward sending: a lookup error must not silently mute a user. + console.error("[email] unsubscribe lookup failed, sending anyway:", e); + } + if (optedOut) { + console.log( + `[email] Suppressed ${type} to ${to} (opted out: ${category})`, + ); + await logEmail(env, to, subject, type, "suppressed", null, null, ctx); + return; + } + } + const resend = new Resend(env.RESEND_API_KEY); const from = `Alphland <${env.FROM_EMAIL || "onboarding@resend.dev"}>`; + // Signed one-click unsubscribe. Omitted entirely when we cannot sign — + // an unsigned link would let anyone unsubscribe anyone. + const token = + ctx.userId && category + ? await signUnsubscribe(env, ctx.userId, category) + : null; + const unsubUrl = token + ? `${BASE_URL}/api/email/unsubscribe?u=${encodeURIComponent(ctx.userId!)}&c=${encodeURIComponent(category!)}&t=${token}` + : null; + + const body = unsubUrl + ? html + + `

` + + `Unsubscribe from these emails

` + : html; + + // Gmail and Outlook bin bulk mail that has no machine-readable opt-out. + const headers = unsubUrl + ? { + "List-Unsubscribe": `<${unsubUrl}>`, + "List-Unsubscribe-Post": "List-Unsubscribe=One-Click", + } + : undefined; + let resendId: string | null = null; let errorMsg: string | null = null; @@ -25,7 +227,8 @@ export async function sendAndLog( from, to, subject, - html, + html: body, + ...(headers ? { headers } : {}), }); if (error) { errorMsg = String((error as any).message || error); @@ -39,18 +242,48 @@ export async function sendAndLog( console.error(`[email] Exception sending ${type}:`, errorMsg); } + await logEmail( + env, + to, + subject, + type, + errorMsg ? "failed" : "sent", + resendId, + errorMsg, + ctx, + ); +} + +/** + * Append a row to email_logs. + * + * Suppressed sends are logged too: without a row, "I never got the email" is + * unanswerable — you cannot tell an opt-out from a delivery failure. + */ +async function logEmail( + env: Env, + to: string, + subject: string, + type: string, + status: "sent" | "failed" | "suppressed", + resendId: string | null, + errorMsg: string | null, + ctx: EmailContext, +): Promise { try { await env.DB.prepare( - "INSERT INTO email_logs (id, to_email, subject, type, status, resend_id, error, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, unixepoch())", + "INSERT INTO email_logs (id, to_email, subject, type, status, resend_id, error, user_id, bounty_id, created_at) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, unixepoch())", ) .bind( crypto.randomUUID(), to, subject, type, - errorMsg ? "failed" : "sent", + status, resendId, errorMsg, + ctx.userId ?? null, + ctx.bountyId ?? null, ) .run(); } catch (e) { @@ -65,7 +298,7 @@ export async function notifyUserSubmissionApproved( submissionId: string, ): Promise { const row = (await env.DB.prepare( - `SELECT u.email, u.name, b.title, b.reward_amount, b.reward_currency, bs.reviewer_notes, bs.transaction_hash + `SELECT u.id as user_id, u.email, u.name, b.id as bounty_id, b.title, b.reward_amount, b.reward_currency, bs.reviewer_notes, bs.transaction_hash FROM bounty_submissions bs JOIN user u ON bs.user_id = u.id JOIN bounties b ON bs.bounty_id = b.id @@ -73,8 +306,10 @@ export async function notifyUserSubmissionApproved( ) .bind(submissionId) .first()) as { + user_id: string; email: string; name: string | null; + bounty_id: string; title: string; reward_amount: number | null; reward_currency: string | null; @@ -104,6 +339,7 @@ export async function notifyUserSubmissionApproved(

Alphland · alph.land

`, + { userId: row.user_id, bountyId: row.bounty_id }, ); } @@ -112,7 +348,7 @@ export async function notifyUserSubmissionRejected( submissionId: string, ): Promise { const row = (await env.DB.prepare( - `SELECT u.email, u.name, b.title, bs.reviewer_notes + `SELECT u.id as user_id, u.email, u.name, b.id as bounty_id, b.title, bs.reviewer_notes FROM bounty_submissions bs JOIN user u ON bs.user_id = u.id JOIN bounties b ON bs.bounty_id = b.id @@ -120,8 +356,10 @@ export async function notifyUserSubmissionRejected( ) .bind(submissionId) .first()) as { + user_id: string; email: string; name: string | null; + bounty_id: string; title: string; reviewer_notes: string | null; } | null; @@ -143,6 +381,7 @@ export async function notifyUserSubmissionRejected(

Alphland · alph.land

`, + { userId: row.user_id, bountyId: row.bounty_id }, ); } @@ -154,6 +393,7 @@ export async function notifySponsorNewSubmission( `SELECT b.title as bounty_title, b.id as bounty_id, + u_sponsor.id as sponsor_user_id, u_sponsor.email as sponsor_email, u_sponsor.name as sponsor_name, u_submitter.name as submitter_name, @@ -169,6 +409,7 @@ export async function notifySponsorNewSubmission( .first()) as { bounty_title: string; bounty_id: string; + sponsor_user_id: string; sponsor_email: string; sponsor_name: string | null; submitter_name: string | null; @@ -192,6 +433,7 @@ export async function notifySponsorNewSubmission(

Alphland · alph.land

`, + { userId: row.sponsor_user_id, bountyId: row.bounty_id }, ); } @@ -200,7 +442,7 @@ export async function notifyUserRevisionRequested( submissionId: string, ): Promise { const row = (await env.DB.prepare( - `SELECT u.email, u.name, b.title as bounty_title, b.id as bounty_id, bs.reviewer_notes + `SELECT u.id as user_id, u.email, u.name, b.title as bounty_title, b.id as bounty_id, bs.reviewer_notes FROM bounty_submissions bs JOIN user u ON bs.user_id = u.id JOIN bounties b ON bs.bounty_id = b.id @@ -208,6 +450,7 @@ export async function notifyUserRevisionRequested( ) .bind(submissionId) .first()) as { + user_id: string; email: string; name: string | null; bounty_title: string; @@ -232,6 +475,7 @@ export async function notifyUserRevisionRequested(

Alphland · alph.land

`, + { userId: row.user_id, bountyId: row.bounty_id }, ); } @@ -243,6 +487,7 @@ export async function notifySponsorSubmissionResubmitted( `SELECT b.title as bounty_title, b.id as bounty_id, + u_sponsor.id as sponsor_user_id, u_sponsor.email as sponsor_email, u_sponsor.name as sponsor_name, u_submitter.name as submitter_name, @@ -258,6 +503,7 @@ export async function notifySponsorSubmissionResubmitted( .first()) as { bounty_title: string; bounty_id: string; + sponsor_user_id: string; sponsor_email: string; sponsor_name: string | null; submitter_name: string | null; @@ -281,21 +527,132 @@ export async function notifySponsorSubmissionResubmitted(

Alphland · alph.land

`, + { userId: row.sponsor_user_id, bountyId: row.bounty_id }, + ); +} + +/** + * "Closing soon" nudge to someone who bookmarked a bounty. + * + * Takes the rows it needs as arguments rather than re-querying by id: the + * reminder cron has already loaded and filtered them, and re-reading per + * recipient would turn one query into hundreds. + */ +export async function notifyBountyDeadlineSoon( + env: Env, + user: { user_id: string; email: string; name: string | null }, + bounty: { id: string; title: string; end_date: number }, +): Promise { + const closes = new Date(bounty.end_date * 1000).toUTCString(); + + await sendAndLog( + env, + user.email, + `Closing soon – ${bounty.title}`, + "bounty_deadline", + `
+

This bounty closes soon

+

Hi ${user.name || "there"},

+

You bookmarked ${bounty.title} and it stops accepting submissions on ${closes}.

+ View Bounty +
+

Alphland · alph.land

+
`, + { userId: user.user_id, bountyId: bounty.id }, + ); +} + +/** Remind a sponsor that submissions have been waiting since the deadline. */ +export async function notifySponsorReviewOverdue( + env: Env, + b: { + id: string; + title: string; + sponsor_user_id: string; + sponsor_email: string; + sponsor_contact: string | null; + submission_count: number; + }, +): Promise { + const plural = b.submission_count === 1 ? "submission" : "submissions"; + + await sendAndLog( + env, + b.sponsor_email, + `${b.submission_count} ${plural} still waiting – ${b.title}`, + "review_overdue", + `
+

Submissions are waiting on you

+

Hi ${b.sponsor_contact || "there"},

+

${b.title} closed a week ago and ${b.submission_count} ${plural} have not been reviewed yet.

+

Participants cannot be paid until winners are announced.

+ Review Submissions +
+

Alphland · alph.land

+
`, + { userId: b.sponsor_user_id, bountyId: b.id }, ); } -export async function notifySponsorVerified( +/** + * Tell admins a bounty has gone two weeks without a verdict. + * + * Uncategorised on purpose — this is operational mail to staff, not a + * newsletter, and must not be silenced by an opt-out. + */ +export async function notifyAdminReviewEscalation( + env: Env, + admin: { id: string; email: string }, + b: { + id: string; + title: string; + sponsor_name: string; + submission_count: number; + }, +): Promise { + await sendAndLog( + env, + admin.email, + `Unreviewed for 14 days – ${b.title}`, + "review_escalation", + `
+

Bounty unreviewed for 14 days

+

${b.title} by ${b.sponsor_name} closed two weeks ago with + ${b.submission_count} submission(s) and still has no announced winners.

+

The sponsor was reminded at 7 days. This needs a human decision.

+ View Bounty +
+

Alphland admin notification

+
`, + { userId: admin.id, bountyId: b.id }, + ); +} + +/** + * Email for any sponsor account status change. + * + * One function for all four transitions rather than one per verb. The + * previous shape had `verified` wired to its own email and the other three + * to nothing, which is how "we notify sponsors" ended up meaning "we notify + * them when it is good news" — a ban is precisely the change someone needs + * to be told about. + * + * Uncategorised, so it is transactional and can never be suppressed by an + * opt-out: these are account-state facts, not updates a user can decline. + */ +export async function notifySponsorAccountChange( env: Env, sponsorId: string, + change: "verified" | "unverified" | "banned" | "unbanned", ): Promise { const row = (await env.DB.prepare( - `SELECT u.email, u.name, s.name as sponsor_name - FROM sponsors s - JOIN user u ON s.user_id = u.id - WHERE s.id = ?`, + `SELECT u.id as user_id, u.email, u.name, s.name as sponsor_name + FROM sponsors s JOIN user u ON s.user_id = u.id + WHERE s.id = ?`, ) .bind(sponsorId) .first()) as { + user_id: string; email: string; name: string | null; sponsor_name: string; @@ -303,19 +660,63 @@ export async function notifySponsorVerified( if (!row?.email) return; + const copy = { + verified: { + subject: "Your sponsor account has been approved – Alphland", + heading: "Your account is approved! 🎉", + body: `Your sponsor account ${row.sponsor_name} has been reviewed and approved. You can now post bounties and start receiving submissions from the community.`, + cta: { + label: "Go to Dashboard", + href: `${BASE_URL}/bounty/sponsor/dashboard`, + }, + accent: "#E05C2A", + }, + unverified: { + subject: "Your sponsor account needs review – Alphland", + heading: "Your account is back under review", + body: `Verification for ${row.sponsor_name} has been withdrawn, so new bounties cannot be published until it is approved again. Bounties already live are unaffected.`, + cta: { + label: "View Account", + href: `${BASE_URL}/bounty/sponsor/dashboard`, + }, + accent: "#111", + }, + banned: { + subject: "Your sponsor account has been suspended – Alphland", + heading: "Your account has been suspended", + body: `The sponsor account ${row.sponsor_name} has been suspended and can no longer post bounties. If you believe this is a mistake, reply to this email.`, + cta: null, + accent: "#dc2626", + }, + unbanned: { + subject: "Your sponsor account has been restored – Alphland", + heading: "Your account has been restored", + body: `The suspension on ${row.sponsor_name} has been lifted. You can post bounties again.`, + cta: { + label: "Go to Dashboard", + href: `${BASE_URL}/bounty/sponsor/dashboard`, + }, + accent: "#E05C2A", + }, + }[change]; + await sendAndLog( env, row.email, - "Your sponsor account has been approved – Alphland", - "sponsor_verified", + copy.subject, + `sponsor_${change}`, `
-

Your account is approved! 🎉

+

${copy.heading}

Hi ${row.name || "there"},

-

Your sponsor account ${row.sponsor_name} has been reviewed and approved on Alphland.

-

You can now post bounties and start receiving submissions from the community.

- Go to Dashboard +

${copy.body}

+ ${ + copy.cta + ? `${copy.cta.label}` + : "" + }

Alphland · alph.land

`, + { userId: row.user_id }, ); } diff --git a/src/worker/handlers.ts b/src/worker/handlers.ts index 0981a3b2..c4323ce1 100644 --- a/src/worker/handlers.ts +++ b/src/worker/handlers.ts @@ -9,7 +9,19 @@ import { notifySponsorNewSubmission, notifyUserRevisionRequested, notifySponsorSubmissionResubmitted, + verifyUnsubscribe, + EMAIL_CATEGORIES, } from "./email"; +import { isAcceptingSubmissions } from "@/features/bounty/utils/bountyStatus"; +import { + notifySubmissionReviewed, + notifyNewSubmission as notifySponsorOfSubmission, + notifySponsorStatusChanged, + notifyCommentReply, + notifyNewComment, + notifyCommentLike, + notify, +} from "./notifications"; const corsHeaders = { "Access-Control-Allow-Origin": "*", @@ -17,7 +29,7 @@ const corsHeaders = { }; /** Returns true if the user with the given ID has the 'god' superadmin role. */ -async function isGodUser(env: Env, userId: string): Promise { +export async function isGodUser(env: Env, userId: string): Promise { try { const result = (await env.DB.prepare("SELECT role FROM user WHERE id = ?") .bind(userId) @@ -29,29 +41,95 @@ async function isGodUser(env: Env, userId: string): Promise { } /** - * Resolves the god role of the requester directly from the session cookie, - * without needing the full auth instance. Returns true if the session belongs - * to a god user. + * Resolves the requester's userId from the better-auth session cookie. + * Returns null if there's no cookie, the token doesn't match a live + * session, or the session has expired. This is the single source of truth + * for "who is actually making this request" — never trust a client-supplied + * user_id for authorization decisions, only for authorization *targets*. */ -async function requestIsFromGod(env: Env, request: Request): Promise { +export async function getSessionUserId( + env: Env, + request: Request, +): Promise { try { const cookieHeader = request.headers.get("cookie") || ""; // better-auth stores the token in "better-auth.session_token" const match = cookieHeader.match(/better-auth\.session_token=([^;]+)/); - if (!match) return false; + if (!match) return null; const token = decodeURIComponent(match[1]); const session = (await env.DB.prepare( "SELECT userId FROM session WHERE token = ? AND expiresAt > ?", ) .bind(token, Date.now()) .first()) as { userId: string } | null; - if (!session?.userId) return false; - return isGodUser(env, session.userId); + return session?.userId ?? null; + } catch { + return null; + } +} + +/** + * Resolves the god role of the requester directly from the session cookie, + * without needing the full auth instance. Returns true if the session belongs + * to a god user. + */ +async function requestIsFromGod(env: Env, request: Request): Promise { + const userId = await getSessionUserId(env, request); + if (!userId) return false; + return isGodUser(env, userId); +} + +/** + * Rejects dangerous URL schemes (javascript:, data:, vbscript:, etc.) that + * would execute in a viewer's session if rendered as a raw . Only + * http/https links are considered safe to store and render. + */ +function isSafeSubmissionUrl(url: string): boolean { + try { + const parsed = new URL(url); + return parsed.protocol === "http:" || parsed.protocol === "https:"; } catch { return false; } } +/** + * Canonical form of a sponsor's public URL segment. + * + * `/bounty/sponsor/:slug` resolves a sponsor by matching the slug against + * either `username` or the punctuation-stripped, lowercased `name`, so both + * must be compared in the same normalised space — otherwise "Alephium", + * "alephium" and "aleph-ium" look distinct to a uniqueness check while + * colliding at lookup time. + */ +export function normalizeSponsorSlug(value: string): string { + return value.toLowerCase().replace(/[^a-z0-9]/g, ""); +} + +/** + * Whether `slug` is already taken by a sponsor other than `excludeSponsorId`. + * + * A courtesy check so the form can say so before submitting; the UNIQUE index + * on sponsors(slug) is what actually guarantees it, closing the race between + * this read and the write that follows. + */ +export async function isSponsorSlugTaken( + env: Env, + slug: string, + excludeSponsorId?: string, +): Promise { + const normalized = normalizeSponsorSlug(slug); + if (!normalized) return false; + + const row = await env.DB.prepare( + `SELECT id FROM sponsors WHERE slug = ? AND id IS NOT ?`, + ) + .bind(normalized, excludeSponsorId ?? null) + .first(); + + return row !== null; +} + /** * Transform bounty object to include computed reward field for backwards compatibility */ @@ -82,24 +160,27 @@ export async function handleSubmissionsAPI( // POST /api/submissions - Create submission if (request.method === "POST" && pathname === "/api/submissions") { try { + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + const body = (await request.json()) as any; console.log("Creating submission:", { bounty_id: body.bounty_id, - user_id: body.user_id || body.submitted_by, + user_id: sessionUserId, submission_url: body.submission_url, description: body.description, }); // Validate required fields - if ( - !body.bounty_id || - !(body.user_id || body.submitted_by) || - !body.submission_url - ) { + if (!body.bounty_id || !body.submission_url) { console.log("Missing required fields:", { bounty_id: !!body.bounty_id, - user_id: !!(body.user_id || body.submitted_by), submission_url: !!body.submission_url, }); return new Response( @@ -111,10 +192,80 @@ export async function handleSubmissionsAPI( ); } + if (!isSafeSubmissionUrl(body.submission_url)) { + return new Response( + JSON.stringify({ error: "submission_url must be an http(s) URL" }), + { status: 400, headers: corsHeaders }, + ); + } + + // A submitter must be reachable and payable before they can enter. + // Checked here rather than in the form: the form can be bypassed, and + // the cost of finding out at payout time is a winner nobody can pay. + const profile = (await env.DB.prepare( + `SELECT username, wallet_address FROM user_profiles WHERE user_id = ?`, + ) + .bind(sessionUserId) + .first()) as { + username: string | null; + wallet_address: string | null; + } | null; + + const missing = [ + !profile?.username?.trim() && "a username", + !profile?.wallet_address?.trim() && "a wallet address", + ].filter(Boolean) as string[]; + + if (missing.length > 0) { + return new Response( + JSON.stringify({ + error: `Add ${missing.join(" and ")} to your profile before submitting.`, + missing_profile_fields: missing, + }), + { status: 403, headers: corsHeaders }, + ); + } + + // Only accept submissions to bounties that are actually live. + // + // The check used to be `status !== 'open'`, which 032 turned into a + // hole: a draft also carries status 'open', so an unpublished bounty + // would have accepted submissions. It also never looked at the + // deadline, so a closed bounty stayed open to submissions. Both are + // covered by the shared derivation. + const bounty = (await env.DB.prepare( + `SELECT status, is_published, published_at, is_winners_announced, end_date + FROM bounties WHERE id = ?`, + ) + .bind(body.bounty_id) + .first()) as { + status: string; + is_published: number; + published_at: number | null; + is_winners_announced: number; + end_date: number | null; + } | null; + + if (!bounty || !bounty.is_published) { + // A draft's existence is not something a stranger should be able to + // confirm, so this is 404 rather than "not accepting". + return new Response(JSON.stringify({ error: "Bounty not found" }), { + status: 404, + headers: corsHeaders, + }); + } + if (!isAcceptingSubmissions(bounty)) { + return new Response( + JSON.stringify({ error: "This bounty is not accepting submissions" }), + { status: 400, headers: corsHeaders }, + ); + } + + // Identity always comes from the session, never from the request body. const existing = await env.DB.prepare( `SELECT id FROM bounty_submissions WHERE bounty_id = ? AND user_id = ?`, ) - .bind(body.bounty_id, body.submitted_by || body.user_id) + .bind(body.bounty_id, sessionUserId) .first(); if (existing) { @@ -139,7 +290,7 @@ export async function handleSubmissionsAPI( .bind( id, body.bounty_id, - body.submitted_by || body.user_id, + sessionUserId, body.submission_url, body.description || null, now, @@ -153,6 +304,34 @@ export async function handleSubmissionsAPI( .bind(id) .first(); + const forSponsor = (await env.DB.prepare( + `SELECT sp.user_id AS sponsor_user_id, + b.title AS bounty_title, + COALESCE(up.username, u.name, u.email) AS submitter_name + FROM bounties b + JOIN sponsors sp ON b.sponsor_id = sp.id + LEFT JOIN user u ON u.id = ? + LEFT JOIN user_profiles up ON up.user_id = u.id + WHERE b.id = ?`, + ) + .bind(sessionUserId, body.bounty_id) + .first()) as { + sponsor_user_id: string; + bounty_title: string; + submitter_name: string; + } | null; + + if (forSponsor) { + await notifySponsorOfSubmission(env, { + sponsorUserId: forSponsor.sponsor_user_id, + actorUserId: sessionUserId, + bountyId: body.bounty_id, + submissionId: id, + bountyTitle: forSponsor.bounty_title, + submitterName: forSponsor.submitter_name || "Someone", + }); + } + notifySponsorNewSubmission(env, id).catch((e) => console.error("[email] notifySponsorNewSubmission failed:", e), ); @@ -163,6 +342,15 @@ export async function handleSubmissionsAPI( }); } catch (error: any) { console.error("Error creating submission:", error); + // Race-safe backstop for the SELECT-then-INSERT duplicate check above: + // a UNIQUE constraint violation means another request already inserted + // a submission for this (bounty_id, user_id) pair concurrently. + if (String(error?.message || "").includes("UNIQUE constraint failed")) { + return new Response( + JSON.stringify({ error: "Already submitted to this bounty" }), + { status: 409, headers: corsHeaders }, + ); + } return new Response( JSON.stringify({ error: "Failed to create submission", @@ -336,9 +524,60 @@ export async function handleSubmissionsAPI( const body = (await request.json()) as any; const now = Math.floor(Date.now() / 1000); + const ALLOWED_REVIEW_STATUSES = [ + "approved", + "rejected", + "revision_requested", + ]; + if (!ALLOWED_REVIEW_STATUSES.includes(body.status)) { + return new Response( + JSON.stringify({ + error: `status must be one of: ${ALLOWED_REVIEW_STATUSES.join(", ")}`, + }), + { status: 400, headers: corsHeaders }, + ); + } + + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + + // Only the sponsor who owns this submission's bounty (or a god user) + // may approve/reject/request revision on it. + const owningSponsor = (await env.DB.prepare( + `SELECT s.user_id + FROM bounty_submissions bs + JOIN bounties b ON bs.bounty_id = b.id + JOIN sponsors s ON b.sponsor_id = s.id + WHERE bs.id = ?`, + ) + .bind(id) + .first()) as { user_id: string } | null; + + if (!owningSponsor) { + return new Response(JSON.stringify({ error: "Submission not found" }), { + status: 404, + headers: corsHeaders, + }); + } + + if (owningSponsor.user_id !== sessionUserId) { + const isGod = await isGodUser(env, sessionUserId); + if (!isGod) { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: corsHeaders, + }); + } + } + // God users can approve directly without transaction verification const godOverride = - body.status === "approved" && (await requestIsFromGod(env, request)); + body.status === "approved" && (await isGodUser(env, sessionUserId)); // Verify transaction on Alephium if transaction_hash is provided (skipped for god users) if (!godOverride && body.transaction_hash && body.status === "approved") { @@ -467,24 +706,104 @@ export async function handleSubmissionsAPI( } } - await env.DB.prepare( - `UPDATE bounty_submissions + // Structured outcome. Callers send winner_position / reward_amount / + // reward_usd as numbers; they used to be prose inside reviewer_notes and + // read back out with a regex, which meant rewording a note could change + // someone's lifetime earnings. + const isApproved = body.status === "approved"; + + const winnerPosition = + body.winner_position === undefined || body.winner_position === null + ? null + : Number(body.winner_position); + if ( + winnerPosition !== null && + (!Number.isInteger(winnerPosition) || winnerPosition < 1) + ) { + return new Response( + JSON.stringify({ error: "winner_position must be a positive integer" }), + { status: 400, headers: corsHeaders }, + ); + } + + const numeric = (v: unknown, field: string): number | null => { + if (v === undefined || v === null || v === "") return null; + const n = Number(v); + if (!Number.isFinite(n) || n < 0) { + throw new Error(`${field} must be a non-negative number`); + } + return n; + }; + + let rewardAmount: number | null; + let rewardUsd: number | null; + try { + rewardAmount = numeric(body.reward_amount, "reward_amount"); + rewardUsd = numeric(body.reward_usd, "reward_usd"); + } catch (err: any) { + return new Response(JSON.stringify({ error: err.message }), { + status: 400, + headers: corsHeaders, + }); + } + + try { + await env.DB.prepare( + `UPDATE bounty_submissions SET status = ?, reviewer_notes = ?, transaction_hash = ?, reviewed_at = ?, - updated_at = ? + updated_at = ?, + is_winner = ?, + winner_position = ?, + reward_amount = ?, + reward_currency = ?, + reward_usd = ?, + is_paid = ?, + paid_at = ?, + label = 'reviewed' WHERE id = ?`, - ) - .bind( - body.status, - body.reviewer_notes || null, - body.transaction_hash || null, - body.status === "approved" || body.status === "rejected" ? now : null, - now, - id, ) - .run(); + .bind( + body.status, + body.reviewer_notes || null, + // Only an approval records a payment. Writing a stale hash from the + // form onto a rejected submission would consume it against the + // unique index, and the real winner could then never be approved + // with that transaction. + isApproved ? body.transaction_hash || null : null, + body.status === "approved" || body.status === "rejected" ? now : null, + now, + isApproved ? 1 : 0, + isApproved ? winnerPosition : null, + isApproved ? rewardAmount : null, + isApproved ? body.reward_currency || "ALPH" : null, + isApproved ? rewardUsd : null, + // Paid is a separate fact from the verdict, but this flow only + // approves once a verified transaction hash is supplied. + isApproved && body.transaction_hash ? 1 : 0, + isApproved && body.transaction_hash ? now : null, + id, + ) + .run(); + } catch (error: any) { + // A transaction hash may only pay one submission. The on-chain checks + // above confirm the transaction is real and large enough, but not that + // it has already been spent on someone else -- that is what the unique + // index catches. + if (String(error?.message || "").includes("UNIQUE constraint failed")) { + return new Response( + JSON.stringify({ + error: + "That transaction hash has already been used to pay another " + + "submission. Each winner needs their own payment.", + }), + { status: 409, headers: corsHeaders }, + ); + } + throw error; + } const submission = await env.DB.prepare( `SELECT * FROM bounty_submissions WHERE id = ?`, @@ -492,6 +811,32 @@ export async function handleSubmissionsAPI( .bind(id) .first(); + // In-app notification, written here rather than by the browser so the + // submitter is told even if the reviewer closes the tab straight after. + const reviewed = (await env.DB.prepare( + `SELECT bs.user_id, bs.bounty_id, b.title AS bounty_title + FROM bounty_submissions bs + JOIN bounties b ON bs.bounty_id = b.id + WHERE bs.id = ?`, + ) + .bind(id) + .first()) as { + user_id: string; + bounty_id: string; + bounty_title: string; + } | null; + + if (reviewed) { + await notifySubmissionReviewed(env, { + submitterUserId: reviewed.user_id, + bountyId: reviewed.bounty_id, + submissionId: id as string, + bountyTitle: reviewed.bounty_title, + status: body.status, + reviewerNotes: body.reviewer_notes, + }); + } + if (body.status === "approved") { notifyUserSubmissionApproved(env, id as string).catch((e) => console.error("[email] notifyUserSubmissionApproved failed:", e), @@ -516,17 +861,32 @@ export async function handleSubmissionsAPI( request.method === "PATCH" && pathname.match(/^\/api\/submissions\/[^/]+$/) ) { + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + const id = pathname.split("/").pop(); const body = (await request.json()) as any; const now = Math.floor(Date.now() / 1000); - if (!body.user_id || !body.submission_url) { + if (!body.submission_url) { return new Response( JSON.stringify({ error: "Missing required fields" }), { status: 400, headers: corsHeaders }, ); } + if (!isSafeSubmissionUrl(body.submission_url)) { + return new Response( + JSON.stringify({ error: "submission_url must be an http(s) URL" }), + { status: 400, headers: corsHeaders }, + ); + } + // Verify the submission belongs to this user and check bounty is still open const existing = (await env.DB.prepare( `SELECT bs.id, bs.user_id, bs.status, b.status as bounty_status, b.end_date @@ -550,11 +910,14 @@ export async function handleSubmissionsAPI( }); } - if (existing.user_id !== body.user_id) { - return new Response(JSON.stringify({ error: "Unauthorized" }), { - status: 403, - headers: corsHeaders, - }); + if (existing.user_id !== sessionUserId) { + const isGod = await requestIsFromGod(env, request); + if (!isGod) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 403, + headers: corsHeaders, + }); + } } if (existing.status === "approved" || existing.status === "rejected") { @@ -668,7 +1031,41 @@ export async function handleCommentsAPI( // POST /api/comments - Create comment if (request.method === "POST" && pathname === "/api/comments") { + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + const body = (await request.json()) as any; + + if (!body.bounty_id || !body.content?.trim()) { + return new Response( + JSON.stringify({ error: "bounty_id and content are required" }), + { status: 400, headers: corsHeaders }, + ); + } + + // A reply must point at a parent comment that actually belongs to the + // same bounty — otherwise replies can be misattached across bounties. + if (body.parent_comment_id) { + const parent = await env.DB.prepare( + `SELECT id FROM bounty_comments WHERE id = ? AND bounty_id = ?`, + ) + .bind(body.parent_comment_id, body.bounty_id) + .first(); + if (!parent) { + return new Response( + JSON.stringify({ + error: "parent_comment_id does not belong to this bounty", + }), + { status: 400, headers: corsHeaders }, + ); + } + } + const id = crypto.randomUUID(); const now = Math.floor(Date.now() / 1000); @@ -681,7 +1078,7 @@ export async function handleCommentsAPI( .bind( id, body.bounty_id, - body.user_id, + sessionUserId, body.content, body.parent_comment_id || null, now, @@ -689,7 +1086,7 @@ export async function handleCommentsAPI( ) .run(); - const comment = await env.DB.prepare( + const comment = (await env.DB.prepare( `SELECT c.*, COALESCE(up.username, usr.name, usr.email) as user_username, usr.image as user_avatar @@ -699,7 +1096,50 @@ export async function handleCommentsAPI( WHERE c.id = ?`, ) .bind(id) - .first(); + .first()) as any; + + // Notify server-side. A reply pings the parent author; a top-level comment + // pings the bounty's sponsor. Both go through notify(), which skips muted + // bounties -- the old client-side code only checked mutes on the top-level + // path, so a muted bounty still delivered replies. + const ctx = (await env.DB.prepare( + `SELECT b.title AS bounty_title, sp.user_id AS sponsor_user_id + FROM bounties b + LEFT JOIN sponsors sp ON b.sponsor_id = sp.id + WHERE b.id = ?`, + ) + .bind(body.bounty_id) + .first()) as { + bounty_title: string; + sponsor_user_id: string | null; + } | null; + + const commenterName = comment?.user_username || "Someone"; + + if (body.parent_comment_id) { + const parent = (await env.DB.prepare( + `SELECT user_id FROM bounty_comments WHERE id = ?`, + ) + .bind(body.parent_comment_id) + .first()) as { user_id: string } | null; + + if (parent) { + await notifyCommentReply(env, { + parentAuthorUserId: parent.user_id, + actorUserId: sessionUserId, + bountyId: body.bounty_id, + replierName: commenterName, + }); + } + } else if (ctx?.sponsor_user_id) { + await notifyNewComment(env, { + sponsorUserId: ctx.sponsor_user_id, + actorUserId: sessionUserId, + bountyId: body.bounty_id, + bountyTitle: ctx.bounty_title, + commenterName, + }); + } return new Response(JSON.stringify({ comment }), { status: 201, @@ -709,10 +1149,41 @@ export async function handleCommentsAPI( // PUT /api/comments/:id - Edit comment if (request.method === "PUT" && pathname.match(/^\/api\/comments\/[^/]+$/)) { + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + const id = pathname.split("/").pop(); const body = (await request.json()) as any; const now = Math.floor(Date.now() / 1000); + const existingComment = (await env.DB.prepare( + `SELECT user_id FROM bounty_comments WHERE id = ?`, + ) + .bind(id) + .first()) as { user_id: string } | null; + + if (!existingComment) { + return new Response(JSON.stringify({ error: "Comment not found" }), { + status: 404, + headers: corsHeaders, + }); + } + + if (existingComment.user_id !== sessionUserId) { + const isGod = await isGodUser(env, sessionUserId); + if (!isGod) { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: corsHeaders, + }); + } + } + await env.DB.prepare( `UPDATE bounty_comments SET content = ?, updated_at = ? WHERE id = ?`, ) @@ -765,9 +1236,40 @@ export async function handleCommentsAPI( request.method === "DELETE" && pathname.match(/^\/api\/comments\/[^/]+$/) ) { + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + const id = pathname.split("/").pop(); const now = Math.floor(Date.now() / 1000); + const existingComment = (await env.DB.prepare( + `SELECT user_id FROM bounty_comments WHERE id = ?`, + ) + .bind(id) + .first()) as { user_id: string } | null; + + if (!existingComment) { + return new Response(JSON.stringify({ error: "Comment not found" }), { + status: 404, + headers: corsHeaders, + }); + } + + if (existingComment.user_id !== sessionUserId) { + const isGod = await isGodUser(env, sessionUserId); + if (!isGod) { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: corsHeaders, + }); + } + } + await env.DB.prepare( `UPDATE bounty_comments SET deleted_at = ? WHERE id = ?`, ) @@ -843,15 +1345,41 @@ export async function handleCommentsAPI( // Add user to liked_by array likedBy.push(body.user_id); - // Update comment with new liked_by array and increment like_count + // liked_by is the single source of truth; the count is derived on read. + // The like_count column was dropped in 033 -- it was written here and + // never read back, so it could only ever drift. await env.DB.prepare( `UPDATE bounty_comments - SET liked_by = ?, like_count = ?, updated_at = ? + SET liked_by = ?, updated_at = ? WHERE id = ?`, ) - .bind(JSON.stringify(likedBy), likedBy.length, now, commentId) + .bind(JSON.stringify(likedBy), now, commentId) .run(); + const liked = (await env.DB.prepare( + `SELECT c.user_id, c.bounty_id, + COALESCE(up.username, u.name, u.email) AS liker_name + FROM bounty_comments c + LEFT JOIN user u ON u.id = ? + LEFT JOIN user_profiles up ON up.user_id = u.id + WHERE c.id = ?`, + ) + .bind(body.user_id, commentId) + .first()) as { + user_id: string; + bounty_id: string; + liker_name: string; + } | null; + + if (liked) { + await notifyCommentLike(env, { + commentAuthorUserId: liked.user_id, + actorUserId: body.user_id, + bountyId: liked.bounty_id, + likerName: liked.liker_name || "Someone", + }); + } + return new Response( JSON.stringify({ success: true, like_count: likedBy.length }), { @@ -914,13 +1442,12 @@ export async function handleCommentsAPI( // Remove user from liked_by array const newLikedBy = likedBy.filter((id: string) => id !== userId); - // Update comment with new liked_by array and decrement like_count await env.DB.prepare( `UPDATE bounty_comments - SET liked_by = ?, like_count = ?, updated_at = ? + SET liked_by = ?, updated_at = ? WHERE id = ?`, ) - .bind(JSON.stringify(newLikedBy), newLikedBy.length, now, commentId) + .bind(JSON.stringify(newLikedBy), now, commentId) .run(); return new Response( @@ -947,6 +1474,38 @@ export async function handleSponsorsAPI( ): Promise { const pathname = url.pathname; + // GET /api/sponsors/check-slug?u=xxx[&exclude=] + // Live availability check for the sign-up / edit forms, mirroring the + // server-side rule so the user finds out before submitting rather than + // through a 409. Must be matched before /api/sponsors/:id below, which + // would otherwise swallow "check-slug" as an id. + if (request.method === "GET" && pathname === "/api/sponsors/check-slug") { + const url = new URL(request.url); + const raw = url.searchParams.get("u") || ""; + const normalized = normalizeSponsorSlug(raw); + + if (!normalized) { + return new Response( + JSON.stringify({ + available: false, + normalized, + reason: "Profile URL must contain at least one letter or number", + }), + { status: 200, headers: corsHeaders }, + ); + } + + const taken = await isSponsorSlugTaken( + env, + normalized, + url.searchParams.get("exclude") || undefined, + ); + + return new Response(JSON.stringify({ available: !taken, normalized }), { + headers: corsHeaders, + }); + } + // GET /api/sponsors/:id - Get sponsor if (request.method === "GET" && pathname.match(/^\/api\/sponsors\/[^/]+$/)) { const id = pathname.split("/").pop(); @@ -972,16 +1531,17 @@ export async function handleSponsorsAPI( request.method === "GET" && pathname.match(/^\/api\/sponsors\/name\/[^/]+$/) ) { - const slug = pathname.split("/").pop()!; + const slug = normalizeSponsorSlug( + decodeURIComponent(pathname.split("/").pop()!), + ); - // Normalize: lowercase, remove all non-alphanumeric characters for slug comparison + // Single-column exact match against a UNIQUE index. The previous query + // ORed `username` against a normalised `name`, which meant one slug could + // match two rows and LIMIT 1 returned whichever the planner reached first. const sponsor = await env.DB.prepare( - `SELECT * FROM sponsors - WHERE username = ? - OR LOWER(REPLACE(REPLACE(REPLACE(name, ' ', ''), '-', ''), '_', '')) = LOWER(REPLACE(REPLACE(?, '-', ''), '_', '')) - LIMIT 1`, + `SELECT * FROM sponsors WHERE slug = ?`, ) - .bind(slug, slug) + .bind(slug) .first(); if (!sponsor) { @@ -1014,14 +1574,14 @@ export async function handleSponsorsAPI( let allSponsors: any[] = []; try { const { results } = await env.DB.prepare( - `SELECT id, name, username, logo_url, is_verified, is_banned, created_at FROM sponsors ORDER BY name ASC`, + `SELECT id, name, slug, logo_url, is_verified, is_banned, created_at FROM sponsors ORDER BY name ASC`, ).all(); allSponsors = results; } catch { // Fallback: is_verified/is_banned columns may not exist yet try { const { results } = await env.DB.prepare( - `SELECT id, name, username, logo_url, created_at FROM sponsors ORDER BY name ASC`, + `SELECT id, name, slug, logo_url, created_at FROM sponsors ORDER BY name ASC`, ).all(); allSponsors = results; } catch { @@ -1046,13 +1606,60 @@ export async function handleSponsorsAPI( // POST /api/sponsors - Create sponsor (auto-approved) if (request.method === "POST" && pathname === "/api/sponsors") { + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + + const existingSponsor = await env.DB.prepare( + `SELECT id FROM sponsors WHERE user_id = ?`, + ) + .bind(sessionUserId) + .first(); + if (existingSponsor) { + return new Response( + JSON.stringify({ error: "This user already has a sponsor profile" }), + { status: 409, headers: corsHeaders }, + ); + } + const body = (await request.json()) as any; const id = crypto.randomUUID(); const now = Math.floor(Date.now() / 1000); + // Same slug rules as the update path — a sponsor must not be able to + // claim another's public URL at sign-up either. The form has only ever + // sent `name`; the `username` branch that used to sit here read a field no + // client sends, against a column dropped in 034. + const desiredSlug = normalizeSponsorSlug(String(body.name || "")); + + // The slug is the profile URL and cannot be derived later, so a name that + // normalises to nothing (e.g. all punctuation) has to be rejected here + // rather than silently producing an unreachable profile. + if (!desiredSlug) { + return new Response( + JSON.stringify({ + error: "Sponsor name must contain at least one letter or number", + }), + { status: 400, headers: corsHeaders }, + ); + } + + if (await isSponsorSlugTaken(env, desiredSlug)) { + return new Response( + JSON.stringify({ + error: `The URL /bounty/sponsor/${desiredSlug} is already taken`, + }), + { status: 409, headers: corsHeaders }, + ); + } + await env.DB.prepare( `INSERT INTO sponsors ( - id, user_id, name, username, description, entity_name, industry, + id, user_id, name, slug, description, entity_name, industry, logo_url, banner_url, website, twitter, discord, telegram, wallet_address, contact_first_name, contact_last_name, contact_username, contact_telegram, contact_email, status, approved_at, created_at, updated_at @@ -1060,9 +1667,9 @@ export async function handleSponsorsAPI( ) .bind( id, - body.user_id, + sessionUserId, body.name, - body.username || null, + desiredSlug, body.description || null, body.entity_name || null, body.industry || null, @@ -1089,7 +1696,7 @@ export async function handleSponsorsAPI( await env.DB.prepare( `UPDATE user SET is_sponsor = 1, sponsor_id = ? WHERE id = ?`, ) - .bind(id, body.user_id) + .bind(id, sessionUserId) .run(); } catch { // Columns don't exist yet, skip @@ -1113,9 +1720,9 @@ export async function handleSponsorsAPI( const id = pathname.replace("/dashboard", "").split("/").pop(); // Get sponsor info - const sponsor = await env.DB.prepare(`SELECT * FROM sponsors WHERE id = ?`) + const sponsor = (await env.DB.prepare(`SELECT * FROM sponsors WHERE id = ?`) .bind(id) - .first(); + .first()) as { user_id: string } | null; if (!sponsor) { return new Response(JSON.stringify({ error: "Sponsor not found" }), { @@ -1124,15 +1731,41 @@ export async function handleSponsorsAPI( }); } - // Get bounties for this sponsor + // This endpoint serves two audiences: the sponsor's own dashboard, and + // the public sponsor profile page (SponsorProfile.tsx fetches it from + // getServerSideProps). It had no authorization at all, so anyone who knew + // a sponsor id could read every submission -- including + // user_wallet_address and the submitter's identity -- and, once drafts + // existed, their unpublished bounties too. + // + // Rather than lock the whole route and break the public page, the owner + // gets everything and everyone else gets only what that page actually + // reads: the sponsor record and the published bounties. + const sessionUserId = await getSessionUserId(env, request); + const isOwner = + !!sessionUserId && + (sponsor.user_id === sessionUserId || + (await isGodUser(env, sessionUserId))); + const { results: bounties } = await env.DB.prepare( - `SELECT * FROM bounties - WHERE sponsor_id = ? - ORDER BY created_at DESC`, + isOwner + ? `SELECT * FROM bounties + WHERE sponsor_id = ? + ORDER BY created_at DESC` + : `SELECT * FROM bounties + WHERE sponsor_id = ? AND is_published = 1 AND status != 'deleted' + ORDER BY created_at DESC`, ) .bind(id) .all(); + if (!isOwner) { + return new Response( + JSON.stringify({ sponsor, bounties, submissions: [] }), + { headers: corsHeaders }, + ); + } + // Get submissions for all bounties by this sponsor (with user info) const { results: submissions } = await env.DB.prepare( `SELECT bs.*, @@ -1155,13 +1788,13 @@ export async function handleSponsorsAPI( const total_bounties_count = bounties.length; const total_projects_count = 0; // TODO: Implement projects const total_reward_amount = bounties.reduce((sum: number, b: any) => { - // If reward_currency is USD, use reward_amount - // Otherwise, use reward_usd_value if available, else use reward_amount - const rewardValue = - b.reward_currency === "USD" - ? parseFloat(b.reward_amount) || 0 - : parseFloat(b.reward_usd_value) || parseFloat(b.reward_amount) || 0; - return sum + rewardValue; + // Since 025 reward_usd is the one USD figure, kept current by the daily + // revaluation. The branch that used to stand here predates it: it read + // reward_currency='USD' to mean "reward_amount is dollars" and otherwise + // fell back to the hand-typed reward_usd_value, so a post-025 bounty -- + // reward_currency always 'ALPH', reward_usd_value always 0 -- summed its + // ALPH count as if it were dollars. + return sum + (parseFloat(b.reward_usd) || 0); }, 0); return new Response( @@ -1183,7 +1816,39 @@ export async function handleSponsorsAPI( // PUT /api/sponsors/:id - Update sponsor if (request.method === "PUT" && pathname.match(/^\/api\/sponsors\/[^/]+$/)) { + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + const id = pathname.split("/").pop(); + + const targetSponsor = (await env.DB.prepare( + `SELECT user_id FROM sponsors WHERE id = ?`, + ) + .bind(id) + .first()) as { user_id: string } | null; + + if (!targetSponsor) { + return new Response(JSON.stringify({ error: "Sponsor not found" }), { + status: 404, + headers: corsHeaders, + }); + } + + if (targetSponsor.user_id !== sessionUserId) { + const isGod = await isGodUser(env, sessionUserId); + if (!isGod) { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: corsHeaders, + }); + } + } + const body = (await request.json()) as any; const now = Math.floor(Date.now() / 1000); @@ -1238,6 +1903,54 @@ export async function handleSponsorsAPI( updates.push("contact_telegram = ?"); values.push(body.contact_telegram || null); } + // `slug` is only ever changed when explicitly asked for. Renaming a + // sponsor deliberately leaves it alone -- that stability is the point of + // storing it, since links previously derived from the name at render time + // and every one of them broke on a rename. + if (body.slug !== undefined) { + const desired = normalizeSponsorSlug(String(body.slug || "")); + + if (!desired) { + return new Response( + JSON.stringify({ + error: "Profile URL must contain at least one letter or number", + }), + { status: 400, headers: corsHeaders }, + ); + } + + if (await isSponsorSlugTaken(env, desired, id)) { + return new Response( + JSON.stringify({ + error: `The URL /bounty/sponsor/${desired} is already taken`, + }), + { status: 409, headers: corsHeaders }, + ); + } + + updates.push("slug = ?"); + values.push(desired); + } + if (body.entity_name !== undefined) { + updates.push("entity_name = ?"); + values.push(body.entity_name || null); + } + if (body.industry !== undefined) { + updates.push("industry = ?"); + values.push(body.industry || null); + } + if (body.contact_first_name !== undefined) { + updates.push("contact_first_name = ?"); + values.push(body.contact_first_name || null); + } + if (body.contact_last_name !== undefined) { + updates.push("contact_last_name = ?"); + values.push(body.contact_last_name || null); + } + if (body.contact_username !== undefined) { + updates.push("contact_username = ?"); + values.push(body.contact_username || null); + } // Always update updated_at updates.push("updated_at = ?"); @@ -1403,25 +2116,15 @@ export async function handleSponsorsAPI( request.method === "POST" && pathname.match(/^\/api\/sponsors\/[^/]+\/transfer$/) ) { - const cookieHeader = request.headers.get("cookie") || ""; - const tokenMatch = cookieHeader.match(/better-auth\.session_token=([^;]+)/); - const token = tokenMatch ? decodeURIComponent(tokenMatch[1]) : null; - const sessionRow = token - ? ((await env.DB.prepare( - "SELECT userId FROM session WHERE token = ? AND expiresAt > ?", - ) - .bind(token, Date.now()) - .first()) as { userId: string } | null) - : null; + const currentUserId = await getSessionUserId(env, request); - if (!sessionRow?.userId) { + if (!currentUserId) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 401, headers: corsHeaders, }); } - const currentUserId = sessionRow.userId; const id = pathname.split("/")[3]; const now = Math.floor(Date.now() / 1000); @@ -1631,6 +2334,24 @@ export async function handleNotificationsAPI( // POST /api/notifications - Create notification if (request.method === "POST" && pathname === "/api/notifications") { + // Notifications are created by the worker in the same handler as the state + // change they describe (see notifications.ts). This endpoint used to accept + // an arbitrary user_id/title/message/link from anyone, which made it a + // ready-made phishing channel; it is now god-only and kept for admin use. + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + if (!(await isGodUser(env, sessionUserId))) { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: corsHeaders, + }); + } + const body = (await request.json()) as any; // Validate required fields @@ -1652,31 +2373,35 @@ export async function handleNotificationsAPI( ); } - const id = crypto.randomUUID(); - const now = Math.floor(Date.now() / 1000); + // Routed through notify() rather than inserting directly, so this path + // honours notification_mutes like every other one. A direct INSERT here + // meant an admin-authored notice about a bounty reached users who had + // explicitly muted that bounty. Mutes are bounty-scoped, so a + // notification with no bounty attached is unaffected. + const created = await notify(env, { + userId: body.user_id, + type: body.type, + title: body.title, + message: body.message, + link: body.link || null, + bountyId: body.related_bounty_id || null, + submissionId: body.related_submission_id || null, + }); - await env.DB.prepare( - `INSERT INTO notifications ( - id, user_id, type, title, message, link, related_bounty_id, related_submission_id, read, created_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`, - ) - .bind( - id, - body.user_id, - body.type, - body.title, - body.message, - body.link || null, - body.related_bounty_id || null, - body.related_submission_id || null, - now, - ) - .run(); + if (!created) { + return new Response( + JSON.stringify({ + suppressed: true, + reason: "recipient muted this bounty", + }), + { status: 200, headers: corsHeaders }, + ); + } const notification = await env.DB.prepare( - `SELECT * FROM notifications WHERE id = ?`, + `SELECT * FROM notifications WHERE user_id = ? ORDER BY created_at DESC LIMIT 1`, ) - .bind(id) + .bind(body.user_id) .first(); return new Response(JSON.stringify({ notification }), { @@ -1859,39 +2584,6 @@ export async function handleNotificationPreferencesAPI( }); } -/** - * Helper function to create a notification - */ -export async function createNotification( - env: Env, - data: { - user_id: string; - type: string; - title: string; - message: string; - link?: string; - }, -): Promise { - const id = crypto.randomUUID(); - const now = Math.floor(Date.now() / 1000); - - await env.DB.prepare( - `INSERT INTO notifications ( - id, user_id, type, title, message, link, read, created_at - ) VALUES (?, ?, ?, ?, ?, ?, 0, ?)`, - ) - .bind( - id, - data.user_id, - data.type, - data.title, - data.message, - data.link || null, - now, - ) - .run(); -} - /** * Helper to check if notifications are muted for a bounty * Updated to use notification_mutes table (simpler approach) @@ -2032,14 +2724,16 @@ export async function handleBookmarksAPI( // GET /api/bookmarks - Get user's bookmarks if (request.method === "GET" && pathname === "/api/bookmarks") { - const userId = url.searchParams.get("user_id"); - - if (!userId) { - return new Response(JSON.stringify({ error: "user_id is required" }), { - status: 400, + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, headers: corsHeaders, }); } + // Ignore any user_id query param for identity — only the session owner's + // own bookmarks can be listed this way. + const userId = sessionUserId; try { const bookmarks = await env.DB.prepare( @@ -2056,6 +2750,7 @@ export async function handleBookmarksAPI( bo.end_date, bo.sponsor_id, s.name as sponsor_name, + s.slug as sponsor_slug, s.logo_url as sponsor_logo_url FROM bookmarks b LEFT JOIN bounties bo ON b.bounty_id = bo.id @@ -2093,17 +2788,21 @@ export async function handleBookmarksAPI( // GET /api/bookmarks/check - Check if bounty is bookmarked if (request.method === "GET" && pathname === "/api/bookmarks/check") { - const userId = url.searchParams.get("user_id"); + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + const userId = sessionUserId; const bountyId = url.searchParams.get("bounty_id"); - if (!userId || !bountyId) { - return new Response( - JSON.stringify({ error: "user_id and bounty_id are required" }), - { - status: 400, - headers: corsHeaders, - }, - ); + if (!bountyId) { + return new Response(JSON.stringify({ error: "bounty_id is required" }), { + status: 400, + headers: corsHeaders, + }); } try { @@ -2131,6 +2830,14 @@ export async function handleBookmarksAPI( // POST /api/bookmarks - Create bookmark if (request.method === "POST" && pathname === "/api/bookmarks") { try { + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + const body = (await request.json()) as any; const id = crypto.randomUUID(); const now = Math.floor(Date.now() / 1000); @@ -2139,7 +2846,7 @@ export async function handleBookmarksAPI( const existing = await env.DB.prepare( `SELECT id FROM bookmarks WHERE user_id = ? AND bounty_id = ?`, ) - .bind(body.user_id, body.bounty_id) + .bind(sessionUserId, body.bounty_id) .first(); if (existing) { @@ -2156,7 +2863,7 @@ export async function handleBookmarksAPI( `INSERT INTO bookmarks (id, user_id, bounty_id, created_at) VALUES (?, ?, ?, ?)`, ) - .bind(id, body.user_id, body.bounty_id, now) + .bind(id, sessionUserId, body.bounty_id, now) .run(); const bookmark = await env.DB.prepare( @@ -2183,17 +2890,21 @@ export async function handleBookmarksAPI( // DELETE /api/bookmarks - Delete bookmark if (request.method === "DELETE" && pathname === "/api/bookmarks") { - const userId = url.searchParams.get("user_id"); + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + const userId = sessionUserId; const bountyId = url.searchParams.get("bounty_id"); - if (!userId || !bountyId) { - return new Response( - JSON.stringify({ error: "user_id and bounty_id are required" }), - { - status: 400, - headers: corsHeaders, - }, - ); + if (!bountyId) { + return new Response(JSON.stringify({ error: "bounty_id is required" }), { + status: 400, + headers: corsHeaders, + }); } try { @@ -3316,3 +4027,128 @@ ${body.description} ); } } + +/** + * Email preferences and one-click unsubscribe. + * + * GET /api/email/preferences -> { unsubscribed: string[] } (session) + * PUT /api/email/preferences <- { unsubscribed: string[] } (session) + * GET /api/email/unsubscribe?u&c&t -> HTML confirmation (signed) + * POST /api/email/unsubscribe?u&c&t -> 200 (signed) + * + * The unsubscribe routes take no session on purpose: the link is followed + * from a mail client, often on a device that is not logged in, and RFC 8058 + * one-click requires an unauthenticated POST. Authorization comes from the + * HMAC over (userId, category) instead — a bare user id in the URL would let + * anyone unsubscribe anyone. + */ +export async function handleEmailAPI( + request: Request, + env: Env, + url: URL, +): Promise { + const json = (obj: unknown, status = 200) => + new Response(JSON.stringify(obj), { status, headers: corsHeaders }); + + try { + if (url.pathname === "/api/email/unsubscribe") { + const userId = url.searchParams.get("u") || ""; + const category = url.searchParams.get("c") || ""; + const token = url.searchParams.get("t") || ""; + + if (!userId || !category || !token) { + return json({ error: "Missing parameters" }, 400); + } + if (category !== "all" && !EMAIL_CATEGORIES.includes(category as any)) { + return json({ error: "Unknown category" }, 400); + } + if (!(await verifyUnsubscribe(env, userId, category, token))) { + return json({ error: "Invalid or expired link" }, 403); + } + + await env.DB.prepare( + `INSERT INTO email_unsubscribes (user_id, category) VALUES (?, ?) + ON CONFLICT(user_id, category) DO NOTHING`, + ) + .bind(userId, category) + .run(); + + // One-click clients want a bare 200, not a page. + if (request.method === "POST") return json({ ok: true }); + + return new Response( + ` + Unsubscribed – Alphland + `, + { + status: 200, + headers: { "Content-Type": "text/html; charset=utf-8" }, + }, + ); + } + + if (url.pathname === "/api/email/preferences") { + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) return json({ error: "Unauthorized" }, 401); + + if (request.method === "GET") { + const rows = (await env.DB.prepare( + `SELECT category FROM email_unsubscribes WHERE user_id = ?`, + ) + .bind(sessionUserId) + .all()) as { results?: { category: string }[] }; + + return json({ + categories: EMAIL_CATEGORIES, + unsubscribed: (rows.results ?? []).map((r) => r.category), + }); + } + + if (request.method === "PUT") { + const body = (await request.json()) as { unsubscribed?: unknown }; + const list = Array.isArray(body.unsubscribed) ? body.unsubscribed : []; + + const valid = list.filter( + (c): c is string => + typeof c === "string" && + (c === "all" || EMAIL_CATEGORIES.includes(c as any)), + ); + if (valid.length !== list.length) { + return json({ error: "Unknown category" }, 400); + } + + // Replace the whole set: the settings page always sends the full + // state, so a removed entry means "resubscribe". + await env.DB.prepare(`DELETE FROM email_unsubscribes WHERE user_id = ?`) + .bind(sessionUserId) + .run(); + + for (const category of valid) { + await env.DB.prepare( + `INSERT INTO email_unsubscribes (user_id, category) VALUES (?, ?) + ON CONFLICT(user_id, category) DO NOTHING`, + ) + .bind(sessionUserId, category) + .run(); + } + + return json({ ok: true, unsubscribed: valid }); + } + + return json({ error: "Method not allowed" }, 405); + } + + return json({ error: "Not found" }, 404); + } catch (err) { + console.error("[email-api] error:", err); + return json( + { error: err instanceof Error ? err.message : "Internal server error" }, + 500, + ); + } +} diff --git a/src/worker/index.ts b/src/worker/index.ts index 774d2e1d..225859f2 100644 --- a/src/worker/index.ts +++ b/src/worker/index.ts @@ -3,7 +3,8 @@ * This worker handles API requests and connects to D1 database */ import { createAuth } from "./auth"; -import { notifySponsorVerified } from "./email"; +import { notifySponsorAccountChange } from "./email"; +import { notifySponsorStatusChanged } from "./notifications"; import { verifySignedMessage } from "@alephium/web3"; import { DAPP_LIST } from "./dappList"; import { @@ -11,6 +12,7 @@ import { handleCommentsAPI, handleSponsorsAPI, handleNotificationsAPI, + handleEmailAPI, handleNotificationPreferencesAPI, handleUserDeletionAPI, handleBookmarksAPI, @@ -19,7 +21,21 @@ import { handleImageServingAPI, handleProofOfWorkAPI, handleSubmitDappAPI, + getSessionUserId, + isGodUser, } from "./handlers"; +import { + getAlphPrice, + maybeRefreshDailyPrice, + refreshAlphPrice, + resolveRewardInput, + RateUnavailableError, +} from "./price"; +// Shared with the frontend on purpose — one derivation of display status for +// both sides. The module is dependency-free so it bundles into the Worker. +import { getBountyDisplayStatus } from "@/features/bounty/utils/bountyStatus"; +import { bountySlug } from "@/features/bounty/utils/validators"; +import { runBountyReminders } from "./reminders"; // Type definition for D1Database (fallback for when @cloudflare/workers-types is not available) type D1Database = any; @@ -38,6 +54,7 @@ export interface Env { GITHUB_ISSUES_TOKEN?: string; // GitHub token for creating issues GITHUB_BOT_TOKEN?: string; // unused — dApp submissions use GITHUB_ISSUES_TOKEN INTERNAL_SECRET?: string; // shared secret for internal Vercel → Worker calls + COINGECKO_API_KEY?: string; // optional demo key; keyless works at 1 call/day } // Note: Do NOT cache auth instance globally @@ -166,6 +183,20 @@ const worker = { // Admin Analytics APIs // ========================================== + // All /api/admin/* routes require a god-user session + if (url.pathname.startsWith("/api/admin/")) { + const sessionUserId = await getSessionUserId(env, request); + const isGod = sessionUserId + ? await isGodUser(env, sessionUserId) + : false; + if (!isGod) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: sessionUserId ? 403 : 401, + headers: { "Content-Type": "application/json", ...corsHeaders }, + }); + } + } + // GET /api/admin/user-stats - User statistics overview if (url.pathname === "/api/admin/user-stats") { const nowMs = Date.now(); @@ -498,6 +529,8 @@ const worker = { ) .bind(now, now, id) .run(); + + await notifySponsorNotice(env, id, "banned"); await env.DB.prepare( `UPDATE user SET is_banned = 1, updatedAt = ? WHERE id = ?`, ) @@ -538,6 +571,8 @@ const worker = { ) .bind(now, id) .run(); + + await notifySponsorNotice(env, id, "unbanned"); await env.DB.prepare( `UPDATE user SET is_banned = 0, updatedAt = ? WHERE id = ?`, ) @@ -573,9 +608,8 @@ const worker = { .bind(now, now, id) .run(); - notifySponsorVerified(env, id).catch((e) => - console.error("[email] notifySponsorVerified failed:", e), - ); + // Both the in-app notice and the approval email come from here now. + await notifySponsorNotice(env, id, "verified"); return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json", ...corsHeaders }, @@ -605,6 +639,8 @@ const worker = { ) .bind(now, id) .run(); + + await notifySponsorNotice(env, id, "unverified"); return new Response(JSON.stringify({ success: true }), { headers: { "Content-Type": "application/json", ...corsHeaders }, }); @@ -667,12 +703,7 @@ const worker = { // Average reward for approved submissions // Get reward from bounties table, not submissions const avgReward = await env.DB.prepare( - `SELECT AVG( - CASE - WHEN b.reward_currency = 'USD' THEN b.reward_amount - ELSE COALESCE(b.reward_usd_value, 0) - END - ) as avg_amount + `SELECT AVG(COALESCE(b.reward_usd, 0)) as avg_amount FROM bounty_submissions s JOIN bounties b ON s.bounty_id = b.id WHERE s.status = 'approved'`, @@ -803,15 +834,86 @@ const worker = { // End Admin Analytics APIs // ========================================== + // ALPH/USD rate — public read. Frontend uses this for the "≈ $X" + // secondary figure next to every ALPH amount. + if (url.pathname === "/api/price/alph" && request.method === "GET") { + const price = await getAlphPrice(env); + + if (!price) { + return new Response( + JSON.stringify({ error: "No ALPH price available yet" }), + { status: 503, headers: corsHeaders }, + ); + } + + return new Response( + JSON.stringify({ + usd: price.usd, + fetched_at: price.fetched_at, + source_updated_at: price.source_updated_at, + source: price.source, + // Age lets the UI grey out or hide the USD figure if the feed + // has been stuck (e.g. several failed daily runs in a row). + age_seconds: Math.floor(Date.now() / 1000) - price.fetched_at, + }), + { + headers: { + ...corsHeaders, + // Rate changes at most once a day; let the edge hold it. + "Cache-Control": "public, max-age=300", + }, + }, + ); + } + + // Manual refresh — god users only. For testing and for recovering + // after a failed daily run without waiting for the next 08:00. + if ( + url.pathname === "/api/price/alph/refresh" && + request.method === "POST" + ) { + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + if (!(await isGodUser(env, sessionUserId))) { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: corsHeaders, + }); + } + + try { + const price = await refreshAlphPrice(env); + return new Response(JSON.stringify({ price }), { + headers: corsHeaders, + }); + } catch (err: any) { + return new Response( + JSON.stringify({ error: err?.message ?? "Refresh failed" }), + { status: 502, headers: corsHeaders }, + ); + } + } + // Bounty overview stats endpoint - compute dynamically if (url.pathname === "/api/bounty-overview") { // Get bounty stats + // reward_amount is ALPH and reward_usd is USD for every row regardless + // of denomination, so both totals are now real totals covering all + // bounties — not the two disjoint piles the old CASE expressions + // produced (an ALPH bounty contributed nothing to the USD figure and + // vice versa, so neither number described the whole programme). const bountyStats = await env.DB.prepare( `SELECT COUNT(*) as list_number, - COALESCE(SUM(CASE WHEN reward_currency = 'USD' THEN reward_amount ELSE 0 END), 0) as total_value_usd, - COALESCE(SUM(CASE WHEN reward_currency = 'ALPH' THEN reward_amount ELSE 0 END), 0) as total_value_alph - FROM bounties`, + COALESCE(SUM(reward_usd), 0) as total_value_usd, + COALESCE(SUM(reward_amount), 0) as total_value_alph + FROM bounties + WHERE status != 'deleted'`, ).first(); // Get user count @@ -935,6 +1037,12 @@ const worker = { return handleNotificationsAPI(request, env, url); } + // Email preferences + one-click unsubscribe (the latter is unauthenticated + // by design — see handleEmailAPI) + if (url.pathname.startsWith("/api/email/")) { + return handleEmailAPI(request, env, url); + } + // Notification preferences and mutes endpoints if ( url.pathname.startsWith("/api/notification-preferences") || @@ -1107,6 +1215,17 @@ const worker = { // fetches blocks since last run, extracts sender addresses, and upserts into // active_addresses. After 7 days the dashboard will show real data. async scheduled(_event: any, env: Env, ctx: any): Promise { + // Daily ALPH/USD refresh at 08:00 Europe/Berlin. The cron fires hourly; + // this is a no-op on the other 23 runs. Never throws, so a price-feed + // outage cannot stop the address indexer below. + await maybeRefreshDailyPrice(env); + + // Deadline and overdue-review reminders. Also never throws — each stage + // is isolated inside runBountyReminders, so a reminder failure cannot + // stop the indexer either. Deduplicated against email_logs, not by the + // schedule: this handler fires hourly. + await runBountyReminders(env); + const EXPLORER = "https://lb-fullnode-alephium.notrustverify.ch"; const MAX_WINDOW_MS = 2 * 60 * 60_000; // process at most 2 hours per run const now = Date.now(); @@ -1208,6 +1327,44 @@ const worker = { }, }; +/** + * In-app notice for an admin-driven sponsor status change. + * + * These four routes previously only sent email (and only on verify), so a + * sponsor whose account was suspended or whose verification was pulled had no + * way to find out in the product. + */ +async function notifySponsorNotice( + env: Env, + sponsorId: string, + change: "verified" | "unverified" | "banned" | "unbanned", +): Promise { + try { + const sponsor = (await env.DB.prepare( + `SELECT user_id, name FROM sponsors WHERE id = ?`, + ) + .bind(sponsorId) + .first()) as { user_id: string; name: string } | null; + if (!sponsor) return; + + await notifySponsorStatusChanged(env, { + sponsorUserId: sponsor.user_id, + sponsorName: sponsor.name, + change, + }); + + // Email from the same place as the in-app notice. Previously only + // `verified` sent one, from its own call site, so the two channels could + // — and did — disagree about which changes are worth telling someone. + await notifySponsorAccountChange(env, sponsorId, change); + } catch (err: any) { + console.error( + "[notify] sponsor status notice failed:", + err?.message ?? err, + ); + } +} + export default worker; /** @@ -1218,13 +1375,91 @@ function transformBounty(bounty: any) { return { ...bounty, reward: { + // Always ALPH. For a USD-denominated bounty this is the derived figure + // at the last daily valuation, not a promise — the UI should lead with + // usd_equivalent for those and label the ALPH figure as approximate. amount: bounty.reward_amount || 0, token: bounty.reward_currency || "ALPH", - usd_equivalent: bounty.reward_usd_value || 0, + // Always USD, and populated for both denominations now. + usd_equivalent: bounty.reward_usd || 0, }, + // Which side the sponsor fixed. Drives whether the UI shows + // "500 ALPH (≈ $19)" or "$100 (≈ 2,565 ALPH)". + denomination: bounty.denomination || "alph", + // Derived server-side from the same function the client uses, so a list + // rendered from this payload and a page that recomputes it can never + // disagree. Payment counts are not in scope here, so an announced bounty + // reports "Payment Pending" rather than guessing "Completed". + display_status: getBountyDisplayStatus(bounty), }; } +/** + * Pick a free slug for a title. + * + * The UNIQUE index is what actually guarantees uniqueness; this just avoids + * losing the insert to an avoidable collision. Two bounties named the same + * thing become "…-2", "…-3". Returns null when the title yields no usable + * slug (all punctuation, or non-Latin script) — the row then keeps NULL and + * stays reachable by id, which is why the index is partial. + */ +async function pickBountySlug( + env: Env, + title: string, + excludeId?: string, +): Promise { + const base = bountySlug(title); + if (!base) return null; + + for (let n = 1; n <= 20; n++) { + const candidate = n === 1 ? base : `${base}-${n}`; + const taken = (await env.DB.prepare( + `SELECT id FROM bounties WHERE slug = ? AND id IS NOT ? LIMIT 1`, + ) + .bind(candidate, excludeId ?? null) + .first()) as { id: string } | null; + if (!taken) return candidate; + } + + // Twenty identical titles is not a real case; fall back to id-only routing + // rather than looping forever or throwing away the whole create. + return null; +} + +/** A bare UUID, i.e. the pre-slug URL form that must keep resolving. */ +const UUID_RE = + /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i; + +/** + * What a bounty must have before it can be public. + * + * Returns an error message, or null when it is publishable. Used by both + * POST /api/bounties (creating as published) and POST /api/bounties/:id/publish + * (promoting a draft) — the gate has to be the same on both paths, or a draft + * becomes a way to publish a bounty that direct creation would have rejected. + * + * Takes a plain object so it works on a request body and on a database row. + */ +function publishRequirements(b: { + title?: unknown; + description?: unknown; + start_date?: unknown; + end_date?: unknown; +}): string | null { + const filled = (v: unknown) => + typeof v === "string" ? v.trim() !== "" : v !== null && v !== undefined; + + const missing = [ + !filled(b.title) && "title", + !filled(b.description) && "description", + !filled(b.start_date) && "start_date", + !filled(b.end_date) && "end_date", + ].filter(Boolean) as string[]; + + if (missing.length === 0) return null; + return `Cannot publish without: ${missing.join(", ")}`; +} + /** * Handle Bounties API requests */ @@ -1243,9 +1478,30 @@ async function handleBountiesAPI( // GET /api/bounties - List all bounties (with optional dapp_name filter) if (request.method === "GET" && pathname === "/api/bounties") { const dappName = url.searchParams.get("dapp_name"); + // The sponsor dashboard needs its own drafts back; nothing else does. + // Scoped to one sponsor and checked against the session below, so this + // cannot be used to read someone else's unpublished work. + const includeDrafts = url.searchParams.get("include_drafts") === "1"; + const sponsorId = url.searchParams.get("sponsor_id"); + + let showDrafts = false; + if (includeDrafts && sponsorId) { + const sessionUserId = await getSessionUserId(env, request); + if (sessionUserId) { + const owner = (await env.DB.prepare( + "SELECT user_id FROM sponsors WHERE id = ?", + ) + .bind(sponsorId) + .first()) as { user_id: string } | null; + showDrafts = + owner?.user_id === sessionUserId || + (await isGodUser(env, sessionUserId)); + } + } let query = ` - SELECT b.*, s.name as sponsor_name, s.logo_url as sponsor_logo_url, + SELECT b.*, s.name as sponsor_name, s.slug as sponsor_slug, + s.logo_url as sponsor_logo_url, s.is_verified as sponsor_is_verified, (SELECT COUNT(*) FROM bounty_submissions WHERE bounty_id = b.id) as submission_count FROM bounties b @@ -1255,6 +1511,15 @@ async function handleBountiesAPI( const params: string[] = []; + if (showDrafts) { + query += ` AND b.sponsor_id = ?`; + params.push(sponsorId!); + } else { + // A draft has never been public. Filtering here rather than in each + // caller means a new consumer of this endpoint cannot leak them. + query += ` AND b.is_published = 1`; + } + // Filter by dapp_name if provided (case-insensitive) if (dappName) { query += ` AND LOWER(b.dapp_name) = LOWER(?)`; @@ -1278,22 +1543,29 @@ async function handleBountiesAPI( ); } - // GET /api/bounties/:id - Get single bounty + // GET /api/bounties/:idOrSlug - Get single bounty + // + // Accepts either form. Every /bounty/ link already shared publicly + // predates slugs, so dropping id lookup would 404 them; matching on both + // keeps those alive permanently. A bare UUID is matched against id only, so + // a slug can never shadow an id (and vice versa). if (request.method === "GET" && pathname.match(/^\/api\/bounties\/[^/]+$/)) { - const id = pathname.split("/").pop(); + const key = decodeURIComponent(pathname.split("/").pop() || ""); const bounty = await env.DB.prepare( ` SELECT b.*, s.name as sponsor_name, + s.slug as sponsor_slug, s.logo_url as sponsor_logo_url, s.is_verified as sponsor_is_verified, (SELECT COUNT(*) FROM bounty_submissions WHERE bounty_id = b.id) as submission_count FROM bounties b LEFT JOIN sponsors s ON b.sponsor_id = s.id - WHERE b.id = ? + WHERE b.id = ? OR (? = 0 AND b.slug = ?) + LIMIT 1 `, ) - .bind(id) + .bind(key, UUID_RE.test(key) ? 1 : 0, key) .first(); if (!bounty) { @@ -1303,6 +1575,23 @@ async function handleBountiesAPI( }); } + // A draft is visible only to its own sponsor (so they can preview it) and + // to god. 404 rather than 403 — the id of an unpublished bounty is not + // something a stranger should be able to confirm exists. + if ((bounty as any).is_published === 0) { + const sessionUserId = await getSessionUserId(env, request); + const owner = (bounty as any).created_by; + const allowed = + !!sessionUserId && + (sessionUserId === owner || (await isGodUser(env, sessionUserId))); + if (!allowed) { + return new Response(JSON.stringify({ error: "Bounty not found" }), { + status: 404, + headers: corsHeaders, + }); + } + } + return new Response(JSON.stringify({ bounty: transformBounty(bounty) }), { headers: corsHeaders, }); @@ -1311,27 +1600,19 @@ async function handleBountiesAPI( // POST /api/bounties - Create new bounty if (request.method === "POST" && pathname === "/api/bounties") { try { + const created_by = await getSessionUserId(env, request); + if (!created_by) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + const body = (await request.json()) as any; const id = crypto.randomUUID(); const now = Math.floor(Date.now() / 1000); - // Get user_id from session or request - const created_by = body.created_by || body.user_id; - - // Validate required fields - if (!created_by) { - return new Response( - JSON.stringify({ - error: "User ID is required", - }), - { - status: 400, - headers: corsHeaders, - }, - ); - } - if (!body.sponsor_id) { return new Response( JSON.stringify({ @@ -1346,11 +1627,12 @@ async function handleBountiesAPI( // Verify sponsor exists, is verified, and is not banned const sponsor = (await env.DB.prepare( - "SELECT id, is_verified, is_banned FROM sponsors WHERE id = ?", + "SELECT id, user_id, is_verified, is_banned FROM sponsors WHERE id = ?", ) .bind(body.sponsor_id) .first()) as { id: string; + user_id: string; is_verified: number; is_banned: number; } | null; @@ -1379,7 +1661,7 @@ async function handleBountiesAPI( ); } - // God users bypass the is_verified check + // God users bypass the is_verified check and the sponsor-ownership check const creatorRole = (await env.DB.prepare( "SELECT role FROM user WHERE id = ?", ) @@ -1387,11 +1669,13 @@ async function handleBountiesAPI( .first()) as { role: string | null } | null; const creatorIsGod = creatorRole?.role === "god"; - if (!sponsor.is_verified && !creatorIsGod) { + // Only the sponsor's own owner (or a god user) may create bounties + // under that sponsor's identity. + if (sponsor.user_id !== created_by && !creatorIsGod) { return new Response( JSON.stringify({ error: - "Sponsor account is pending review. Please wait for admin approval before creating bounties.", + "You do not have permission to create bounties for this sponsor.", }), { status: 403, @@ -1400,27 +1684,94 @@ async function handleBountiesAPI( ); } - // Verify user exists - const user = await env.DB.prepare("SELECT id FROM user WHERE id = ?") - .bind(created_by) - .first(); - - if (!user) { + if (!sponsor.is_verified && !creatorIsGod) { return new Response( JSON.stringify({ - error: "User not found", + error: + "Sponsor account is pending review. Please wait for admin approval before creating bounties.", }), { - status: 404, + status: 403, headers: corsHeaders, }, ); } + // Draft vs publish. Absent means publish, so every existing client that + // does not know about drafts keeps working exactly as before. + const isPublished = body.is_published === false ? 0 : 1; + + // A draft is unfinished by definition — refusing to save one because + // the deadline is still blank defeats the point of the button. Only a + // title is required, so the dashboard has something to list it under. + // Everything else is enforced at publish time, in one place: the + // publishRequirements() check used by both this endpoint and + // POST /api/bounties/:id/publish. + if (!body.title?.trim()) { + return new Response(JSON.stringify({ error: "title is required" }), { + status: 400, + headers: corsHeaders, + }); + } + + if (isPublished) { + const missing = publishRequirements(body); + if (missing) { + return new Response(JSON.stringify({ error: missing }), { + status: 400, + headers: corsHeaders, + }); + } + } + + // reward_amount is always ALPH; target_usd is always USD. Which one the + // caller must supply depends on `denomination`, and the other side is + // derived from the daily rate. + let reward: Awaited>; + try { + reward = await resolveRewardInput(env, body); + } catch (err: any) { + // A missing rate is a server-side outage, not bad input. + const status = err instanceof RateUnavailableError ? 503 : 400; + return new Response(JSON.stringify({ error: err.message }), { + status, + headers: corsHeaders, + }); + } + + const rewardType = body.reward_type || "fixed"; + if (rewardType !== "fixed" && rewardType !== "tiered") { + return new Response( + JSON.stringify({ error: "reward_type must be 'fixed' or 'tiered'" }), + { status: 400, headers: corsHeaders }, + ); + } + + const tierCount = body.tier_count ?? 5; + if (![3, 5, 10].includes(Number(tierCount))) { + return new Response( + JSON.stringify({ error: "tier_count must be 3, 5, or 10" }), + { status: 400, headers: corsHeaders }, + ); + } + + // Only meaningful once both dates exist — a draft may have neither. + if ( + body.start_date && + body.end_date && + new Date(body.end_date) <= new Date(body.start_date) + ) { + return new Response( + JSON.stringify({ error: "end_date must be after start_date" }), + { status: 400, headers: corsHeaders }, + ); + } + // Convert arrays to JSON strings const requirements = JSON.stringify(body.requirements || []); const deliverables = JSON.stringify(body.deliverables || []); const skills = JSON.stringify(body.skills || []); + const tags = JSON.stringify(body.tags || []); // Normalize category and difficulty to lowercase for CHECK constraint const category = body.category ? body.category.toLowerCase() : null; @@ -1433,32 +1784,44 @@ async function handleBountiesAPI( ` INSERT INTO bounties ( id, sponsor_id, title, description, - requirements, deliverables, skills, - reward_amount, reward_currency, reward_type, reward_usd_value, tier_count, + requirements, deliverables, skills, tags, + reward_amount, reward_currency, reward_type, tier_count, + denomination, target_usd, reward_usd, token_usd_at_valuation, + valuation_updated_at, category, difficulty, dapp_name, start_date, end_date, + is_published, published_at, slug, status, created_by, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?) `, ) .bind( id, body.sponsor_id, body.title, - body.description, + // NOT NULL in the schema, and a draft may not have one yet. + body.description ?? "", requirements, deliverables, skills, - body.reward_amount || 0, - body.reward_currency || "ALPH", - body.reward_type || "fixed", - body.reward_usd_value || 0, - body.tier_count || 5, + tags, + reward.reward_amount, + "ALPH", // settlement token + rewardType, + Number(tierCount), + reward.denomination, + reward.target_usd, + reward.reward_usd, + reward.token_usd_at_valuation, + reward.token_usd_at_valuation ? now : null, category, difficulty, body.dapp_name || null, - body.start_date, - body.end_date, + body.start_date || null, + body.end_date || null, + isPublished, + isPublished ? now : null, + await pickBountySlug(env, body.title), "open", created_by, now, @@ -1470,46 +1833,11 @@ async function handleBountiesAPI( .bind(id) .first(); - // Update bounty_overview statistics - try { - // Get current overview - const overview = await env.DB.prepare( - "SELECT * FROM bounty_overview WHERE id = 1", - ).first(); - - // Calculate reward value - use USD value or ALPH amount - const rewardCurrency = body.reward_currency || "ALPH"; - const rewardAmount = body.reward_amount || 0; - const rewardUsdValue = body.reward_usd_value || 0; - - const rewardUsd = - rewardCurrency === "USD" ? rewardAmount : rewardUsdValue; - const rewardAlph = rewardCurrency === "ALPH" ? rewardAmount : 0; - - if (overview) { - await env.DB.prepare( - `UPDATE bounty_overview - SET total_value_usd = total_value_usd + ?, - total_value_alph = total_value_alph + ?, - list_number = list_number + 1, - updated_at = ? - WHERE id = 1`, - ) - .bind(rewardUsd, rewardAlph, now) - .run(); - } else { - // Initialize overview if it doesn't exist - await env.DB.prepare( - `INSERT INTO bounty_overview (id, total_value_usd, total_value_alph, list_number, user_number, sponsor_number, updated_at) - VALUES (1, ?, ?, 1, 0, 0, ?)`, - ) - .bind(rewardUsd, rewardAlph, now) - .run(); - } - } catch (overviewError) { - console.error("Failed to update bounty_overview:", overviewError); - // Don't fail the bounty creation if overview update fails - } + // The bounty_overview table used to be incremented here with the old + // split total_value_usd / total_value_alph counters. Nothing reads that + // table — /api/bounty-overview computes its numbers live — and those two + // counters could not be added together anyway. Dropped rather than + // ported onto the new columns. return new Response(JSON.stringify({ bounty: transformBounty(bounty) }), { status: 201, @@ -1530,24 +1858,218 @@ async function handleBountiesAPI( } } + // POST /api/bounties/:id/publish - Take a draft live (idempotent) + // POST /api/bounties/:id/announce - Declare winners announced (idempotent) + // + // Both are conditional UPDATEs rather than SELECT-then-UPDATE. D1 is single + // writer, so `WHERE = 0` plus a check on meta.changes gives us + // idempotency without a distributed lock: a double-clicked button, a + // retried request or two tabs racing all leave exactly one transition, and + // the loser gets 409 instead of silently re-stamping the timestamp. + if ( + request.method === "POST" && + pathname.match(/^\/api\/bounties\/[^/]+\/(publish|announce)$/) + ) { + const action = pathname.endsWith("/publish") ? "publish" : "announce"; + const id = pathname.split("/")[3]; + + try { + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + + const bounty = (await env.DB.prepare( + `SELECT b.id, b.created_by, b.status, b.is_published, b.is_winners_announced, + b.title, b.description, b.start_date, b.end_date, + s.user_id as sponsor_user_id + FROM bounties b + LEFT JOIN sponsors s ON b.sponsor_id = s.id + WHERE b.id = ?`, + ) + .bind(id) + .first()) as { + id: string; + created_by: string; + status: string; + is_published: number; + is_winners_announced: number; + title: string | null; + description: string | null; + start_date: number | null; + end_date: number | null; + sponsor_user_id: string | null; + } | null; + + if (!bounty || bounty.status === "deleted") { + return new Response(JSON.stringify({ error: "Bounty not found" }), { + status: 404, + headers: corsHeaders, + }); + } + + const isOwner = + bounty.created_by === sessionUserId || + bounty.sponsor_user_id === sessionUserId; + if (!isOwner && !(await isGodUser(env, sessionUserId))) { + return new Response( + JSON.stringify({ error: "You do not have permission to do that." }), + { status: 403, headers: corsHeaders }, + ); + } + + if (bounty.status === "cancelled") { + return new Response( + JSON.stringify({ error: "This bounty has been cancelled." }), + { status: 409, headers: corsHeaders }, + ); + } + + const now = Math.floor(Date.now() / 1000); + + if (action === "publish") { + // Same gate as creating-as-published, so a draft is not a back door + // to publishing something direct creation would have rejected. + const missing = publishRequirements(bounty); + if (missing) { + return new Response(JSON.stringify({ error: missing }), { + status: 400, + headers: corsHeaders, + }); + } + + // Sponsor standing is re-checked at publish, not just at create: a + // sponsor can be banned or have their verification pulled while a + // draft sits unpublished, and the draft must not become live anyway. + const standing = (await env.DB.prepare( + `SELECT s.is_verified, s.is_banned + FROM bounties b JOIN sponsors s ON b.sponsor_id = s.id + WHERE b.id = ?`, + ) + .bind(id) + .first()) as { is_verified: number; is_banned: number } | null; + + const publisherIsGod = await isGodUser(env, sessionUserId); + + if (standing?.is_banned) { + return new Response( + JSON.stringify({ + error: "This sponsor account is banned and cannot publish.", + }), + { status: 403, headers: corsHeaders }, + ); + } + if (!standing?.is_verified && !publisherIsGod) { + return new Response( + JSON.stringify({ + error: + "Sponsor account is pending review. Please wait for admin approval before publishing.", + }), + { status: 403, headers: corsHeaders }, + ); + } + } + + if (action === "announce") { + // Announcing with no winners picked would move the bounty into + // "Payment Pending" with nothing to pay, and the flag is one-way. + const winners = (await env.DB.prepare( + `SELECT COUNT(*) as n FROM bounty_submissions + WHERE bounty_id = ? AND is_winner = 1`, + ) + .bind(id) + .first()) as { n: number } | null; + + if (!winners?.n) { + return new Response( + JSON.stringify({ + error: "Select at least one winner before announcing.", + }), + { status: 400, headers: corsHeaders }, + ); + } + } + + const result = + action === "publish" + ? await env.DB.prepare( + `UPDATE bounties + SET is_published = 1, + published_at = COALESCE(published_at, ?), + updated_at = ? + WHERE id = ? AND is_published = 0`, + ) + .bind(now, now, id) + .run() + : await env.DB.prepare( + `UPDATE bounties + SET is_winners_announced = 1, + winners_announced_at = ?, + updated_at = ? + WHERE id = ? AND is_winners_announced = 0`, + ) + .bind(now, now, id) + .run(); + + if ((result?.meta?.changes ?? 0) === 0) { + return new Response( + JSON.stringify({ + error: + action === "publish" + ? "This bounty is already published." + : "Winners have already been announced for this bounty.", + }), + { status: 409, headers: corsHeaders }, + ); + } + + const updated = await env.DB.prepare( + "SELECT * FROM bounties WHERE id = ?", + ) + .bind(id) + .first(); + + return new Response( + JSON.stringify({ bounty: transformBounty(updated) }), + { headers: corsHeaders }, + ); + } catch (error: any) { + console.error(`Error on bounty ${action}:`, error); + return new Response( + JSON.stringify({ error: `Failed to ${action} bounty` }), + { status: 500, headers: corsHeaders }, + ); + } + } + // POST /api/bounties/:id/republish - Create a new copy of an existing bounty with updated dates if ( request.method === "POST" && pathname.match(/^\/api\/bounties\/[^/]+\/republish$/) ) { try { + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + const id = pathname.split("/")[3]; const body = (await request.json()) as { - user_id: string; end_date: string; start_date?: string; }; - if (!body.user_id || !body.end_date) { - return new Response( - JSON.stringify({ error: "user_id and end_date are required" }), - { status: 400, headers: corsHeaders }, - ); + if (!body.end_date) { + return new Response(JSON.stringify({ error: "end_date is required" }), { + status: 400, + headers: corsHeaders, + }); } const original = (await env.DB.prepare( @@ -1570,14 +2092,9 @@ async function handleBountiesAPI( .bind(original.sponsor_id) .first()) as { user_id: string } | null; - const creatorRole = (await env.DB.prepare( - "SELECT role FROM user WHERE id = ?", - ) - .bind(body.user_id) - .first()) as { role: string | null } | null; - const isGod = creatorRole?.role === "god"; + const isGod = await isGodUser(env, sessionUserId); - if (!isGod && sponsor?.user_id !== body.user_id) { + if (!isGod && sponsor?.user_id !== sessionUserId) { return new Response(JSON.stringify({ error: "Unauthorized" }), { status: 403, headers: corsHeaders, @@ -1589,15 +2106,26 @@ async function handleBountiesAPI( const startDate = body.start_date || new Date().toISOString().split("T")[0]; + // Carry over which side the sponsor fixed, but revalue the derived side + // at today's rate — a republished bounty is a new promise, and copying a + // months-old valuation would misstate it. + const republishReward = await resolveRewardInput(env, { + denomination: original.denomination, + reward_amount: original.reward_amount, + target_usd: original.target_usd, + }); + await env.DB.prepare( `INSERT INTO bounties ( id, sponsor_id, title, description, requirements, deliverables, skills, - reward_amount, reward_currency, reward_type, reward_usd_value, tier_count, + reward_amount, reward_currency, reward_type, tier_count, + denomination, target_usd, reward_usd, token_usd_at_valuation, + valuation_updated_at, category, difficulty, dapp_name, start_date, end_date, status, created_by, created_at, updated_at - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`, ) .bind( newId, @@ -1607,47 +2135,27 @@ async function handleBountiesAPI( original.requirements, original.deliverables, original.skills, - original.reward_amount, - original.reward_currency, + republishReward.reward_amount, + "ALPH", original.reward_type, - original.reward_usd_value, original.tier_count, + republishReward.denomination, + republishReward.target_usd, + republishReward.reward_usd, + republishReward.token_usd_at_valuation, + republishReward.token_usd_at_valuation ? now : null, original.category, original.difficulty, original.dapp_name, startDate, body.end_date, "open", - body.user_id, + sessionUserId, now, now, ) .run(); - // Update bounty_overview - try { - const rewardCurrency = original.reward_currency || "ALPH"; - const rewardUsd = - rewardCurrency === "USD" - ? original.reward_amount - : original.reward_usd_value || 0; - const rewardAlph = - rewardCurrency === "ALPH" ? original.reward_amount : 0; - - await env.DB.prepare( - `UPDATE bounty_overview - SET total_value_usd = total_value_usd + ?, - total_value_alph = total_value_alph + ?, - list_number = list_number + 1, - updated_at = ? - WHERE id = 1`, - ) - .bind(rewardUsd, rewardAlph, now) - .run(); - } catch { - // don't fail if overview update fails - } - const newBounty = await env.DB.prepare( "SELECT * FROM bounties WHERE id = ?", ) @@ -1676,14 +2184,25 @@ async function handleBountiesAPI( pathname.match(/^\/api\/bounties\/[^/]+$/) ) { try { + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + const id = pathname.split("/").pop(); // Get the bounty before deleting to update overview - const bounty = await env.DB.prepare( - "SELECT reward_amount, reward_currency, reward_usd_value, status FROM bounties WHERE id = ?", + const bounty = (await env.DB.prepare( + "SELECT status, sponsor_id FROM bounties WHERE id = ?", ) .bind(id) - .first(); + .first()) as { + status: string; + sponsor_id: string; + } | null; if (!bounty) { return new Response(JSON.stringify({ error: "Bounty not found" }), { @@ -1692,6 +2211,22 @@ async function handleBountiesAPI( }); } + const owningSponsor = (await env.DB.prepare( + "SELECT user_id FROM sponsors WHERE id = ?", + ) + .bind(bounty.sponsor_id) + .first()) as { user_id: string } | null; + + if (owningSponsor && owningSponsor.user_id !== sessionUserId) { + const isGod = await isGodUser(env, sessionUserId); + if (!isGod) { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: corsHeaders, + }); + } + } + // Only update overview if bounty is not already deleted if (bounty.status !== "deleted") { const now = Math.floor(Date.now() / 1000); @@ -1703,44 +2238,9 @@ async function handleBountiesAPI( .bind(now, id) .run(); - // Update bounty_overview statistics - try { - // Get reward values from actual columns - const rewardCurrency = bounty.reward_currency || "ALPH"; - const rewardAmount = bounty.reward_amount || 0; - - const rewardUsd = - rewardCurrency === "USD" - ? rewardAmount - : bounty.reward_usd_value || 0; - const rewardAlph = rewardCurrency === "ALPH" ? rewardAmount : 0; - - await env.DB.prepare( - `UPDATE bounty_overview - SET total_value_usd = CASE - WHEN total_value_usd - ? >= 0 THEN total_value_usd - ? - ELSE 0 - END, - total_value_alph = CASE - WHEN total_value_alph - ? >= 0 THEN total_value_alph - ? - ELSE 0 - END, - list_number = CASE - WHEN list_number > 0 THEN list_number - 1 - ELSE 0 - END, - updated_at = ? - WHERE id = 1`, - ) - .bind(rewardUsd, rewardUsd, rewardAlph, rewardAlph, now) - .run(); - } catch (overviewError) { - console.error( - "Failed to update bounty_overview after deletion:", - overviewError, - ); - // Don't fail the deletion if overview update fails - } + // The bounty_overview decrement that used to live here is gone along + // with the increments on create/republish — /api/bounty-overview + // computes from bounties directly and already excludes status='deleted'. } return new Response( @@ -1770,16 +2270,32 @@ async function handleBountiesAPI( // PUT /api/bounties/:id - Update a bounty if (request.method === "PUT" && pathname.match(/^\/api\/bounties\/[^/]+$/)) { try { + const sessionUserId = await getSessionUserId(env, request); + if (!sessionUserId) { + return new Response(JSON.stringify({ error: "Unauthorized" }), { + status: 401, + headers: corsHeaders, + }); + } + const id = pathname.split("/").pop(); const body = (await request.json()) as any; const now = Math.floor(Date.now() / 1000); // Get the old bounty data to calculate overview changes - const oldBounty = await env.DB.prepare( - "SELECT reward_amount, reward_currency, reward_usd_value, status FROM bounties WHERE id = ?", + const oldBounty = (await env.DB.prepare( + "SELECT reward_amount, denomination, target_usd, status, sponsor_id, start_date, end_date FROM bounties WHERE id = ?", ) .bind(id) - .first(); + .first()) as { + reward_amount: number | null; + denomination: string | null; + target_usd: number | null; + status: string; + sponsor_id: string; + start_date: string | null; + end_date: string | null; + } | null; if (!oldBounty) { return new Response(JSON.stringify({ error: "Bounty not found" }), { @@ -1788,6 +2304,22 @@ async function handleBountiesAPI( }); } + const owningSponsor = (await env.DB.prepare( + "SELECT user_id FROM sponsors WHERE id = ?", + ) + .bind(oldBounty.sponsor_id) + .first()) as { user_id: string } | null; + + if (owningSponsor && owningSponsor.user_id !== sessionUserId) { + const isGod = await isGodUser(env, sessionUserId); + if (!isGod) { + return new Response(JSON.stringify({ error: "Forbidden" }), { + status: 403, + headers: corsHeaders, + }); + } + } + // Build update query dynamically based on provided fields const updates: string[] = []; const values: any[] = []; @@ -1800,18 +2332,70 @@ async function handleBountiesAPI( updates.push("description = ?"); values.push(body.description); } - // Update reward fields if provided - if (body.reward_amount !== undefined) { - updates.push("reward_amount = ?"); - values.push(body.reward_amount); + // Reward edits go through resolveRewardInput so both sides stay + // consistent — writing reward_amount without recomputing reward_usd is + // what let the two drift apart before. Touching either the amount or + // the denomination re-derives the whole set. + if ( + body.reward_amount !== undefined || + body.target_usd !== undefined || + body.denomination !== undefined + ) { + let reward: Awaited>; + try { + reward = await resolveRewardInput(env, { + // Fall back to what the bounty already uses, so a caller can send + // just a new amount without restating the denomination. + denomination: body.denomination ?? oldBounty.denomination, + reward_amount: body.reward_amount ?? oldBounty.reward_amount, + target_usd: body.target_usd ?? oldBounty.target_usd, + }); + } catch (err: any) { + const status = err instanceof RateUnavailableError ? 503 : 400; + return new Response(JSON.stringify({ error: err.message }), { + status, + headers: corsHeaders, + }); + } + + updates.push( + "reward_amount = ?", + "denomination = ?", + "target_usd = ?", + "reward_usd = ?", + "token_usd_at_valuation = ?", + "valuation_updated_at = ?", + ); + values.push( + reward.reward_amount, + reward.denomination, + reward.target_usd, + reward.reward_usd, + reward.token_usd_at_valuation, + reward.token_usd_at_valuation ? now : null, + ); } - if (body.reward_currency !== undefined) { - updates.push("reward_currency = ?"); - values.push(body.reward_currency); + if (body.reward_type !== undefined) { + if (body.reward_type !== "fixed" && body.reward_type !== "tiered") { + return new Response( + JSON.stringify({ + error: "reward_type must be 'fixed' or 'tiered'", + }), + { status: 400, headers: corsHeaders }, + ); + } + updates.push("reward_type = ?"); + values.push(body.reward_type); } - if (body.reward_usd_value !== undefined) { - updates.push("reward_usd_value = ?"); - values.push(body.reward_usd_value); + if (body.tier_count !== undefined) { + if (![3, 5, 10].includes(Number(body.tier_count))) { + return new Response( + JSON.stringify({ error: "tier_count must be 3, 5, or 10" }), + { status: 400, headers: corsHeaders }, + ); + } + updates.push("tier_count = ?"); + values.push(Number(body.tier_count)); } if (body.status !== undefined) { updates.push("status = ?"); @@ -1822,6 +2406,46 @@ async function handleBountiesAPI( updates.push("category = ?"); values.push(body.category); } + if (body.difficulty !== undefined) { + updates.push("difficulty = ?"); + values.push(body.difficulty ? body.difficulty.toLowerCase() : null); + } + if (body.dapp_name !== undefined) { + updates.push("dapp_name = ?"); + values.push(body.dapp_name || null); + } + if (body.tags !== undefined) { + updates.push("tags = ?"); + values.push(JSON.stringify(body.tags || [])); + } + if (body.requirements !== undefined) { + updates.push("requirements = ?"); + values.push(JSON.stringify(body.requirements || [])); + } + if (body.deliverables !== undefined) { + updates.push("deliverables = ?"); + values.push(JSON.stringify(body.deliverables || [])); + } + if (body.skills !== undefined) { + updates.push("skills = ?"); + values.push(JSON.stringify(body.skills || [])); + } + + const effectiveStartDate = + body.start_date !== undefined ? body.start_date : oldBounty.start_date; + const effectiveEndDate = + body.end_date !== undefined ? body.end_date : oldBounty.end_date; + if ( + effectiveStartDate && + effectiveEndDate && + new Date(effectiveEndDate) <= new Date(effectiveStartDate) + ) { + return new Response( + JSON.stringify({ error: "end_date must be after start_date" }), + { status: 400, headers: corsHeaders }, + ); + } + if (body.start_date !== undefined) { updates.push("start_date = ?"); values.push(body.start_date); @@ -1843,53 +2467,8 @@ async function handleBountiesAPI( .bind(...values) .run(); - // Update overview if reward changed - if ( - body.reward_amount !== undefined || - body.reward_currency !== undefined || - body.reward_usd_value !== undefined - ) { - try { - // Get old reward values from actual columns - const oldCurrency = oldBounty.reward_currency || "ALPH"; - const oldAmount = oldBounty.reward_amount || 0; - const oldRewardUsd = - oldCurrency === "USD" - ? oldAmount - : oldBounty.reward_usd_value || 0; - const oldRewardAlph = oldCurrency === "ALPH" ? oldAmount : 0; - - // Calculate new reward - const newCurrency = body.reward_currency || oldCurrency; - const newAmount = - body.reward_amount !== undefined ? body.reward_amount : oldAmount; - const newUsdValue = - body.reward_usd_value !== undefined - ? body.reward_usd_value - : oldBounty.reward_usd_value || 0; - const newRewardUsd = - newCurrency === "USD" ? newAmount : newUsdValue; - const newRewardAlph = newCurrency === "ALPH" ? newAmount : 0; - - const diffUsd = newRewardUsd - oldRewardUsd; - const diffAlph = newRewardAlph - oldRewardAlph; - - await env.DB.prepare( - `UPDATE bounty_overview - SET total_value_usd = total_value_usd + ?, - total_value_alph = total_value_alph + ?, - updated_at = ? - WHERE id = 1`, - ) - .bind(diffUsd, diffAlph, now) - .run(); - } catch (overviewError) { - console.error( - "Failed to update bounty_overview after edit:", - overviewError, - ); - } - } + // The bounty_overview delta bookkeeping that used to run here is gone + // with the rest of that table's maintenance. } const updatedBounty = await env.DB.prepare( @@ -2233,41 +2812,30 @@ async function handleUsersAPI( .first(); const totalSubmissions = (submissionsResult?.count as number) || 0; - // Get approved submissions count (won) + // Wins count is_winner, not status='approved'. They agree today, but + // once winners can be picked before payment the two diverge. const wonResult = await env.DB.prepare( - `SELECT COUNT(*) as count FROM bounty_submissions WHERE user_id = ? AND status = 'approved'`, + `SELECT COUNT(*) as count FROM bounty_submissions WHERE user_id = ? AND is_winner = 1`, ) .bind(id) .first(); const totalWon = (wonResult?.count as number) || 0; - // Get total earnings (sum of approved submission rewards in USD from reviewer_notes field) - const { results: approvedSubmissions } = await env.DB.prepare( - `SELECT reviewer_notes FROM bounty_submissions WHERE user_id = ? AND status = 'approved'`, + // Earnings come straight from the frozen settlement columns. This used + // to parse reviewer_notes with three regexes, so a sponsor rewording a + // note -- or writing one containing "Reward: 100 ALPH" -- silently + // changed a user's lifetime total with nothing to catch it. + const earnings = (await env.DB.prepare( + `SELECT COALESCE(SUM(reward_usd), 0) AS earned, + COALESCE(SUM(reward_amount), 0) AS alph_earned + FROM bounty_submissions + WHERE user_id = ? AND is_winner = 1`, ) .bind(id) - .all(); - - let totalEarned = 0; - let totalAlphEarned = 0; - for (const submission of approvedSubmissions) { - // Format written by SubmissionReviewModal: "Reward: X ALPH (for Y USD bounty)" - // Extract both the actual ALPH paid and the USD reference value. - // Handle legacy entries with locale-formatted numbers (e.g. "1,500"). - const notes = submission.reviewer_notes as string; - if (notes) { - const usdMatch = notes.match( - /for\s+\$?([\d,]+\.?\d*)\s*USD\s+bounty/i, - ); - if (usdMatch) { - totalEarned += parseFloat(usdMatch[1].replace(/,/g, "")); - } - const alphMatch = notes.match(/Reward:\s+([\d.]+)\s*ALPH/i); - if (alphMatch) { - totalAlphEarned += parseFloat(alphMatch[1]); - } - } - } + .first()) as { earned: number; alph_earned: number } | null; + + const totalEarned = earnings?.earned || 0; + const totalAlphEarned = earnings?.alph_earned || 0; return new Response( JSON.stringify({ diff --git a/src/worker/notifications.ts b/src/worker/notifications.ts new file mode 100644 index 00000000..3c9d417a --- /dev/null +++ b/src/worker/notifications.ts @@ -0,0 +1,307 @@ +/** + * In-app notifications, created server-side. + * + * These used to be triggered from the browser: a component would await the + * state-changing request, then fire a second request to POST /api/notifications. + * Two problems came out of that. + * + * First, the endpoint had to accept an arbitrary `user_id`, `title`, `message` + * and `link` from whoever called it, with no way to tell a real trigger from a + * forged one -- anyone could post a "claim your reward" notification with an + * external link to any user. + * + * Second, the notification only happened if the tab stayed open. A closed tab, + * a dropped connection or an unmounted component left the database updated and + * the user never told. That is the root cause behind the reviewer requesting + * changes with no in-app notice, and sponsors never hearing about their own + * approval -- not a forgotten call site. + * + * Everything here runs in the same handler as the state change it describes. + */ + +type D1Database = any; + +interface NotifyEnv { + DB: D1Database; +} + +export type NotificationType = + | "submission_accepted" + | "submission_rejected" + | "submission_revision_requested" + | "bounty_completed" + | "comment_reply" + | "comment_like" + | "new_comment" + | "new_submission" + | "sponsor_approved" + | "sponsor_rejected" + | "sponsor_banned" + | "sponsor_unbanned" + | "general"; + +interface NotifyInput { + userId: string; + type: NotificationType; + title: string; + message: string; + link?: string | null; + bountyId?: string | null; + submissionId?: string | null; +} + +/** + * Whether the user has muted this bounty. + * + * Checked for every bounty-scoped notification. The old client-side code only + * checked it on the "new top-level comment" path, so muting a bounty still let + * replies to your own comments through. + */ +async function isMuted( + env: NotifyEnv, + userId: string, + bountyId: string | null | undefined, +): Promise { + if (!bountyId) return false; + const row = await env.DB.prepare( + `SELECT 1 AS x FROM notification_mutes WHERE user_id = ? AND bounty_id = ?`, + ) + .bind(userId, bountyId) + .first(); + return row !== null; +} + +/** + * Write one notification. Returns false when it was deliberately skipped + * (muted, or the actor is the recipient). + * + * Never throws: a notification failing must not roll back or 500 the action + * that triggered it. The caller has already changed state by this point. + */ +export async function notify( + env: NotifyEnv, + input: NotifyInput, +): Promise { + try { + if (!input.userId) return false; + if (await isMuted(env, input.userId, input.bountyId)) return false; + + await env.DB.prepare( + `INSERT INTO notifications ( + id, user_id, type, title, message, link, + related_bounty_id, related_submission_id, read, created_at + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 0, ?)`, + ) + .bind( + crypto.randomUUID(), + input.userId, + input.type, + input.title, + input.message, + input.link ?? null, + input.bountyId ?? null, + input.submissionId ?? null, + Math.floor(Date.now() / 1000), + ) + .run(); + + return true; + } catch (err: any) { + console.error("[notify] failed:", err?.message ?? err); + return false; + } +} + +/** + * Don't notify someone about their own action -- a sponsor commenting on their + * own bounty, or liking their own comment, should not ping themselves. + */ +export async function notifyUnlessActor( + env: NotifyEnv, + actorUserId: string | null, + input: NotifyInput, +): Promise { + if (actorUserId && actorUserId === input.userId) return false; + return notify(env, input); +} + +// ── Submission review ────────────────────────────────────────────────────── + +export async function notifySubmissionReviewed( + env: NotifyEnv, + opts: { + submitterUserId: string; + bountyId: string; + submissionId: string; + bountyTitle: string; + status: "approved" | "rejected" | "revision_requested"; + reviewerNotes?: string | null; + }, +): Promise { + const link = `/bounty/${opts.bountyId}`; + + // Review outcomes deliberately ignore the mute: muting a bounty silences its + // comment chatter, not the verdict on your own submission. + const base = { + userId: opts.submitterUserId, + bountyId: null, // bypasses isMuted + submissionId: opts.submissionId, + link, + }; + + if (opts.status === "approved") { + return notify(env, { + ...base, + type: "submission_accepted", + title: "Submission Accepted", + message: `Your submission to "${opts.bountyTitle}" has been accepted.`, + }); + } + + if (opts.status === "rejected") { + return notify(env, { + ...base, + type: "submission_rejected", + title: "Submission Not Selected", + message: `Your submission to "${opts.bountyTitle}" was not selected.`, + }); + } + + // Previously email-only, with no in-app notice at all. + return notify(env, { + ...base, + type: "submission_revision_requested", + title: "Changes Requested", + message: + `The sponsor has requested changes to your submission for ` + + `"${opts.bountyTitle}". You can edit and resubmit it.`, + }); +} + +export async function notifyNewSubmission( + env: NotifyEnv, + opts: { + sponsorUserId: string; + actorUserId: string; + bountyId: string; + submissionId: string; + bountyTitle: string; + submitterName: string; + }, +): Promise { + return notifyUnlessActor(env, opts.actorUserId, { + userId: opts.sponsorUserId, + type: "new_submission", + title: "New Submission", + message: `${opts.submitterName} submitted to "${opts.bountyTitle}".`, + link: `/bounty/${opts.bountyId}`, + bountyId: opts.bountyId, + submissionId: opts.submissionId, + }); +} + +// ── Sponsor status ───────────────────────────────────────────────────────── + +export async function notifySponsorStatusChanged( + env: NotifyEnv, + opts: { + sponsorUserId: string; + sponsorName: string; + change: "verified" | "unverified" | "banned" | "unbanned"; + }, +): Promise { + const copy: Record< + typeof opts.change, + { type: NotificationType; title: string; message: string } + > = { + verified: { + type: "sponsor_approved", + title: "Sponsor Account Verified", + message: `"${opts.sponsorName}" is verified. You can now create bounties.`, + }, + unverified: { + type: "sponsor_rejected", + title: "Sponsor Verification Removed", + message: `Verification for "${opts.sponsorName}" has been removed. You cannot create new bounties until it is restored.`, + }, + banned: { + type: "sponsor_banned", + title: "Sponsor Account Suspended", + message: `"${opts.sponsorName}" has been suspended and cannot create bounties.`, + }, + unbanned: { + type: "sponsor_unbanned", + title: "Sponsor Account Restored", + message: `"${opts.sponsorName}" has been restored.`, + }, + }; + + const c = copy[opts.change]; + return notify(env, { + userId: opts.sponsorUserId, + type: c.type, + title: c.title, + message: c.message, + link: "/bounty/sponsor/dashboard", + }); +} + +// ── Comments ─────────────────────────────────────────────────────────────── + +export async function notifyCommentReply( + env: NotifyEnv, + opts: { + parentAuthorUserId: string; + actorUserId: string; + bountyId: string; + replierName: string; + }, +): Promise { + return notifyUnlessActor(env, opts.actorUserId, { + userId: opts.parentAuthorUserId, + type: "comment_reply", + title: "New Reply", + message: `${opts.replierName} replied to your comment.`, + link: `/bounty/${opts.bountyId}`, + bountyId: opts.bountyId, + }); +} + +export async function notifyNewComment( + env: NotifyEnv, + opts: { + sponsorUserId: string; + actorUserId: string; + bountyId: string; + bountyTitle: string; + commenterName: string; + }, +): Promise { + return notifyUnlessActor(env, opts.actorUserId, { + userId: opts.sponsorUserId, + type: "new_comment", + title: "New Comment", + message: `${opts.commenterName} commented on "${opts.bountyTitle}".`, + link: `/bounty/${opts.bountyId}`, + bountyId: opts.bountyId, + }); +} + +export async function notifyCommentLike( + env: NotifyEnv, + opts: { + commentAuthorUserId: string; + actorUserId: string; + bountyId: string; + likerName: string; + }, +): Promise { + return notifyUnlessActor(env, opts.actorUserId, { + userId: opts.commentAuthorUserId, + type: "comment_like", + title: "Comment Liked", + message: `${opts.likerName} liked your comment.`, + link: `/bounty/${opts.bountyId}`, + bountyId: opts.bountyId, + }); +} diff --git a/src/worker/price.ts b/src/worker/price.ts new file mode 100644 index 00000000..219879c6 --- /dev/null +++ b/src/worker/price.ts @@ -0,0 +1,419 @@ +/** + * ALPH/USD price feed. + * + * Single source of truth for the USD side of every bounty amount. Bounties + * store token amounts; USD is always derived from a snapshot of this rate, + * never typed in by hand. + * + * The rate is refreshed once a day at 08:00 Europe/Berlin. The worker's cron + * fires hourly, so the scheduled handler calls maybeRefreshDailyPrice() every + * hour and this module decides whether it is actually time to fetch. + */ + +type D1Database = any; + +interface PriceEnv { + DB: D1Database; + COINGECKO_API_KEY?: string; +} + +export type AlphPriceSource = "coinpaprika" | "coingecko" | "mexc"; + +/** Row stored in indexer_state under key 'alph_usd_price'. */ +export interface AlphPrice { + /** USD per 1 ALPH. */ + usd: number; + /** When we fetched it (unix seconds). */ + fetched_at: number; + /** The upstream's own last-updated time (unix seconds), if it provides one. */ + source_updated_at: number | null; + /** Berlin-local date of the run that wrote this row, 'YYYY-MM-DD'. */ + run_date: string; + /** Which upstream actually answered — sources are tried in order. */ + source: AlphPriceSource; +} + +const STATE_KEY = "alph_usd_price"; + +/** + * Every request needs a descriptive User-Agent. Workers' fetch() sends none by + * default, and CoinGecko answers anonymous requests with a 403 telling you so. + */ +const USER_AGENT = "alphland-price-bot (+https://alph.land)"; + +interface PriceSource { + name: AlphPriceSource; + url: string; + /** Returns null when the body parsed but held no usable price. */ + parse(body: any): { usd: number; source_updated_at: number | null } | null; +} + +/** + * Keyless price sources, tried in order until one answers. + * + * Why a chain rather than one source with an API key: CoinGecko's keyless + * tier rate-limits per client IP, and a Worker's egress IP is shared with + * everyone else on that Cloudflare edge — so the quota is routinely spent by + * strangers before our once-a-day call arrives, and no amount of retrying + * fixes it. Every source here works without signup. + * + * CoinPaprika leads because it is the least contended of the aggregators; + * CoinGecko stays as second so we still prefer it whenever it is reachable. + * MEXC is a single venue quoting ALPH/USDT rather than a volume-weighted USD + * aggregate, so it is last resort only — close enough to keep the site + * working, not the number we would choose. + */ +const PRICE_SOURCES: PriceSource[] = [ + { + name: "coinpaprika", + url: "https://api.coinpaprika.com/v1/tickers/alph-alephium", + parse: (b) => { + const usd = b?.quotes?.USD?.price; + if (typeof usd !== "number") return null; + const t = Date.parse(b?.last_updated ?? ""); + return { + usd, + source_updated_at: Number.isFinite(t) ? Math.floor(t / 1000) : null, + }; + }, + }, + { + name: "coingecko", + url: + "https://api.coingecko.com/api/v3/simple/price" + + "?ids=alephium&vs_currencies=usd&include_last_updated_at=true", + parse: (b) => { + const usd = b?.alephium?.usd; + if (typeof usd !== "number") return null; + const t = b?.alephium?.last_updated_at; + return { usd, source_updated_at: typeof t === "number" ? t : null }; + }, + }, + { + name: "mexc", + url: "https://api.mexc.com/api/v3/ticker/price?symbol=ALPHUSDT", + parse: (b) => { + const usd = Number(b?.price); + return Number.isFinite(usd) ? { usd, source_updated_at: null } : null; + }, + }, +]; + +/** Hour of the Berlin day at which the daily refresh runs. */ +export const REFRESH_HOUR_BERLIN = 8; + +/** + * Berlin-local date and hour for a given instant, DST-correct. + * + * Cloudflare cron schedules are UTC only, and Berlin is UTC+1 in winter but + * UTC+2 in summer — so "08:00 Berlin" is not a fixed UTC hour. Rather than + * schedule two crons and hope, we let the hourly cron fire and ask Intl what + * the local wall-clock time actually is. + */ +export function berlinParts(at: Date = new Date()): { + date: string; + hour: number; +} { + const parts = new Intl.DateTimeFormat("en-CA", { + timeZone: "Europe/Berlin", + year: "numeric", + month: "2-digit", + day: "2-digit", + hour: "2-digit", + hour12: false, + }).formatToParts(at); + + const get = (t: string) => parts.find((p) => p.type === t)?.value ?? ""; + // 'en-CA' gives ISO-ordered date parts; hour is 00-23 because hour12: false. + // Intl can emit "24" for midnight in some ICU versions — normalise it. + const hour = Number(get("hour")) % 24; + + return { date: `${get("year")}-${get("month")}-${get("day")}`, hour }; +} + +/** Read the cached rate. Returns null if we have never fetched one. */ +export async function getAlphPrice(env: PriceEnv): Promise { + const row = (await env.DB.prepare( + `SELECT value FROM indexer_state WHERE key = ?`, + ) + .bind(STATE_KEY) + .first()) as { value: string } | null; + + if (!row?.value) return null; + + try { + const parsed = JSON.parse(row.value) as AlphPrice; + return Number.isFinite(parsed?.usd) && parsed.usd > 0 ? parsed : null; + } catch { + return null; + } +} + +/** + * Fetch the current ALPH/USD rate, trying each keyless source in turn. + * + * Only throws when every source failed, and then the message carries all of + * their errors — one upstream being down looks nothing like all of them being + * down, and the difference decides whether anyone needs to act. + */ +export async function fetchAlphPrice(env: PriceEnv): Promise<{ + usd: number; + source_updated_at: number | null; + source: AlphPriceSource; +}> { + const failures: string[] = []; + + for (const src of PRICE_SOURCES) { + const headers: Record = { + Accept: "application/json", + "User-Agent": USER_AGENT, + }; + // Harmless on the other sources' requests, so only sent where it means + // something. A demo key raises CoinGecko's limit if one is ever set. + if (src.name === "coingecko" && env.COINGECKO_API_KEY) { + headers["x-cg-demo-api-key"] = env.COINGECKO_API_KEY; + } + + try { + const res = await fetch(src.url, { headers }); + + if (!res.ok) { + // Include the body: upstreams say why they refused, and a bare status + // makes a policy rejection look identical to an IP block. + const detail = (await res.text()).slice(0, 200); + failures.push(`${src.name} ${res.status}: ${detail}`); + continue; + } + + const parsed = src.parse(await res.json()); + if (!parsed || !Number.isFinite(parsed.usd) || parsed.usd <= 0) { + failures.push(`${src.name}: no usable price in response`); + continue; + } + + if (failures.length > 0) { + console.warn( + `[price] fell back to ${src.name}: ${failures.join("; ")}`, + ); + } + return { ...parsed, source: src.name }; + } catch (err: any) { + failures.push(`${src.name}: ${err?.message ?? err}`); + } + } + + throw new Error(`all price sources failed — ${failures.join("; ")}`); +} + +/** + * Fetch and persist the rate, unconditionally. Used by the daily job and by + * the god-only manual refresh endpoint. + */ +export async function refreshAlphPrice( + env: PriceEnv, + runDate: string = berlinParts().date, +): Promise { + const { usd, source_updated_at, source } = await fetchAlphPrice(env); + + const record: AlphPrice = { + usd, + fetched_at: Math.floor(Date.now() / 1000), + source_updated_at, + run_date: runDate, + source, + }; + + await env.DB.prepare( + `INSERT INTO indexer_state (key, value) VALUES (?, ?) + ON CONFLICT(key) DO UPDATE SET value = excluded.value`, + ) + .bind(STATE_KEY, JSON.stringify(record)) + .run(); + + return record; +} + +export interface ResolvedReward { + denomination: "alph" | "usd"; + /** + * ALPH. Authoritative when denomination='alph', else derived. + * Never null — bounties.reward_amount is NOT NULL. + */ + reward_amount: number; + /** USD. Authoritative when denomination='usd', else null. */ + target_usd: number | null; + /** USD value of the whole bounty — comparable across denominations. */ + reward_usd: number | null; + token_usd_at_valuation: number | null; +} + +/** + * Thrown when a USD-denominated reward cannot be converted because no rate + * has been cached yet. Distinct from a validation error: the request is fine, + * the server just cannot price it right now, so callers should answer 503. + */ +export class RateUnavailableError extends Error { + constructor() { + super( + "ALPH price is temporarily unavailable, so a USD-denominated reward " + + "cannot be converted. Retry shortly, or price the bounty in ALPH.", + ); + this.name = "RateUnavailableError"; + } +} + +/** + * Turn a create/update request body into the reward columns. + * + * Callers send exactly one authoritative number: + * { denomination: 'alph', reward_amount: 500 } -> "I pay 500 ALPH" + * { denomination: 'usd', target_usd: 1000 } -> "I pay $1000 worth" + * + * The other side is derived here from the cached rate. Throws a message + * suitable for a 400 response on bad input. + * + * If no rate is cached yet, an ALPH-denominated reward still resolves — the + * sponsor's own number is authoritative and reward_usd is simply left for the + * next daily run to fill. A USD-denominated one cannot: reward_amount is NOT + * NULL and there is no honest value to put in it, so this throws + * RateUnavailableError for the caller to turn into a 503. + */ +export async function resolveRewardInput( + env: PriceEnv, + body: { + denomination?: unknown; + reward_amount?: unknown; + target_usd?: unknown; + }, +): Promise { + // `denomination` is the only signal. The pre-025 shim that read + // reward_currency='USD' with the USD figure in reward_amount is gone: every + // bounty write path (ManualCreateBounty, EditBounty, the republish handler) + // sends `denomination`, so the shim could only ever have fired on a payload + // no client produces. + const denomination = body.denomination === "usd" ? "usd" : "alph"; + + const raw = denomination === "usd" ? body.target_usd : body.reward_amount; + const field = denomination === "usd" ? "target_usd" : "reward_amount"; + + const value = Number(raw ?? 0); + if (!Number.isFinite(value) || value < 0) { + throw new Error(`${field} must be a non-negative number`); + } + + const price = await getAlphPrice(env); + const rate = price?.usd ?? null; + + if (denomination === "usd") { + if (!rate) throw new RateUnavailableError(); + return { + denomination, + target_usd: value, + reward_usd: value, + reward_amount: value / rate, + token_usd_at_valuation: rate, + }; + } + + return { + denomination, + reward_amount: value, + target_usd: null, + reward_usd: rate ? value * rate : null, + token_usd_at_valuation: rate, + }; +} + +/** + * Recompute the derived side of every bounty's reward at the given rate. + * + * bounties.reward_amount is always ALPH and bounties.reward_usd is always USD; + * which of the two is authoritative depends on `denomination`: + * + * denomination='alph' -> reward_amount is fixed, reward_usd is derived + * denomination='usd' -> target_usd is fixed, reward_amount is derived + * + * Only bounties that can still pay out are touched. Once a bounty is closed, + * completed or cancelled its figures are frozen, so historical totals stay + * reproducible and do not drift with the ALPH price. + */ +export async function revalueBounties( + env: PriceEnv, + usd: number, +): Promise { + if (!Number.isFinite(usd) || usd <= 0) { + throw new Error(`revalueBounties called with unusable rate: ${usd}`); + } + + const now = Math.floor(Date.now() / 1000); + + const alphSide = await env.DB.prepare( + `UPDATE bounties + SET reward_usd = reward_amount * ?, + token_usd_at_valuation = ?, + valuation_updated_at = ? + WHERE denomination = 'alph' + AND status = 'open' + AND reward_amount IS NOT NULL`, + ) + .bind(usd, usd, now) + .run(); + + const usdSide = await env.DB.prepare( + `UPDATE bounties + SET reward_amount = target_usd / ?, + reward_usd = target_usd, + token_usd_at_valuation = ?, + valuation_updated_at = ? + WHERE denomination = 'usd' + AND status = 'open' + AND target_usd IS NOT NULL`, + ) + .bind(usd, usd, now) + .run(); + + return (alphSide?.meta?.changes ?? 0) + (usdSide?.meta?.changes ?? 0); +} + +/** + * Called once per hour by the cron. Fetches when it is at or past 08:00 in + * Berlin and we have not yet stored a rate for that Berlin date. + * + * Two consequences of the ">= 08:00, once per date" rule, both intended: + * - a double-delivered cron in the same hour will not re-fetch, because the + * first success already recorded today's date; + * - a failed 08:00 run retries at 09:00, 10:00, … instead of losing the whole + * day to one CoinGecko blip. + * + * Never throws: a price-feed outage must not break the address indexer that + * shares this scheduled handler. The previous rate simply stays in place. + */ +export async function maybeRefreshDailyPrice(env: PriceEnv): Promise { + try { + const { date, hour } = berlinParts(); + if (hour < REFRESH_HOUR_BERLIN) return; + + const existing = await getAlphPrice(env); + if (existing?.run_date === date) return; + + const record = await refreshAlphPrice(env, date); + // Log the hour we actually ran at, not the target — a value other than + // 08 means an earlier attempt that day failed and this was a retry. + console.log( + `[price] ALPH/USD = ${record.usd} via ${record.source} (Berlin ${date} ${String(hour).padStart(2, "0")}:00)`, + ); + + // Revalue separately: if this fails we still keep the new rate, and the + // next hourly run will not retry (today's date is already recorded), so + // surface it loudly rather than silently serving stale bounty figures. + try { + const changed = await revalueBounties(env, record.usd); + console.log(`[price] revalued ${changed} open bounties`); + } catch (err: any) { + console.error("[price] revaluation failed:", err?.message ?? err); + } + } catch (err: any) { + // Keep serving the last known rate. + console.error("[price] daily refresh failed:", err?.message ?? err); + } +} diff --git a/src/worker/reminders.ts b/src/worker/reminders.ts new file mode 100644 index 00000000..a399fe68 --- /dev/null +++ b/src/worker/reminders.ts @@ -0,0 +1,219 @@ +/** + * Deadline and overdue-review reminders. + * + * Runs from the same hourly cron as the price refresh. "Hourly" is the + * trigger, not the cadence of any individual reminder — each one below is + * meant to reach a given person once, and that is enforced by alreadySent() + * against email_logs rather than by the schedule. Without that check every + * reminder would go out twenty-four times a day, which is the specific + * failure migration 030 exists to prevent. + * + * Nothing here throws: a reminder failure must not take down the price + * refresh or the address indexer that share the scheduled handler. + */ + +import { Env } from "./index"; +import { + alreadySent, + sendAndLog, + notifyBountyDeadlineSoon, + notifySponsorReviewOverdue, + notifyAdminReviewEscalation, +} from "./email"; + +const HOUR = 3600; +const DAY = 24 * HOUR; + +/** How close to the deadline the "closing soon" nudge goes out. */ +export const DEADLINE_WARN_SECONDS = 48 * HOUR; +/** How long after the deadline the sponsor is reminded to review. */ +export const REVIEW_OVERDUE_SECONDS = 7 * DAY; +/** How long after the deadline an unreviewed bounty is escalated to admins. */ +export const REVIEW_ESCALATE_SECONDS = 14 * DAY; + +export interface ReminderCounts { + deadlineSoon: number; + reviewOverdue: number; + escalated: number; +} + +/** + * Bounties whose deadline falls inside the warning window. + * + * Bounded on both sides: a bounty that closed three weeks ago is not + * "closing soon", and without the lower bound every past bounty would match + * forever and be re-checked on every run. + */ +async function closingSoon(env: Env, now: number) { + const { results } = await env.DB.prepare( + `SELECT id, title, end_date + FROM bounties + WHERE is_published = 1 + AND is_winners_announced = 0 + AND status = 'open' + AND end_date IS NOT NULL + AND end_date > ? + AND end_date <= ?`, + ) + .bind(now, now + DEADLINE_WARN_SECONDS) + .all(); + + return (results ?? []) as { id: string; title: string; end_date: number }[]; +} + +/** + * Nudge people who bookmarked a bounty that is about to close. + * + * Bookmarkers, not submitters: someone who already submitted has nothing to + * do with this information, and telling them their own deadline is about to + * pass reads as a demand rather than a reminder. + */ +async function remindBookmarkers(env: Env, now: number): Promise { + let sent = 0; + + for (const bounty of await closingSoon(env, now)) { + const { results } = await env.DB.prepare( + `SELECT u.id as user_id, u.email, u.name + FROM bookmarks bm + JOIN user u ON bm.user_id = u.id + WHERE bm.bounty_id = ? + AND NOT EXISTS ( + SELECT 1 FROM bounty_submissions s + WHERE s.bounty_id = bm.bounty_id AND s.user_id = bm.user_id + )`, + ) + .bind(bounty.id) + .all(); + + for (const row of (results ?? []) as { + user_id: string; + email: string; + name: string | null; + }[]) { + if (!row.email) continue; + + const ctx = { userId: row.user_id, bountyId: bounty.id }; + if (await alreadySent(env, "bounty_deadline", ctx)) continue; + + await notifyBountyDeadlineSoon(env, row, bounty); + sent++; + } + } + + return sent; +} + +/** Bounties past their deadline by at least `after` seconds, still unjudged. */ +async function overdueForReview(env: Env, now: number, after: number) { + const { results } = await env.DB.prepare( + `SELECT b.id, b.title, b.end_date, + s.name as sponsor_name, + u.id as sponsor_user_id, u.email as sponsor_email, u.name as sponsor_contact, + (SELECT COUNT(*) FROM bounty_submissions bs WHERE bs.bounty_id = b.id) as submission_count + FROM bounties b + JOIN sponsors s ON b.sponsor_id = s.id + JOIN user u ON s.user_id = u.id + WHERE b.is_published = 1 + AND b.is_winners_announced = 0 + AND b.status NOT IN ('cancelled', 'deleted') + AND b.end_date IS NOT NULL + AND b.end_date <= ?`, + ) + .bind(now - after) + .all(); + + // Only bounties that actually received something are worth chasing — a + // bounty nobody entered has nothing for the sponsor to review. + return ((results ?? []) as any[]).filter((r) => r.submission_count > 0); +} + +/** Remind the sponsor that submissions are waiting on them. */ +async function remindSponsors(env: Env, now: number): Promise { + let sent = 0; + + for (const b of await overdueForReview(env, now, REVIEW_OVERDUE_SECONDS)) { + if (!b.sponsor_email) continue; + + const ctx = { userId: b.sponsor_user_id, bountyId: b.id }; + if (await alreadySent(env, "review_overdue", ctx)) continue; + + await notifySponsorReviewOverdue(env, b); + sent++; + } + + return sent; +} + +/** + * Escalate to admins when a bounty has sat unreviewed for two weeks. + * + * The original plan flagged the *sponsor* as needing manual review, but a + * per-sponsor risk flag is exactly the `isCaution` column that was rejected — + * this platform does not hold funds, so it has no basis for scoring sponsors. + * Telling the admins is the part that was actually wanted: a human decides + * what to do, and nothing is recorded against the sponsor automatically. + */ +async function escalateToAdmins(env: Env, now: number): Promise { + const stale = await overdueForReview(env, now, REVIEW_ESCALATE_SECONDS); + if (stale.length === 0) return 0; + + const { results } = await env.DB.prepare( + `SELECT id, email FROM user WHERE role = 'god' AND email IS NOT NULL`, + ).all(); + + const admins = (results ?? []) as { id: string; email: string }[]; + if (admins.length === 0) return 0; + + let sent = 0; + for (const b of stale) { + for (const admin of admins) { + const ctx = { userId: admin.id, bountyId: b.id }; + // Transactional: no category, so it is never suppressed by an opt-out. + if (await alreadySent(env, "review_escalation", ctx)) continue; + + await notifyAdminReviewEscalation(env, admin, b); + sent++; + } + } + + return sent; +} + +/** + * Called once per hour by the scheduled handler. + * + * Each stage is isolated: one failing query must not stop the others, since + * they are independent reminders with independent audiences. + */ +export async function runBountyReminders(env: Env): Promise { + const now = Math.floor(Date.now() / 1000); + const counts: ReminderCounts = { + deadlineSoon: 0, + reviewOverdue: 0, + escalated: 0, + }; + + for (const [key, fn] of [ + ["deadlineSoon", remindBookmarkers], + ["reviewOverdue", remindSponsors], + ["escalated", escalateToAdmins], + ] as const) { + try { + counts[key] = await fn(env, now); + } catch (err: any) { + console.error(`[reminders] ${key} failed:`, err?.message ?? err); + } + } + + if (counts.deadlineSoon || counts.reviewOverdue || counts.escalated) { + console.log( + `[reminders] sent ${counts.deadlineSoon} deadline, ` + + `${counts.reviewOverdue} overdue, ${counts.escalated} escalation`, + ); + } + + return counts; +} + +// Re-exported so tests can drive a single stage without the wrapper. +export { remindBookmarkers, remindSponsors, escalateToAdmins, sendAndLog }; diff --git a/src/worker/types.ts b/src/worker/types.ts deleted file mode 100644 index f911cbfd..00000000 --- a/src/worker/types.ts +++ /dev/null @@ -1,118 +0,0 @@ -/** - * Type definitions for Cloudflare Worker environment - */ - -// Type definition for D1Database (fallback for when @cloudflare/workers-types is not available) -type D1Database = any; - -export interface Env { - // D1 Database binding - DB: D1Database; - - // Environment variables - GOOGLE_CLIENT_ID: string; - GOOGLE_CLIENT_SECRET: string; - BETTER_AUTH_SECRET: string; - BETTER_AUTH_URL: string; -} - -// Database schema types -export interface User { - id: string; - email: string; - emailVerified: number; - name: string | null; - createdAt: number; - updatedAt: number; - image: string | null; -} - -export interface UserProfile { - id: string; - user_id: string; - username: string | null; - bio: string | null; - wallet_address: string | null; - github_username: string | null; - twitter_username: string | null; - discord_username: string | null; - reputation_score: number; - total_bounties_completed: number; - total_earnings: number; - created_at: string; - updated_at: string; -} - -export interface Bounty { - id: string; - title: string; - description: string; - reward_amount: number; - reward_currency: string; - difficulty: "beginner" | "intermediate" | "advanced" | "expert"; - category: string; - status: - | "open" - | "in_progress" - | "review" - | "completed" - | "cancelled" - | "deleted"; - created_by: string; - assigned_to: string | null; - tags: string | null; // JSON array - requirements: string | null; - submission_url: string | null; - start_date: string | null; - end_date: string | null; - created_at: string; - updated_at: string; - completed_at: string | null; -} - -export interface BountyApplication { - id: string; - bounty_id: string; - applicant_id: string; - cover_letter: string | null; - estimated_completion: string | null; - status: "pending" | "accepted" | "rejected" | "withdrawn"; - created_at: string; - updated_at: string; -} - -export interface BountySubmission { - id: string; - bounty_id: string; - submitted_by: string; - submission_url: string; - description: string | null; - status: "pending" | "approved" | "rejected" | "revision_requested"; - reviewer_notes: string | null; - reviewed_by: string | null; - reviewed_at: string | null; - created_at: string; - updated_at: string; -} - -export interface BountyComment { - id: string; - bounty_id: string; - user_id: string; - parent_comment_id: string | null; - content: string; - created_at: string; - updated_at: string; - deleted_at: string | null; -} - -export interface Notification { - id: string; - user_id: string; - type: string; - title: string; - message: string; - link: string | null; - read: number; - created_at: string; -} diff --git a/tests/features/bounty/types/submission-type-drift.test.ts b/tests/features/bounty/types/submission-type-drift.test.ts new file mode 100644 index 00000000..23c506ca --- /dev/null +++ b/tests/features/bounty/types/submission-type-drift.test.ts @@ -0,0 +1,196 @@ +import { describe, it, expect } from "vitest"; +import { existsSync, readFileSync } from "node:fs"; +import { resolve } from "node:path"; + +/** + * Guards the submission types against describing an API that does not exist. + * + * `Submission` and `BountySubmission` used to promise `submitted_at`, + * `completed_at`, `review_started_at`, `title` and `tweet_url`. No endpoint + * sends any of them, and TypeScript cannot tell -- every one was a silent + * `undefined` at runtime. That is how the sponsor dashboard came to render a + * blank review date on every submission, and the literal word "Submission" as + * every submission's title. + * + * A declared field must be a real column or a registered join alias. + */ + +const ROOT = resolve(__dirname, "../../../.."); +const SCHEMA_DUMP = resolve(ROOT, "migrations/000_baseline_schema.sql"); + +/** + * Columns on `bounty_submissions`, as of migration 029. + * + * Inlined rather than read from the schema dump because `migrations/` is + * gitignored -- CI has no dump to read. That makes this a second copy of the + * schema, so the last test in this file re-derives it from the dump whenever + * one is present (i.e. on a machine that has run the migrations) and fails if + * the two have drifted apart. + */ +const SUBMISSION_COLUMNS = [ + "id", + "bounty_id", + "user_id", + "submission_url", + "description", + "status", + "reviewer_notes", + "reviewed_by", + "reviewed_at", + "transaction_hash", + "created_at", + "updated_at", + // 029_structured_submission_result.sql + "is_winner", + "winner_position", + "reward_amount", + "reward_currency", + "reward_usd", + "is_paid", + "paid_at", + "label", +]; + +/** + * Fields the API joins in or computes. Each must be traceable to a SELECT in + * the worker -- if you cannot point at the query that produces it, it does not + * belong here. + */ +const JOIN_ALIASES = [ + "bounty_title", // b.title + "sponsor_id", // b.sponsor_id + "sponsor_name", // sp.name + "sponsor_logo_url", // sp.logo_url + "user_username", // COALESCE(up.username, u.name, u.email) + "user_avatar_url", // u.image + "user_full_name", // u.name + "user_wallet_address", // up.wallet_address + "reward", // assembled by transformBounty +]; + +const allowed = new Set(SUBMISSION_COLUMNS.concat(JOIN_ALIASES)); + +/** Column names in the CREATE TABLE for bounty_submissions. */ +function columnsFromDump(): string[] { + const sql = readFileSync(SCHEMA_DUMP, "utf8"); + const stmt = sql.split("CREATE TABLE bounty_submissions")[1]?.split(");")[0]; + if (!stmt) throw new Error("bounty_submissions not found in schema dump"); + + // Drop everything up to the opening bracket; leaving it in would start the + // depth counter at 1 and no top-level comma would ever be seen. + const body = stmt.slice(stmt.indexOf("(") + 1); + + // Columns added by ALTER TABLE are appended by SQLite onto a single line at + // the end of the statement, so splitting on newlines misses every one of + // them. Split on commas at bracket depth zero instead. + const parts: string[] = []; + let depth = 0; + let buf = ""; + for (const ch of body) { + if (ch === "(") depth++; + if (ch === ")") depth--; + if (ch === "," && depth === 0) { + parts.push(buf); + buf = ""; + } else { + buf += ch; + } + } + parts.push(buf); + + const cols: string[] = []; + for (const part of parts) { + const m = part.trim().match(/^(\w+)\s+(TEXT|INTEGER|REAL|BLOB|NUMERIC)\b/i); + if (m?.[1]) cols.push(m[1]); + } + return cols; +} + +function declaredFields(file: string, iface: string): string[] { + const src = readFileSync(resolve(ROOT, file), "utf8"); + const body = src.split(`export interface ${iface} {`)[1]?.split("\n}")[0]; + if (!body) throw new Error(`${iface} not found in ${file}`); + + const fields: string[] = []; + let depth = 0; + for (const line of body.split("\n")) { + // Skip nested object literals (e.g. `reward: { token: ... }`) + if (depth === 0) { + const m = line.match(/^\s{2}(\w+)\??:/); + if (m?.[1]) fields.push(m[1]); + } + depth += (line.match(/\{/g) || []).length; + depth -= (line.match(/\}/g) || []).length; + } + return fields; +} + +const INTERFACES = [ + ["src/features/bounty/types/submission.types.ts", "Submission"], + ["src/lib/api-client.ts", "BountySubmission"], +] as const; + +describe("submission types match what the API returns", () => { + for (const [file, iface] of INTERFACES) { + it(`${iface} declares no field the API cannot supply`, () => { + const unknown = declaredFields(file, iface).filter( + (f) => !allowed.has(f), + ); + expect( + unknown, + `${iface} declares ${unknown.join(", ")} — add the column, register ` + + `the join alias, or drop the field. A field nothing supplies is an ` + + `undefined at runtime that TypeScript will not catch.`, + ).toEqual([]); + }); + } + + it("rejects the exact fields that caused the blank review date", () => { + // Regression: these five were declared but never sent. + const gone = [ + "submitted_at", + "completed_at", + "review_started_at", + "tweet_url", + "title", + ]; + for (const [file, iface] of INTERFACES) { + const fields = declaredFields(file, iface); + for (const f of gone) expect(fields).not.toContain(f); + } + }); + + it("keeps every join alias traceable to a worker query", () => { + // A stale alias is how the list stops being a guard and becomes a rubber + // stamp, so each must still appear in a SELECT. + const worker = + readFileSync(resolve(ROOT, "src/worker/handlers.ts"), "utf8") + + readFileSync(resolve(ROOT, "src/worker/index.ts"), "utf8"); + + for (const alias of JOIN_ALIASES) { + if (alias === "reward") continue; // built in transformBounty, not SQL + // Either renamed (`b.title as bounty_title`) or selected under a name it + // already has (`b.sponsor_id`). + const selected = + worker.includes(`as ${alias}`) || + new RegExp(`\\b\\w+\\.${alias}\\b`).test(worker); + expect(selected, `join alias "${alias}" is no longer selected`).toBe( + true, + ); + } + }); + + // Reported as skipped, not passed, when there is no dump -- a silent pass + // would quietly turn the inlined list into an unchecked second source. + it.skipIf(!existsSync(SCHEMA_DUMP))( + "inlined column list still matches the schema dump", + () => { + expect( + columnsFromDump().sort(), + "SUBMISSION_COLUMNS has drifted from the database. Re-dump the " + + "baseline and update the list — the command is in the header of " + + "migrations/000_baseline_schema.sql.", + ).toEqual([...SUBMISSION_COLUMNS].sort()); + }, + ); +}); diff --git a/tests/features/bounty/utils/bountySlug.test.ts b/tests/features/bounty/utils/bountySlug.test.ts new file mode 100644 index 00000000..a4a19c4c --- /dev/null +++ b/tests/features/bounty/utils/bountySlug.test.ts @@ -0,0 +1,57 @@ +import { describe, it, expect } from "vitest"; +import { + bountySlug, + sponsorSlug, + BOUNTY_SLUG_MAX, +} from "@/features/bounty/utils/validators"; + +describe("bountySlug", () => { + it("hyphenates words instead of deleting the gaps", () => { + // The whole point: sponsorSlug would give "createayoutubetutorial...". + expect(bountySlug("Create a YouTube Tutorial: How to Use Linx App")).toBe( + "create-a-youtube-tutorial-how-to-use-linx-app", + ); + expect(sponsorSlug("Create a YouTube Tutorial")).toBe( + "createayoutubetutorial", + ); + }); + + it("collapses runs of punctuation into a single hyphen", () => { + expect(bountySlug("Test Bounty #1")).toBe("test-bounty-1"); + expect(bountySlug("A -- B")).toBe("a-b"); + }); + + it("trims leading and trailing separators", () => { + expect(bountySlug(" !!! Hello World !!! ")).toBe("hello-world"); + }); + + it("caps the length and cuts on a word boundary", () => { + const slug = bountySlug( + "Create an X Thread: How to Farm Linx Points as an Ethereum User", + ); + expect(slug.length).toBeLessThanOrEqual(BOUNTY_SLUG_MAX); + // Cut at a boundary, so no dangling fragment and no trailing hyphen. + expect(slug).toBe( + "create-an-x-thread-how-to-farm-linx-points-as-an-ethereum", + ); + expect(slug.endsWith("-")).toBe(false); + }); + + it("still truncates when the first word is longer than the cap", () => { + const slug = bountySlug("a".repeat(100)); + expect(slug.length).toBe(BOUNTY_SLUG_MAX); + }); + + it("returns empty for a title with nothing sluggable, rather than junk", () => { + // Callers must store NULL and fall back to id routing for these. + expect(bountySlug("!!!")).toBe(""); + expect(bountySlug("赏金任务")).toBe(""); + expect(bountySlug("")).toBe(""); + }); + + it("is stable — the same title always yields the same slug", () => { + const t = "Video showcasing ALPH 2048 Arena"; + expect(bountySlug(t)).toBe(bountySlug(t)); + expect(bountySlug(t)).toBe("video-showcasing-alph-2048-arena"); + }); +}); diff --git a/tests/features/bounty/utils/bountyStatus.test.ts b/tests/features/bounty/utils/bountyStatus.test.ts new file mode 100644 index 00000000..a583d89e --- /dev/null +++ b/tests/features/bounty/utils/bountyStatus.test.ts @@ -0,0 +1,169 @@ +import { describe, it, expect } from "vitest"; +import { + getBountyDisplayStatus, + deadlinePassed, + endDateMs, + isAcceptingSubmissions, +} from "@/features/bounty/utils/bountyStatus"; + +const NOW = Date.parse("2026-07-28T12:00:00Z"); +const FUTURE = Math.floor(Date.parse("2026-08-30T12:00:00Z") / 1000); +const PAST = Math.floor(Date.parse("2026-07-01T12:00:00Z") / 1000); + +const live = { + status: "open", + is_published: 1, + published_at: 1785000000, + is_winners_announced: 0, + end_date: FUTURE, +}; + +describe("endDateMs", () => { + it("treats small numbers as unix seconds and large ones as milliseconds", () => { + expect(endDateMs(PAST)).toBe(PAST * 1000); + expect(endDateMs(PAST * 1000)).toBe(PAST * 1000); + }); + + it("parses ISO strings, which several client paths still send", () => { + expect(endDateMs("2026-07-01T12:00:00Z")).toBe(PAST * 1000); + }); + + it("returns null for nothing usable rather than NaN", () => { + for (const v of [null, undefined, "", "not a date"]) { + expect(endDateMs(v as any)).toBeNull(); + } + }); +}); + +describe("deadlinePassed", () => { + it("is false when there is no deadline at all", () => { + // A missing deadline must not read as "expired" — that would push every + // dateless draft straight to In Review. + expect(deadlinePassed(null, NOW)).toBe(false); + }); + + it("is true exactly at the deadline", () => { + expect(deadlinePassed(Math.floor(NOW / 1000), NOW)).toBe(true); + }); +}); + +describe("getBountyDisplayStatus", () => { + it("reports Draft only when it was never published", () => { + expect( + getBountyDisplayStatus( + { ...live, is_published: 0, published_at: null }, + undefined, + NOW, + ), + ).toBe("Draft"); + }); + + it("distinguishes Unpublished from Draft by published_at", () => { + expect( + getBountyDisplayStatus( + { ...live, is_published: 0, published_at: 1785000000 }, + undefined, + NOW, + ), + ).toBe("Unpublished"); + }); + + it("is In Progress before the deadline and In Review after", () => { + expect(getBountyDisplayStatus(live, undefined, NOW)).toBe("In Progress"); + expect( + getBountyDisplayStatus({ ...live, end_date: PAST }, undefined, NOW), + ).toBe("In Review"); + }); + + it("reports Cancelled regardless of every other flag", () => { + expect( + getBountyDisplayStatus( + { + ...live, + status: "cancelled", + is_published: 0, + is_winners_announced: 1, + }, + { winnerCount: 3, paidCount: 3 }, + NOW, + ), + ).toBe("Cancelled"); + }); + + it("hides a deleted bounty behind Cancelled too", () => { + expect( + getBountyDisplayStatus({ ...live, status: "deleted" }, undefined, NOW), + ).toBe("Cancelled"); + }); + + it("is Payment Pending once announced until every winner is paid", () => { + const announced = { ...live, is_winners_announced: 1, end_date: PAST }; + expect( + getBountyDisplayStatus(announced, { winnerCount: 3, paidCount: 0 }, NOW), + ).toBe("Payment Pending"); + expect( + getBountyDisplayStatus(announced, { winnerCount: 3, paidCount: 2 }, NOW), + ).toBe("Payment Pending"); + expect( + getBountyDisplayStatus(announced, { winnerCount: 3, paidCount: 3 }, NOW), + ).toBe("Completed"); + }); + + it("does not claim Completed when the counts are unknown", () => { + // The worker's list payload has no payment counts. Guessing "Completed" + // there would tell a user they have been paid when they have not. + expect( + getBountyDisplayStatus( + { ...live, is_winners_announced: 1 }, + undefined, + NOW, + ), + ).toBe("Payment Pending"); + }); + + it("does not call an announced bounty with zero winners Completed", () => { + expect( + getBountyDisplayStatus( + { ...live, is_winners_announced: 1 }, + { winnerCount: 0, paidCount: 0 }, + NOW, + ), + ).toBe("Payment Pending"); + }); + + it("accepts booleans as well as SQLite's 0/1", () => { + expect( + getBountyDisplayStatus( + { ...live, is_published: true, is_winners_announced: false }, + undefined, + NOW, + ), + ).toBe("In Progress"); + }); + + it("treats a missing is_published as unpublished, not as published", () => { + // Reading a row from before migration 032 must not silently publish it. + expect( + getBountyDisplayStatus( + { status: "open", end_date: FUTURE }, + undefined, + NOW, + ), + ).toBe("Draft"); + }); +}); + +describe("isAcceptingSubmissions", () => { + it("is true only while live and before the deadline", () => { + expect(isAcceptingSubmissions(live, NOW)).toBe(true); + expect(isAcceptingSubmissions({ ...live, end_date: PAST }, NOW)).toBe( + false, + ); + expect(isAcceptingSubmissions({ ...live, is_published: 0 }, NOW)).toBe( + false, + ); + expect(isAcceptingSubmissions({ ...live, status: "cancelled" }, NOW)).toBe( + false, + ); + }); +}); diff --git a/src/features/bounty/utils/rewardCalculator.test.ts b/tests/features/bounty/utils/rewardCalculator.test.ts similarity index 96% rename from src/features/bounty/utils/rewardCalculator.test.ts rename to tests/features/bounty/utils/rewardCalculator.test.ts index a88b3b04..3667742c 100644 --- a/src/features/bounty/utils/rewardCalculator.test.ts +++ b/tests/features/bounty/utils/rewardCalculator.test.ts @@ -6,8 +6,8 @@ import { formatRewardAmount, formatUSDAmount, generateTieredRewards, -} from "./rewardCalculator"; -import type { Reward } from "../types"; +} from "@/features/bounty/utils/rewardCalculator"; +import type { Reward } from "@/features/bounty/types"; const baseReward: Reward = { amount: 1000, token: "ALPH", usd_equivalent: 500 }; diff --git a/src/features/bounty/utils/timeFormatter.test.ts b/tests/features/bounty/utils/timeFormatter.test.ts similarity index 97% rename from src/features/bounty/utils/timeFormatter.test.ts rename to tests/features/bounty/utils/timeFormatter.test.ts index c8c1219c..cd9e7396 100644 --- a/src/features/bounty/utils/timeFormatter.test.ts +++ b/tests/features/bounty/utils/timeFormatter.test.ts @@ -3,7 +3,7 @@ import { calculateTimeRemaining, formatDate, formatDateTime, -} from "./timeFormatter"; +} from "@/features/bounty/utils/timeFormatter"; describe("calculateTimeRemaining", () => { it('returns "Expired" for a past date', () => { diff --git a/tests/features/bounty/utils/validators.test.ts b/tests/features/bounty/utils/validators.test.ts new file mode 100644 index 00000000..2572f7ef --- /dev/null +++ b/tests/features/bounty/utils/validators.test.ts @@ -0,0 +1,132 @@ +import { describe, expect, it } from "vitest"; +import { + isValidUrl, + isValidWalletAddress, + normalizeUrl, + sponsorSlug, + submissionTitle, +} from "@/features/bounty/utils/validators"; + +describe("sponsorSlug", () => { + it("lowercases and strips non-alphanumeric characters", () => { + expect(sponsorSlug("Linx Labs")).toBe("linxlabs"); + expect(sponsorSlug("BabyPoolTool")).toBe("babypooltool"); + expect(sponsorSlug("Aleph-ium 2.0!")).toBe("alephium20"); + }); +}); + +describe("isValidUrl", () => { + it("accepts valid URLs", () => { + expect(isValidUrl("https://example.com")).toBe(true); + expect(isValidUrl("http://localhost:3000/path?q=1")).toBe(true); + }); + + it("rejects invalid URLs", () => { + expect(isValidUrl("not-a-url")).toBe(false); + expect(isValidUrl("")).toBe(false); + expect(isValidUrl("ftp://")).toBe(false); + }); + + it("rejects dangerous URL schemes that could execute as script", () => { + expect(isValidUrl("javascript:alert(1)")).toBe(false); + expect(isValidUrl("data:text/html,")).toBe(false); + expect(isValidUrl("vbscript:msgbox(1)")).toBe(false); + }); +}); + +describe("normalizeUrl", () => { + it("passes through URLs that already have a protocol", () => { + expect(normalizeUrl("https://example.com")).toBe("https://example.com"); + expect(normalizeUrl("http://example.com")).toBe("http://example.com"); + }); + + it("prepends https:// when protocol is missing", () => { + expect(normalizeUrl("example.com")).toBe("https://example.com"); + expect(normalizeUrl("www.example.com")).toBe("https://www.example.com"); + }); + + it("returns empty string unchanged", () => { + expect(normalizeUrl("")).toBe(""); + expect(normalizeUrl(" ")).toBe(""); + }); +}); + +describe("isValidWalletAddress", () => { + it("accepts a valid Ethereum-style address", () => { + expect( + isValidWalletAddress("0xAbCdEf1234567890AbCdEf1234567890AbCdEf12"), + ).toBe(true); + }); + + it("rejects empty or whitespace addresses", () => { + expect(isValidWalletAddress("")).toBe(false); + expect(isValidWalletAddress(" ")).toBe(false); + }); + + it("rejects addresses with wrong length", () => { + expect(isValidWalletAddress("0x123")).toBe(false); + }); + + it("rejects addresses without 0x prefix", () => { + expect( + isValidWalletAddress("AbCdEf1234567890AbCdEf1234567890AbCdEf12"), + ).toBe(false); + }); +}); + +describe("submissionTitle", () => { + it("reads the bolded title SubmissionModal writes", () => { + // Real shapes from production. + expect( + submissionTitle("**ALPH 2048 Arena**\n\nI made a video walkthrough."), + ).toBe("ALPH 2048 Arena"); + expect( + submissionTitle( + "**Farm Linx Points from Ethereum (Aave → Bridge → Linx)**\n\nThread.", + ), + ).toBe("Farm Linx Points from Ethereum (Aave → Bridge → Linx)"); + }); + + it("handles a title with no body after it", () => { + expect(submissionTitle("**ETH Farmers Are Missing This**")).toBe( + "ETH Farmers Are Missing This", + ); + }); + + it("falls back to the first line for submissions with no bold wrapper", () => { + // Predates the modal adding the wrapper — a placeholder would lose real + // information that is sitting right there. + expect(submissionTitle("just a plain description\nsecond line")).toBe( + "just a plain description", + ); + }); + + it("truncates a long first line", () => { + const long = "x".repeat(80); + const out = submissionTitle(long); + expect(out).toHaveLength(53); // 50 + "..." + expect(out.endsWith("...")).toBe(true); + }); + + it("does not truncate a bolded title", () => { + // The author chose it deliberately; only the guessed fallback is clipped. + const title = "A".repeat(80); + expect(submissionTitle(`**${title}**`)).toBe(title); + }); + + it("uses the placeholder only when there is nothing to read", () => { + for (const empty of [null, undefined, "", " ", "\n\n"]) { + expect(submissionTitle(empty)).toBe("Submission"); + } + }); + + it("accepts a caller-supplied fallback", () => { + expect(submissionTitle(null, "Untitled")).toBe("Untitled"); + }); + + it("ignores bold that is not at the start", () => { + expect(submissionTitle("intro text **not the title**")).toBe( + "intro text **not the title**", + ); + }); +}); diff --git a/tests/worker/admin-auth.test.ts b/tests/worker/admin-auth.test.ts new file mode 100644 index 00000000..1c99981c --- /dev/null +++ b/tests/worker/admin-auth.test.ts @@ -0,0 +1,219 @@ +import { describe, it, expect } from "vitest"; +import worker, { type Env } from "@/worker/index"; + +/** + * Minimal D1 mock. Special-cases the two auth queries + * (session lookup + role lookup) based on the scenario under test; + * everything else returns harmless defaults so downstream handler + * code (past the auth guard) can run to completion without throwing. + */ +function makeEnv(opts: { + sessionUserId?: string | null; // null = no matching/live session row + role?: string | null; // role of the session's user, if session exists +}): Env { + const { sessionUserId = null, role = null } = opts; + + const DB = { + prepare(sql: string) { + const builder = { + bind(..._args: any[]) { + return builder; + }, + async first() { + if (sql.includes("FROM session WHERE token")) { + return sessionUserId ? { userId: sessionUserId } : null; + } + if (sql.includes("SELECT role FROM user WHERE id")) { + return role !== null ? { role } : null; + } + // Generic fallback for downstream admin-handler queries + return {}; + }, + async all() { + return { results: [] }; + }, + async run() { + return { success: true }; + }, + }; + return builder; + }, + }; + + return { DB } as unknown as Env; +} + +function req( + path: string, + opts: { method?: string; cookie?: string } = {}, +): Request { + const headers = new Headers(); + if (opts.cookie) headers.set("cookie", opts.cookie); + return new Request(`https://alph.land${path}`, { + method: opts.method || "GET", + headers, + }); +} + +const VALID_COOKIE = "better-auth.session_token=live-token"; + +// Every /api/admin/* route the worker exposes, with its method. +const ADMIN_ROUTES: { path: string; method: string }[] = [ + { path: "/api/admin/user-stats", method: "GET" }, + { path: "/api/admin/users", method: "GET" }, + { path: "/api/admin/users/user_123", method: "GET" }, + { path: "/api/admin/users/user_123/ban", method: "PUT" }, + { path: "/api/admin/users/user_123/unban", method: "PUT" }, + { path: "/api/admin/sponsors/sponsor_123/ban", method: "PUT" }, + { path: "/api/admin/sponsors/sponsor_123/unban", method: "PUT" }, + { path: "/api/admin/sponsors/sponsor_123/verify", method: "PUT" }, + { path: "/api/admin/sponsors/sponsor_123/unverify", method: "PUT" }, + { path: "/api/admin/submission-stats", method: "GET" }, + { path: "/api/admin/bounty-popularity", method: "GET" }, + { path: "/api/admin/email-logs", method: "GET" }, +]; + +describe("/api/admin/* auth guard", () => { + describe("no session cookie", () => { + for (const { path, method } of ADMIN_ROUTES) { + it(`${method} ${path} -> 401`, async () => { + const env = makeEnv({ sessionUserId: null }); + const res = await worker.fetch(req(path, { method }), env, {}); + expect(res.status).toBe(401); + const body = await res.json(); + expect(body).toEqual({ error: "Unauthorized" }); + }); + } + }); + + describe("session cookie present but no matching/live session row", () => { + // getSessionUserId returns null for an unknown or expired token — + // guard must treat this the same as "not logged in" (401), not crash. + for (const { path, method } of ADMIN_ROUTES) { + it(`${method} ${path} -> 401`, async () => { + const env = makeEnv({ sessionUserId: null }); + const res = await worker.fetch( + req(path, { method, cookie: "better-auth.session_token=bogus" }), + env, + {}, + ); + expect(res.status).toBe(401); + }); + } + }); + + describe("logged in but not a god user", () => { + for (const { path, method } of ADMIN_ROUTES) { + it(`${method} ${path} -> 403`, async () => { + const env = makeEnv({ sessionUserId: "user_1", role: "sponsor" }); + const res = await worker.fetch( + req(path, { method, cookie: VALID_COOKIE }), + env, + {}, + ); + expect(res.status).toBe(403); + const body = await res.json(); + expect(body).toEqual({ error: "Unauthorized" }); + }); + } + + it("treats a user row with role=null the same as non-god -> 403", async () => { + const env = makeEnv({ sessionUserId: "user_1", role: null as any }); + // role null in DB means the SELECT still returns a row ({role: null}), + // so simulate that distinctly from "no user row at all". + const dbEnv: Env = { + DB: { + prepare(sql: string) { + return { + bind: () => ({ + first: async () => { + if (sql.includes("FROM session WHERE token")) { + return { userId: "user_1" }; + } + if (sql.includes("SELECT role FROM user WHERE id")) { + return { role: null }; + } + return {}; + }, + all: async () => ({ results: [] }), + run: async () => ({ success: true }), + }), + }; + }, + }, + } as unknown as Env; + void env; + const res = await worker.fetch( + req("/api/admin/users", { cookie: VALID_COOKIE }), + dbEnv, + {}, + ); + expect(res.status).toBe(403); + }); + }); + + describe("logged in as a god user", () => { + for (const { path, method } of ADMIN_ROUTES) { + it(`${method} ${path} -> passes the guard (not 401/403)`, async () => { + const env = makeEnv({ sessionUserId: "god_1", role: "god" }); + const res = await worker.fetch( + req(path, { method, cookie: VALID_COOKIE }), + env, + {}, + ); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + } + + it("GET /api/admin/user-stats returns real data once past the guard", async () => { + const env = makeEnv({ sessionUserId: "god_1", role: "god" }); + const res = await worker.fetch( + req("/api/admin/user-stats", { cookie: VALID_COOKIE }), + env, + {}, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toHaveProperty("total_users"); + expect(body).toHaveProperty("daily_trend"); + }); + + it("PUT /api/admin/users/:id/ban actually runs the ban query", async () => { + const env = makeEnv({ sessionUserId: "god_1", role: "god" }); + const res = await worker.fetch( + req("/api/admin/users/user_123/ban", { + method: "PUT", + cookie: VALID_COOKIE, + }), + env, + {}, + ); + expect(res.status).toBe(200); + const body = await res.json(); + expect(body).toEqual({ success: true }); + }); + }); +}); + +describe("non-admin routes are unaffected by the /api/admin/* guard", () => { + it("GET /api/db-test does not require auth", async () => { + const env = makeEnv({ sessionUserId: null }); + const res = await worker.fetch(req("/api/db-test"), env, {}); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("GET /api/bounty-overview does not require auth", async () => { + const env = makeEnv({ sessionUserId: null }); + const res = await worker.fetch(req("/api/bounty-overview"), env, {}); + expect(res.status).not.toBe(401); + expect(res.status).not.toBe(403); + }); + + it("GET /api/dapp-list does not require auth", async () => { + const env = makeEnv({ sessionUserId: null }); + const res = await worker.fetch(req("/api/dapp-list"), env, {}); + expect(res.status).toBe(200); + }); +}); diff --git a/tests/worker/email-dedupe.test.ts b/tests/worker/email-dedupe.test.ts new file mode 100644 index 00000000..1b27eed5 --- /dev/null +++ b/tests/worker/email-dedupe.test.ts @@ -0,0 +1,153 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { alreadySent, sendAndLog } from "@/worker/email"; + +vi.mock("resend", () => ({ + Resend: class { + emails = { + send: async () => ({ data: { id: "re_test" }, error: null }), + }; + }, +})); + +/** + * Captures email_logs inserts; `sent` seeds rows that alreadySent() should + * match against. + */ +function makeEnv( + sent: { type: string; userId: string; bountyId?: string }[] = [], +) { + const logged: Record[] = []; + + const DB = { + prepare(sql: string) { + let args: any[] = []; + const b = { + bind(...a: any[]) { + args = a; + return b; + }, + async first() { + if (sql.includes("FROM email_logs")) { + const [type, userId, bountyId] = args; + const hit = sent.some( + (s) => + s.type === type && + s.userId === userId && + (bountyId == null || s.bountyId === bountyId), + ); + return hit ? { hit: 1 } : null; + } + return null; + }, + async run() { + if (sql.includes("INSERT INTO email_logs")) { + const [ + id, + to_email, + subject, + type, + status, + resend_id, + error, + user_id, + bounty_id, + ] = args; + logged.push({ + id, + to_email, + subject, + type, + status, + resend_id, + error, + user_id, + bounty_id, + }); + } + return { meta: { changes: 1 } }; + }, + }; + return b; + }, + }; + + return { env: { DB, RESEND_API_KEY: "re_key" } as any, logged }; +} + +beforeEach(() => { + vi.restoreAllMocks(); +}); + +describe("sendAndLog", () => { + it("records the recipient and bounty on the log row", async () => { + const { env, logged } = makeEnv(); + + await sendAndLog(env, "a@b.c", "Subject", "submission_approved", "

", { + userId: "u1", + bountyId: "b1", + }); + + expect(logged[0]).toMatchObject({ + to_email: "a@b.c", + type: "submission_approved", + status: "sent", + user_id: "u1", + bounty_id: "b1", + }); + }); + + it("writes NULLs when no context is given, so transactional mail still logs", async () => { + const { env, logged } = makeEnv(); + + await sendAndLog(env, "a@b.c", "Verify", "verification", "

"); + + expect(logged[0]?.user_id).toBeNull(); + expect(logged[0]?.bounty_id).toBeNull(); + }); +}); + +describe("alreadySent", () => { + it("is true for a repeat of the same type to the same user on the same bounty", async () => { + const { env } = makeEnv([ + { type: "bounty_deadline", userId: "u1", bountyId: "b1" }, + ]); + + expect( + await alreadySent(env, "bounty_deadline", { + userId: "u1", + bountyId: "b1", + }), + ).toBe(true); + }); + + it("is false for a different bounty, user, or type", async () => { + const { env } = makeEnv([ + { type: "bounty_deadline", userId: "u1", bountyId: "b1" }, + ]); + + expect( + await alreadySent(env, "bounty_deadline", { + userId: "u1", + bountyId: "b2", + }), + ).toBe(false); + expect( + await alreadySent(env, "bounty_deadline", { + userId: "u2", + bountyId: "b1", + }), + ).toBe(false); + expect( + await alreadySent(env, "bounty_closed", { userId: "u1", bountyId: "b1" }), + ).toBe(false); + }); + + it("is false without a userId rather than suppressing everyone's mail", async () => { + const { env } = makeEnv([{ type: "bounty_deadline", userId: "u1" }]); + + expect(await alreadySent(env, "bounty_deadline", {})).toBe(false); + expect(await alreadySent(env, "bounty_deadline", { bountyId: "b1" })).toBe( + false, + ); + }); +}); diff --git a/tests/worker/email-unsubscribe.test.ts b/tests/worker/email-unsubscribe.test.ts new file mode 100644 index 00000000..e67c5d73 --- /dev/null +++ b/tests/worker/email-unsubscribe.test.ts @@ -0,0 +1,220 @@ +import { describe, it, expect, vi } from "vitest"; +import { + categoryForType, + isUnsubscribed, + signUnsubscribe, + verifyUnsubscribe, + sendAndLog, + EMAIL_CATEGORIES, +} from "@/worker/email"; + +const sent: { to: string; headers?: Record; html: string }[] = + []; + +vi.mock("resend", () => ({ + Resend: class { + emails = { + send: async (opts: any) => { + sent.push({ to: opts.to, headers: opts.headers, html: opts.html }); + return { data: { id: "re_test" }, error: null }; + }, + }; + }, +})); + +/** `optedOut` seeds email_unsubscribes rows as (userId, category) pairs. */ +function makeEnv(optedOut: [string, string][] = [], secret = "s3cret") { + const logged: Record[] = []; + const out = new Set(optedOut.map(([u, c]) => `${u}|${c}`)); + + const DB = { + prepare(sql: string) { + let args: any[] = []; + const b = { + bind(...a: any[]) { + args = a; + return b; + }, + async first() { + if (sql.includes("FROM email_unsubscribes")) { + const [userId, category] = args; + return out.has(`${userId}|${category}`) || out.has(`${userId}|all`) + ? { hit: 1 } + : null; + } + return null; + }, + async all() { + return { results: [] }; + }, + async run() { + if (sql.includes("INSERT INTO email_logs")) { + const [, to_email, , type, status] = args; + logged.push({ to_email, type, status }); + } + return { meta: { changes: 1 } }; + }, + }; + return b; + }, + }; + + return { + env: { + DB, + RESEND_API_KEY: "re_key", + INTERNAL_SECRET: secret, + } as any, + logged, + }; +} + +describe("categoryForType", () => { + it("maps notification mail to a category", () => { + expect(categoryForType("submission_approved")).toBe("submission_result"); + expect(categoryForType("new_submission")).toBe("sponsor_activity"); + expect(categoryForType("bounty_deadline")).toBe("deadline"); + }); + + it("leaves transactional mail uncategorised so it can never be suppressed", () => { + expect(categoryForType("sponsor_verified")).toBeNull(); + expect(categoryForType("verification")).toBeNull(); + expect(categoryForType("reset_password")).toBeNull(); + }); + + it("only maps to categories the settings page knows about", () => { + for (const t of [ + "submission_approved", + "submission_rejected", + "revision_requested", + "new_submission", + "submission_resubmitted", + "bounty_deadline", + "review_overdue", + "scout_invite", + "product", + ]) { + expect(EMAIL_CATEGORIES).toContain(categoryForType(t) as any); + } + }); +}); + +describe("isUnsubscribed", () => { + it("matches the category itself", async () => { + const { env } = makeEnv([["u1", "deadline"]]); + expect(await isUnsubscribed(env, "u1", "deadline")).toBe(true); + expect(await isUnsubscribed(env, "u1", "product")).toBe(false); + }); + + it("treats 'all' as covering every category", async () => { + const { env } = makeEnv([["u1", "all"]]); + expect(await isUnsubscribed(env, "u1", "deadline")).toBe(true); + expect(await isUnsubscribed(env, "u1", "submission_result")).toBe(true); + }); +}); + +describe("unsubscribe tokens", () => { + it("round-trips", async () => { + const { env } = makeEnv(); + const t = await signUnsubscribe(env, "u1", "deadline"); + expect(t).toBeTruthy(); + expect(await verifyUnsubscribe(env, "u1", "deadline", t!)).toBe(true); + }); + + it("does not let one user's token unsubscribe another user", async () => { + const { env } = makeEnv(); + const t = (await signUnsubscribe(env, "u1", "deadline"))!; + + expect(await verifyUnsubscribe(env, "u2", "deadline", t)).toBe(false); + expect(await verifyUnsubscribe(env, "u1", "product", t)).toBe(false); + expect(await verifyUnsubscribe(env, "u1", "deadline", "garbage")).toBe( + false, + ); + }); + + it("refuses to sign when INTERNAL_SECRET is unset", async () => { + const { env } = makeEnv([], ""); + expect(await signUnsubscribe(env, "u1", "deadline")).toBeNull(); + expect(await verifyUnsubscribe(env, "u1", "deadline", "anything")).toBe( + false, + ); + }); +}); + +describe("sendAndLog suppression", () => { + it("does not send to an opted-out user, but still logs it", async () => { + sent.length = 0; + const { env, logged } = makeEnv([["u1", "submission_result"]]); + + await sendAndLog(env, "a@b.c", "S", "submission_approved", "

", { + userId: "u1", + bountyId: "b1", + }); + + expect(sent).toHaveLength(0); + expect(logged[0]).toMatchObject({ status: "suppressed" }); + }); + + it("still sends transactional mail to a user who opted out of everything", async () => { + sent.length = 0; + const { env, logged } = makeEnv([["u1", "all"]]); + + await sendAndLog(env, "a@b.c", "S", "sponsor_verified", "

", { + userId: "u1", + }); + + expect(sent).toHaveLength(1); + expect(logged[0]).toMatchObject({ status: "sent" }); + }); + + it("attaches List-Unsubscribe headers and a footer link to categorised mail", async () => { + sent.length = 0; + const { env } = makeEnv(); + + await sendAndLog(env, "a@b.c", "S", "submission_approved", "

body

", { + userId: "u1", + bountyId: "b1", + }); + + expect(sent[0]?.headers?.["List-Unsubscribe"]).toMatch( + /^$/, + ); + expect(sent[0]?.headers?.["List-Unsubscribe-Post"]).toBe( + "List-Unsubscribe=One-Click", + ); + expect(sent[0]?.html).toContain("Unsubscribe from these emails"); + }); + + it("omits the unsubscribe link entirely rather than emitting an unsigned one", async () => { + sent.length = 0; + const { env } = makeEnv([], ""); + + await sendAndLog(env, "a@b.c", "S", "submission_approved", "

body

", { + userId: "u1", + }); + + expect(sent[0]?.headers).toBeUndefined(); + expect(sent[0]?.html).not.toContain("Unsubscribe"); + }); + + it("sends anyway when the opt-out lookup throws", async () => { + sent.length = 0; + const { env } = makeEnv(); + env.DB.prepare = () => ({ + bind: () => ({ + first: async () => { + throw new Error("D1 down"); + }, + async run() { + return { meta: { changes: 1 } }; + }, + }), + }); + + await sendAndLog(env, "a@b.c", "S", "submission_approved", "

", { + userId: "u1", + }); + + expect(sent).toHaveLength(1); + }); +}); diff --git a/tests/worker/notifications.test.ts b/tests/worker/notifications.test.ts new file mode 100644 index 00000000..0e6727e0 --- /dev/null +++ b/tests/worker/notifications.test.ts @@ -0,0 +1,394 @@ +import { describe, it, expect } from "vitest"; +import { + notify, + notifyUnlessActor, + notifySubmissionReviewed, + notifySponsorStatusChanged, + notifyCommentReply, +} from "@/worker/notifications"; +import { handleNotificationsAPI } from "@/worker/handlers"; + +/** Captures inserted notifications; `muted` lists muted (user, bounty) pairs. */ +function makeEnv(muted: [string, string][] = []) { + const inserted: Record[] = []; + const mutedSet = new Set(muted.map(([u, b]) => `${u}|${b}`)); + + const DB = { + prepare(sql: string) { + let args: any[] = []; + const b = { + bind(...a: any[]) { + args = a; + return b; + }, + async first() { + if (sql.includes("FROM notification_mutes")) { + return mutedSet.has(`${args[0]}|${args[1]}`) ? { x: 1 } : null; + } + return null; + }, + async all() { + return { results: [] }; + }, + async run() { + if (sql.includes("INSERT INTO notifications")) { + const [ + id, + user_id, + type, + title, + message, + link, + bounty, + submission, + ] = args; + inserted.push({ + id, + user_id, + type, + title, + message, + link, + bounty, + submission, + }); + } + return { success: true }; + }, + }; + return b; + }, + }; + + return { env: { DB } as any, inserted }; +} + +describe("notify", () => { + it("writes a notification", async () => { + const { env, inserted } = makeEnv(); + const ok = await notify(env, { + userId: "u1", + type: "general", + title: "T", + message: "M", + }); + + expect(ok).toBe(true); + expect(inserted).toHaveLength(1); + expect(inserted[0]).toMatchObject({ user_id: "u1", type: "general" }); + }); + + it("skips a bounty the user has muted", async () => { + const { env, inserted } = makeEnv([["u1", "b1"]]); + const ok = await notify(env, { + userId: "u1", + type: "new_comment", + title: "T", + message: "M", + bountyId: "b1", + }); + + expect(ok).toBe(false); + expect(inserted).toHaveLength(0); + }); + + it("does not apply another user's mute", async () => { + const { env, inserted } = makeEnv([["someone-else", "b1"]]); + await notify(env, { + userId: "u1", + type: "new_comment", + title: "T", + message: "M", + bountyId: "b1", + }); + expect(inserted).toHaveLength(1); + }); + + it("never throws — a failed notification must not break the action", async () => { + const DB = { + prepare() { + return { + bind() { + return this; + }, + async first() { + return null; + }, + async run() { + throw new Error("db exploded"); + }, + }; + }, + }; + await expect( + notify({ DB } as any, { + userId: "u1", + type: "general", + title: "T", + message: "M", + }), + ).resolves.toBe(false); + }); + + it("ignores an empty recipient", async () => { + const { env, inserted } = makeEnv(); + expect( + await notify(env, { + userId: "", + type: "general", + title: "T", + message: "M", + }), + ).toBe(false); + expect(inserted).toHaveLength(0); + }); +}); + +describe("notifyUnlessActor", () => { + it("does not notify someone about their own action", async () => { + const { env, inserted } = makeEnv(); + const ok = await notifyUnlessActor(env, "u1", { + userId: "u1", + type: "new_comment", + title: "T", + message: "M", + }); + expect(ok).toBe(false); + expect(inserted).toHaveLength(0); + }); + + it("notifies a different user", async () => { + const { env, inserted } = makeEnv(); + await notifyUnlessActor(env, "u2", { + userId: "u1", + type: "new_comment", + title: "T", + message: "M", + }); + expect(inserted).toHaveLength(1); + }); +}); + +describe("notifySubmissionReviewed", () => { + const base = { + submitterUserId: "winner", + bountyId: "b1", + submissionId: "s1", + bountyTitle: "Write a tutorial", + }; + + it("notifies on revision_requested — previously email-only with no in-app notice", async () => { + const { env, inserted } = makeEnv(); + await notifySubmissionReviewed(env, { + ...base, + status: "revision_requested", + }); + + expect(inserted).toHaveLength(1); + expect(inserted[0]!.type).toBe("submission_revision_requested"); + expect(inserted[0]!.message).toMatch(/requested changes/i); + }); + + it("notifies on approved and rejected", async () => { + for (const status of ["approved", "rejected"] as const) { + const { env, inserted } = makeEnv(); + await notifySubmissionReviewed(env, { ...base, status }); + expect(inserted).toHaveLength(1); + expect(inserted[0]!.type).toBe( + status === "approved" ? "submission_accepted" : "submission_rejected", + ); + } + }); + + it("delivers the verdict even when the bounty is muted", async () => { + // Muting a bounty silences its comment chatter, not the decision on your + // own submission. + const { env, inserted } = makeEnv([["winner", "b1"]]); + await notifySubmissionReviewed(env, { ...base, status: "approved" }); + expect(inserted).toHaveLength(1); + }); + + it("links to the bounty and records the submission id", async () => { + const { env, inserted } = makeEnv(); + await notifySubmissionReviewed(env, { ...base, status: "approved" }); + expect(inserted[0]!.link).toBe("/bounty/b1"); + expect(inserted[0]!.submission).toBe("s1"); + }); +}); + +describe("notifySponsorStatusChanged", () => { + it("covers all four transitions, not just verify", async () => { + const seen: string[] = []; + for (const change of [ + "verified", + "unverified", + "banned", + "unbanned", + ] as const) { + const { env, inserted } = makeEnv(); + await notifySponsorStatusChanged(env, { + sponsorUserId: "sp", + sponsorName: "Acme", + change, + }); + expect(inserted).toHaveLength(1); + seen.push(inserted[0]!.type); + } + expect(new Set(seen).size).toBe(4); + }); + + it("names the sponsor so the recipient knows which account", async () => { + const { env, inserted } = makeEnv(); + await notifySponsorStatusChanged(env, { + sponsorUserId: "sp", + sponsorName: "Acme", + change: "banned", + }); + expect(inserted[0]!.message).toContain("Acme"); + }); +}); + +describe("notifyCommentReply", () => { + it("honours the bounty mute — the old client path did not", async () => { + const { env, inserted } = makeEnv([["author", "b1"]]); + await notifyCommentReply(env, { + parentAuthorUserId: "author", + actorUserId: "replier", + bountyId: "b1", + replierName: "Bob", + }); + expect(inserted).toHaveLength(0); + }); + + it("does not ping you for replying to yourself", async () => { + const { env, inserted } = makeEnv(); + await notifyCommentReply(env, { + parentAuthorUserId: "author", + actorUserId: "author", + bountyId: "b1", + replierName: "Me", + }); + expect(inserted).toHaveLength(0); + }); +}); + +// ── Endpoint lockdown ────────────────────────────────────────────────────── + +function apiEnv(opts: { sessionUserId?: string | null; role?: string | null }) { + const { sessionUserId = null, role = null } = opts; + const DB = { + prepare(sql: string) { + let args: any[] = []; + const b = { + bind(...a: any[]) { + args = a; + return b; + }, + async first() { + if (sql.includes("FROM session WHERE token")) + return sessionUserId ? { userId: sessionUserId } : null; + if (sql.includes("SELECT role FROM user")) + return role !== null ? { role } : null; + return {}; + }, + async all() { + return { results: [] }; + }, + async run() { + return { success: true }; + }, + }; + return b; + }, + }; + return { DB } as any; +} + +const postNotification = (env: any, withCookie = true) => + handleNotificationsAPI( + new Request("https://x/api/notifications", { + method: "POST", + headers: withCookie + ? { Cookie: "better-auth.session_token=tok" } + : undefined, + body: JSON.stringify({ + user_id: "victim", + type: "general", + title: "You won 1000 ALPH", + message: "Claim at evil.example", + link: "https://evil.example", + }), + }), + env, + new URL("https://x/api/notifications"), + ); + +describe("POST /api/notifications lockdown", () => { + it("rejects an anonymous caller", async () => { + // This is the phishing vector: before, anyone could post a notification + // with any title, body and link to any user. + const res = await postNotification(apiEnv({}), false); + expect(res.status).toBe(401); + }); + + it("rejects an ordinary logged-in user", async () => { + const res = await postNotification( + apiEnv({ sessionUserId: "u1", role: null }), + ); + expect(res.status).toBe(403); + }); + + it("allows a god user", async () => { + const res = await postNotification( + apiEnv({ sessionUserId: "admin", role: "god" }), + ); + expect(res.status).toBe(201); + }); +}); + +describe("mute enforcement is a single chokepoint", () => { + it("suppresses a bounty notification for a user who muted that bounty", async () => { + const { env, inserted } = makeEnv([["u1", "b1"]]); + + const created = await notify(env, { + userId: "u1", + type: "comment_reply", + title: "New Reply", + message: "someone replied", + bountyId: "b1", + }); + + expect(created).toBe(false); + expect(inserted).toHaveLength(0); + }); + + it("still delivers a notification that is not tied to a bounty", async () => { + // Mutes are bounty-scoped; a global notice must not be swallowed by one. + const { env, inserted } = makeEnv([["u1", "b1"]]); + + expect( + await notify(env, { + userId: "u1", + type: "comment_reply", + title: "Announcement", + message: "platform news", + }), + ).toBe(true); + expect(inserted).toHaveLength(1); + }); + + it("delivers a reply on a bounty the user did not mute", async () => { + const { env, inserted } = makeEnv([["u1", "b1"]]); + + expect( + await notify(env, { + userId: "u1", + type: "comment_reply", + title: "New Reply", + message: "someone replied", + bountyId: "b2", + }), + ).toBe(true); + expect(inserted).toHaveLength(1); + }); +}); diff --git a/tests/worker/price.test.ts b/tests/worker/price.test.ts new file mode 100644 index 00000000..318b0747 --- /dev/null +++ b/tests/worker/price.test.ts @@ -0,0 +1,699 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { + berlinParts, + fetchAlphPrice, + getAlphPrice, + maybeRefreshDailyPrice, + refreshAlphPrice, + resolveRewardInput, + revalueBounties, + RateUnavailableError, + REFRESH_HOUR_BERLIN, +} from "@/worker/price"; + +/** In-memory stand-in for the indexer_state KV table. */ +function makeEnv(seed: Record = {}) { + const state = new Map(Object.entries(seed)); + + const DB = { + prepare(sql: string) { + let args: any[] = []; + const builder = { + bind(...a: any[]) { + args = a; + return builder; + }, + async first() { + if (sql.includes("SELECT value FROM indexer_state")) { + const v = state.get(args[0]); + return v === undefined ? null : { value: v }; + } + return null; + }, + async run() { + if (sql.includes("INSERT INTO indexer_state")) { + state.set(args[0], args[1]); + } + return { success: true }; + }, + }; + return builder; + }, + }; + + return { env: { DB } as any, state }; +} + +/** A CoinPaprika-shaped body — the first source in the chain. */ +const okResponse = (usd: number, lastUpdated: number | null = 1785160310) => + ({ + ok: true, + status: 200, + json: async () => ({ + quotes: { USD: { price: usd } }, + ...(lastUpdated === null + ? {} + : { last_updated: new Date(lastUpdated * 1000).toISOString() }), + }), + }) as any; + +/** A CoinGecko-shaped body — the second source. */ +const geckoResponse = (usd: number, lastUpdated: number | null = 1785160310) => + ({ + ok: true, + status: 200, + json: async () => ({ + alephium: + lastUpdated === null ? { usd } : { usd, last_updated_at: lastUpdated }, + }), + }) as any; + +/** An upstream refusal, e.g. CoinGecko's shared-IP 429. */ +const errResponse = (status: number, body = "") => + ({ ok: false, status, text: async () => body }) as any; + +beforeEach(() => { + vi.useFakeTimers(); +}); + +afterEach(() => { + vi.useRealTimers(); + vi.unstubAllGlobals(); +}); + +describe("berlinParts — DST correctness", () => { + it("maps 07:00 UTC to 08:00 Berlin in winter (CET, UTC+1)", () => { + // 2026-01-15 07:00 UTC + const { date, hour } = berlinParts(new Date("2026-01-15T07:00:00Z")); + expect(hour).toBe(8); + expect(date).toBe("2026-01-15"); + }); + + it("maps 06:00 UTC to 08:00 Berlin in summer (CEST, UTC+2)", () => { + // 2026-07-15 06:00 UTC + const { date, hour } = berlinParts(new Date("2026-07-15T06:00:00Z")); + expect(hour).toBe(8); + expect(date).toBe("2026-07-15"); + }); + + it("does NOT report 08:00 Berlin at 07:00 UTC during summer", () => { + // The naive "always 07:00 UTC" schedule would fire an hour late in summer. + expect(berlinParts(new Date("2026-07-15T07:00:00Z")).hour).toBe(9); + }); + + it("rolls the Berlin date over before UTC midnight", () => { + // 23:30 UTC is already the next day in Berlin. + expect(berlinParts(new Date("2026-07-15T23:30:00Z")).date).toBe( + "2026-07-16", + ); + }); + + it("reports hour 0, never 24, at Berlin midnight", () => { + expect(berlinParts(new Date("2026-07-15T22:00:00Z")).hour).toBe(0); + }); +}); + +describe("fetchAlphPrice", () => { + it("parses a well-formed response from the primary source", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => okResponse(0.03890974)), + ); + const { env } = makeEnv(); + + const result = await fetchAlphPrice(env); + expect(result.usd).toBeCloseTo(0.03890974); + expect(result.source_updated_at).toBe(1785160310); + expect(result.source).toBe("coinpaprika"); + }); + + it("stops at the first source that answers", async () => { + const spy = vi.fn(async () => okResponse(0.04)); + vi.stubGlobal("fetch", spy); + + await fetchAlphPrice(makeEnv().env); + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("falls back to the next source when one refuses", async () => { + // The live failure: CoinGecko 429s on the shared Worker egress IP. + const spy = vi + .fn() + .mockResolvedValueOnce(errResponse(503, "paprika down")) + .mockResolvedValueOnce(geckoResponse(0.041)); + vi.stubGlobal("fetch", spy); + + const result = await fetchAlphPrice(makeEnv().env); + expect(result.usd).toBeCloseTo(0.041); + expect(result.source).toBe("coingecko"); + }); + + it("falls through to the last source when both aggregators fail", async () => { + const spy = vi + .fn() + .mockResolvedValueOnce(errResponse(503)) + .mockResolvedValueOnce(errResponse(429, "rate limited")) + .mockResolvedValueOnce({ + ok: true, + status: 200, + json: async () => ({ symbol: "ALPHUSDT", price: "0.0381" }), + } as any); + vi.stubGlobal("fetch", spy); + + const result = await fetchAlphPrice(makeEnv().env); + expect(result.usd).toBeCloseTo(0.0381); + expect(result.source).toBe("mexc"); + expect(result.source_updated_at).toBeNull(); + }); + + it("keeps going when a source throws rather than refusing", async () => { + const spy = vi + .fn() + .mockRejectedValueOnce(new Error("network unreachable")) + .mockResolvedValueOnce(geckoResponse(0.042)); + vi.stubGlobal("fetch", spy); + + expect((await fetchAlphPrice(makeEnv().env)).source).toBe("coingecko"); + }); + + it("reports every source's error when all of them fail", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => errResponse(429, "rate limited")), + ); + + await expect(fetchAlphPrice(makeEnv().env)).rejects.toThrow( + /all price sources failed[\s\S]*coinpaprika[\s\S]*coingecko[\s\S]*mexc/, + ); + }); + + it("sends the demo key header to CoinGecko only, and only when configured", async () => { + // Reached only after the primary fails, so fail it deliberately. + const spy = vi + .fn() + .mockResolvedValueOnce(errResponse(503)) + .mockResolvedValueOnce(geckoResponse(0.04)); + vi.stubGlobal("fetch", spy); + + await fetchAlphPrice({ + DB: null, + COINGECKO_API_KEY: "cg-demo-123", + } as any); + expect( + spy.mock.calls[0]?.[1]?.headers["x-cg-demo-api-key"], + ).toBeUndefined(); + expect(spy.mock.calls[1]?.[1]?.headers["x-cg-demo-api-key"]).toBe( + "cg-demo-123", + ); + + const spy2 = vi + .fn() + .mockResolvedValueOnce(errResponse(503)) + .mockResolvedValueOnce(geckoResponse(0.04)); + vi.stubGlobal("fetch", spy2); + + await fetchAlphPrice({ DB: null } as any); + expect( + spy2.mock.calls[1]?.[1]?.headers["x-cg-demo-api-key"], + ).toBeUndefined(); + }); + + it("sends a descriptive User-Agent", async () => { + const spy = vi.fn(async (_url: string, _init: any) => okResponse(0.04)); + vi.stubGlobal("fetch", spy); + + await fetchAlphPrice(makeEnv().env); + expect(spy.mock.calls[0]?.[1]?.headers["User-Agent"]).toMatch(/alphland/); + }); + + it("throws on a rate-limit response rather than storing garbage", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + ({ ok: false, status: 429, text: async () => "rate limited" }) as any, + ), + ); + await expect(fetchAlphPrice(makeEnv().env)).rejects.toThrow("429"); + }); + + it("rejects a zero / missing / non-numeric price", async () => { + const bodies = [ + { quotes: { USD: { price: 0 } } }, + { quotes: { USD: {} } }, + {}, + { quotes: { USD: { price: "0.04" } } }, + ]; + for (const body of bodies) { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + ({ ok: true, status: 200, json: async () => body }) as any, + ), + ); + await expect(fetchAlphPrice(makeEnv().env)).rejects.toThrow( + /no usable price/, + ); + } + }); + + it("tolerates a missing last_updated_at", async () => { + vi.stubGlobal( + "fetch", + vi.fn(async () => okResponse(0.04, null)), + ); + const result = await fetchAlphPrice(makeEnv().env); + expect(result.source_updated_at).toBeNull(); + }); +}); + +describe("getAlphPrice", () => { + it("returns null when nothing has been stored yet", async () => { + expect(await getAlphPrice(makeEnv().env)).toBeNull(); + }); + + it("returns null on corrupt JSON instead of throwing", async () => { + const { env } = makeEnv({ alph_usd_price: "{not json" }); + expect(await getAlphPrice(env)).toBeNull(); + }); + + it("returns null on a stored non-positive price", async () => { + const { env } = makeEnv({ + alph_usd_price: JSON.stringify({ usd: 0, run_date: "2026-07-15" }), + }); + expect(await getAlphPrice(env)).toBeNull(); + }); +}); + +describe("maybeRefreshDailyPrice — scheduling and idempotency", () => { + it("fetches at 08:00 Berlin", async () => { + vi.setSystemTime(new Date("2026-07-15T06:00:00Z")); // 08:00 CEST + const spy = vi.fn(async () => okResponse(0.0389)); + vi.stubGlobal("fetch", spy); + const { env, state } = makeEnv(); + + await maybeRefreshDailyPrice(env); + + expect(spy).toHaveBeenCalledTimes(1); + const stored = JSON.parse(state.get("alph_usd_price")!); + expect(stored.usd).toBeCloseTo(0.0389); + expect(stored.run_date).toBe("2026-07-15"); + }); + + it("fetches exactly once across a full day of hourly runs", async () => { + const spy = vi.fn(async () => okResponse(0.0389)); + vi.stubGlobal("fetch", spy); + const { env } = makeEnv(); + + for (let utcHour = 0; utcHour < 24; utcHour++) { + vi.setSystemTime( + new Date(`2026-07-15T${String(utcHour).padStart(2, "0")}:00:00Z`), + ); + await maybeRefreshDailyPrice(env); + } + + // First run at/after 08:00 Berlin wins; the rest are no-ops for that date. + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("skips every hour before 08:00 Berlin", async () => { + const spy = vi.fn(async () => okResponse(0.0389)); + vi.stubGlobal("fetch", spy); + const { env } = makeEnv(); + + // 00:00–07:00 Berlin on a summer day is 22:00–05:00 UTC. + for (const utc of ["2026-07-14T22:00:00Z", "2026-07-15T05:00:00Z"]) { + vi.setSystemTime(new Date(utc)); + await maybeRefreshDailyPrice(env); + } + + expect(spy).not.toHaveBeenCalled(); + }); + + it("does not re-fetch if the cron double-fires within the same hour", async () => { + vi.setSystemTime(new Date("2026-07-15T06:00:00Z")); + const spy = vi.fn(async () => okResponse(0.0389)); + vi.stubGlobal("fetch", spy); + const { env } = makeEnv(); + + await maybeRefreshDailyPrice(env); + await maybeRefreshDailyPrice(env); + await maybeRefreshDailyPrice(env); + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("fetches again the next day", async () => { + const spy = vi.fn(async () => okResponse(0.0389)); + vi.stubGlobal("fetch", spy); + const { env } = makeEnv(); + + vi.setSystemTime(new Date("2026-07-15T06:00:00Z")); + await maybeRefreshDailyPrice(env); + vi.setSystemTime(new Date("2026-07-16T06:00:00Z")); + await maybeRefreshDailyPrice(env); + + expect(spy).toHaveBeenCalledTimes(2); + }); + + it("keeps the previous rate and does not throw when CoinGecko fails", async () => { + const { env, state } = makeEnv({ + alph_usd_price: JSON.stringify({ + usd: 0.05, + fetched_at: 1, + source_updated_at: null, + run_date: "2026-07-14", + source: "coingecko", + }), + }); + + vi.setSystemTime(new Date("2026-07-15T06:00:00Z")); + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 429 }) as any), + ); + + await expect(maybeRefreshDailyPrice(env)).resolves.toBeUndefined(); + + // Last known good rate survives untouched. + expect(JSON.parse(state.get("alph_usd_price")!).usd).toBe(0.05); + }); + + it("retries at 09:00 after the 08:00 fetch fails", async () => { + const { env, state } = makeEnv(); + + vi.setSystemTime(new Date("2026-07-15T06:00:00Z")); // 08:00 Berlin + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ ok: false, status: 429 }) as any), + ); + await maybeRefreshDailyPrice(env); + expect(state.has("alph_usd_price")).toBe(false); + + // One CoinGecko blip must not cost the whole day. + vi.setSystemTime(new Date("2026-07-15T07:00:00Z")); // 09:00 Berlin + vi.stubGlobal( + "fetch", + vi.fn(async () => okResponse(0.0389)), + ); + await maybeRefreshDailyPrice(env); + + const stored = JSON.parse(state.get("alph_usd_price")!); + expect(stored.usd).toBeCloseTo(0.0389); + expect(stored.run_date).toBe("2026-07-15"); + }); + + it("stops retrying once the day's fetch has succeeded", async () => { + const { env } = makeEnv(); + const spy = vi.fn(async () => okResponse(0.0389)); + vi.stubGlobal("fetch", spy); + + for (const utc of [ + "2026-07-15T06:00:00Z", // 08:00 — succeeds + "2026-07-15T07:00:00Z", // 09:00 + "2026-07-15T12:00:00Z", // 14:00 + ]) { + vi.setSystemTime(new Date(utc)); + await maybeRefreshDailyPrice(env); + } + + expect(spy).toHaveBeenCalledTimes(1); + }); + + it("backfills immediately on a first deploy later in the day", async () => { + // Fresh DB, worker deployed at 17:00 Berlin: take a rate now rather than + // serving no USD figure until tomorrow morning. + const { env, state } = makeEnv(); + vi.setSystemTime(new Date("2026-07-15T15:00:00Z")); + vi.stubGlobal( + "fetch", + vi.fn(async () => okResponse(0.0389)), + ); + + await maybeRefreshDailyPrice(env); + expect(state.has("alph_usd_price")).toBe(true); + }); +}); + +describe("refreshAlphPrice — manual/god path", () => { + it("writes a record regardless of the hour", async () => { + vi.setSystemTime(new Date("2026-07-15T15:00:00Z")); // 17:00 Berlin + vi.stubGlobal( + "fetch", + vi.fn(async () => okResponse(0.042)), + ); + const { env, state } = makeEnv(); + + const record = await refreshAlphPrice(env); + + expect(record.usd).toBeCloseTo(0.042); + // Records whichever source answered, not a hardcoded name. + expect(record.source).toBe("coinpaprika"); + expect(JSON.parse(state.get("alph_usd_price")!).usd).toBeCloseTo(0.042); + }); + + it("propagates failures to the caller so the endpoint can 502", async () => { + vi.stubGlobal( + "fetch", + vi.fn( + async () => + ({ ok: false, status: 500, text: async () => "upstream" }) as any, + ), + ); + await expect(refreshAlphPrice(makeEnv().env)).rejects.toThrow("500"); + }); +}); + +describe("REFRESH_HOUR_BERLIN", () => { + it("is 08:00 as specified", () => { + expect(REFRESH_HOUR_BERLIN).toBe(8); + }); +}); + +/** Env whose stored rate is fixed, for reward-resolution tests. */ +function envAtRate(usd: number | null) { + return makeEnv( + usd === null + ? {} + : { + alph_usd_price: JSON.stringify({ + usd, + fetched_at: 1, + source_updated_at: null, + run_date: "2026-07-15", + source: "coingecko", + }), + }, + ).env; +} + +describe("resolveRewardInput — reward_amount is always ALPH", () => { + it("keeps ALPH authoritative and derives USD", async () => { + const r = await resolveRewardInput(envAtRate(0.04), { + denomination: "alph", + reward_amount: 500, + }); + + expect(r.denomination).toBe("alph"); + expect(r.reward_amount).toBe(500); // untouched + expect(r.reward_usd).toBeCloseTo(20); // 500 × 0.04 + expect(r.target_usd).toBeNull(); + }); + + it("keeps USD authoritative and derives an ALPH amount", async () => { + const r = await resolveRewardInput(envAtRate(0.04), { + denomination: "usd", + target_usd: 1000, + }); + + expect(r.denomination).toBe("usd"); + expect(r.target_usd).toBe(1000); // untouched + expect(r.reward_usd).toBe(1000); + expect(r.reward_amount).toBeCloseTo(25000); // 1000 / 0.04 + }); + + it("never puts a USD figure into reward_amount", async () => { + // The old schema's core bug: a $1000 bounty stored reward_amount = 1000. + const r = await resolveRewardInput(envAtRate(0.04), { + denomination: "usd", + target_usd: 1000, + }); + expect(r.reward_amount).not.toBe(1000); + }); + + it("defaults to ALPH denomination when unspecified", async () => { + const r = await resolveRewardInput(envAtRate(0.04), { reward_amount: 7 }); + expect(r.denomination).toBe("alph"); + expect(r.reward_amount).toBe(7); + }); + + it("treats any unrecognised denomination as ALPH rather than guessing", async () => { + const r = await resolveRewardInput(envAtRate(0.04), { + denomination: "EUR", + reward_amount: 7, + }); + expect(r.denomination).toBe("alph"); + }); + + it("rejects negative and non-numeric amounts, naming the right field", async () => { + await expect( + resolveRewardInput(envAtRate(0.04), { + denomination: "alph", + reward_amount: -1, + }), + ).rejects.toThrow("reward_amount"); + + await expect( + resolveRewardInput(envAtRate(0.04), { + denomination: "usd", + target_usd: "abc", + }), + ).rejects.toThrow("target_usd"); + }); + + it("accepts zero (an unpaid / placeholder bounty)", async () => { + const r = await resolveRewardInput(envAtRate(0.04), { reward_amount: 0 }); + expect(r.reward_amount).toBe(0); + expect(r.reward_usd).toBe(0); + }); + + it("still resolves an ALPH reward when no rate is cached yet", async () => { + // First deploy, before the price job has ever run: the sponsor's own + // number is authoritative, so the bounty saves; the daily job fills in + // reward_usd on its next run. + const alph = await resolveRewardInput(envAtRate(null), { + denomination: "alph", + reward_amount: 500, + }); + expect(alph.reward_amount).toBe(500); + expect(alph.reward_usd).toBeNull(); + expect(alph.token_usd_at_valuation).toBeNull(); + }); + + it("refuses a USD reward with no rate rather than storing 0 ALPH", async () => { + // bounties.reward_amount is NOT NULL and there is no honest ALPH figure + // to write; storing 0 would render as "0 ALPH" on the bounty page. + await expect( + resolveRewardInput(envAtRate(null), { + denomination: "usd", + target_usd: 1000, + }), + ).rejects.toThrow(RateUnavailableError); + }); + + it("never returns a reward_amount the NOT NULL column would reject", async () => { + for (const body of [ + { denomination: "alph", reward_amount: 500 }, + { denomination: "usd", target_usd: 1000 }, + { reward_amount: 0 }, + ]) { + const r = await resolveRewardInput(envAtRate(0.04), body); + expect(r.reward_amount).not.toBeNull(); + expect(Number.isFinite(r.reward_amount)).toBe(true); + } + }); + + it("ignores reward_currency — denomination is the only signal", async () => { + // The pre-025 shim read {reward_currency:'USD', reward_amount:100} as + // $100. It is gone, so the field is inert and reward_amount is taken at + // face value in ALPH. Passed through a variable because the parameter type + // no longer names reward_currency at all. + const stale = { reward_currency: "USD", reward_amount: 100 }; + const r = await resolveRewardInput(envAtRate(0.04), stale); + + expect(r.denomination).toBe("alph"); + expect(r.reward_amount).toBe(100); + expect(r.target_usd).toBeNull(); + }); + + it("defaults to ALPH when denomination is absent", async () => { + const r = await resolveRewardInput(envAtRate(0.04), { + reward_amount: 500, + }); + expect(r.denomination).toBe("alph"); + expect(r.reward_amount).toBe(500); + }); + + it("round-trips a USD bounty back to the same USD figure", async () => { + const rate = 0.03897934; + const r = await resolveRewardInput(envAtRate(rate), { + denomination: "usd", + target_usd: 100, + }); + expect((r.reward_amount as number) * rate).toBeCloseTo(100, 6); + }); +}); + +describe("revalueBounties", () => { + /** Captures the UPDATE statements so we can assert on the SQL semantics. */ + function captureEnv() { + const runs: { sql: string; args: any[] }[] = []; + const DB = { + prepare(sql: string) { + let args: any[] = []; + const b = { + bind(...a: any[]) { + args = a; + return b; + }, + async run() { + runs.push({ sql, args }); + return { meta: { changes: 1 } }; + }, + async first() { + return null; + }, + }; + return b; + }, + }; + return { env: { DB } as any, runs }; + } + + it("updates the derived side of each denomination", async () => { + const { env, runs } = captureEnv(); + await revalueBounties(env, 0.04); + + const alph = runs.find((r) => r.sql.includes("denomination = 'alph'"))!; + const usd = runs.find((r) => r.sql.includes("denomination = 'usd'"))!; + + // ALPH-denominated: USD recomputed, ALPH left alone. + expect(alph.sql).toContain("reward_usd = reward_amount * ?"); + expect(alph.sql).not.toContain("SET reward_amount ="); + + // USD-denominated: ALPH recomputed, USD pinned to the promise. + expect(usd.sql).toContain("reward_amount = target_usd / ?"); + expect(usd.sql).toContain("reward_usd = target_usd"); + }); + + it("only touches open bounties, so settled figures cannot drift", async () => { + const { env, runs } = captureEnv(); + await revalueBounties(env, 0.04); + for (const r of runs) expect(r.sql).toContain("status = 'open'"); + }); + + it("skips rows missing their authoritative value", async () => { + const { env, runs } = captureEnv(); + await revalueBounties(env, 0.04); + expect( + runs.find((r) => r.sql.includes("denomination = 'alph'"))!.sql, + ).toContain("reward_amount IS NOT NULL"); + expect( + runs.find((r) => r.sql.includes("denomination = 'usd'"))!.sql, + ).toContain("target_usd IS NOT NULL"); + }); + + it("returns the number of rows revalued", async () => { + const { env } = captureEnv(); + expect(await revalueBounties(env, 0.04)).toBe(2); // 1 per statement + }); + + it("refuses a zero or negative rate instead of dividing by it", async () => { + // target_usd / 0 would write Infinity into every USD-denominated bounty. + const { env } = captureEnv(); + for (const bad of [0, -1, NaN]) { + await expect(revalueBounties(env, bad)).rejects.toThrow(/unusable rate/); + } + }); +}); diff --git a/tests/worker/reminders.test.ts b/tests/worker/reminders.test.ts new file mode 100644 index 00000000..2b2e723a --- /dev/null +++ b/tests/worker/reminders.test.ts @@ -0,0 +1,239 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { + runBountyReminders, + DEADLINE_WARN_SECONDS, + REVIEW_OVERDUE_SECONDS, + REVIEW_ESCALATE_SECONDS, +} from "@/worker/reminders"; + +const sent: { to: string; type: string }[] = []; + +vi.mock("resend", () => ({ + Resend: class { + emails = { + send: async (opts: any) => { + sent.push({ to: opts.to, type: "" }); + return { data: { id: "re_test" }, error: null }; + }, + }; + }, +})); + +const NOW = 1785254400; // 2026-07-28T16:00:00Z +const HOUR = 3600; +const DAY = 24 * HOUR; + +/** + * Minimal D1 stand-in. `rows` maps a fragment of each query to its result, + * and every INSERT into email_logs is recorded so dedupe can be exercised. + */ +function makeEnv(opts: { + closingSoon?: any[]; + bookmarkers?: any[]; + overdue?: any[]; + admins?: any[]; + alreadyLogged?: { type: string; userId: string; bountyId: string }[]; +}) { + const logged = [...(opts.alreadyLogged ?? [])]; + + const DB = { + prepare(sql: string) { + let args: any[] = []; + const b = { + bind(...a: any[]) { + args = a; + return b; + }, + async first() { + if (sql.includes("FROM email_logs")) { + const [type, userId, bountyId] = args; + const hit = logged.some( + (l) => + l.type === type && + l.userId === userId && + (bountyId == null || l.bountyId === bountyId), + ); + return hit ? { hit: 1 } : null; + } + if (sql.includes("FROM email_unsubscribes")) return null; + return null; + }, + async all() { + if (sql.includes("FROM bounties") && sql.includes("end_date > ?")) { + return { results: opts.closingSoon ?? [] }; + } + if (sql.includes("FROM bookmarks")) { + return { results: opts.bookmarkers ?? [] }; + } + if (sql.includes("JOIN sponsors s ON b.sponsor_id")) { + return { results: opts.overdue ?? [] }; + } + if (sql.includes("role = 'god'")) { + return { results: opts.admins ?? [] }; + } + return { results: [] }; + }, + async run() { + if (sql.includes("INSERT INTO email_logs")) { + const [, , , type, status, , , user_id, bounty_id] = args; + if (status === "sent") { + logged.push({ type, userId: user_id, bountyId: bounty_id }); + } + } + return { meta: { changes: 1 } }; + }, + }; + return b; + }, + }; + + return { env: { DB, RESEND_API_KEY: "re_key" } as any, logged }; +} + +beforeEach(() => { + sent.length = 0; + vi.useFakeTimers(); + vi.setSystemTime(NOW * 1000); +}); + +describe("window constants match the documented policy", () => { + it("warns at 48h, reminds at 7 days, escalates at 14", () => { + expect(DEADLINE_WARN_SECONDS).toBe(48 * HOUR); + expect(REVIEW_OVERDUE_SECONDS).toBe(7 * DAY); + expect(REVIEW_ESCALATE_SECONDS).toBe(14 * DAY); + }); +}); + +describe("deadline reminders", () => { + it("emails a bookmarker who has not submitted", async () => { + const { env } = makeEnv({ + closingSoon: [{ id: "b1", title: "Test", end_date: NOW + 10 * HOUR }], + bookmarkers: [{ user_id: "u1", email: "a@b.c", name: "A" }], + }); + + const counts = await runBountyReminders(env); + expect(counts.deadlineSoon).toBe(1); + expect(sent).toHaveLength(1); + }); + + it("does not send the same reminder twice — the cron runs hourly", async () => { + const { env } = makeEnv({ + closingSoon: [{ id: "b1", title: "Test", end_date: NOW + 10 * HOUR }], + bookmarkers: [{ user_id: "u1", email: "a@b.c", name: "A" }], + }); + + await runBountyReminders(env); + await runBountyReminders(env); + await runBountyReminders(env); + + expect(sent).toHaveLength(1); + }); + + it("skips a bookmarker with no email address", async () => { + const { env } = makeEnv({ + closingSoon: [{ id: "b1", title: "Test", end_date: NOW + HOUR }], + bookmarkers: [{ user_id: "u1", email: null, name: "A" }], + }); + + expect((await runBountyReminders(env)).deadlineSoon).toBe(0); + expect(sent).toHaveLength(0); + }); +}); + +describe("overdue review", () => { + const overdue = [ + { + id: "b1", + title: "Late", + sponsor_name: "Acme", + sponsor_user_id: "s1", + sponsor_email: "s@b.c", + sponsor_contact: "S", + submission_count: 3, + }, + ]; + + it("reminds the sponsor once", async () => { + const { env } = makeEnv({ overdue }); + + await runBountyReminders(env); + await runBountyReminders(env); + + // One sponsor reminder; escalation reuses the same query in this stub, + // so filter to the sponsor address. + expect(sent.filter((s) => s.to === "s@b.c")).toHaveLength(1); + }); + + it("ignores a bounty nobody entered", async () => { + const { env } = makeEnv({ + overdue: [{ ...overdue[0], submission_count: 0 }], + }); + + const counts = await runBountyReminders(env); + expect(counts.reviewOverdue).toBe(0); + expect(counts.escalated).toBe(0); + }); + + it("escalates to every admin, once each", async () => { + const { env } = makeEnv({ + overdue, + admins: [ + { id: "g1", email: "g1@b.c" }, + { id: "g2", email: "g2@b.c" }, + ], + }); + + await runBountyReminders(env); + await runBountyReminders(env); + + expect(sent.filter((s) => s.to === "g1@b.c")).toHaveLength(1); + expect(sent.filter((s) => s.to === "g2@b.c")).toHaveLength(1); + }); + + it("does nothing when there are no admins to escalate to", async () => { + const { env } = makeEnv({ overdue, admins: [] }); + expect((await runBountyReminders(env)).escalated).toBe(0); + }); +}); + +describe("isolation", () => { + it("keeps running the other stages when one query throws", async () => { + const { env } = makeEnv({ + overdue: [ + { + id: "b1", + title: "Late", + sponsor_name: "Acme", + sponsor_user_id: "s1", + sponsor_email: "s@b.c", + sponsor_contact: "S", + submission_count: 1, + }, + ], + }); + + const realPrepare = env.DB.prepare.bind(env.DB); + env.DB.prepare = (sql: string) => { + if (sql.includes("FROM bookmarks")) throw new Error("D1 down"); + return realPrepare(sql); + }; + + const counts = await runBountyReminders(env); + expect(counts.deadlineSoon).toBe(0); + // The sponsor reminder still went out despite the bookmark stage failing. + expect(counts.reviewOverdue).toBe(1); + }); + + it("never throws, so it cannot take down the indexer sharing the handler", async () => { + const { env } = makeEnv({}); + env.DB.prepare = () => { + throw new Error("everything is broken"); + }; + + await expect(runBountyReminders(env)).resolves.toEqual({ + deadlineSoon: 0, + reviewOverdue: 0, + escalated: 0, + }); + }); +}); diff --git a/tests/worker/sponsor-slug.test.ts b/tests/worker/sponsor-slug.test.ts new file mode 100644 index 00000000..2f415619 --- /dev/null +++ b/tests/worker/sponsor-slug.test.ts @@ -0,0 +1,115 @@ +import { describe, it, expect } from "vitest"; +import { normalizeSponsorSlug, isSponsorSlugTaken } from "@/worker/handlers"; + +/** + * D1 stand-in over a fixed sponsors table, answering the single-column + * `WHERE slug = ? AND id IS NOT ?` lookup the collision check now uses. + */ +function makeEnv(sponsors: { id: string; slug: string | null }[]) { + const DB = { + prepare(_sql: string) { + let args: any[] = []; + const b = { + bind(...a: any[]) { + args = a; + return b; + }, + async first() { + const [slug, exclude] = args; + const hit = sponsors.find((s) => s.slug === slug && s.id !== exclude); + return hit ? { id: hit.id } : null; + }, + async all() { + return { results: sponsors }; + }, + async run() { + return { success: true }; + }, + }; + return b; + }, + }; + return { DB } as any; +} + +/** Slugs as migration 027 backfilled them in production. */ +const PROD = [ + { id: "s1", slug: "alephium" }, + { id: "s2", slug: "linxlabs" }, + { id: "s3", slug: "babypooltool" }, +]; + +describe("normalizeSponsorSlug", () => { + it("matches the client-side sponsorSlug() exactly", () => { + // validators.ts:7 — name.toLowerCase().replace(/[^a-z0-9]/g, "") + // Any divergence means a link the UI renders would 404. + const clientSide = (n: string) => n.toLowerCase().replace(/[^a-z0-9]/g, ""); + for (const n of [ + "Alephium", + "Linx Labs", + "Very Good Company", + "ALPH2048", + "BlockflowDAO", + "Alph & Co.", + "über-sponsor", + " spaced out ", + ]) { + expect(normalizeSponsorSlug(n)).toBe(clientSide(n)); + } + }); + + it("collapses the variants the old lookup treated as equal", () => { + const forms = [ + "Alephium", + "alephium", + "ALEPHIUM", + "aleph-ium", + "aleph_ium", + ]; + expect(new Set(forms.map(normalizeSponsorSlug)).size).toBe(1); + }); + + it("returns empty when nothing usable remains", () => { + expect(normalizeSponsorSlug("---")).toBe(""); + expect(normalizeSponsorSlug(" ")).toBe(""); + expect(normalizeSponsorSlug("")).toBe(""); + }); +}); + +describe("isSponsorSlugTaken", () => { + it("reports a slug already held by another sponsor", async () => { + expect(await isSponsorSlugTaken(makeEnv(PROD), "alephium")).toBe(true); + }); + + it("normalises the candidate before comparing", async () => { + // A rival typing "Aleph-ium" must not slip past a check on the raw string. + for (const attempt of ["Alephium", "ALEPHIUM", "aleph-ium", "aleph_ium"]) { + expect(await isSponsorSlugTaken(makeEnv(PROD), attempt)).toBe(true); + } + }); + + it("allows a genuinely free slug", async () => { + expect(await isSponsorSlugTaken(makeEnv(PROD), "brandnewsponsor")).toBe( + false, + ); + }); + + it("does not report a sponsor as colliding with itself", async () => { + // Re-saving the edit form unchanged must not 409. + expect(await isSponsorSlugTaken(makeEnv(PROD), "alephium", "s1")).toBe( + false, + ); + expect(await isSponsorSlugTaken(makeEnv(PROD), "alephium")).toBe(true); + }); + + it("treats an empty normalised slug as not taken", async () => { + // Callers reject this with a 400 first; it must not match rows whose slug + // is NULL by comparing empty-to-empty. + expect(await isSponsorSlugTaken(makeEnv(PROD), "---")).toBe(false); + }); + + it("ignores sponsors whose slug is still NULL", async () => { + const env = makeEnv([...PROD, { id: "s9", slug: null }]); + expect(await isSponsorSlugTaken(env, "anything")).toBe(false); + }); +}); diff --git a/tests/worker/sponsor-status-email.test.ts b/tests/worker/sponsor-status-email.test.ts new file mode 100644 index 00000000..8f1785e6 --- /dev/null +++ b/tests/worker/sponsor-status-email.test.ts @@ -0,0 +1,106 @@ +import { describe, it, expect, vi, beforeEach } from "vitest"; +import { notifySponsorAccountChange, categoryForType } from "@/worker/email"; + +const sent: { to: string; subject: string; html: string }[] = []; + +vi.mock("resend", () => ({ + Resend: class { + emails = { + send: async (o: any) => { + sent.push({ to: o.to, subject: o.subject, html: o.html }); + return { data: { id: "re_test" }, error: null }; + }, + }; + }, +})); + +function makeEnv( + row: any = { + user_id: "u1", + email: "s@b.c", + name: "Sam", + sponsor_name: "Acme", + }, +) { + const logged: any[] = []; + const DB = { + prepare(sql: string) { + let args: any[] = []; + const b = { + bind(...a: any[]) { + args = a; + return b; + }, + async first() { + if (sql.includes("FROM sponsors s JOIN user u")) return row; + // No opt-out rows: these must send regardless anyway. + return null; + }, + async run() { + if (sql.includes("INSERT INTO email_logs")) { + logged.push({ type: args[3], status: args[4], user_id: args[7] }); + } + return { meta: { changes: 1 } }; + }, + }; + return b; + }, + }; + return { + env: { DB, RESEND_API_KEY: "k", INTERNAL_SECRET: "s" } as any, + logged, + }; +} + +beforeEach(() => { + sent.length = 0; +}); + +describe("notifySponsorAccountChange", () => { + it("sends for every transition, not just the good news", async () => { + for (const change of [ + "verified", + "unverified", + "banned", + "unbanned", + ] as const) { + sent.length = 0; + const { env, logged } = makeEnv(); + await notifySponsorAccountChange(env, "sp1", change); + + expect(sent, `${change} should send`).toHaveLength(1); + expect(logged[0]).toMatchObject({ + type: `sponsor_${change}`, + status: "sent", + user_id: "u1", + }); + } + }); + + it("tells a suspended sponsor what happened and how to appeal", async () => { + const { env } = makeEnv(); + await notifySponsorAccountChange(env, "sp1", "banned"); + + expect(sent[0].subject).toMatch(/suspended/i); + expect(sent[0].html).toContain("Acme"); + expect(sent[0].html).toMatch(/reply to this email/i); + }); + + it("is transactional, so an opt-out can never suppress it", () => { + // Uncategorised types bypass the unsubscribe check in sendAndLog. + for (const c of ["verified", "unverified", "banned", "unbanned"]) { + expect(categoryForType(`sponsor_${c}`)).toBeNull(); + } + }); + + it("does nothing when the sponsor has no email on file", async () => { + const { env } = makeEnv({ + user_id: "u1", + email: null, + name: null, + sponsor_name: "Acme", + }); + await notifySponsorAccountChange(env, "sp1", "banned"); + expect(sent).toHaveLength(0); + }); +}); diff --git a/tests/worker/submission-payment.test.ts b/tests/worker/submission-payment.test.ts new file mode 100644 index 00000000..49931dc4 --- /dev/null +++ b/tests/worker/submission-payment.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { handleSubmissionsAPI } from "@/worker/handlers"; + +/** + * Reviewing a submission as its sponsor, with control over what the UPDATE + * does. `updateError` simulates the unique index on transaction_hash firing. + */ +function makeEnv(opts: { + sponsorUserId?: string; + sessionUserId?: string | null; + updateError?: string; + walletAddress?: string | null; +}) { + const { + sponsorUserId = "sponsor-user", + sessionUserId = "sponsor-user", + updateError, + walletAddress = "1DrDBaLDkxpJKQYXeGmpBjBnbUwFHnJgDBHZW1xhbCyF9", + } = opts; + + const updates: any[][] = []; + + const DB = { + prepare(sql: string) { + let args: any[] = []; + const b = { + bind(...a: any[]) { + args = a; + return b; + }, + async first() { + if (sql.includes("FROM session WHERE token")) { + return sessionUserId ? { userId: sessionUserId } : null; + } + if (sql.includes("SELECT role FROM user")) return { role: null }; + // sponsor that owns the submission's bounty + if (sql.includes("JOIN sponsors s ON b.sponsor_id = s.id")) { + return { user_id: sponsorUserId }; + } + if (sql.includes("LEFT JOIN user_profiles up")) { + return { + user_id: "winner", + bounty_id: "b1", + wallet_address: walletAddress, + }; + } + if (sql.includes("SELECT * FROM bounty_submissions WHERE id")) { + return { id: "sub1", status: "approved" }; + } + return null; + }, + async all() { + return { results: [] }; + }, + async run() { + if (sql.includes("UPDATE bounty_submissions")) { + updates.push(args); + if (updateError) throw new Error(updateError); + } + return { success: true }; + }, + }; + return b; + }, + }; + + return { env: { DB } as any, updates }; +} + +const review = (env: any, body: any) => + handleSubmissionsAPI( + new Request("https://x/api/submissions/sub1", { + method: "PUT", + headers: { Cookie: "better-auth.session_token=tok" }, + body: JSON.stringify(body), + }), + env, + new URL("https://x/api/submissions/sub1"), + ); + +beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ + blockHash: "block1", + outputs: [ + { + address: "1DrDBaLDkxpJKQYXeGmpBjBnbUwFHnJgDBHZW1xhbCyF9", + attoAlphAmount: "500000000000000000000", + }, + ], + }), + })) as any, + ); +}); + +afterEach(() => vi.unstubAllGlobals()); + +describe("PUT /api/submissions/:id — reused transaction hash", () => { + it("returns 409, not 500, when the unique index rejects a reused hash", async () => { + // The on-chain checks pass -- the transaction is real, confirmed and large + // enough. What they cannot see is that it already paid someone else. + const { env } = makeEnv({ + updateError: + "UNIQUE constraint failed: bounty_submissions.transaction_hash", + }); + + const res = await review(env, { + status: "approved", + transaction_hash: "already-spent-hash", + }); + + expect(res.status).toBe(409); + const body = await res.json(); + expect(body.error).toMatch(/already been used/i); + }); + + it("names the transaction, not a generic failure, so the sponsor can act", async () => { + const { env } = makeEnv({ + updateError: + "UNIQUE constraint failed: bounty_submissions.transaction_hash", + }); + const body = await ( + await review(env, { status: "approved", transaction_hash: "dup" }) + ).json(); + + expect(body.error).toMatch(/transaction hash/i); + expect(body.error).not.toMatch(/UNIQUE constraint/); + }); + + it("still surfaces unrelated database errors instead of masking them as 409", async () => { + const { env } = makeEnv({ updateError: "database is locked" }); + await expect( + review(env, { status: "approved", transaction_hash: "h" }), + ).rejects.toThrow("database is locked"); + }); + + it("accepts a first-time hash", async () => { + const { env, updates } = makeEnv({}); + const res = await review(env, { + status: "approved", + transaction_hash: "fresh-hash", + reward_amount: "500", + }); + + expect(res.status).toBe(200); + expect(updates).toHaveLength(1); + expect(updates[0]).toContain("fresh-hash"); + }); + + it("does not touch the hash when rejecting a submission", async () => { + const { env, updates } = makeEnv({}); + const res = await review(env, { status: "rejected" }); + + expect(res.status).toBe(200); + expect(updates[0]?.[2]).toBeNull(); // transaction_hash bind + }); +}); + +describe("PUT /api/submissions/:id — authorisation still holds", () => { + it("rejects a caller who does not own the bounty's sponsor", async () => { + const { env } = makeEnv({ + sponsorUserId: "the-real-sponsor", + sessionUserId: "someone-else", + }); + const res = await review(env, { status: "approved" }); + expect(res.status).toBe(403); + }); + + it("rejects an unauthenticated caller", async () => { + const { env } = makeEnv({ sessionUserId: null }); + const res = await review(env, { status: "approved" }); + expect(res.status).toBe(401); + }); + + it("rejects a status outside the review enum", async () => { + const { env } = makeEnv({}); + const res = await review(env, { status: "whatever" }); + expect(res.status).toBe(400); + }); +}); diff --git a/tests/worker/submission-result.test.ts b/tests/worker/submission-result.test.ts new file mode 100644 index 00000000..d2142a89 --- /dev/null +++ b/tests/worker/submission-result.test.ts @@ -0,0 +1,260 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from "vitest"; +import { handleSubmissionsAPI } from "@/worker/handlers"; + +/** + * Reviewing a submission, capturing the column values bound to the UPDATE. + * + * The bind order follows the statement in handlers.ts: + * status, reviewer_notes, transaction_hash, reviewed_at, updated_at, + * is_winner, winner_position, reward_amount, reward_currency, reward_usd, + * is_paid, paid_at, id + */ +const COL = { + status: 0, + reviewerNotes: 1, + txHash: 2, + isWinner: 5, + winnerPosition: 6, + rewardAmount: 7, + rewardCurrency: 8, + rewardUsd: 9, + isPaid: 10, + paidAt: 11, +} as const; + +function makeEnv() { + let updateArgs: any[] = []; + const DB = { + prepare(sql: string) { + let args: any[] = []; + const b = { + bind(...a: any[]) { + args = a; + return b; + }, + async first() { + if (sql.includes("FROM session WHERE token")) + return { userId: "sponsor-user" }; + if (sql.includes("SELECT role FROM user")) return { role: null }; + if (sql.includes("JOIN sponsors s ON b.sponsor_id = s.id")) + return { user_id: "sponsor-user" }; + if (sql.includes("LEFT JOIN user_profiles up")) + return { + user_id: "winner", + bounty_id: "b1", + wallet_address: "1DrDBaLDkxpJKQYXeGmpBjBnbUwFHnJgDBHZW1xhbCyF9", + }; + if (sql.includes("JOIN bounties b ON bs.bounty_id = b.id")) + return { user_id: "winner", bounty_id: "b1", bounty_title: "T" }; + if (sql.includes("SELECT * FROM bounty_submissions WHERE id")) + return { id: "sub1" }; + if (sql.includes("FROM notification_mutes")) return null; + return null; + }, + async all() { + return { results: [] }; + }, + async run() { + if (sql.includes("UPDATE bounty_submissions")) updateArgs = args; + return { success: true }; + }, + }; + return b; + }, + }; + return { env: { DB } as any, cols: () => updateArgs }; +} + +const review = (env: any, body: any) => + handleSubmissionsAPI( + new Request("https://x/api/submissions/sub1", { + method: "PUT", + headers: { Cookie: "better-auth.session_token=tok" }, + body: JSON.stringify(body), + }), + env, + new URL("https://x/api/submissions/sub1"), + ); + +beforeEach(() => { + vi.stubGlobal( + "fetch", + vi.fn(async () => ({ + ok: true, + json: async () => ({ + blockHash: "b", + outputs: [ + { + address: "1DrDBaLDkxpJKQYXeGmpBjBnbUwFHnJgDBHZW1xhbCyF9", + attoAlphAmount: "9000000000000000000000", + }, + ], + }), + })) as any, + ); +}); +afterEach(() => vi.unstubAllGlobals()); + +describe("approving a submission writes structured columns", () => { + it("stores placement and payout as numbers, not prose", async () => { + const { env, cols } = makeEnv(); + const res = await review(env, { + status: "approved", + winner_position: 2, + reward_amount: 305, + reward_currency: "ALPH", + reward_usd: 18, + transaction_hash: "tx1", + reviewer_notes: "Nice work", + }); + + expect(res.status).toBe(200); + const c = cols(); + expect(c[COL.isWinner]).toBe(1); + expect(c[COL.winnerPosition]).toBe(2); + expect(c[COL.rewardAmount]).toBe(305); + expect(c[COL.rewardCurrency]).toBe("ALPH"); + expect(c[COL.rewardUsd]).toBe(18); + }); + + it("keeps the reviewer's note free of reward text", async () => { + // The note used to have "Tier 2 placement. Reward: 305 ALPH (...)" + // appended, which the earnings query then parsed back out. + const { env, cols } = makeEnv(); + await review(env, { + status: "approved", + winner_position: 1, + reward_amount: 100, + reward_usd: 5, + transaction_hash: "tx2", + reviewer_notes: "Nice work", + }); + + expect(cols()[COL.reviewerNotes]).toBe("Nice work"); + expect(cols()[COL.reviewerNotes]).not.toMatch(/Reward:|placement/); + }); + + it("marks paid only when a transaction hash is supplied", async () => { + const { env, cols } = makeEnv(); + await review(env, { + status: "approved", + winner_position: 1, + reward_amount: 100, + transaction_hash: "tx3", + }); + expect(cols()[COL.isPaid]).toBe(1); + expect(cols()[COL.paidAt]).toBeTypeOf("number"); + + const b = makeEnv(); + await review(b.env, { + status: "approved", + winner_position: 1, + reward_amount: 100, + }); + expect(b.cols()[COL.isPaid]).toBe(0); + expect(b.cols()[COL.paidAt]).toBeNull(); + }); + + it("defaults the settlement currency to ALPH", async () => { + const { env, cols } = makeEnv(); + await review(env, { status: "approved", winner_position: 1 }); + expect(cols()[COL.rewardCurrency]).toBe("ALPH"); + }); +}); + +describe("non-approved outcomes do not create a winner", () => { + it("clears the winner columns on reject", async () => { + const { env, cols } = makeEnv(); + await review(env, { status: "rejected", reviewer_notes: "Off-topic" }); + + const c = cols(); + expect(c[COL.isWinner]).toBe(0); + expect(c[COL.winnerPosition]).toBeNull(); + expect(c[COL.rewardAmount]).toBeNull(); + expect(c[COL.rewardUsd]).toBeNull(); + expect(c[COL.isPaid]).toBe(0); + }); + + it("clears them on revision_requested too", async () => { + const { env, cols } = makeEnv(); + await review(env, { status: "revision_requested" }); + expect(cols()[COL.isWinner]).toBe(0); + expect(cols()[COL.winnerPosition]).toBeNull(); + }); + + it("does not record a transaction hash on a rejection", async () => { + // A stale hash left in the form would otherwise be consumed against the + // unique index from migration 028, and the actual winner could never be + // approved with that transaction -- they would get "already been used". + const { env, cols } = makeEnv(); + await review(env, { + status: "rejected", + transaction_hash: "hash-meant-for-the-winner", + }); + expect(cols()[COL.txHash]).toBeNull(); + }); + + it("ignores reward fields sent alongside a rejection", async () => { + // A stale form must not leave payout data on a rejected submission. + const { env, cols } = makeEnv(); + await review(env, { + status: "rejected", + winner_position: 1, + reward_amount: 500, + reward_usd: 20, + }); + expect(cols()[COL.rewardAmount]).toBeNull(); + expect(cols()[COL.rewardUsd]).toBeNull(); + }); +}); + +describe("validation of the structured fields", () => { + it("rejects a non-positive or fractional winner_position", async () => { + for (const pos of [0, -1, 1.5]) { + const { env } = makeEnv(); + const res = await review(env, { + status: "approved", + winner_position: pos, + }); + expect(res.status).toBe(400); + expect((await res.json()).error).toMatch(/winner_position/); + } + }); + + it("rejects negative or non-numeric amounts instead of storing them", async () => { + for (const body of [ + { reward_amount: -1 }, + { reward_amount: "abc" }, + { reward_usd: -0.5 }, + { reward_usd: "lots" }, + ]) { + const { env } = makeEnv(); + const res = await review(env, { + status: "approved", + winner_position: 1, + ...body, + }); + expect(res.status).toBe(400); + } + }); + + it("accepts an approval with no payout recorded yet", async () => { + // Winner chosen now, amount settled later. + const { env, cols } = makeEnv(); + const res = await review(env, { status: "approved", winner_position: 1 }); + expect(res.status).toBe(200); + expect(cols()[COL.isWinner]).toBe(1); + expect(cols()[COL.rewardAmount]).toBeNull(); + }); + + it("accepts a zero reward", async () => { + const { env, cols } = makeEnv(); + const res = await review(env, { + status: "approved", + winner_position: 1, + reward_amount: 0, + }); + expect(res.status).toBe(200); + expect(cols()[COL.rewardAmount]).toBe(0); + }); +}); diff --git a/vitest.config.ts b/vitest.config.ts index 5d85bcc9..d2988c7a 100644 --- a/vitest.config.ts +++ b/vitest.config.ts @@ -4,7 +4,7 @@ import { defineConfig } from "vitest/config"; export default defineConfig({ test: { environment: "node", - include: ["src/**/*.test.ts"], + include: ["tests/**/*.test.ts"], exclude: ["node_modules", "d1-manager-main"], }, resolve: {