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
1 change: 1 addition & 0 deletions .cursor/rules/sync-before-code-changes.mdc
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ Before **any** file edit, commit, or new branch work:
3. If this branch has (or had) an open PR, check merge state **before** stacking more commits:
- `gh pr list --head $(git branch --show-current) --state all --json number,state,mergedAt`
- If `MERGED`: do **not** keep committing on this branch. Follow `CLAUDE.md` — stash if needed, fast-forward `main`, start a **new** branch for follow-up work.
- If it is **not** merged and `git log origin/main..HEAD` is non-empty: **keep this branch**. Rebase onto `origin/main` if behind; do not open a second branch for the same line of work.
4. If `git rev-parse --verify MERGE_HEAD` or `.git/rebase-merge` exists, finish or abort that operation first; do not stash over an in-progress merge/rebase without asking the user.

Only after the branch is aligned with intent (rebased on current `main` or confirmed still the right feature branch) should you edit files.
3 changes: 3 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@ Also check the **current branch** before editing: `git log HEAD..origin/main
--oneline` and whether its PR is already merged (`gh pr list --head
<branch> --state all`). If the PR merged, stop committing on that branch —
rebase or branch fresh from `origin/main` for follow-ups (see step 2 below).
If the PR is **not** merged and the branch already has commits not in
`origin/main`, keep working on that branch (rebase if behind); do not start
a second branch for the same work.

If `origin/main` has commits not in local `main`:

Expand Down
17 changes: 13 additions & 4 deletions apps/bff/src/contributions/contributions.controller.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,19 @@
import { Body, Controller, Get, Param, Post, UseGuards } from '@nestjs/common';
import { ContributionsService } from './contributions.service.js';
import { VerifiedGuard, CurrentUser, type AuthenticatedUser } from '@bubltec/mycota-auth';
import {
JwtAuthGuard,
VerifiedGuard,
CurrentUser,
type AuthenticatedUser,
} from '@bubltec/mycota-auth';
// Value import — `import type` erases the class before emitDecoratorMetadata runs,
// so ValidationPipe sees paramtypes [Function, Object] and never transforms `payload`.
import { CreateContributionDto } from './dto/create-contribution.dto.js';

// Prod still requires verifiedContributor. Dev/local only needs a session —
// otherwise a first login (email, no quiz) hits 403 and the queue looks empty.
const ModerationGuard = process.env.STAGE === 'prod' ? VerifiedGuard : JwtAuthGuard;

