From 42e4228405ef79c927e1ee478a86f6efc2635820 Mon Sep 17 00:00:00 2001
From: Eugene Mutembei <103780583+CodeWithEugene@users.noreply.github.com>
Date: Sat, 26 Sep 2026 12:23:37 +0300
Subject: [PATCH 1/2] fix(payout): enforce the 14-day dispute wait on the
server and drop the unused trust bonus
---
app/(app)/dashboard/winnings/page.tsx | 5 +++--
app/(marketing)/for-organizers/page.tsx | 5 +++--
lib/blog/posts/the-48-hour-media-standard.tsx | 7 ++-----
services/legacy/dispute-window.ts | 15 +++++++++++++++
services/legacy/service.ts | 15 ++++++++++++++-
services/media/trust.ts | 8 --------
tests/integration/legacy.test.ts | 16 ++++++++++++++++
tests/unit/dispute-window.test.ts | 19 +++++++++++++++++++
tests/unit/trust.test.ts | 11 +----------
9 files changed, 73 insertions(+), 28 deletions(-)
create mode 100644 services/legacy/dispute-window.ts
create mode 100644 tests/unit/dispute-window.test.ts
diff --git a/app/(app)/dashboard/winnings/page.tsx b/app/(app)/dashboard/winnings/page.tsx
index 8160a7f..944e2b6 100644
--- a/app/(app)/dashboard/winnings/page.tsx
+++ b/app/(app)/dashboard/winnings/page.tsx
@@ -5,6 +5,7 @@ import { HandCoins } from "lucide-react";
import { Badge } from "@/components/ui/badge";
import { Card, CardDescription, CardTitle } from "@/components/ui/card";
import { DisputeForm } from "@/components/legacy/legacy-cards";
+import { canOpenDispute } from "@/services/legacy/dispute-window";
import { EmptyState } from "@/components/ui/empty-state";
import { requireOnboardedUser } from "@/lib/auth/guards";
import { prisma } from "@/lib/db";
@@ -165,10 +166,10 @@ export default async function WinningsPage() {
))}
- {/* Dispute entry when milestone is stuck + 14 days past announcement */}
+ {/* Dispute entry when the milestone is still unpaid 14 days after the announcement */}
{win.milestoneRequired &&
!win.payouts.some((p) => p.tranche === "MILESTONE") &&
- Date.now() - win.announcedAt.getTime() > 14 * 24 * 60 * 60 * 1000 ? (
+ canOpenDispute(win.announcedAt) ? (
Endorse great builders. Your judges can endorse the winners they judged,
diff --git a/lib/blog/posts/the-48-hour-media-standard.tsx b/lib/blog/posts/the-48-hour-media-standard.tsx
index 9754d03..e14da82 100644
--- a/lib/blog/posts/the-48-hour-media-standard.tsx
+++ b/lib/blog/posts/the-48-hour-media-standard.tsx
@@ -28,11 +28,8 @@ function Body() {
How The Trust Score Works
- Every organization starts at 100.
- -
- Each hackathon whose payouts reach winners within an hour of the announcement adds a
- point.
-
- - Missed media deadlines take points away.
+ - Missed media deadlines take points away, unless an appeal is granted.
+ - The HackVillage team can adjust a score, always with a recorded reason.
- The score never drops below 0 and tops out at 150.
diff --git a/services/legacy/dispute-window.ts b/services/legacy/dispute-window.ts
new file mode 100644
index 0000000..9648228
--- /dev/null
+++ b/services/legacy/dispute-window.ts
@@ -0,0 +1,15 @@
+/**
+ * When a winner may dispute an unconfirmed milestone: 14 days after winners
+ * were announced (plan §10.5). The winnings page shows the dispute button
+ * from then on and the server refuses earlier requests, so both read this
+ * one rule.
+ */
+export const MILESTONE_DISPUTE_WINDOW_MS = 14 * 24 * 60 * 60 * 1000;
+
+export function disputeOpensAt(announcedAt: Date): Date {
+ return new Date(announcedAt.getTime() + MILESTONE_DISPUTE_WINDOW_MS);
+}
+
+export function canOpenDispute(announcedAt: Date, now: Date = new Date()): boolean {
+ return now.getTime() >= disputeOpensAt(announcedAt).getTime();
+}
diff --git a/services/legacy/service.ts b/services/legacy/service.ts
index be591ca..b0de69e 100644
--- a/services/legacy/service.ts
+++ b/services/legacy/service.ts
@@ -14,6 +14,7 @@ import {
milestoneReminderEmail,
} from "@/lib/notifications/templates/legacy";
import { appUrl } from "@/lib/url";
+import { canOpenDispute, disputeOpensAt } from "@/services/legacy/dispute-window";
/**
* Legacy Tracker (Phase 8 — plan §10.7): 3-month check-ins on every
@@ -22,7 +23,6 @@ import { appUrl } from "@/lib/url";
*/
export const LEGACY_CHECKIN_DELAY_MS = 90 * 24 * 60 * 60 * 1000; // 3 months
-export const MILESTONE_DISPUTE_WINDOW_MS = 14 * 24 * 60 * 60 * 1000; // 14 days
export class LegacyError extends Error {
constructor(
@@ -273,6 +273,19 @@ export async function openMilestoneDispute(input: {
if (winner.milestone.confirmedAt) {
throw new LegacyError("This milestone is already confirmed.", "WRONG_STATE");
}
+ // Give the organizer 14 days from the announcement to confirm first.
+ if (!canOpenDispute(winner.announcedAt)) {
+ const opens = new Intl.DateTimeFormat("en-KE", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ timeZone: "Africa/Nairobi",
+ }).format(disputeOpensAt(winner.announcedAt));
+ throw new LegacyError(
+ `You can open a dispute from ${opens}, 14 days after winners were announced.`,
+ "TOO_EARLY",
+ );
+ }
const existing = await prisma.dispute.findUnique({ where: { winnerId: input.winnerId } });
if (existing && existing.status === "OPEN") {
diff --git a/services/media/trust.ts b/services/media/trust.ts
index 05182bf..315b835 100644
--- a/services/media/trust.ts
+++ b/services/media/trust.ts
@@ -17,11 +17,3 @@ export function mediaDeadlineFor(endsAt: Date): Date {
}
export const MEDIA_PENALTY_DELTA = -10;
-
-/**
- * Payout excellence bonus (+1 per event hitting the 1h KPI) is applied by
- * the payout pipeline — derived here for display consistency.
- */
-export function payoutExcellenceDelta(eventsInWindow: number): number {
- return Math.min(eventsInWindow, 20);
-}
diff --git a/tests/integration/legacy.test.ts b/tests/integration/legacy.test.ts
index a25f977..4010497 100644
--- a/tests/integration/legacy.test.ts
+++ b/tests/integration/legacy.test.ts
@@ -259,6 +259,22 @@ describe("milestone disputes (integration)", () => {
})
).rejects.toMatchObject({ code: "FORBIDDEN" });
+ // Too early: within 14 days of the announcement the organizer still has time.
+ const realAnnouncedAt = (await prisma.winner.findUniqueOrThrow({ where: { id: world.winnerId } }))
+ .announcedAt;
+ await prisma.winner.update({
+ where: { id: world.winnerId },
+ data: { announcedAt: new Date(Date.now() - 3 * 24 * 3600 * 1000) },
+ });
+ await expect(
+ openMilestoneDispute({
+ winnerId: world.winnerId,
+ userId: world.winnerUserId,
+ claim: "Only three days after the announcement, which is too soon.",
+ })
+ ).rejects.toMatchObject({ code: "TOO_EARLY" });
+ await prisma.winner.update({ where: { id: world.winnerId }, data: { announcedAt: realAnnouncedAt } });
+
await openMilestoneDispute({
winnerId: world.winnerId,
userId: world.winnerUserId,
diff --git a/tests/unit/dispute-window.test.ts b/tests/unit/dispute-window.test.ts
new file mode 100644
index 0000000..62595c2
--- /dev/null
+++ b/tests/unit/dispute-window.test.ts
@@ -0,0 +1,19 @@
+import { describe, expect, it } from "vitest";
+
+import { canOpenDispute, disputeOpensAt } from "@/services/legacy/dispute-window";
+
+const ANNOUNCED = new Date("2026-09-01T12:00:00Z");
+const DAY = 24 * 60 * 60 * 1000;
+
+describe("milestone dispute window", () => {
+ it("opens exactly 14 days after winners are announced", () => {
+ expect(disputeOpensAt(ANNOUNCED).toISOString()).toBe("2026-09-15T12:00:00.000Z");
+ });
+
+ it("refuses disputes before then and allows them from then on", () => {
+ expect(canOpenDispute(ANNOUNCED, new Date(ANNOUNCED.getTime() + 13 * DAY))).toBe(false);
+ expect(canOpenDispute(ANNOUNCED, new Date(ANNOUNCED.getTime() + 14 * DAY - 1))).toBe(false);
+ expect(canOpenDispute(ANNOUNCED, new Date(ANNOUNCED.getTime() + 14 * DAY))).toBe(true);
+ expect(canOpenDispute(ANNOUNCED, new Date(ANNOUNCED.getTime() + 40 * DAY))).toBe(true);
+ });
+});
diff --git a/tests/unit/trust.test.ts b/tests/unit/trust.test.ts
index f03008c..1fd4f57 100644
--- a/tests/unit/trust.test.ts
+++ b/tests/unit/trust.test.ts
@@ -3,7 +3,6 @@ import { describe, expect, it } from "vitest";
import {
MAX_TRUST_SCORE,
mediaDeadlineFor,
- payoutExcellenceDelta,
trustScoreFrom,
MEDIA_PENALTY_DELTA,
} from "@/services/media/trust";
@@ -19,7 +18,7 @@ describe("trust score math", () => {
expect(trustScoreFrom(0, -10)).toBe(0);
});
- it("caps at 150 — excellence bonuses can't run away", () => {
+ it("caps at 150, however many points are added", () => {
expect(trustScoreFrom(145, 10)).toBe(MAX_TRUST_SCORE);
expect(trustScoreFrom(MAX_TRUST_SCORE, 10)).toBe(MAX_TRUST_SCORE);
});
@@ -36,11 +35,3 @@ describe("media deadline", () => {
expect(MEDIA_PENALTY_DELTA).toBe(-10);
});
});
-
-describe("payout excellence bonus", () => {
- it("is +1 per event, capped at 20", () => {
- expect(payoutExcellenceDelta(0)).toBe(0);
- expect(payoutExcellenceDelta(3)).toBe(3);
- expect(payoutExcellenceDelta(50)).toBe(20);
- });
-});
From e10fceb385bfd277d74d2ab28e9f07cdf96f0309 Mon Sep 17 00:00:00 2001
From: Eugene Mutembei <103780583+CodeWithEugene@users.noreply.github.com>
Date: Sat, 26 Sep 2026 12:36:34 +0300
Subject: [PATCH 2/2] feat(events): announce new Prize Verified hackathons to
builders and add robots content signals
---
app/(app)/dashboard/notifications/page.tsx | 5 +
app/robots.ts | 56 ---------
app/robots.txt/route.ts | 10 ++
lib/events/announce.ts | 115 ++++++++++++++++++
lib/jobs/handlers.ts | 7 ++
lib/notifications/templates/events.ts | 44 +++++++
lib/queue.ts | 1 +
lib/seo/robots.ts | 69 +++++++++++
services/escrow/deposits.ts | 5 +
tests/integration/announce.test.ts | 133 +++++++++++++++++++++
tests/unit/seo.test.ts | 18 +++
11 files changed, 407 insertions(+), 56 deletions(-)
delete mode 100644 app/robots.ts
create mode 100644 app/robots.txt/route.ts
create mode 100644 lib/events/announce.ts
create mode 100644 lib/seo/robots.ts
create mode 100644 tests/integration/announce.test.ts
diff --git a/app/(app)/dashboard/notifications/page.tsx b/app/(app)/dashboard/notifications/page.tsx
index deb5d0d..ab40b7b 100644
--- a/app/(app)/dashboard/notifications/page.tsx
+++ b/app/(app)/dashboard/notifications/page.tsx
@@ -10,6 +10,11 @@ import { prisma } from "@/lib/db";
export const metadata: Metadata = { title: "Notifications" };
const TYPE_LABELS: Record) => string }> = {
+ "hackathon.live": {
+ title: "New Prize Verified Hackathon",
+ detail: (p) =>
+ `${String(p.eventTitle ?? "A new hackathon")} is live with its full prize pool locked in escrow.`,
+ },
"media.penalty": {
title: "Trust Penalty: Media Deadline",
detail: (p) =>
diff --git a/app/robots.ts b/app/robots.ts
deleted file mode 100644
index f65e048..0000000
--- a/app/robots.ts
+++ /dev/null
@@ -1,56 +0,0 @@
-import type { MetadataRoute } from "next";
-
-import { appUrl } from "@/lib/url";
-
-/**
- * Private, tokenized, or machine-only surfaces. Search-facing pages stay
- * allowed; auth pages (signin/signup) are deliberately NOT blocked here —
- * they carry a noindex meta tag instead, which crawlers can only read if
- * they are allowed to fetch the page.
- */
-const PRIVATE_PATHS = [
- "/dashboard",
- "/organizer",
- "/judge",
- "/admin",
- "/settings",
- "/oauth",
- "/api",
- "/onboarding",
- "/invites",
- "/hackathons/*/workspace",
-];
-
-/**
- * AI answer engines are explicitly welcomed: being cited by ChatGPT,
- * Perplexity, Claude and Gemini is a goal for this platform, not a risk.
- * (Cloudflare Content Signals should be set to match — see the SEO runbook.)
- */
-const AI_CRAWLERS = [
- "GPTBot",
- "OAI-SearchBot",
- "ChatGPT-User",
- "ClaudeBot",
- "Claude-Web",
- "Claude-SearchBot",
- "anthropic-ai",
- "PerplexityBot",
- "Perplexity-User",
- "Google-Extended",
- "Applebot-Extended",
- "Amazonbot",
- "CCBot",
- "meta-externalagent",
- "cohere-ai",
- "MistralAI-User",
-];
-
-export default function robots(): MetadataRoute.Robots {
- return {
- rules: [
- { userAgent: "*", disallow: PRIVATE_PATHS },
- { userAgent: AI_CRAWLERS, disallow: PRIVATE_PATHS },
- ],
- sitemap: appUrl("/sitemap.xml"),
- };
-}
diff --git a/app/robots.txt/route.ts b/app/robots.txt/route.ts
new file mode 100644
index 0000000..1257caa
--- /dev/null
+++ b/app/robots.txt/route.ts
@@ -0,0 +1,10 @@
+import { robotsTxt } from "@/lib/seo/robots";
+
+export function GET(): Response {
+ return new Response(robotsTxt(), {
+ headers: {
+ "content-type": "text/plain; charset=utf-8",
+ "cache-control": "public, max-age=3600, must-revalidate",
+ },
+ });
+}
diff --git a/lib/events/announce.ts b/lib/events/announce.ts
new file mode 100644
index 0000000..dcde6d9
--- /dev/null
+++ b/lib/events/announce.ts
@@ -0,0 +1,115 @@
+import type { Prisma } from "@prisma/client";
+
+import { prisma } from "@/lib/db";
+import { formatEventDates } from "@/lib/events/format";
+import { PUBLIC_HACKATHON_WHERE } from "@/lib/events/visibility";
+import { sendNotification } from "@/lib/notifications/send";
+import { newPrizeVerifiedHackathonEmail } from "@/lib/notifications/templates/events";
+import { isRealAccountEmail } from "@/lib/seo/indexable";
+import { appUrl } from "@/lib/url";
+import { formatKes } from "@/lib/utils";
+
+/** In-app notification type; also how a builder is marked as already told. */
+export const HACKATHON_LIVE_NOTIFICATION = "hackathon.live";
+
+const SEND_BATCH_SIZE = 10;
+
+const dayFormat = new Intl.DateTimeFormat("en-KE", {
+ day: "numeric",
+ month: "long",
+ year: "numeric",
+ timeZone: "Africa/Nairobi",
+});
+
+/**
+ * Who hears about a newly live hackathon: verified builders (developer role)
+ * with hackathon updates on, excluding the hosting organization's own team and
+ * anyone already told about this hackathon (so a retried job never emails
+ * twice).
+ */
+export function announcementRecipientsWhere(event: {
+ id: string;
+ orgId: string;
+}): Prisma.UserWhereInput {
+ return {
+ deletedAt: null,
+ emailVerified: { not: null },
+ roleGrants: { some: { role: "DEVELOPER" } },
+ OR: [
+ { notificationPreference: { is: null } },
+ { notificationPreference: { is: { eventUpdates: true } } },
+ ],
+ orgMemberships: { none: { orgId: event.orgId, status: "ACTIVE" } },
+ notifications: {
+ none: { type: HACKATHON_LIVE_NOTIFICATION, payload: { path: ["eventId"], equals: event.id } },
+ },
+ };
+}
+
+/**
+ * Emails builders that a hackathon just went live with its prize locked, and
+ * records an in-app notification for each. Demo hackathons and anything not
+ * public are never announced. Returns how many builders were told.
+ */
+export async function announceHackathonToBuilders(eventId: string): Promise {
+ const event = await prisma.event.findFirst({
+ where: { id: eventId, ...PUBLIC_HACKATHON_WHERE, isDemo: false },
+ select: {
+ id: true,
+ orgId: true,
+ slug: true,
+ title: true,
+ startsAt: true,
+ endsAt: true,
+ registrationDeadline: true,
+ venueType: true,
+ location: true,
+ prizes: { select: { amountKes: true } },
+ },
+ });
+ if (!event) return 0;
+
+ const recipients = (
+ await prisma.user.findMany({
+ where: announcementRecipientsWhere(event),
+ select: { id: true, email: true },
+ })
+ ).filter((user) => isRealAccountEmail(user.email));
+
+ const template = newPrizeVerifiedHackathonEmail({
+ eventTitle: event.title,
+ eventUrl: appUrl(`/hackathons/${event.slug}`),
+ prizePool: formatKes(event.prizes.reduce((sum, prize) => sum + prize.amountKes, 0)),
+ dates: formatEventDates(event.startsAt, event.endsAt),
+ venue: event.venueType === "ONLINE" ? "Online" : (event.location ?? "Kenya"),
+ registrationCloses: dayFormat.format(event.registrationDeadline),
+ });
+
+ let told = 0;
+ for (let start = 0; start < recipients.length; start += SEND_BATCH_SIZE) {
+ const batch = recipients.slice(start, start + SEND_BATCH_SIZE);
+ const results = await Promise.allSettled(
+ batch.map(async (user) => {
+ // Record first: if the email fails, a retry won't send it twice.
+ await prisma.notification.create({
+ data: {
+ userId: user.id,
+ type: HACKATHON_LIVE_NOTIFICATION,
+ payload: { eventId: event.id, eventTitle: event.title, slug: event.slug },
+ },
+ });
+ await sendNotification({
+ userId: user.id,
+ to: user.email,
+ category: "eventUpdates",
+ template,
+ });
+ }),
+ );
+ for (const result of results) {
+ if (result.status === "fulfilled") told += 1;
+ else console.error(`[announce] notifying a builder about ${event.id} failed`, result.reason);
+ }
+ }
+ return told;
+}
diff --git a/lib/jobs/handlers.ts b/lib/jobs/handlers.ts
index 82a8c4e..1d803fb 100644
--- a/lib/jobs/handlers.ts
+++ b/lib/jobs/handlers.ts
@@ -52,6 +52,13 @@ export async function registerJobs(): Promise {
attestations.attestVaultLocked(String(data.eventId))
);
+ // New Prize Verified hackathon alert to builders (fan-out off the money path).
+ await registerJob(boss, "hackathon.announce", async (data) => {
+ const { announceHackathonToBuilders } = await import("@/lib/events/announce");
+ const told = await announceHackathonToBuilders(String(data.eventId));
+ console.info(`[jobs] hackathon.announce told ${told} builders about ${String(data.eventId)}`);
+ });
+
// Payout families (Phase 5): execution, attestations, and the sweep.
await registerJob(boss, "payout.execute", async (data) => {
await payouts.executePayout(String(data.payoutId));
diff --git a/lib/notifications/templates/events.ts b/lib/notifications/templates/events.ts
index 44c7d21..36f594f 100644
--- a/lib/notifications/templates/events.ts
+++ b/lib/notifications/templates/events.ts
@@ -94,3 +94,47 @@ export function registrationCancelledEmail(eventTitle: string): EmailTemplate {
text: renderText([`Your registration for ${eventTitle} was cancelled.`]),
};
}
+
+export interface NewHackathonDetails {
+ eventTitle: string;
+ eventUrl: string;
+ /** "KES 300,000" */
+ prizePool: string;
+ /** "10 to 12 October 2026" */
+ dates: string;
+ /** "Nairobi" or "Online" */
+ venue: string;
+ /** "8 October 2026" */
+ registrationCloses: string;
+}
+
+/**
+ * Sent to builders who have hackathon updates on, the moment a hackathon's
+ * full prize pool is locked and it goes live. Only real, public hackathons
+ * are announced (see lib/events/announce.ts).
+ */
+export function newPrizeVerifiedHackathonEmail(details: NewHackathonDetails): EmailTemplate {
+ return {
+ subject: `New Prize Verified Hackathon: ${details.eventTitle}`,
+ html: renderEmail({
+ preheader: `${details.prizePool} is already locked in escrow. Registration closes ${details.registrationCloses}.`,
+ section: {
+ heading: "A New Hackathon Is Live",
+ bodyHtml: html`${details.eventTitle} just went live on HackVillage with its full prize pool locked in escrow, so the money is real before you write a line of code.
`,
+ details: [
+ { label: "Prize pool", value: details.prizePool },
+ { label: "When", value: details.dates },
+ { label: "Where", value: details.venue },
+ { label: "Registration closes", value: details.registrationCloses },
+ ],
+ ctaUrl: details.eventUrl,
+ ctaLabel: "View The Hackathon",
+ },
+ }),
+ text: renderText([
+ `${details.eventTitle} just went live on HackVillage with its full prize pool locked in escrow.`,
+ `Prize pool: ${details.prizePool}. When: ${details.dates}. Where: ${details.venue}. Registration closes ${details.registrationCloses}.`,
+ details.eventUrl,
+ ]),
+ };
+}
diff --git a/lib/queue.ts b/lib/queue.ts
index 37f2024..dd070ad 100644
--- a/lib/queue.ts
+++ b/lib/queue.ts
@@ -48,6 +48,7 @@ export const JOB_NAMES = {
attestVaultCreated: "escrow.attest-vault-created",
attestVaultLocked: "escrow.attest-vault-locked",
escrowCron: "escrow.cron",
+ announceHackathon: "hackathon.announce",
} as const;
/**
diff --git a/lib/seo/robots.ts b/lib/seo/robots.ts
new file mode 100644
index 0000000..cea635d
--- /dev/null
+++ b/lib/seo/robots.ts
@@ -0,0 +1,69 @@
+import { appUrl } from "@/lib/url";
+
+/**
+ * robots.txt (served by app/robots.txt/route.ts), written by hand rather than with Next's MetadataRoute.Robots so
+ * it can carry a Content-Signal line (Cloudflare's content signals policy),
+ * which the built-in generator doesn't support.
+ *
+ * Private, tokenized, or machine-only surfaces are disallowed. Search-facing
+ * pages stay allowed; auth pages (signin/signup) are deliberately NOT blocked
+ * here: they carry a noindex meta tag instead, which crawlers can only read if
+ * they are allowed to fetch the page.
+ */
+const PRIVATE_PATHS = [
+ "/dashboard",
+ "/organizer",
+ "/judge",
+ "/admin",
+ "/settings",
+ "/oauth",
+ "/api",
+ "/onboarding",
+ "/invites",
+ "/hackathons/*/workspace",
+];
+
+/**
+ * AI answer engines are explicitly welcomed: being cited by ChatGPT,
+ * Perplexity, Claude and Gemini is a goal for this platform, not a risk.
+ * Content-Signal below states that openly.
+ */
+const AI_CRAWLERS = [
+ "GPTBot",
+ "OAI-SearchBot",
+ "ChatGPT-User",
+ "ClaudeBot",
+ "Claude-Web",
+ "Claude-SearchBot",
+ "anthropic-ai",
+ "PerplexityBot",
+ "Perplexity-User",
+ "Google-Extended",
+ "Applebot-Extended",
+ "Amazonbot",
+ "CCBot",
+ "meta-externalagent",
+ "cohere-ai",
+ "MistralAI-User",
+];
+
+/**
+ * Content signals (https://contentsignals.org): HackVillage allows search
+ * indexing, use in AI answers (ai-input), and AI training (ai-train).
+ */
+const CONTENT_SIGNAL = "search=yes, ai-input=yes, ai-train=yes";
+
+function group(userAgents: readonly string[]): string {
+ return [
+ ...userAgents.map((agent) => `User-Agent: ${agent}`),
+ `Content-Signal: ${CONTENT_SIGNAL}`,
+ "Allow: /",
+ ...PRIVATE_PATHS.map((path) => `Disallow: ${path}`),
+ ].join("\n");
+}
+
+export function robotsTxt(): string {
+ return (
+ [group(["*"]), group(AI_CRAWLERS), `Sitemap: ${appUrl("/sitemap.xml")}`].join("\n\n") + "\n"
+ );
+}
diff --git a/services/escrow/deposits.ts b/services/escrow/deposits.ts
index 85c7cb1..30514db 100644
--- a/services/escrow/deposits.ts
+++ b/services/escrow/deposits.ts
@@ -189,6 +189,11 @@ export async function recordChargeSuccess(input: {
await notifyEventLive(event.id).catch((error: unknown) => {
console.error("[escrow] event live notification failed", error);
});
+ // Tell builders a new Prize Verified hackathon is live. A queued job, so
+ // emailing every builder never slows this webhook; enqueue never throws.
+ await enqueue("hackathon.announce", { eventId: event.id }, {
+ singletonKey: `announce:${event.id}`,
+ });
}
return { outcome: "recorded", vaultLocked: covered };
diff --git a/tests/integration/announce.test.ts b/tests/integration/announce.test.ts
new file mode 100644
index 0000000..2601919
--- /dev/null
+++ b/tests/integration/announce.test.ts
@@ -0,0 +1,133 @@
+import { afterAll, beforeAll, describe, expect, it } from "vitest";
+
+import { prisma } from "@/lib/db";
+import { announceHackathonToBuilders, HACKATHON_LIVE_NOTIFICATION } from "@/lib/events/announce";
+
+/**
+ * The "new Prize Verified hackathon" alert: verified builders with hackathon
+ * updates on hear about it once; opted-out builders, the host's own team,
+ * demo accounts, unverified emails and demo hackathons never do.
+ */
+
+const KEY = `announce-int-${Date.now().toString(36)}`;
+const DAY = 24 * 60 * 60 * 1000;
+const userIds: string[] = [];
+let orgId = "";
+let eventId = "";
+let demoEventId = "";
+const people: Record = {};
+
+async function builder(
+ label: string,
+ options: { email?: string; verified?: boolean; developer?: boolean } = {},
+) {
+ const user = await prisma.user.create({
+ data: {
+ email: options.email ?? `${KEY}-${label}@builders.example.net`,
+ name: `Builder ${label}`,
+ handle: `${KEY}-${label}`.slice(-28),
+ emailVerified: options.verified === false ? null : new Date(),
+ primaryRole: "DEVELOPER",
+ onboardingCompletedAt: new Date(),
+ },
+ });
+ if (options.developer !== false)
+ await prisma.roleGrant.create({ data: { userId: user.id, role: "DEVELOPER" } });
+ userIds.push(user.id);
+ people[label] = user.id;
+ return user.id;
+}
+
+async function hackathon(slug: string, isDemo: boolean) {
+ const now = Date.now();
+ const event = await prisma.event.create({
+ data: {
+ orgId,
+ slug,
+ title: `Announce Test ${slug}`,
+ venueType: "PHYSICAL",
+ location: "Nairobi",
+ startsAt: new Date(now + 10 * DAY),
+ endsAt: new Date(now + 12 * DAY),
+ registrationDeadline: new Date(now + 8 * DAY),
+ status: "LIVE",
+ publishedAt: new Date(),
+ prizeVerifiedAt: new Date(),
+ isDemo,
+ },
+ });
+ await prisma.prizeBreakdown.create({
+ data: { eventId: event.id, place: 1, label: "1st", amountKes: 50_000 },
+ });
+ return event.id;
+}
+
+beforeAll(async () => {
+ const owner = await builder("owner", { developer: false });
+ const org = await prisma.organization.create({
+ data: { name: "Announce Org", slug: KEY, ownerId: owner },
+ });
+ orgId = org.id;
+ await prisma.orgMember.create({
+ data: { orgId, userId: owner, role: "OWNER", status: "ACTIVE" },
+ });
+
+ await builder("eager");
+ const optedOut = await builder("opted-out");
+ await prisma.notificationPreference.create({ data: { userId: optedOut, eventUpdates: false } });
+ const teammate = await builder("teammate");
+ await prisma.orgMember.create({
+ data: { orgId, userId: teammate, role: "MEMBER", status: "ACTIVE" },
+ });
+ await builder("demo", { email: `${KEY}-demo@hackvillage.dev` });
+ await builder("unverified", { verified: false });
+
+ eventId = await hackathon(`${KEY}-real`, false);
+ demoEventId = await hackathon(`${KEY}-demo`, true);
+});
+
+afterAll(async () => {
+ // The announcement reaches every eligible builder in the database, not just
+ // this file's fixtures, so clean up by hackathon as well as by user.
+ for (const id of [eventId, demoEventId]) {
+ await prisma.notification.deleteMany({
+ where: { type: HACKATHON_LIVE_NOTIFICATION, payload: { path: ["eventId"], equals: id } },
+ });
+ }
+ await prisma.notification.deleteMany({ where: { userId: { in: userIds } } });
+ await prisma.prizeBreakdown.deleteMany({ where: { eventId: { in: [eventId, demoEventId] } } });
+ await prisma.event.deleteMany({ where: { id: { in: [eventId, demoEventId] } } });
+ await prisma.orgMember.deleteMany({ where: { orgId } });
+ await prisma.organization.deleteMany({ where: { id: orgId } });
+ await prisma.notificationPreference.deleteMany({ where: { userId: { in: userIds } } });
+ await prisma.roleGrant.deleteMany({ where: { userId: { in: userIds } } });
+ await prisma.user.deleteMany({ where: { id: { in: userIds } } });
+});
+
+describe("announceHackathonToBuilders", () => {
+ it("tells only verified builders with hackathon updates on, outside the host's team", async () => {
+ const told = await announceHackathonToBuilders(eventId);
+ const notified = await prisma.notification.findMany({
+ where: { type: HACKATHON_LIVE_NOTIFICATION, userId: { in: userIds } },
+ select: { userId: true },
+ });
+ const notifiedIds = notified.map((n) => n.userId);
+ expect(notifiedIds).toContain(people.eager);
+ for (const skipped of ["opted-out", "teammate", "demo", "unverified", "owner"]) {
+ expect(notifiedIds).not.toContain(people[skipped]);
+ }
+ expect(told).toBeGreaterThanOrEqual(1);
+ });
+
+ it("never tells the same builder twice, even if the job runs again", async () => {
+ await announceHackathonToBuilders(eventId);
+ const count = await prisma.notification.count({
+ where: { type: HACKATHON_LIVE_NOTIFICATION, userId: people.eager },
+ });
+ expect(count).toBe(1);
+ });
+
+ it("never announces a demo hackathon", async () => {
+ expect(await announceHackathonToBuilders(demoEventId)).toBe(0);
+ });
+});
diff --git a/tests/unit/seo.test.ts b/tests/unit/seo.test.ts
index d523ee6..74ad546 100644
--- a/tests/unit/seo.test.ts
+++ b/tests/unit/seo.test.ts
@@ -110,3 +110,21 @@ describe("site copy", () => {
expect(JSON.stringify(organizationSchema())).not.toMatch(/[—–]/);
});
});
+
+describe("robots.txt", () => {
+ it("states the content signals and keeps private areas out, for every group", async () => {
+ const { robotsTxt } = await import("@/lib/seo/robots");
+ const text = robotsTxt();
+ const groups = text.split("\n\n").filter((block) => block.startsWith("User-Agent"));
+ expect(groups.length).toBe(2);
+ for (const block of groups) {
+ expect(block).toContain("Content-Signal: search=yes, ai-input=yes, ai-train=yes");
+ expect(block).toContain("Disallow: /dashboard");
+ expect(block).toContain("Allow: /");
+ }
+ expect(text).toMatch(/^User-Agent: \*$/m);
+ expect(text).toMatch(/^User-Agent: GPTBot$/m);
+ expect(text).toMatch(/^Sitemap: .+\/sitemap\.xml$/m);
+ expect(text).not.toMatch(/^Host:/m);
+ });
+});