Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions app/(app)/dashboard/notifications/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,11 @@ import { prisma } from "@/lib/db";
export const metadata: Metadata = { title: "Notifications" };

const TYPE_LABELS: Record<string, { title: string; detail: (payload: Record<string, unknown>) => 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) =>
Expand Down
5 changes: 3 additions & 2 deletions app/(app)/dashboard/winnings/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -165,10 +166,10 @@ export default async function WinningsPage() {
))}
</ul>

{/* 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) ? (
<div className="mt-3">
<DisputeForm winnerId={win.id} />
</div>
Expand Down
5 changes: 3 additions & 2 deletions app/(marketing)/for-organizers/page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -274,8 +274,9 @@ export default function ForOrganizersPage() {
<li>
<strong>Confirm milestones within 30 days.</strong> 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&apos;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&apos;s settled.
</li>
<li>
<strong>Endorse great builders.</strong> Your judges can endorse the winners they judged,
Expand Down
56 changes: 0 additions & 56 deletions app/robots.ts

This file was deleted.

10 changes: 10 additions & 0 deletions app/robots.txt/route.ts
Original file line number Diff line number Diff line change
@@ -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",
},
});
}
7 changes: 2 additions & 5 deletions lib/blog/posts/the-48-hour-media-standard.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -28,11 +28,8 @@ function Body() {
<h2>How The Trust Score Works</h2>
<ul>
<li>Every organization starts at 100.</li>
<li>
Each hackathon whose payouts reach winners within an hour of the announcement adds a
point.
</li>
<li>Missed media deadlines take points away.</li>
<li>Missed media deadlines take points away, unless an appeal is granted.</li>
<li>The HackVillage team can adjust a score, always with a recorded reason.</li>
<li>The score never drops below 0 and tops out at 150.</li>
</ul>
<p>
Expand Down
115 changes: 115 additions & 0 deletions lib/events/announce.ts
Original file line number Diff line number Diff line change
@@ -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<number> {
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;
}
7 changes: 7 additions & 0 deletions lib/jobs/handlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,13 @@ export async function registerJobs(): Promise<void> {
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));
Expand Down
44 changes: 44 additions & 0 deletions lib/notifications/templates/events.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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`<p style="margin:0;"><strong>${details.eventTitle}</strong> 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.</p>`,
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,
]),
};
}
1 change: 1 addition & 0 deletions lib/queue.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;

/**
Expand Down
69 changes: 69 additions & 0 deletions lib/seo/robots.ts
Original file line number Diff line number Diff line change
@@ -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"
);
}
5 changes: 5 additions & 0 deletions services/escrow/deposits.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 };
Expand Down
Loading
Loading