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
9 changes: 7 additions & 2 deletions app/api/account/route.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { NextRequest, NextResponse } from "next/server";
import { getAccountById, updateAccountProfile, updateAccountConfig, setAccountDomain, listParkedDomains } from "@/lib/db";
import { getAccountById, updateAccountProfile, updateAccountConfig, setAccountDomain, listParkedDomains, DomainTakenError } from "@/lib/db";
import { resolveAccountId } from "@/lib/api";
import {
normalizeHandle, normalizeUrl, coerceRgba, parseHashtags, safeDomain, cleanCustomCode, cleanCustomCss,
Expand Down Expand Up @@ -158,7 +158,12 @@ export async function POST(req: NextRequest) {
if (typeof body?.domain === "string" && body.domain.trim()) {
const dn = safeDomain(body.domain);
if (!dn) return NextResponse.json({ error: "Enter a valid domain name." }, { status: 400 });
await setAccountDomain(id, dn);
try {
await setAccountDomain(id, dn);
} catch (err) {
if (err instanceof DomainTakenError) return NextResponse.json({ error: err.message }, { status: 409 });
throw err;
}
}

// Payout wallet is edited on its own; only touch it when provided.
Expand Down
21 changes: 13 additions & 8 deletions app/api/affiliate/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { readSession, authConfigured, SESSION_COOKIE } from "@/lib/session";
import { getAffiliate, enrollAffiliate, setAffiliateCommission, listReferrals, AFFILIATE_FLOOR, findOrCreateAccountByEmail } from "@/lib/db";
import { hasPaidPlan, paidUntil } from "@/lib/plan";
import { getAffiliate, enrollAffiliate, setAffiliateCommission, listReferrals, AFFILIATE_FLOOR, accountIdForCoinpay } from "@/lib/db";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Expand All @@ -11,8 +12,7 @@ async function accountId(req: NextRequest): Promise<string | null> {
const s = readSession(req.cookies.get(SESSION_COOKIE)?.value);
if (!s) return null;
if (s.sub?.startsWith("acct:")) return s.sub.slice("acct:".length);
if (s.email) return (await findOrCreateAccountByEmail(s.email)).id;
return null;
return accountIdForCoinpay(s.sub, s.email);
}

function baseUrl(): string {
Expand All @@ -23,11 +23,16 @@ async function payload(id: string) {
const aff = await getAffiliate(id);
if (!aff) return { affiliate: null, floor: AFFILIATE_FLOOR };
const referrals = await listReferrals(aff.code);
// The paid plan is on while the stored plan says so or granted days remain
// (credit codes, invite bonuses). Once it lapses, the floor applies again.
const paid = await hasPaidPlan(id, aff.plan);
const until = await paidUntil(id);
return {
affiliate: {
code: aff.code,
commission_pct: aff.commission_pct,
plan: aff.plan,
commission_pct: paid ? aff.commission_pct : Math.max(aff.commission_pct, AFFILIATE_FLOOR),
plan: paid ? "paid" : "free",
paid_until: until && until > Date.now() ? new Date(until).toISOString() : null,
shareUrl: `${baseUrl()}/signup?ref=${encodeURIComponent(aff.code)}`,
refUrl: `${baseUrl()}/?ref=${encodeURIComponent(aff.code)}`,
},
Expand All @@ -51,15 +56,15 @@ export async function POST(req: NextRequest) {
if (body?.action === "setCommission" && body?.commission_pct !== undefined) {
const aff = await getAffiliate(id);
if (!aff) return NextResponse.json({ error: "Not enrolled yet." }, { status: 400 });
if (aff.plan !== "paid") {
return NextResponse.json({ error: `Free plan is floored at ${AFFILIATE_FLOOR}%. Upgrade to $1/mo to lower it.` }, { status: 403 });
if (!(await hasPaidPlan(id, aff.plan))) {
return NextResponse.json({ error: `Free plan is floored at ${AFFILIATE_FLOOR}%. Upgrade to $1/mo, or redeem a code at /invites, to lower it.` }, { status: 403 });
}
const raw = body.commission_pct;
const pct = typeof raw === "number" ? raw : typeof raw === "string" ? Number(raw) : NaN;
if (!Number.isFinite(pct) || pct < 1 || pct > 100) {
return NextResponse.json({ error: "commission_pct must be a number between 1 and 100." }, { status: 400 });
}
await setAffiliateCommission(id, pct);
await setAffiliateCommission(id, pct, "paid");
} else {
// Default action: enroll (idempotent).
await enrollAffiliate(id);
Expand Down
5 changes: 2 additions & 3 deletions app/api/auctions/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { readSession, authConfigured, SESSION_COOKIE } from "@/lib/session";
import { safeDomain } from "@/lib/config";
import {
findOrCreateAccountByEmail,
accountIdForCoinpay,
accountOwnsDomain,
getAuction,
upsertAuction,
Expand All @@ -22,8 +22,7 @@ async function resolveAccountId(req: NextRequest): Promise<string | null> {
const s = readSession(req.cookies.get(SESSION_COOKIE)?.value);
if (!s) return null;
if (s.sub?.startsWith("acct:")) return s.sub.slice("acct:".length);
if (s.email) return (await findOrCreateAccountByEmail(s.email)).id;
return null;
return accountIdForCoinpay(s.sub, s.email);
}

/** Dollars → integer cents; "" / null / 0 → null (clears the field). */
Expand Down
21 changes: 20 additions & 1 deletion app/api/auth/signup/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@ import { createAccount, getAccountByEmail, activateAccount } from "@/lib/db";
import { safeDomain, normalizeHandle } from "@/lib/config";
import { authConfigured, signSession, SESSION_COOKIE, sessionCookieOptions } from "@/lib/session";
import { provisionTenant } from "@/lib/provision";
import { acceptInviteAndNotify, INVITE_COOKIE } from "@/lib/credits";
import { requestOrigin } from "@/lib/request-origin";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";
Expand Down Expand Up @@ -71,6 +73,9 @@ export async function POST(req: NextRequest) {
domain, handles, payoutWallet, payoutChain, ref,
});
} catch (err: any) {
if (err?.name === "DomainTakenError") {
return NextResponse.json({ error: "That domain is already claimed. Pick another one." }, { status: 409 });
}
// Unique-email race.
if (/unique/i.test(String(err?.message))) {
return NextResponse.json({ error: "An account with that email already exists." }, { status: 409 });
Expand All @@ -94,5 +99,19 @@ export async function POST(req: NextRequest) {
// Getting started is FREE — activate + provision immediately, no charge.
const active = await activateAccount({ accountId: account.id });
if (active) await provisionTenant(active);
return setCookie(NextResponse.json({ ok: true, pending: false, activated: true, redirect: "/dashboard" }));

// An invite from the request body or the /i/<code> cookie. The bonus waits
// for the emailed confirmation; a refused invite never fails the sign-up.
const invite = (typeof body?.invite === "string" && body.invite.trim().slice(0, 64)) || req.cookies.get(INVITE_COOKIE)?.value || "";
let redirect = "/dashboard";
if (invite) {
const r = await acceptInviteAndNotify(invite, account.id, requestOrigin(req)).catch((err: any) => {
console.error("signup: invite failed:", err?.message);
return null;
});
redirect = `/invites?invite=${encodeURIComponent(r ? (r.ok ? r.status : r.reason) : "error")}`;
}
const res = setCookie(NextResponse.json({ ok: true, pending: false, activated: true, redirect }));
if (invite) res.cookies.delete(INVITE_COOKIE);
return res;
}
38 changes: 38 additions & 0 deletions app/api/credits/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from "next/server";
import { resolveAccountIdOrToken, unauthorized, bad } from "@/lib/api";
import { getAffiliate } from "@/lib/db";
import { hasPaidPlan, paidUntil } from "@/lib/plan";
import { redeemCode, REDEEM_MESSAGES, CREDIT_UNIT } from "@/lib/credits";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

async function status(id: string) {
const aff = await getAffiliate(id);
const until = await paidUntil(id);
return {
unit: CREDIT_UNIT,
plan: (await hasPaidPlan(id, aff?.plan)) ? "paid" : "free",
paid_until: until && until > Date.now() ? new Date(until).toISOString() : null,
};
}

// GET /api/credits -> the paid affiliate plan ($1/mo) and how long granted days last.
export async function GET(req: NextRequest) {
const id = await resolveAccountIdOrToken(req);
if (!id) return unauthorized();
return NextResponse.json(await status(id));
}

// POST /api/credits {code} -> redeem a credit code (days of the paid plan).
export async function POST(req: NextRequest) {
const id = await resolveAccountIdOrToken(req);
if (!id) return unauthorized();
let body: any = {};
try { body = await req.json(); } catch { /* empty */ }
const code = typeof body?.code === "string" ? body.code.trim().slice(0, 64) : "";
if (!code) return bad("Enter a code.");
const r = await redeemCode(code, id);
if ("reason" in r) return NextResponse.json({ error: REDEEM_MESSAGES[r.reason] || "That code did not work.", reason: r.reason }, { status: 400 });
return NextResponse.json({ ok: true, days: r.days, ...(await status(id)) });
}
5 changes: 2 additions & 3 deletions app/api/domain-webhooks/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import { readSession, authConfigured, SESSION_COOKIE } from "@/lib/session";
import { safeDomain } from "@/lib/config";
import {
findOrCreateAccountByEmail,
accountIdForCoinpay,
accountOwnsDomain,
listDomainWebhooks,
addDomainWebhook,
Expand All @@ -22,8 +22,7 @@ async function resolveAccountId(req: NextRequest): Promise<string | null> {
const s = readSession(req.cookies.get(SESSION_COOKIE)?.value);
if (!s) return null;
if (s.sub?.startsWith("acct:")) return s.sub.slice("acct:".length);
if (s.email) return (await findOrCreateAccountByEmail(s.email)).id;
return null;
return accountIdForCoinpay(s.sub, s.email);
}

function inboundUrl(req: NextRequest, dn: string): string {
Expand Down
27 changes: 27 additions & 0 deletions app/api/invites/accept/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { NextRequest, NextResponse } from "next/server";
import { resolveAccountIdOrToken, unauthorized, bad } from "@/lib/api";
import { acceptInviteAndNotify, ACCEPT_MESSAGES, inviteBonusDays } from "@/lib/credits";
import { requestOrigin } from "@/lib/request-origin";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

// POST /api/invites/accept {code} -> accept an invite on this account (new accounts only).
export async function POST(req: NextRequest) {
const id = await resolveAccountIdOrToken(req);
if (!id) return unauthorized();
let body: any = {};
try { body = await req.json(); } catch { /* empty */ }
const code = typeof body?.code === "string" ? body.code.trim().slice(0, 64) : "";
if (!code) return bad("Enter an invite code.");
const r = await acceptInviteAndNotify(code, id, requestOrigin(req));
if ("reason" in r) return NextResponse.json({ error: ACCEPT_MESSAGES[r.reason] || "That invite did not work.", reason: r.reason }, { status: 400 });
return NextResponse.json({
ok: true,
status: r.status,
bonus_days: r.status === "pending" ? inviteBonusDays() : 0,
message: r.status === "pending"
? "Invite accepted. Check your email and confirm your address to unlock the bonus."
: "Invite accepted. This sign-up does not qualify for the bonus.",
});
}
16 changes: 16 additions & 0 deletions app/api/invites/resend/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
import { NextRequest, NextResponse } from "next/server";
import { resolveAccountIdOrToken, unauthorized, bad } from "@/lib/api";
import { sendConfirmLink } from "@/lib/credits";
import { requestOrigin } from "@/lib/request-origin";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

// POST /api/invites/resend -> email the confirm link again (pending bonus only).
export async function POST(req: NextRequest) {
const id = await resolveAccountIdOrToken(req);
if (!id) return unauthorized();
const ok = await sendConfirmLink(id, requestOrigin(req));
if (!ok) return bad("Nothing to confirm.");
return NextResponse.json({ ok: true });
}
63 changes: 63 additions & 0 deletions app/api/invites/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { resolveAccountIdOrToken, unauthorized, bad } from "@/lib/api";
import { getAccountById } from "@/lib/db";
import { affiliates, canSendInvites, inviteBonusDays, inviteBonusFor } from "@/lib/credits";
import { requestOrigin } from "@/lib/request-origin";

export const runtime = "nodejs";
export const dynamic = "force-dynamic";

const iso = (ms: number | null) => (ms ? new Date(ms).toISOString() : null);

// GET /api/invites -> quota, the invites you made, your own bonus status.
export async function GET(req: NextRequest) {
const id = await resolveAccountIdOrToken(req);
if (!id) return unauthorized();
const acct = await getAccountById(id);
if (!acct) return unauthorized();
const aff = affiliates();
const [quota, invites, bonus, send] = await Promise.all([
aff.quota(id), aff.invites(id), inviteBonusFor(id), canSendInvites(acct),
]);
return NextResponse.json({
quota,
bonus_days: inviteBonusDays(),
can_send: send.ok,
send_error: "error" in send ? send.error : null,
my_bonus: bonus,
invites: invites.map((i) => ({
id: i.id,
note: i.note,
created_at: iso(i.createdAt),
expires_at: iso(i.expiresAt),
used: Boolean(i.usedBy),
used_at: iso(i.usedAt),
revoked: Boolean((i as any).revokedAt),
})),
});
}

// POST /api/invites {note?} -> a new invite link (shown once).
export async function POST(req: NextRequest) {
const id = await resolveAccountIdOrToken(req);
if (!id) return unauthorized();
const acct = await getAccountById(id);
if (!acct) return unauthorized();
const send = await canSendInvites(acct);
if ("error" in send) return bad(send.error, 403);
let body: any = {};
try { body = await req.json(); } catch { /* empty */ }
const note = typeof body?.note === "string" ? body.note.trim().slice(0, 80) : "";
try {
const invite = await affiliates().createInvite(id, note);
return NextResponse.json({
code: invite.code,
link: `${requestOrigin(req)}/i/${invite.code}`,
expires_at: iso(invite.expiresAt),
left: (await affiliates().quota(id)).left,
}, { status: 201 });
} catch (err: any) {
if (/no invites left/i.test(String(err?.message))) return bad("You have no invites left.", 403);
throw err;
}
}
13 changes: 8 additions & 5 deletions app/api/tenant/route.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { NextRequest, NextResponse } from "next/server";
import { readSession, authConfigured, SESSION_COOKIE } from "@/lib/session";
import {
findOrCreateAccountByEmail, ownsParkedDomain, addParkedDomain, removeParkedDomain,
accountIdForCoinpay, ownsParkedDomain, addParkedDomain, removeParkedDomain,
listParkedDomains, getTenantConfig, upsertTenant,
} from "@/lib/db";
import { safeDomain } from "@/lib/config";
Expand All @@ -19,8 +19,7 @@ async function accountId(req: NextRequest): Promise<string | null> {
const s = readSession(req.cookies.get(SESSION_COOKIE)?.value);
if (!s) return null;
if (s.sub?.startsWith("acct:")) return s.sub.slice("acct:".length);
if (s.email) return (await findOrCreateAccountByEmail(s.email)).id;
return null;
return accountIdForCoinpay(s.sub, s.email);
}

// GET /api/tenant?dn=<domain> → that domain's config (owner only).
Expand All @@ -45,7 +44,9 @@ export async function POST(req: NextRequest) {
if (body?.addDomain) {
const dn = safeDomain(body.addDomain);
if (!dn) return NextResponse.json({ error: "Enter a valid domain name." }, { status: 400 });
await addParkedDomain(id, dn);
if (!(await addParkedDomain(id, dn))) {
return NextResponse.json({ error: `${dn} is already claimed by another account.` }, { status: 409 });
}
return NextResponse.json({ ok: true, dn, domains: await listParkedDomains(id) });
}

Expand All @@ -65,7 +66,9 @@ export async function POST(req: NextRequest) {
if (Array.isArray(prior?.assets)) config.assets = prior.assets;
}
}
await upsertTenant(dn, id, config);
if (!(await upsertTenant(dn, id, config))) {
return NextResponse.json({ error: `${dn}'s page belongs to another account.` }, { status: 409 });
}
return NextResponse.json({ dn, config, pageUrl: `/?dn=${encodeURIComponent(dn)}`, ...(warning ? { warning } : {}) });
}

Expand Down
5 changes: 2 additions & 3 deletions app/api/upload/route.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { NextRequest, NextResponse } from "next/server";
import fs from "node:fs/promises";
import path from "node:path";
import { readSession, authConfigured, SESSION_COOKIE } from "@/lib/session";
import { findOrCreateAccountByEmail, ownsParkedDomain, getTenantConfig, upsertTenant } from "@/lib/db";
import { accountIdForCoinpay, ownsParkedDomain, getTenantConfig, upsertTenant } from "@/lib/db";
import { safeDomain } from "@/lib/config";
import { ffmpegPoster, mediaTypeForUpload } from "@/lib/media";

Expand All @@ -17,8 +17,7 @@ async function accountId(req: NextRequest): Promise<string | null> {
const s = readSession(req.cookies.get(SESSION_COOKIE)?.value);
if (!s) return null;
if (s.sub?.startsWith("acct:")) return s.sub.slice("acct:".length);
if (s.email) return (await findOrCreateAccountByEmail(s.email)).id;
return null;
return accountIdForCoinpay(s.sub, s.email);
}

const domSlug = (dn: string) => dn.trim().toLowerCase().replace(/[^a-z0-9.-]/g, "");
Expand Down
5 changes: 2 additions & 3 deletions app/api/waitlist/manage/route.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { NextRequest, NextResponse } from "next/server";
import { readSession, authConfigured, SESSION_COOKIE } from "@/lib/session";
import { findOrCreateAccountByEmail, ownsParkedDomain, listDomainSignups } from "@/lib/db";
import { accountIdForCoinpay, ownsParkedDomain, listDomainSignups } from "@/lib/db";
import { safeDomain } from "@/lib/config";
import {
filterSignupsByQuery,
Expand All @@ -17,8 +17,7 @@ async function accountId(req: NextRequest): Promise<string | null> {
const s = readSession(req.cookies.get(SESSION_COOKIE)?.value);
if (!s) return null;
if (s.sub?.startsWith("acct:")) return s.sub.slice("acct:".length);
if (s.email) return (await findOrCreateAccountByEmail(s.email)).id;
return null;
return accountIdForCoinpay(s.sub, s.email);
}

// GET /api/waitlist/manage?dn=<domain>[&format=csv][&status=verified|pending]
Expand Down
Loading
Loading