Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
ba0a714
Add posts module (POST/MEDICAL/RESOURCE) with LLM moderation & classi…
May 16, 2026
9c07076
Refine posts: LLM-driven tags, 小T auto-reply, async moderation, retries
May 16, 2026
0323580
Auto-classify across POST/MEDICAL/RESOURCE; keep EVENT gate firm
May 16, 2026
830d170
Auto-migrate event-shaped posts to /activities or block them
May 16, 2026
155c6a4
Split EVENT into ORGANIZING vs DISCUSSING; add edit + report UI
May 16, 2026
a3d06b4
LLM auto-handles reports; close out posts/comment feature gaps
May 16, 2026
77c6fdc
Post-implementation review: backend hardening + frontend polish
May 16, 2026
3adc316
events: parameterize visibility clause (match posts.ts)
May 16, 2026
4b3cdf2
Merge pull request #13 from TransHistoria/default
cyanmint May 24, 2026
a7809ab
Simplify conditions for build and deploy jobs
cyanmint May 24, 2026
bebc3f5
chore: plan CSR + backend API fixes
Copilot May 24, 2026
158a55c
fix: make frontend fully CSR and resolve event API regressions
Copilot May 24, 2026
2db0b18
fix: revert event creation to TRUSTED, fix posts route order, add adm…
Copilot May 24, 2026
01516a9
test(api): add trusted fixture and /api/posts integration coverage
Copilot May 24, 2026
4f5f05a
ci: add worker deploy workflow and clarify API fallback host
Copilot May 24, 2026
638140e
plan: extend fixes for bookmarks menu grouping and event drafts
Copilot May 24, 2026
927e54a
fix: bookmarks route menu grouping and draft save/publish flows
Copilot May 24, 2026
099ace0
fix draft publish status add admin draft toggles and merge profile co…
Copilot May 24, 2026
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
4 changes: 2 additions & 2 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -5,8 +5,8 @@
# -----------------------------------------------------------------------

# Full base URL of the Cloudflare Worker backend, no trailing slash.
# If left empty or unset the frontend falls back to the default worker:
# https://transcommunity.cyanmint.workers.dev
# If left empty or unset the frontend falls back to:
# https://communityapi.transhistoria.org
NEXT_PUBLIC_API_URL=https://communityapi.transhistoria.org

# Optional secondary API base URL tried when the primary host is unreachable
Expand Down
2 changes: 0 additions & 2 deletions .github/workflows/deploy-pages.yml
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ concurrency:
jobs:
build:
name: Build static frontend
if: github.ref_name == github.event.repository.default_branch || startsWith(github.ref, 'refs/heads/copilot/')
runs-on: ubuntu-latest
steps:
- name: Checkout
Expand Down Expand Up @@ -64,7 +63,6 @@ jobs:

deploy:
name: Deploy to GitHub Pages
if: github.ref_name == github.event.repository.default_branch || startsWith(github.ref, 'refs/heads/copilot/')
needs: build
runs-on: ubuntu-latest
environment:
Expand Down
51 changes: 51 additions & 0 deletions .github/workflows/deploy-worker.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
name: Deploy Worker API

on:
push:
branches: ["default", "main"]
paths:
- "worker/**"
- "wrangler.jsonc"
- ".github/workflows/deploy-worker.yml"
workflow_dispatch:

concurrency:
group: worker-deploy
cancel-in-progress: true

jobs:
deploy:
name: Migrate + deploy Cloudflare Worker
runs-on: ubuntu-latest
permissions:
contents: read

steps:
- name: Checkout
uses: actions/checkout@v4

- name: Setup pnpm
uses: pnpm/action-setup@v4
with:
version: 9

- name: Setup Node.js
uses: actions/setup-node@v4
with:
node-version: "22"
cache: "pnpm"

- name: Install dependencies
run: pnpm install --frozen-lockfile

- name: Apply remote D1 migrations
run: pnpm run setup
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}

