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
8 changes: 7 additions & 1 deletion .env.local.example
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,12 @@ YF_OAUTH_CALLBACK_URL=http://localhost:5050/api/auth/callback
# Production: https://yorkfactory.buildcanada.com/api/v1
YORK_FACTORY_API_URL=http://localhost:3000/api/v1

# Memo engagement cache busting — bearer token York Factory presents to
# POST /api/revalidate when an endorsement/critique changes. MUST match
# york_factory's NEXTJS_REVALIDATE_SECRET. Leave unset to disable revalidation
# (engagement counts then refresh on the normal 5-minute ISR window).
REVALIDATE_SECRET=dev-revalidate-secret

# Bill data sources.
CIVICS_PROJECT_API_KEY=
OPENAI_API_KEY=
Expand All @@ -34,4 +40,4 @@ MONGO_URI=
# Bills admin auth (NextAuth). getServerSession throws in production without a
# secret — set one of these in every deployed environment.
NEXTAUTH_SECRET=
# AUTH_SECRET is accepted as an alias.
# AUTH_SECRET is accepted as an alias.
58 changes: 58 additions & 0 deletions src/app/api/memos/[slug]/engagement/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from "next/server";
import { getAccessTokenCookie } from "@/lib/auth";
import { API_URL } from "@/lib/api/client";

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

// Server-side proxy for memo engagement writes. The OAuth access token lives in
// an httpOnly cookie the browser can't read, so the client posts here and we
// forward to York Factory with a Bearer token. Keeps the token off the client
// and avoids any CORS/preflight against York Factory.
export async function POST(
req: NextRequest,
{ params }: { params: Promise<{ slug: string }> },
) {
const { slug } = await params;

const token = await getAccessTokenCookie();
if (!token) {
return NextResponse.json({ error: "not_authenticated" }, { status: 401 });
}

let payload: { kind?: string; body?: string };
try {
payload = (await req.json()) as { kind?: string; body?: string };
} catch {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
}

const resource =
payload.kind === "endorsement"
? "endorsements"
: payload.kind === "critique"
? "critiques"
: null;
if (!resource) {
return NextResponse.json({ error: "invalid_kind" }, { status: 400 });
}

const forwardBody =
resource === "critiques" ? { body: payload.body ?? "" } : {};

const res = await fetch(
`${API_URL}/memos/${encodeURIComponent(slug)}/${resource}`,
{
method: "POST",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(forwardBody),
cache: "no-store",
},
);

const data = await res.json().catch(() => null);
return NextResponse.json(data, { status: res.status });
}
35 changes: 35 additions & 0 deletions src/app/api/profile/postal/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from "next/server";
import { getAccessTokenCookie } from "@/lib/auth";
import { API_URL } from "@/lib/api/client";

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

// Sets the signed-in user's postal code (required before they can endorse or
// critique). Forwards to York Factory's PATCH /me with the httpOnly Bearer token.
export async function POST(req: NextRequest) {
const token = await getAccessTokenCookie();
if (!token) {
return NextResponse.json({ error: "not_authenticated" }, { status: 401 });
}

let payload: { postal_code?: string };
try {
payload = (await req.json()) as { postal_code?: string };
} catch {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
}

const res = await fetch(`${API_URL}/me`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
Authorization: `Bearer ${token}`,
},
body: JSON.stringify({ user: { postal_code: payload.postal_code ?? "" } }),
cache: "no-store",
});

const data = await res.json().catch(() => null);
return NextResponse.json(data, { status: res.status });
}
37 changes: 37 additions & 0 deletions src/app/api/revalidate/route.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from "next/server";
import { revalidateTag } from "next/cache";

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

// Called by York Factory when a memo's public engagement changes (endorsement
// added, critique approved). Bearer-protected so only York Factory can bust the
// cache. Must share REVALIDATE_SECRET with York Factory's NEXTJS_REVALIDATE_SECRET.
export async function POST(req: NextRequest) {
const secret = process.env.REVALIDATE_SECRET;
if (!secret) {
return NextResponse.json({ error: "revalidation_disabled" }, { status: 503 });
}

const auth = req.headers.get("authorization");
if (auth !== `Bearer ${secret}`) {
return NextResponse.json({ error: "unauthorized" }, { status: 401 });
}

let body: { tag?: string; tags?: string[] };
try {
body = (await req.json()) as { tag?: string; tags?: string[] };
} catch {
return NextResponse.json({ error: "invalid_json" }, { status: 400 });
}

const tags = body.tags ?? (body.tag ? [body.tag] : []);
if (tags.length === 0) {
return NextResponse.json({ error: "no_tags" }, { status: 400 });
}

// Next 16: the old single-arg revalidateTag(tag) is deprecated; "max" is the
// documented replacement that invalidates entries carrying the tag.
for (const tag of tags) revalidateTag(tag, "max");
return NextResponse.json({ ok: true, revalidated: tags });
}
Loading
Loading