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) ? (
diff --git a/app/(marketing)/for-organizers/page.tsx b/app/(marketing)/for-organizers/page.tsx index 4ff9324..ee7e420 100644 --- a/app/(marketing)/for-organizers/page.tsx +++ b/app/(marketing)/for-organizers/page.tsx @@ -274,8 +274,9 @@ export default function ForOrganizersPage() {
  • Confirm milestones within 30 days. When a winning team delivers, confirm the milestone and the rest of their prize is released. We send reminders if a confirmation - is overdue, and if it stays unconfirmed, the winner can open a dispute. The HackVillage - team reviews it, and the money stays in escrow until it's settled. + is overdue, and if it stays unconfirmed 14 days after you announced the winners, the + winner can open a dispute. The HackVillage team reviews it, and the money stays in escrow + until it's settled.
  • 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

    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); + }); +});