diff --git a/.env.local.example b/.env.local.example index 68cf778..9b81a40 100644 --- a/.env.local.example +++ b/.env.local.example @@ -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= @@ -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. \ No newline at end of file +# AUTH_SECRET is accepted as an alias. diff --git a/src/app/api/memos/[slug]/engagement/route.ts b/src/app/api/memos/[slug]/engagement/route.ts new file mode 100644 index 0000000..050af64 --- /dev/null +++ b/src/app/api/memos/[slug]/engagement/route.ts @@ -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 }); +} diff --git a/src/app/api/profile/postal/route.ts b/src/app/api/profile/postal/route.ts new file mode 100644 index 0000000..3d07c03 --- /dev/null +++ b/src/app/api/profile/postal/route.ts @@ -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 }); +} diff --git a/src/app/api/revalidate/route.ts b/src/app/api/revalidate/route.ts new file mode 100644 index 0000000..66973d4 --- /dev/null +++ b/src/app/api/revalidate/route.ts @@ -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 }); +} diff --git a/src/app/memos/[slug]/MemoEngagement.tsx b/src/app/memos/[slug]/MemoEngagement.tsx new file mode 100644 index 0000000..2a5a056 --- /dev/null +++ b/src/app/memos/[slug]/MemoEngagement.tsx @@ -0,0 +1,442 @@ +"use client"; + +import { useEffect, useState, type FormEvent } from "react"; +import { Dialog } from "@base-ui/react/dialog"; +import { useRouter } from "next/navigation"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; + +type Endorser = { name: string; created_at: string }; +type Critique = { id: number; name: string; body: string; created_at: string }; + +interface Props { + memoSlug: string; + endorsementsCount: number; + critiquesCount: number; + recentEndorsers: Endorser[]; + critiques: Critique[]; + // Resolved server-side from the OAuth session (/me). + signedIn: boolean; + engagementReady: boolean; +} + +type Kind = "endorsement" | "critique"; + +const POSTAL_CODE_REGEX = /^[A-Za-z]\d[A-Za-z] ?\d[A-Za-z]\d$/; + +function loginHref(memoSlug: string) { + return `/api/auth/login?redirect=${encodeURIComponent(`/memos/${memoSlug}`)}`; +} + +async function postJson( + url: string, + body: unknown, +): Promise<{ ok: boolean; status: number; data: unknown }> { + const res = await fetch(url, { + method: "POST", + headers: { "Content-Type": "application/json", Accept: "application/json" }, + body: JSON.stringify(body), + }); + let data: unknown = null; + try { + data = await res.json(); + } catch { + // non-JSON body + } + return { ok: res.ok, status: res.status, data }; +} + +function formatDate(iso: string) { + try { + return new Date(iso).toLocaleDateString("en-CA", { + year: "numeric", + month: "long", + day: "numeric", + timeZone: "UTC", + }); + } catch { + return ""; + } +} + +function splitFirstSentence(text: string): { first: string; rest: string } { + const trimmed = text.trim(); + const match = trimmed.match(/^[\s\S]*?[.!?](?=\s|$)/); + if (!match) return { first: trimmed, rest: "" }; + const first = match[0]; + const rest = trimmed.slice(first.length).trim(); + return { first: first.trim(), rest }; +} + +export function MemoEngagement(props: Props) { + const router = useRouter(); + const [endorsementsCount, setEndorsementsCount] = useState(props.endorsementsCount); + const [recentEndorsers, setRecentEndorsers] = useState(props.recentEndorsers); + const [pendingCritique, setPendingCritique] = useState(null); + // Never mutated client-side — read straight from props (no state needed). + const critiquesCount = props.critiquesCount; + const critiques = props.critiques; + const [openKind, setOpenKind] = useState(null); + + const openEngagement = (kind: Kind) => { + // Not signed in → bounce through York Factory's OAuth (LinkedIn) and come + // back to this memo. The button effectively becomes "sign in to ". + if (!props.signedIn) { + window.location.href = loginHref(props.memoSlug); + return; + } + setOpenKind(kind); + }; + + const handleEndorsed = (endorser: Endorser) => { + setEndorsementsCount((c) => c + 1); + setRecentEndorsers((list) => [endorser, ...list].slice(0, 5)); + setOpenKind(null); + toast.success("Thanks for endorsing this memo."); + router.refresh(); + }; + + const handleCritiqued = (critique: Critique) => { + setPendingCritique(critique); + setOpenKind(null); + toast.success("Critique submitted for review."); + router.refresh(); + }; + + const handleDuplicate = (kind: Kind) => { + toast.error( + kind === "endorsement" + ? "You've already endorsed this memo." + : "You've already submitted a critique for this memo.", + ); + setOpenKind(null); + }; + + const endorseLabel = props.signedIn ? "Endorse this memo" : "Sign in to endorse"; + const critiqueLabel = props.signedIn ? "Critique this memo" : "Sign in to critique"; + + return ( +
+
+ + + + {endorsementsCount} {endorsementsCount === 1 ? "endorsement" : "endorsements"} + {" · "} + {critiquesCount} {critiquesCount === 1 ? "critique" : "critiques"} + +
+ +
+
+