@Controller('contributions')
export class ContributionsController {
constructor(private readonly contributions: ContributionsService) {}
Expand All @@ -16,15 +25,15 @@ export class ContributionsController {
}

@Get('pending')
@UseGuards(VerifiedGuard)
@UseGuards(ModerationGuard)
async listPending() {
// MVP: any verified contributor can see the moderation queue. Restrict to
// Prod: verified contributors. Non-prod: any signed-in user. Restrict to
// an admin allowlist before opening this up publicly.
return this.contributions.listPending();
}

@Post(':thingId/:sk/approve')
@UseGuards(VerifiedGuard)
@UseGuards(ModerationGuard)
async approve(
@Param('thingId') thingId: string,
@Param('sk') sk: string,
Expand Down
5 changes: 4 additions & 1 deletion apps/e2e/scripts/app-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,10 @@ matter here since the test-code endpoint reads the code straight from the
database, never the inbox.)

Verification: a brand-new signed-in user has verifiedContributor: false and
cannot submit things or see the moderation queue. Two real paths exist (a
cannot submit things. On prod they also cannot see the moderation queue; on
non-prod, a signed-in session is enough for the contribution queue (the page
also POSTs /api/auth/test/verify so organization reviews unlock). Two real
paths exist (a
pop quiz with randomized answers, or a slower human-reviewed professional
path) but neither is scriptable deterministically. Instead, call POST
/api/auth/test/verify (authenticated, i.e. after completing email sign-in
Expand Down
3 changes: 2 additions & 1 deletion apps/web/src/components/Nav.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { Link } from 'react-router-dom';
import { isNonProdHost } from '../lib/env.js';
import { useCurrentUser } from '../lib/useCurrentUser.js';
import { EmailSignInDialog } from './EmailSignInDialog.js';

Expand All @@ -21,7 +22,7 @@ export function Nav() {
<span className="hidden sm:inline">Add a thing</span>
<span className="sm:hidden">Add</span>
</Link>
{user?.verifiedContributor && (
{user && (user.verifiedContributor || isNonProdHost()) && (
<Link to="/moderation" className="inline-block py-1.5 hover:underline">
Moderation
</Link>
Expand Down
6 changes: 5 additions & 1 deletion apps/web/src/lib/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,9 @@ async function request<T>(path: string, init?: RequestInit): Promise<T> {
export type CurrentUser = User;

export const api = {
listThings: (params: { q?: string; petType?: string; thingType?: string; breed?: string } = {}) => {
listThings: (
params: { q?: string; petType?: string; thingType?: string; breed?: string } = {},
) => {
const entries = Object.entries(params).filter((entry): entry is [string, string] =>
Boolean(entry[1]),
);
Expand All @@ -39,6 +41,8 @@ export const api = {
submitContribution: (payload: Record<string, unknown>, thingId?: string) =>
request('/contributions', { method: 'POST', body: JSON.stringify({ thingId, payload }) }),
listPendingContributions: () => request<unknown[]>('/contributions/pending'),
unlockDevContributor: () =>
request<{ verifiedContributor: boolean }>('/auth/test/verify', { method: 'POST' }),
requestProfessionalVerification: (email: string) =>
request<{ orgClassification?: string }>('/verification/professional/request', {
method: 'POST',
Expand Down
6 changes: 6 additions & 0 deletions apps/web/src/lib/env.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
/** Runtime host check — not SITE_ORIGIN, which is hardcoded to prod for prerender. */
export function isNonProdHost(): boolean {
if (typeof window === 'undefined') return false;
const host = window.location.hostname;
return host === 'localhost' || host === '127.0.0.1' || host.startsWith('dev.');
}
24 changes: 13 additions & 11 deletions apps/web/src/lib/useCurrentUser.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,25 @@
import { useEffect, useState } from 'react';
import { useCallback, useEffect, useState } from 'react';
import { api, type CurrentUser } from './api.js';

export function useCurrentUser() {
const [user, setUser] = useState<CurrentUser | null | undefined>(undefined);

const refresh = useCallback(
() =>
api
.me()
.then(setUser)
.catch(() => setUser(null)),
[],
);

useEffect(() => {
api
.me()
.then(setUser)
.catch(() => setUser(null));
}, []);
void refresh();
}, [refresh]);

return {
user,
loading: user === undefined,
refresh: () =>
api
.me()
.then(setUser)
.catch(() => setUser(null)),
refresh,
};
}
129 changes: 115 additions & 14 deletions apps/web/src/pages/ModerationPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,9 @@ import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import type { User } from '@btfp/shared-types';
import { api } from '../lib/api.js';
import { isNonProdHost } from '../lib/env.js';
import { useCurrentUser } from '../lib/useCurrentUser.js';
import { EmailSignInDialog } from '../components/EmailSignInDialog.js';

interface PendingContribution {
PK: string;
Expand All @@ -12,15 +15,38 @@ interface PendingContribution {
createdAt: string;
}

function SignInPrompt({ onSignedIn }: { onSignedIn: () => void }) {
return (
<div className="mt-6 text-center">
<p className="text-neutral-500">Sign in to review the queue.</p>
<div className="mt-4 flex flex-wrap justify-center gap-3">
<EmailSignInDialog onSignedIn={onSignedIn} />
<a
href="/api/auth/github"
className="rounded-full bg-paw-500 px-3 py-1 text-sm font-semibold text-white hover:bg-paw-600"
>
Sign in with GitHub
</a>
</div>
</div>
);
}

function ContributionsSection() {
const [items, setItems] = useState<PendingContribution[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

function load() {
setLoading(true);
setError(null);
api
.listPendingContributions()
.then((data) => setItems(data as PendingContribution[]))
.catch((err: unknown) => {
setItems([]);
setError(err instanceof Error ? err.message : 'Could not load pending contributions');
})
.finally(() => setLoading(false));
}

Expand All @@ -30,19 +56,33 @@ function ContributionsSection() {

async function approve(item: PendingContribution) {
const thingId = item.PK.replace('THING#', '');
await fetch(`/api/contributions/${thingId}/${encodeURIComponent(item.SK)}/approve`, {
method: 'POST',
credentials: 'include',
});
load();
try {
const res = await fetch(
`/api/contributions/${thingId}/${encodeURIComponent(item.SK)}/approve`,
{
method: 'POST',
credentials: 'include',
},
);
if (!res.ok) {
const body = (await res.json().catch(() => ({ message: undefined }))) as {
message?: string;
};
throw new Error(body.message ?? `Approve failed: ${res.status}`);
}
load();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Approve failed');
}
}

return (
<section>
<h2 className="text-xl font-bold text-neutral-800">Pending contributions</h2>
{error && <p className="mt-4 text-sm text-alert-600">{error}</p>}
{loading ? (
<p className="mt-4 text-neutral-400">Loading…</p>
) : items.length === 0 ? (
) : items.length === 0 && !error ? (
<p className="mt-4 text-neutral-400">Nothing pending. 🎉</p>
) : (
<ul className="mt-4 space-y-3">
Expand Down Expand Up @@ -79,12 +119,18 @@ function ContributionsSection() {
function ProfessionalVerificationsSection() {
const [items, setItems] = useState<User[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);

function load() {
setLoading(true);
setError(null);
api
.listPendingProfessionalVerifications()
.then(setItems)
.catch((err: unknown) => {
setItems([]);
setError(err instanceof Error ? err.message : 'Could not load organization verifications');
})
.finally(() => setLoading(false));
}

Expand All @@ -93,17 +139,22 @@ function ProfessionalVerificationsSection() {
}, []);

async function review(user: User, approve: boolean) {
const reason = approve ? undefined : (prompt('Rejection reason (optional):') ?? undefined);
await api.reviewProfessionalVerification(user.id, approve, reason);
load();
try {
const reason = approve ? undefined : (prompt('Rejection reason (optional):') ?? undefined);
await api.reviewProfessionalVerification(user.id, approve, reason);
load();
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Review failed');
}
}

return (
<section className="mt-10">
<h2 className="text-xl font-bold text-neutral-800">Pending organization verifications</h2>
{error && <p className="mt-4 text-sm text-alert-600">{error}</p>}
{loading ? (
<p className="mt-4 text-neutral-400">Loading…</p>
) : items.length === 0 ? (
) : items.length === 0 && !error ? (
<p className="mt-4 text-neutral-400">Nothing pending. 🎉</p>
) : (
<ul className="mt-4 space-y-3">
Expand Down Expand Up @@ -139,14 +190,64 @@ function ProfessionalVerificationsSection() {
);
}

function DevUnlock({ onUnlocked }: { onUnlocked: () => void }) {
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(true);

useEffect(() => {
let cancelled = false;
api
.unlockDevContributor()
.then(() => {
if (!cancelled) onUnlocked();
})
.catch((err: unknown) => {
if (!cancelled) {
setError(err instanceof Error ? err.message : 'Could not unlock moderation on dev');
}
})
.finally(() => {
if (!cancelled) setBusy(false);
});
return () => {
cancelled = true;
};
}, [onUnlocked]);

if (busy) {
return <p className="mt-4 text-neutral-400">Unlocking moderation on this environment…</p>;
}
if (error) {
return <p className="mt-4 text-sm text-alert-600">{error}</p>;
}
return null;
}

export function ModerationPage() {
const { user, loading, refresh } = useCurrentUser();
const needsDevUnlock = Boolean(user && !user.verifiedContributor && isNonProdHost());

if (loading) {
return <div className="mx-auto max-w-3xl px-4 py-10 text-neutral-400">Loading…</div>;
}

return (
<div className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-2xl font-bold text-neutral-800">Moderation queue</h1>
<div className="mt-6">
<ContributionsSection />
<ProfessionalVerificationsSection />
</div>
{!user ? (
<SignInPrompt onSignedIn={refresh} />
) : !user.verifiedContributor && !isNonProdHost() ? (
<p className="mt-6 text-neutral-500">
You&apos;re signed in, but only verified contributors can review the queue. Take the quiz
from Add a thing first.
</p>
) : (
<div className="mt-6">
{needsDevUnlock && <DevUnlock onUnlocked={refresh} />}
<ContributionsSection key={user.verifiedContributor ? 'verified' : 'signed-in'} />
{user.verifiedContributor && <ProfessionalVerificationsSection />}
</div>
)}
</div>
);
}
Loading