§22 M3-backend Plan B (write path, propose): project-scoped propose (v0.38.0)

A new entry can be proposed into a specific project; it lands in that project's
content repo and shows under that project's proposals. A non-default project is
no longer read-only.

- api.py: POST /api/projects/{pid}/rfcs/propose (propose body extracted into a
  project-parameterized helper; unscoped /api/rfcs/propose kept as default
  compat). Slug uniqueness, idea-PR reservation, landing state, and the
  proposed_use_cases row scoped to the target project. GET
  /api/projects/{pid}/proposals.
- cache.py: refresh_meta_pulls loops every project's content_repo, stamping
  cached_prs.project_id; projects.content_repo(pid) helper.
- frontend: proposeRFC(projectId,…)/listProposals(projectId); ProposeModal
  takes projectId; App resolves current project from the /p/<id>/ URL; Catalog
  lists that project's proposals.
- tests: test_project_scoped_propose.py (lands scoped + gated 404). 447 backend
  + 11 Vitest green; clean build.

Known limitation: branch/PR/graduation edit flows + default-id re-stamp not yet
scoped (next slice). Per docs/superpowers/specs/2026-06-04-m3-backend-planb-design.md.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Ben Stull
2026-06-04 07:02:19 -07:00
parent 455ef33b29
commit fec51bdbb6
11 changed files with 205 additions and 34 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "rfc-app-frontend",
"private": true,
"version": "0.37.0",
"version": "0.38.0",
"type": "module",
"scripts": {
"dev": "vite",
+7 -1
View File
@@ -61,6 +61,11 @@ export default function App() {
// §22.9 — runtime deployment config (name for the brand, default project id
// for building corpus links this slice; see DeploymentProvider).
const deployment = useDeployment()
// §22.4 — the project the viewer is currently in (from the /p/<id>/ URL),
// so the propose modal (App-level chrome, above the route tree) targets the
// right project. Falls back to the deployment default off a project route.
const _projMatch = location.pathname.match(/^\/p\/([^/]+)/)
const currentProjectId = (_projMatch && _projMatch[1]) || deployment.defaultProjectId
// #28 Parts 23: the LinkedText create/contribute affordances route via
// query params so they need no prop-threading from deep in a comment
// list. `?propose=<term>` opens the propose modal pre-filled;
@@ -372,12 +377,13 @@ export default function App() {
<ProposeModal
viewer={viewer}
initialTitle={proposeParam || ''}
projectId={currentProjectId}
onClose={() => { setProposeOpen(false); clearParams('propose') }}
onSubmitted={({ pr_number }) => {
setProposeOpen(false)
clearParams('propose')
setCatalogVersion(v => v + 1)
navigate(proposalPath(deployment.defaultProjectId, pr_number))
navigate(proposalPath(currentProjectId, pr_number))
}}
/>
)}
+8 -4
View File
@@ -198,16 +198,20 @@ export async function getRFC(projectId, slug) {
return jsonOrThrow(await fetch(`/api/projects/${projectId}/rfcs/${slug}`))
}
export async function listProposals() {
return jsonOrThrow(await fetch('/api/proposals'))
export async function listProposals(projectId) {
const url = projectId ? `/api/projects/${projectId}/proposals` : '/api/proposals'
return jsonOrThrow(await fetch(url))
}
export async function getProposal(prNumber) {
return jsonOrThrow(await fetch(`/api/proposals/${prNumber}`))
}
export async function proposeRFC({ title, slug, pitch, tags, proposedUseCase }) {
const res = await fetch('/api/rfcs/propose', {
// §22.4 (Plan B write): propose into a specific project when projectId is
// given; else the default-project compat path.
export async function proposeRFC(projectId, { title, slug, pitch, tags, proposedUseCase }) {
const url = projectId ? `/api/projects/${projectId}/rfcs/propose` : '/api/rfcs/propose'
const res = await fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
// #26: proposed_use_case is optional; send null when blank so the
+1 -1
View File
@@ -35,7 +35,7 @@ export default function Catalog({ viewer, onProposeRFC, version }) {
useEffect(() => {
listRFCs(pid).then(d => setRfcs(d.items)).catch(() => setRfcs([]))
listProposals().then(d => setProposals(d.items)).catch(() => setProposals([]))
listProposals(pid).then(d => setProposals(d.items)).catch(() => setProposals([]))
}, [version, pid])
const filtered = useMemo(() => {
+2 -2
View File
@@ -28,7 +28,7 @@ function slugify(title) {
.replace(/^-+|-+$/g, '')
}
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '' }) {
export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitle = '', projectId }) {
// #28 Part 2: a "create RFC for '<term>'" affordance pre-fills the title
// (App passes the `?propose=<term>` value here); the slug derives from it
// via the same effect that drives manual typing.
@@ -92,7 +92,7 @@ export default function ProposeModal({ viewer, onClose, onSubmitted, initialTitl
setSubmitting(true)
setError(null)
try {
const result = await proposeRFC({
const result = await proposeRFC(projectId, {
title: title.trim(),
slug,
pitch: pitch.trim(),