Recent endorsers

+ {recentEndorsers.length === 0 ? ( +

+ No endorsements yet. Be the first. +

+ ) : ( +
    + {recentEndorsers.map((e, i) => ( +
  • + {e.name} + + {formatDate(e.created_at)} + +
  • + ))} +
+ )} +
+ +
+

Critiques

+ {critiques.length === 0 && !pendingCritique ? ( +

No critiques yet.

+ ) : ( +
    + {pendingCritique && } + {critiques.map((c) => ( + + ))} +
+ )} +
+
+ + setOpenKind(null)} + onEndorsed={handleEndorsed} + onCritiqued={handleCritiqued} + onDuplicate={handleDuplicate} + /> +
+ ); +} + +function CritiqueItem({ critique, pending = false }: { critique: Critique; pending?: boolean }) { + const [open, setOpen] = useState(false); + const { first, rest } = splitFirstSentence(critique.body); + const hasMore = rest.length > 0; + + return ( +
  • +
    + {critique.name} + + {pending ? "Pending review" : formatDate(critique.created_at)} + +
    +

    + {first} + {hasMore && ( + <> + {" "} + + + )} +

    + + + + + + + {critique.name} + + + {pending ? "Pending review" : formatDate(critique.created_at)} + +

    {critique.body}

    + + + + + +
    +
    +
    +
  • + ); +} + +interface DialogProps { + kind: Kind | null; + memoSlug: string; + engagementReady: boolean; + onClose: () => void; + onEndorsed: (endorser: Endorser) => void; + onCritiqued: (critique: Critique) => void; + onDuplicate: (kind: Kind) => void; +} + +function EngagementDialog({ + kind, + memoSlug, + engagementReady, + onClose, + onEndorsed, + onCritiqued, + onDuplicate, +}: DialogProps) { + const [needsPostal, setNeedsPostal] = useState(!engagementReady); + const [postalCode, setPostalCode] = useState(""); + const [body, setBody] = useState(""); + const [error, setError] = useState(null); + const [submitting, setSubmitting] = useState(false); + + const open = kind !== null; + + useEffect(() => { + if (!open) return; + setNeedsPostal(!engagementReady); + setPostalCode(""); + setBody(""); + setError(null); + setSubmitting(false); + }, [open, kind, engagementReady]); + + const handleSubmit = async (e: FormEvent) => { + e.preventDefault(); + if (!kind) return; + setError(null); + + if (needsPostal && !POSTAL_CODE_REGEX.test(postalCode.trim())) { + setError("Please enter a valid Canadian postal code (e.g. A1A 1A1)."); + return; + } + if (kind === "critique" && body.trim().length < 5) { + setError("Please write a critique of at least 5 characters."); + return; + } + + setSubmitting(true); + try { + // 1) Save the postal code first if we still need it. + if (needsPostal) { + const postalRes = await postJson("/api/profile/postal", { + postal_code: postalCode.trim(), + }); + if (postalRes.status === 401) return redirectToLogin(); + if (!postalRes.ok) { + setError(messageFrom(postalRes.data, "Could not save your postal code.")); + return; + } + } + + // 2) Submit the engagement. + const res = await postJson(`/api/memos/${memoSlug}/engagement`, { + kind, + ...(kind === "critique" ? { body: body.trim() } : {}), + }); + + if (res.status === 401) return redirectToLogin(); + if (res.status === 409) { + onDuplicate(kind); + return; + } + if (res.status === 422 && errorCode(res.data) === "postal_code_required") { + // Session said ready but York Factory disagrees (token race) — collect it. + setNeedsPostal(true); + setError("Please add your postal code to continue."); + return; + } + if (!res.ok) { + setError(messageFrom(res.data, "Something went wrong. Please try again.")); + return; + } + + const data = res.data as { id: number; name: string; body?: string; created_at: string }; + if (kind === "endorsement") { + onEndorsed({ name: data.name, created_at: data.created_at }); + } else { + onCritiqued({ + id: data.id, + name: data.name, + body: data.body ?? body.trim(), + created_at: data.created_at, + }); + } + } finally { + setSubmitting(false); + } + }; + + const redirectToLogin = () => { + window.location.href = loginHref(memoSlug); + }; + + const inputClass = + "border border-charcoal-300 bg-white px-3 py-2.5 type-body placeholder:text-charcoal-400 outline-none focus:border-charcoal-1000 transition-colors w-full"; + + return ( + { if (!isOpen) onClose(); }}> + + + + + {kind === "endorsement" ? "Endorse this memo" : "Critique this memo"} + + + {kind === "endorsement" + ? "Your endorsement is published under your name." + : "Share your critique. A critique must target the memo content constructively. Anything self-promotional, ad hominem, or otherwise hostile will not be approved and may be removed."} + + +
    + {needsPostal && ( +
    + + setPostalCode(e.target.value)} + placeholder="A1A 1A1" + required + maxLength={7} + className={inputClass} + /> +
    + )} + + {kind === "critique" && ( +