Skip to content

Commit 9190c9a

Browse files
committed
v3.9.0: Submission Pipeline, Publication Club, DOAJ integration, HSTS
- Submission Pipeline (new Post-Research lane): per-journal tracker from shortlist -> formatting -> submitted -> peer review -> revision -> accepted/rejected/withdrawn -> published. Stage-by-stage ICMJE/COPE-aligned guidance, status timeline with day counts, days-in-review stats, manuscript ID + notes. Deterministic knowledge in lib/submission/pipeline.ts; state in ProjectState.submissions (browser-first like the rest of the project). - Publication Club (new Community phase): community board to post/join research opportunities, meet researchers by specialty, and share MedCore projects via tokenized share links. Supabase-backed (docs/CLUB_TABLES.sql, RLS-enforced), public browsing, auth-gated posting/joining, plain-text only (control chars stripped), share links restricted to MedCore share URLs, strict rate limits; degrades gracefully when Supabase is absent. - DOAJ integration: lib/scholarly/doaj.ts + /api/doaj/search (articles and journals) and new MCP tool check_open_access_journal — DOAJ listing as a legitimacy signal feeding the predatory self-check. - Security: HSTS (2y, includeSubDomains) added to middleware headers. - Verified: typecheck, lint clean, build, 36/36 engine tests, 25/25 smoke tests (4 new checks for club/DOAJ/MCP surface). https://claude.ai/code/session_01H3LEUNwREz1FNb3PuZe7E2
1 parent 94bc1ae commit 9190c9a

18 files changed

Lines changed: 1401 additions & 4 deletions

File tree

README.md

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,25 @@ A free, no-login, reporting-guideline-driven workspace for building the core of
77
**Live app:** https://medcore-research-builder.vercel.app
88
**Repository:** https://github.com/Abdulsalam3302/medcore-research-builder
99

10+
## What’s new in v3.9
11+
12+
- **Submission Pipeline** — track every target journal from shortlist →
13+
formatting → submission → peer review → revision → acceptance → publication,
14+
with stage-by-stage best practice (ICMJE/COPE-aligned), duration stats, and
15+
a full status timeline. New lane under Post-Research.
16+
- **Publication Club** — a community board (new Community phase): post and
17+
join research opportunities, meet researchers by specialty, and share
18+
MedCore projects via tokenized share links so studies are initiated *and*
19+
finished on the platform. Supabase-backed (`docs/CLUB_TABLES.sql`), browse
20+
as guest, post/join with a free account; plain-text only, RLS-enforced,
21+
strict rate limits.
22+
- **DOAJ integration**`/api/doaj/search` (articles + journals) and a new
23+
`check_open_access_journal` MCP tool: verify a journal's DOAJ listing
24+
(license, APC, publisher) as a legitimacy signal for the predatory check.
25+
- **Security** — HSTS header (2-year max-age) added to the middleware
26+
security set; community endpoints are auth-gated, length-capped,
27+
control-character-stripped, and rate-limited.
28+
1029
## What’s new in v3.8
1130

1231
- **First-party MCP server** — MedCore's engines (journal finder, design
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
import { bad, handleError, ok, safeJson, enforceRateLimit } from "../../../../_utils";
2+
import { createClient, isSupabaseConfigured } from "@/lib/supabase/server";
3+
import { getAppUser } from "@/lib/auth";
4+
5+
export const runtime = "nodejs";
6+
export const dynamic = "force-dynamic";
7+
8+
const UUID_RE = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i;
9+
10+
/** Request to join an opportunity/project (signed-in users only, once per post). */
11+
export async function POST(req: Request, { params }: { params: { id: string } }) {
12+
try {
13+
const limited = await enforceRateLimit(req, "verify");
14+
if (limited) return limited;
15+
if (!isSupabaseConfigured()) return bad("Community features need Supabase configured", 503);
16+
const user = await getAppUser();
17+
if (!user) return bad("Sign in to join opportunities", 401);
18+
if (!UUID_RE.test(params.id)) return bad("Invalid post id");
19+
20+
const body = await safeJson<{ message?: string; joinerName?: string }>(req, "default").catch(
21+
() => ({}) as { message?: string; joinerName?: string },
22+
);
23+
const message = typeof body.message === "string" ? body.message.trim().slice(0, 500) : "";
24+
const joinerName =
25+
(typeof body.joinerName === "string" ? body.joinerName.trim().slice(0, 60) : "") ||
26+
user.email.split("@")[0];
27+
28+
const supabase = createClient();
29+
const { error } = await supabase.from("club_joins").insert({
30+
post_id: params.id,
31+
user_id: user.id,
32+
joiner_name: joinerName,
33+
message: message || null,
34+
});
35+
if (error) {
36+
// unique(post_id, user_id) — joining twice is fine to report cleanly.
37+
if (String(error.code) === "23505") return ok({ joined: true, already: true });
38+
throw error;
39+
}
40+
return ok({ joined: true });
41+
} catch (e) {
42+
return handleError(e);
43+
}
44+
}
45+
46+
/** Withdraw a join request. */
47+
export async function DELETE(req: Request, { params }: { params: { id: string } }) {
48+
try {
49+
const limited = await enforceRateLimit(req, "default");
50+
if (limited) return limited;
51+
if (!isSupabaseConfigured()) return bad("Community features need Supabase configured", 503);
52+
const user = await getAppUser();
53+
if (!user) return bad("Sign in required", 401);
54+
if (!UUID_RE.test(params.id)) return bad("Invalid post id");
55+
56+
const supabase = createClient();
57+
const { error } = await supabase
58+
.from("club_joins")
59+
.delete()
60+
.eq("post_id", params.id)
61+
.eq("user_id", user.id);
62+
if (error) throw error;
63+
return ok({ withdrawn: true });
64+
} catch (e) {
65+
return handleError(e);
66+
}
67+
}