- name: Deploy worker
run: pnpm exec wrangler deploy
env:
CLOUDFLARE_API_TOKEN: ${{ secrets.CLOUDFLARE_API_TOKEN }}
CLOUDFLARE_ACCOUNT_ID: ${{ secrets.CLOUDFLARE_ACCOUNT_ID }}
64 changes: 62 additions & 2 deletions prisma/schema.prisma
Original file line number Diff line number Diff line change
Expand Up @@ -224,6 +224,16 @@ model Event {
// EventStatus: DRAFT | PUBLISHED | CANCELLED | FINISHED
status String @default("PUBLISHED")

// LLM moderation outcome (migration 0005)
moderationVerdict String?
moderationReason String?
moderationCategories String?
moderationRaw String?
moderationClassifier String?
moderatedAt DateTime?
reviewedById String?
reviewedAt DateTime?

registrations Registration[]
comments Comment[]

Expand All @@ -236,6 +246,51 @@ model Event {
@@index([visibility, status])
}

// ============================================================
// 帖子(POST 普通 / MEDICAL 医疗信息 / RESOURCE 资源分享)
// 与活动并列的发帖系统,由 LLM 自动审核 + 板块分类。
// ============================================================

model Post {
id String @id @default(cuid())
authorId String
// PostSection: POST | MEDICAL | RESOURCE
section String
title String
body String
// JSON array (free-form tags)
tags String @default("[]")
// MEDICAL-specific
hospital String?
doctor String?
city String?
// RESOURCE-specific: OFFER (technique on offer) | REQUEST (asking for help)
resourceKind String?
coverUrl String?

// Visibility: PUBLIC | VERIFIED | TRUSTED
visibility String @default("VERIFIED")
// PostStatus: PENDING_REVIEW | PUBLISHED | REJECTED | HIDDEN
status String @default("PENDING_REVIEW")

moderationVerdict String?
moderationReason String?
moderationCategories String?
moderationRaw String?
moderationClassifier String?
moderatedAt DateTime?
reviewedById String?
reviewedAt DateTime?

createdAt DateTime @default(now())
updatedAt DateTime @updatedAt

@@index([section, status])
@@index([authorId])
@@index([hospital])
@@index([createdAt])
}

model Registration {
id String @id @default(cuid())
eventId String
Expand All @@ -260,8 +315,12 @@ model Registration {

model Comment {
id String @id @default(cuid())
eventId String
event Event @relation(fields: [eventId], references: [id], onDelete: Cascade)
// Polymorphic: exactly one of eventId or postId is set.
// Schema kept nullable so Prisma can model both targets;
// application code enforces XOR.
eventId String?
event Event? @relation(fields: [eventId], references: [id], onDelete: Cascade)
postId String?
authorId String
author User @relation(fields: [authorId], references: [id])
body String
Expand All @@ -273,6 +332,7 @@ model Comment {
createdAt DateTime @default(now())

@@index([eventId])
@@index([postId])
@@index([authorId])
}

Expand Down
2 changes: 2 additions & 0 deletions src/app/(app)/events/[slug]/edit/EditEventPageClient.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ type ApiEvent = {
id: string;
slug: string;
title: string;
status: string;
description: string;
category: string;
format: string;
Expand Down Expand Up @@ -103,6 +104,7 @@ export default function EditEventPageClient() {
registrationOpensAt: event.registration_opens_at ?? null,
registrationClosesAt: event.registration_closes_at ?? null,
visibility: formVisibility,
status: event.status,
customQuestions: questions,
}}
/>
Expand Down
4 changes: 2 additions & 2 deletions src/app/(app)/events/edit/page.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import EditEventPageClient from "../[slug]/edit/EditEventPageClient";
"use client";

export const metadata = { title: "编辑活动" };
import EditEventPageClient from "../[slug]/edit/EditEventPageClient";

export default function EditEventStaticPage() {
return <EditEventPageClient />;
Expand Down
2 changes: 2 additions & 0 deletions src/app/(app)/events/manage/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import ManageEventPageClient from "../[slug]/manage/ManageEventPageClient";

export default function ManageEventStaticPage() {
Expand Down
2 changes: 2 additions & 0 deletions src/app/(app)/events/register/page.tsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
"use client";

import RegisterPageClient from "../[slug]/register/RegisterPageClient";

export default function RegisterEventStaticPage() {
Expand Down
56 changes: 56 additions & 0 deletions src/app/(app)/me/bookmarks/page.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
"use client";

import * as React from "react";
import { useRouter } from "next/navigation";
import { api, type Post } from "@/lib/api";
import { useAuth } from "@/contexts/AuthContext";
import { PageHeader } from "@/components/ui/page-header";
import { EmptyState } from "@/components/ui/empty";
import { PostCard } from "@/components/post/PostCard";
import { toQueryRoute } from "@/lib/query-routing";

export default function MyBookmarksPage() {
const { user, loading } = useAuth();
const router = useRouter();
const [posts, setPosts] = React.useState<Post[]>([]);
const [loaded, setLoaded] = React.useState(false);

React.useEffect(() => {
if (!loading && !user) router.replace(toQueryRoute("/sign-in?callbackUrl=/me/bookmarks"));
}, [loading, user, router]);

React.useEffect(() => {
if (!user) return;
api.posts
.myBookmarks()
.then((res) => setPosts(res.posts))
.catch(() => setPosts([]))
.finally(() => setLoaded(true));
}, [user]);

if (loading || !user) return null;

return (
<div className="space-y-8">
<PageHeader
eyebrow="我"
title="收藏的帖子"
description="只有你能看到。已被作者删除或隐藏的帖子不会出现在这里。"
/>
{!loaded ? (
<p className="text-sm text-ink-muted">加载中…</p>
) : posts.length === 0 ? (
<EmptyState
title="还没有收藏"
description="在帖子详情页点收藏按钮,这里就会显示。"
/>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{posts.map((p) => (
<PostCard key={p.id} post={p} />
))}
</div>
)}
</div>
);
}
26 changes: 7 additions & 19 deletions src/app/(app)/me/contacts/page.tsx
Original file line number Diff line number Diff line change
@@ -1,26 +1,14 @@
"use client";
import * as React from "react";
import { api } from "@/lib/api";
import { PageHeader } from "@/components/ui/page-header";
import { ContactsManager } from "./ContactsManager";
import { useRouter } from "next/navigation";
import { toQueryRoute } from "@/lib/query-routing";

export default function MeContactsPage() {
const [contacts, setContacts] = React.useState<unknown[]>([]);
const router = useRouter();

const load = React.useCallback(() => {
api.users.myContacts().then((res: { contacts: unknown[] }) => setContacts(res.contacts));
}, []);
React.useEffect(() => {
router.replace(toQueryRoute("/me/profile"));
}, [router]);

React.useEffect(() => { load(); }, [load]);

return (
<div className="max-w-2xl mx-auto space-y-6">
<PageHeader
eyebrow="我的资料"
title="联系方式"
description="管理你愿意展示给他人的联系方式,每一项都可以独立设置可见范围。"
/>
<ContactsManager contacts={contacts} onRefresh={load} />
</div>
);
return null;
}
Loading
Loading