+
Reward Denomination *
+
+ {(
+ [
+ ["alph", "Fixed in ALPH"],
+ ["usd", "Fixed in USD"],
+ ] as const
+ ).map(([value, label]) => (
+
+ onDenominationChange(value)}
+ className="w-4 h-4 text-orange focus:ring-orange"
+ />
+
+ {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."}
+
+
+
+
+
+ Reward Amount ({denomination === "alph" ? "ALPH" : "USD"}) *
+
+
+ {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" ? (
- <>
-
-
- Reward Amount (ALPH) *
-
-
-
- ALPH
-
+
+ setFormData((prev) => ({ ...prev, denomination: d }))
+ }
+ onAmountChange={(v) =>
+ setFormData((prev) => ({ ...prev, reward_amount: v }))
+ }
+ />
+
+ {/* Reward Structure */}
+
+
+ Reward Structure *
+
+
+ {(
+ [
+ ["fixed", "Single reward"],
+ ["tiered", "Tiered rewards"],
+ ] as const
+ ).map(([value, label]) => (
+
+ setFormData((prev) => ({
+ ...prev,
+ reward_type: value,
+ }))
+ }
+ className="w-4 h-4 text-orange focus:ring-orange"
/>
-
-
-
-
- USD Reference
+
+ {label}
+
-
-
-
-
-
- Approximate USD equivalent for reference (used for stats
- tracking)
-
-
- >
- ) : (
-
-
- Reward Amount (USD) *
+ ))}
+
+
+
+ {/* Tiered Reward Configuration */}
+ {formData.reward_type === "tiered" && (
+
+
+ Number of Winners *
-
-
-
+
+ {[3, 5, 10].map((count) => (
+
+ setFormData((prev) => ({
+ ...prev,
+ tier_count: count,
+ }))
+ }
+ className={`p-4 rounded-lg border-2 transition-all ${
+ formData.tier_count === count
+ ? "border-orange bg-orange/5"
+ : "border-border-grey dark:border-dark-charcoal hover:border-orange/50"
+ }`}
+ >
+
+
+ {count}
+
+
+ Winners
+
+
+
+ ))}
+
+ {/* 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 Type *
+ Reward Structure *
-
-
-
- setFormData((prev) => ({
- ...prev,
- reward_type: "fixed",
- reward_currency: "USD",
- }))
- }
- className="w-4 h-4 text-orange focus:ring-orange"
- />
-
- Fixed (USD)
-
-
-
-
- setFormData((prev) => ({
- ...prev,
- reward_type: "fixed",
- reward_currency: "ALPH",
- reward_usd_value: "",
- }))
- }
- className="w-4 h-4 text-orange focus:ring-orange"
- />
-
- Fixed Token (ALPH)
-
-
-
-
- setFormData((prev) => ({
- ...prev,
- reward_type: "tiered",
- reward_currency: "USD",
- }))
- }
- className="w-4 h-4 text-orange focus:ring-orange"
- />
-
- Tiered (USD)
-
-
-
-
-
- {/* Reward Amount inputs — vary by currency mode */}
- {formData.reward_currency === "ALPH" ? (
- <>
-
-
- Reward Amount (ALPH) *
-
-
-
- ALPH
-
+
+ {(
+ [
+ ["fixed", "Single reward"],
+ ["tiered", "Tiered rewards"],
+ ] as const
+ ).map(([value, label]) => (
+
+ setFormData((prev) => ({
+ ...prev,
+ reward_type: value,
+ }))
+ }
+ className="w-4 h-4 text-orange focus:ring-orange"
/>
-
-
-
-
- USD Reference
+
+ {label}
+
-
-
-
-
-
- Approximate USD equivalent for reference (used for stats
- tracking)
-
-
- >
- ) : (
-
-
- Reward Amount (USD) *
-
-
-
-
-
+ ))}
- )}
+
+ {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
+
+ {loading ? "Saving..." : "Save as Draft"}
+
- {loading ? "Creating..." : "Create Bounty"}
+ {loading ? "Publishing..." : "Publish Bounty"}
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" ? (
+
+ handlePublish(bounty, e)
+ }
+ >
+
+
+ ) : null}
{bounty.status !== "completed" && (
)}
{(() => {
- const ds = getBountyDisplayStatus(bounty);
- return ds === "closed" ||
- ds === "completed" ? (
+ const ds = bountyStatusOf(bounty);
+ return ds === "In Review" ||
+ ds === "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" ? (
+
handlePublish(bounty, e)}
+ >
+
+ {publishingId === bounty.id
+ ? "Publishing..."
+ : "Publish"}
+
+ ) : null}
{bounty.status !== "completed" && (
)}
{(() => {
- const ds = getBountyDisplayStatus(bounty);
- return ds === "closed" ||
- ds === "completed" ? (
+ const ds = bountyStatusOf(bounty);
+ return ds === "In Review" ||
+ ds === "Completed" ? (
@@ -1366,7 +1453,7 @@ export default function SponsorDashboard() {
{submission.user_username || "Anonymous"} •{" "}
- {formatDate(submission.submitted_at)}
+ {formatDate(submission.created_at)}
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