app/api/club/posts/route.ts

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
import { bad, handleError, ok, safeJson, enforceRateLimit, BadRequestError } from "../../_utils";
2+
import { createClient, isSupabaseConfigured } from "@/lib/supabase/server";
3+
import { getAppUser } from "@/lib/auth";
4+
5+
export const runtime = "nodejs";
6+
export const dynamic = "force-dynamic";
7+
8+
const KINDS = new Set(["opportunity", "project", "meet"]);
9+
10+
/** Strip control characters; the club is plain text only (no HTML/markdown). */
11+
function cleanText(v: unknown, max: number): string {
12+
if (typeof v !== "string") return "";
13+
// eslint-disable-next-line no-control-regex
14+
return v.replace(/[\u0000-\u001f\u007f]/g, " ").replace(/\s+/g, " ").trim().slice(0, max);
15+
}
16+
17+
/** List club posts (public — guests can browse before signing up). */
18+
export async function GET(req: Request) {
19+
try {
20+
const limited = await enforceRateLimit(req, "default");
21+
if (limited) return limited;
22+
if (!isSupabaseConfigured()) return ok({ configured: false, posts: [] });
23+
24+
const u = new URL(req.url);
25+
const kind = u.searchParams.get("kind") || "";
26+
const supabase = createClient();
27+
let query = supabase
28+
.from("club_posts")
29+
.select("id, author_name, kind, title, description, specialty, share_url, contact, status, created_at")
30+
.order("created_at", { ascending: false })
31+
.limit(50);
32+
if (kind && KINDS.has(kind)) query = query.eq("kind", kind);
33+
const { data, error } = await query;
34+
if (error) throw error;
35+
36+
// Tell the signed-in caller which posts are theirs / already joined.
37+
const user = await getAppUser();
38+
let joinedIds: string[] = [];
39+
if (user && data?.length) {
40+
const { data: joins } = await supabase
41+
.from("club_joins")
42+
.select("post_id")
43+
.eq("user_id", user.id);
44+
joinedIds = (joins || []).map((j) => j.post_id as string);
45+
}
46+
return ok({
47+
configured: true,
48+
signedIn: Boolean(user),
49+
posts: (data || []).map((p) => ({ ...p, joined: joinedIds.includes(p.id as string) })),
50+
});
51+
} catch (e) {
52+
return handleError(e);
53+
}
54+
}
55+
56+
/** Create a post (signed-in users only). */
57+
export async function POST(req: Request) {
58+
try {
59+
const limited = await enforceRateLimit(req, "verify"); // strict: 12/min/IP
60+
if (limited) return limited;
61+
if (!isSupabaseConfigured()) return bad("Community features need Supabase configured", 503);
62+
const user = await getAppUser();
63+
if (!user) return bad("Sign in to post in the Publication Club", 401);
64+
65+
const body = await safeJson<Record<string, unknown>>(req, "default");
66+
const kind = cleanText(body.kind, 20);
67+
if (!KINDS.has(kind)) throw new BadRequestError("kind must be opportunity | project | meet");
68+
const title = cleanText(body.title, 160);
69+
if (title.length < 8) throw new BadRequestError("title must be at least 8 characters");
70+
const description = cleanText(body.description, 2000);
71+
if (description.length < 20) throw new BadRequestError("description must be at least 20 characters");
72+
const specialty = cleanText(body.specialty, 80);
73+
const contact = cleanText(body.contact, 200);
74+
const shareUrl = cleanText(body.shareUrl, 300);
75+
// Only allow share links that point back to this platform — the club must
76+
// not become a link-drop for arbitrary external URLs.
77+
if (shareUrl && !/^https:\/\/[\w.-]+\/(\?share=|.*[?&]share=)/.test(shareUrl) && !shareUrl.startsWith("/?share=")) {
78+
throw new BadRequestError("shareUrl must be a MedCore share link (…?share=TOKEN)");
79+
}
80+
81+
const authorName = cleanText(body.authorName, 60) || user.email.split("@")[0];
82+
83+
const supabase = createClient();
84+
const { data, error } = await supabase
85+
.from("club_posts")
86+
.insert({
87+
user_id: user.id,
88+
author_name: authorName,
89+
kind,
90+
title,
91+
description,
92+
specialty: specialty || null,
93+
contact: contact || null,
94+
share_url: shareUrl || null,
95+
})
96+
.select("id, created_at")
97+
.single();
98+
if (error) throw error;
99+
return ok({ created: true, id: data.id, createdAt: data.created_at });
100+
} catch (e) {
101+
return handleError(e);
102+
}
103+
}

app/api/doaj/search/route.ts

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import { bad, handleError, ok, enforceRateLimit } from "../../_utils";
2+
import { doajSearchArticles, doajSearchJournals } from "@/lib/scholarly/doaj";
3+
4+
export const runtime = "nodejs";
5+
6+
/**
7+
* DOAJ search — `type=journals` verifies open-access journal legitimacy
8+
* (supports the predatory self-check); `type=articles` (default) finds
9+
* open-access papers with license metadata.
10+
*/
11+
export async function GET(req: Request) {
12+
try {
13+
const limited = await enforceRateLimit(req, "search");
14+
if (limited) return limited;
15+
const u = new URL(req.url);
16+
const q = u.searchParams.get("query") || u.searchParams.get("q") || "";
17+
if (!q) return bad("query is required");
18+
const pageSize = Number(u.searchParams.get("page_size") || "10");
19+
const type = u.searchParams.get("type") === "journals" ? "journals" : "articles";
20+
const results =
21+
type === "journals"
22+
? await doajSearchJournals(q, pageSize)
23+
: await doajSearchArticles(q, pageSize);
24+
return ok({ type, results });
25+
} catch (e) {
26+
return handleError(e);
27+
}
28+
}

components/LifecycleNavigation.tsx

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,8 @@ const PHASE_HELP: Record<string, string> = {
1919
"Extend impact after acceptance: create responsible outreach assets, then export everything for submission and your records.",
2020
Platform:
2121
"Platform information: product news and updates, and the mission, vision, and principles behind MedCore.",
22+
Community:
23+
"Research is a team sport: post or join research opportunities, share MedCore projects to find collaborators, and meet researchers in your specialty — so projects are initiated and finished on the platform.",
2224
};
2325

2426
export type LifecycleKey =
@@ -36,7 +38,9 @@ export type LifecycleKey =
3638
| "references"
3739
| "appendix"
3840
| "journal-finder"
41+
| "pipeline"
3942
| "submission"
43+
| "club"
4044
| "review"
4145
| "toolkit"
4246
| "skills"
@@ -51,7 +55,8 @@ type PhaseName =
5155
| "Intra-Research"
5256
| "Post-Research"
5357
| "Quality & Empowerment"
54-
| "Post-Publication";
58+
| "Post-Publication"
59+
| "Community";
5560

5661
type NavItem = {
5762
key: LifecycleKey;
@@ -106,6 +111,12 @@ const PHASE_THEME: Record<
106111
activeClass: "bg-rose-50 text-rose-800",
107112
barClass: "bg-rose-400",
108113
},
114+
Community: {
115+
headClass: "text-emerald-700",
116+
dotClass: "bg-emerald-500",
117+
activeClass: "bg-emerald-50 text-emerald-800",
118+
barClass: "bg-emerald-400",
119+
},
109120
};
110121

111122
/** Order the groups render in. Platform sits first, at the top. */
@@ -116,6 +127,7 @@ const PHASE_ORDER: PhaseName[] = [
116127
"Post-Research",
117128
"Quality & Empowerment",
118129
"Post-Publication",
130+
"Community",
119131
];
120132

121133
const NAV_ITEMS: NavItem[] = [
@@ -139,6 +151,7 @@ const NAV_ITEMS: NavItem[] = [
139151
{ key: "appendix", label: "Appendix (optional)", phase: "Intra-Research" },
140152

141153
{ key: "journal-finder", label: "Journal Finder", phase: "Post-Research" },
154+
{ key: "pipeline", label: "Submission Pipeline", phase: "Post-Research" },
142155
{ key: "submission", label: "Submission & Quality", phase: "Post-Research" },
143156

144157
{ key: "review", label: "Review & Improve (score + AI swarm)", phase: "Quality & Empowerment" },
@@ -147,6 +160,8 @@ const NAV_ITEMS: NavItem[] = [
147160

148161
{ key: "impact-studio", label: "Post-Publication Impact Studio", phase: "Post-Publication" },
149162
{ key: "export", label: "Export Center", phase: "Post-Publication" },
163+
164+
{ key: "club", label: "Publication Club", phase: "Community" },
150165
];
151166

152167
export function LifecycleNavigation({

0 commit comments

Comments
 (0)