Compare commits
17
Commits
bc93755f87
..
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
68d4f09b72 | ||
|
|
3e368e663e | ||
|
|
54ff5ba89d | ||
|
|
70725e3e52 | ||
|
|
79744667c6 | ||
|
|
804fd756e7 | ||
|
|
ab3f8fecc3 | ||
|
|
5b64422dfb | ||
|
|
30d118027c | ||
|
|
ffc95fd843 | ||
|
|
2bd777c0a0 | ||
|
|
8dbc18c392 | ||
|
|
f4ffd4abfb | ||
|
|
4bf9ccdc95 | ||
|
|
29a67a3375 | ||
|
|
ba3154af59 | ||
|
|
4b62f09d65 |
@@ -0,0 +1,12 @@
|
||||
.env.local
|
||||
.git
|
||||
.next/*
|
||||
!.next/standalone
|
||||
!.next/standalone/**
|
||||
!.next/standalone/node_modules
|
||||
!.next/standalone/node_modules/**
|
||||
!.next/static
|
||||
!.next/static/**
|
||||
.planning
|
||||
node_modules
|
||||
tsconfig.tsbuildinfo
|
||||
@@ -10,3 +10,8 @@ N8N_AI_SEARCH_WEBHOOK="https://n8n.mzaxd.fun/webhook/ai-search"
|
||||
# Internationalization
|
||||
NEXT_INTL_DEFAULT_LOCALE="zh"
|
||||
NEXT_INTL_SUPPORTED_LOCALES="zh,en"
|
||||
|
||||
# Optional self-hosted analytics
|
||||
NEXT_PUBLIC_UMAMI_HOST_URL="https://analytics.example.com"
|
||||
NEXT_PUBLIC_UMAMI_WEBSITE_ID="your-umami-website-id"
|
||||
NEXT_PUBLIC_UMAMI_DOMAINS="example.com,www.example.com"
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
# Architecture
|
||||
|
||||
**Analysis Date:** 2026-04-20
|
||||
|
||||
## Pattern Overview
|
||||
|
||||
**Overall:** Localized Next.js monolith with App Router pages, colocated JSON APIs, Prisma-backed read models, and an external automation perimeter for n8n and discovery-task execution.
|
||||
|
||||
**Key Characteristics:**
|
||||
- User-facing pages and internal JSON APIs ship from the same Next.js app under `src/app/`.
|
||||
- Server components and API handlers read through shared query/read-model modules in `src/hooks/useProjects.ts` and `src/hooks/useHome.ts`.
|
||||
- External automation is first-class context, but not fully implemented in-repo: `docs/integrations/n8n/*` and `.planning/codebase/N8N-*.md` document workflows that call a subset of repository APIs and also use external discovery services or direct database writes.
|
||||
|
||||
## Layers
|
||||
|
||||
**Route & Shell Layer:**
|
||||
- Purpose: Own URL structure, metadata, page composition, SEO documents, and locale-aware layout shells.
|
||||
- Location: `src/app/layout.tsx`, `src/app/[locale]/layout.tsx`, `src/app/[locale]/page.tsx`, `src/app/[locale]/projects/page.tsx`, `src/app/[locale]/projects/[id]/page.tsx`, `src/app/[locale]/signals/page.tsx`, `src/app/[locale]/about/page.tsx`, `src/app/sitemap.ts`, `src/app/robots.ts`, `src/middleware.ts`
|
||||
- Contains: App Router pages, `generateMetadata`, `generateStaticParams`, route-level `revalidate`, and top-level page composition.
|
||||
- Depends on: `next-intl`, `src/hooks/*`, reusable components, and Next.js runtime APIs.
|
||||
- Used by: Browser requests for `/<locale>/*`, `sitemap.xml`, and `robots.txt`.
|
||||
|
||||
**Client Interaction Layer:**
|
||||
- Purpose: Own browser-only state, URL syncing, incremental fetching, and interactive search/filter/feed behavior.
|
||||
- Location: `src/app/[locale]/projects/ProjectsPageClient.tsx`, `src/app/[locale]/projects/ProjectsResultsClient.tsx`, `src/components/search/AISearchBar.tsx`, `src/components/search/HomeSearchBar.tsx`, `src/components/search/AISearchResults.tsx`, `src/components/signals/SignalFeedClient.tsx`, `src/components/layout/AnnouncementBar.tsx`, `src/components/locale/LocaleSwitcher.tsx`, `src/app/VercelMetrics.tsx`
|
||||
- Contains: `"use client"` components, fetch calls to `/api/*`, local state, debounced input, history updates, and analytics instrumentation.
|
||||
- Depends on: Browser APIs, Next navigation hooks, and JSON returned by `src/app/api/*`.
|
||||
- Used by: Localized page routes that mount client islands after server rendering.
|
||||
|
||||
**Query / Read-Model Layer:**
|
||||
- Purpose: Encapsulate Prisma reads, filter normalization, retry logic, caching, and response shape flattening for pages and APIs.
|
||||
- Location: `src/hooks/useProjects.ts`, `src/hooks/useHome.ts`
|
||||
- Contains: `getProjects`, `getProjectBySlug`, `getProjectsByIds`, `getTagCategoryGroups`, `getFixedProjectTypeFilters`, `getTopTags`, and `getHomePageData`.
|
||||
- Depends on: `src/lib/prisma.ts`, `src/lib/cache.ts`, `src/lib/tag-taxonomy.ts`, Prisma types, and `next/cache`.
|
||||
- Used by: Server pages such as `src/app/[locale]/page.tsx` and APIs such as `src/app/api/projects/route.ts`.
|
||||
|
||||
**API Integration & Mutation Layer:**
|
||||
- Purpose: Expose JSON endpoints for browser fetches and authenticated automation/webhook writes.
|
||||
- Location: `src/app/api/projects/route.ts`, `src/app/api/projects/[slug]/route.ts`, `src/app/api/search/ai/route.ts`, `src/app/api/signals/route.ts`, `src/app/api/tags/route.ts`, `src/app/api/tags/maintenance/route.ts`, `src/app/api/tags/maintenance/service.ts`, `src/app/api/tags/reset-projects/route.ts`, `src/app/api/webhook/signals/route.ts`
|
||||
- Contains: Zod request validation, Prisma queries and transactions, n8n proxying, revalidation, and webhook ingestion.
|
||||
- Depends on: `src/hooks/*`, `src/lib/*`, Prisma, and external env/config for webhook integration.
|
||||
- Used by: Client components in this repo, n8n workflows, and other internal automation clients.
|
||||
|
||||
**Shared Domain & Infrastructure Layer:**
|
||||
- Purpose: Centralize cross-cutting rules and infrastructure helpers used by both pages and route handlers.
|
||||
- Location: `src/lib/auth.ts`, `src/lib/cache.ts`, `src/lib/prisma.ts`, `src/lib/prisma-url.ts`, `src/lib/signal-hotness.ts`, `src/lib/slug.ts`, `src/lib/tag-taxonomy.ts`, `src/lib/validations.ts`, `src/lib/github/badges.ts`, `src/i18n/request.ts`
|
||||
- Contains: API-key validation, Prisma bootstrap, cache fallback helpers, hotness scoring, slug generation, taxonomy metadata, and Zod schemas.
|
||||
- Depends on: Node APIs, Prisma, Zod, and Next.js server utilities.
|
||||
- Used by: Both `src/hooks/*` and `src/app/api/*`, plus selected components.
|
||||
|
||||
**Persistence Layer:**
|
||||
- Purpose: Define the application data model, migrations, and seed path.
|
||||
- Location: `prisma/schema.prisma`, `prisma/migrations/*`, `prisma/seed.ts`
|
||||
- Contains: PostgreSQL schema for `projects`, `external_links`, `tags`, `project_tags`, and `signals`, plus migration history and seed logic.
|
||||
- Depends on: Prisma tooling and `DATABASE_URL`.
|
||||
- Used by: `src/lib/prisma.ts` at runtime and Prisma CLI during migration/seed flows.
|
||||
|
||||
**External Automation Context Layer:**
|
||||
- Purpose: Document and constrain the parts of the system that live outside this repository.
|
||||
- Location: `docs/integrations/n8n/README.md`, `docs/integrations/n8n/registry.json`, `docs/integrations/n8n/DATAFLOW.md`, `docs/integrations/n8n/workflows/*.md`, `.planning/codebase/N8N-CONTEXT.md`, `.planning/codebase/N8N-DATAFLOW.md`, `scripts/generate-n8n-context.mjs`
|
||||
- Contains: Workflow inventory, request/response contracts, source-to-DB/API/UI flows, and generated mirrors for GSD.
|
||||
- Depends on: External n8n workflows, registry metadata, and repository scanning.
|
||||
- Used by: Humans and GSD agents to understand how external pipelines affect the in-repo app.
|
||||
|
||||
## Data Flow
|
||||
|
||||
**Localized Page Render:**
|
||||
|
||||
1. `src/middleware.ts` applies locale-prefixed routing for non-API, non-static requests.
|
||||
2. `src/app/[locale]/layout.tsx` validates the locale, runs `setRequestLocale(locale)`, loads messages from `src/i18n/request.ts`, and wraps the subtree in `NextIntlClientProvider`.
|
||||
3. Route pages such as `src/app/[locale]/page.tsx`, `src/app/[locale]/projects/page.tsx`, `src/app/[locale]/projects/[id]/page.tsx`, and `src/app/[locale]/signals/page.tsx` fetch server data or mount client fetchers.
|
||||
4. Reusable components under `src/components/*` render the localized UI.
|
||||
|
||||
**Traditional Projects Browse Flow:**
|
||||
|
||||
1. `src/app/[locale]/projects/page.tsx` normalizes search params for `search`, `tags`, `domains`, `productForms`, `projectType`, `sort`, `page`, and `limit`.
|
||||
2. The page calls `getProjects`, `getFixedProjectTypeFilters`, and `getTagCategoryGroups` from `src/hooks/useProjects.ts`.
|
||||
3. `src/app/[locale]/projects/ProjectsPageClient.tsx` controls search-mode switching and filter-panel expansion.
|
||||
4. `src/app/[locale]/projects/ProjectsResultsClient.tsx` fetches updated pages from `src/app/api/projects/route.ts` and keeps the URL query string in sync with browser state.
|
||||
|
||||
**AI Search Flow:**
|
||||
|
||||
1. `src/components/search/AISearchBar.tsx` captures the search term and mode.
|
||||
2. `src/app/[locale]/projects/ProjectsResultsClient.tsx` posts the normalized request to `src/app/api/search/ai/route.ts`.
|
||||
3. `src/app/api/search/ai/route.ts` validates the body with `ProjectQuerySchema`, forwards a GET request to the external n8n webhook from `N8N_AI_SEARCH_WEBHOOK`, and receives ranked `{id, similarity}` candidates.
|
||||
4. The route hydrates those IDs from PostgreSQL through `getProjectsByIds` in `src/hooks/useProjects.ts`, reapplies local sorting/filtering rules, and returns the final JSON payload.
|
||||
|
||||
**Project Detail Flow:**
|
||||
|
||||
1. `src/app/[locale]/projects/[id]/page.tsx` resolves the route param as a slug and caches `getProjectBySlug` with `react` `cache()`.
|
||||
2. The page fetches the selected project and a small recent-project set through `getProjects({ limit: 3 })`.
|
||||
3. `src/components/project/ProjectDetail.tsx`, `src/components/project/ProjectSidebar.tsx`, and `src/components/project/RelatedProjects.tsx` render the long-form page.
|
||||
4. `generateMetadata` in the same file derives title/description from the fetched project.
|
||||
|
||||
**Signals Read Flow:**
|
||||
|
||||
1. `src/app/[locale]/signals/page.tsx` renders the shell and mounts `src/components/signals/SignalFeedClient.tsx`.
|
||||
2. `SignalFeedClient` manages debounced text search, source filters, sort mode, cursor pagination, and incremental loading in browser state.
|
||||
3. The client calls `GET /api/signals` on `src/app/api/signals/route.ts`.
|
||||
4. `src/app/api/signals/route.ts` validates the request with `SignalQuerySchema`, queries Prisma, converts JSON sections/tags into view models, and uses `src/lib/signal-hotness.ts` when `hotScore`/`isHot` columns are missing or unavailable.
|
||||
|
||||
**Signals Ingestion Flow:**
|
||||
|
||||
1. External automation posts batches to `src/app/api/webhook/signals/route.ts`.
|
||||
2. The route validates the top-level payload with `SignalWebhookPayloadSchema` and each item with `SignalIngestionInputSchema` from `src/lib/validations.ts`.
|
||||
3. `src/lib/auth.ts` checks the shared API key with constant-time comparison.
|
||||
4. The handler computes fallback hotness when needed and upserts each signal into Prisma.
|
||||
|
||||
**Tag Governance Flow:**
|
||||
|
||||
1. Automation or internal tools call `POST /api/tags/maintenance` or `POST /api/tags/reset-projects`.
|
||||
2. `src/app/api/tags/maintenance/route.ts` delegates merge/update logic to `src/app/api/tags/maintenance/service.ts` inside a transaction.
|
||||
3. `src/app/api/tags/reset-projects/route.ts` validates requested tag slugs, resolves project/tag IDs, performs transactional tag replacement, and tracks per-project results.
|
||||
4. Both mutation routes call `revalidatePath` for affected localized pages after successful writes.
|
||||
|
||||
**Home Aggregate Flow:**
|
||||
|
||||
1. `src/app/[locale]/page.tsx` calls `getProjects({ limit: 6 })` and `getHomePageData()` in parallel.
|
||||
2. `src/hooks/useHome.ts` builds counts, rankings, top tags, and recent timeline data from Prisma with `unstable_cache`.
|
||||
3. Home sections under `src/components/home/*` render those aggregates.
|
||||
|
||||
**External Discovery / Ingestion Boundary:**
|
||||
|
||||
1. Workflow specifications in `docs/integrations/n8n/workflows/01-topic-discovery.md`, `docs/integrations/n8n/workflows/02-github-trending-discovery.md`, and `docs/integrations/n8n/workflows/03-project-ingestion-multi-source.md` describe the discovery queue and task lifecycle.
|
||||
2. The documented discovery endpoints include `POST /api/discovery/check-duplicates`, `POST /api/discovery/tasks`, `GET /api/discovery/tasks`, `PATCH /api/discovery/tasks/:id`, and `POST /api/discovery/tasks/:id/complete`.
|
||||
3. No `/api/discovery/*` implementation exists under `src/app/api/`; that queue/task service is external to this repository.
|
||||
4. This repository consumes the results of that pipeline through the shared PostgreSQL schema and its read APIs, not by executing the queue itself.
|
||||
|
||||
**Direct DB Maintenance Boundary:**
|
||||
|
||||
1. `docs/integrations/n8n/workflows/04-github-star-refresh.md` and `docs/integrations/n8n/workflows/05-project-description-vectorization.md` describe workflows that update `projects.githubStars`, `projects.githubStarsUpdatedAt`, `projects.embedding`, and `projects.embeddingUpdatedAt`.
|
||||
2. Those workflows are documented as direct database maintenance jobs, not as repository API routes.
|
||||
3. `src/app/api/search/ai/route.ts` and the home/projects pages consume the resulting columns after the external workflows finish.
|
||||
|
||||
**State Management:**
|
||||
- Persistent domain state lives in PostgreSQL as modeled in `prisma/schema.prisma`.
|
||||
- Cached server read state lives in `unstable_cache` wrappers in `src/hooks/useProjects.ts`, `src/hooks/useHome.ts`, and `src/app/api/tags/route.ts`, with fallback behavior from `src/lib/cache.ts`.
|
||||
- Client UI state lives in route-local/browser components such as `src/app/[locale]/projects/ProjectsResultsClient.tsx`, `src/app/[locale]/projects/ProjectsPageClient.tsx`, `src/components/search/AISearchBar.tsx`, and `src/components/signals/SignalFeedClient.tsx`.
|
||||
- The projects browse experience treats the query string as the canonical state for filters, sort, pagination, and AI mode.
|
||||
|
||||
## Key Abstractions
|
||||
|
||||
**Project Read Model:**
|
||||
- Purpose: Flatten Prisma join-table data into project objects that pages and APIs can render directly.
|
||||
- Examples: `ProjectWithFlatTags`, `getProjects`, `getProjectBySlug`, and `getProjectsByIds` in `src/hooks/useProjects.ts`
|
||||
- Pattern: Prisma `include` queries are transformed into a stable read model with `tags` flattened from `project_tags`.
|
||||
|
||||
**Home Aggregate Read Model:**
|
||||
- Purpose: Produce a homepage-specific summary instead of exposing raw Prisma rows to UI sections.
|
||||
- Examples: `HomePageData` and `getHomePageData` in `src/hooks/useHome.ts`
|
||||
- Pattern: Aggregate counters and ranked slices are computed once, cached, and returned in a UI-ready object graph.
|
||||
|
||||
**Tag Taxonomy:**
|
||||
- Purpose: Encode canonical tag categories, project-type presets, and category ordering rules in one place.
|
||||
- Examples: `src/lib/tag-taxonomy.ts`, `getTagCategoryGroups` in `src/hooks/useProjects.ts`, `isFixedProjectTypeSlug` in `src/lib/tag-taxonomy.ts`
|
||||
- Pattern: Shared domain vocabulary drives both browse filters and maintenance APIs.
|
||||
|
||||
**Signal View Model:**
|
||||
- Purpose: Convert stored signal rows plus JSON payloads into localized feed cards.
|
||||
- Examples: `parseSections` and `toSignalView` in `src/app/api/signals/route.ts`
|
||||
- Pattern: API-specific presentation mapping handles fallback localization, section parsing, and hotness derivation.
|
||||
|
||||
**Internal Webhook/Auth Guard:**
|
||||
- Purpose: Reuse constant-time API-key verification across internal write endpoints.
|
||||
- Examples: `isValidApiKey` in `src/lib/auth.ts`, used by `src/app/api/tags/maintenance/route.ts`, `src/app/api/tags/reset-projects/route.ts`, and `src/app/api/webhook/signals/route.ts`
|
||||
- Pattern: Small shared helper instead of a global auth middleware.
|
||||
|
||||
**Cache Fallback Wrapper:**
|
||||
- Purpose: Allow cached read paths to degrade to direct fetches when Incremental Cache is unavailable.
|
||||
- Examples: `runWithCacheFallback` in `src/lib/cache.ts`
|
||||
- Pattern: Wrap `unstable_cache` fetchers so the app still serves data in environments without Incremental Cache support.
|
||||
|
||||
**n8n Contract Registry:**
|
||||
- Purpose: Keep external workflow contracts reviewable inside the repo without pretending their runtime lives here.
|
||||
- Examples: `docs/integrations/n8n/registry.json`, `docs/integrations/n8n/DATAFLOW.md`, `.planning/codebase/N8N-CONTEXT.md`
|
||||
- Pattern: External workflow metadata is committed and mirrored so architecture work can describe the full system boundary accurately.
|
||||
|
||||
## Entry Points
|
||||
|
||||
**Root HTML Shell:**
|
||||
- Location: `src/app/layout.tsx`
|
||||
- Triggers: Every page render.
|
||||
- Responsibilities: Global HTML/body shell, CSS import, and conditional analytics injection through `src/app/VercelMetrics.tsx`.
|
||||
|
||||
**Locale Middleware:**
|
||||
- Location: `src/middleware.ts`
|
||||
- Triggers: All non-API, non-static requests.
|
||||
- Responsibilities: Apply locale prefix rules and keep `/api` and asset paths out of locale routing.
|
||||
|
||||
**Localized App Shell:**
|
||||
- Location: `src/app/[locale]/layout.tsx`
|
||||
- Triggers: Every localized page render.
|
||||
- Responsibilities: Locale validation, translations, header/footer shell, announcement bar, and provider setup.
|
||||
|
||||
**Home Route:**
|
||||
- Location: `src/app/[locale]/page.tsx`
|
||||
- Triggers: `GET /zh` and `GET /en`
|
||||
- Responsibilities: Render the home hero plus cached rankings and featured projects.
|
||||
|
||||
**Projects Route:**
|
||||
- Location: `src/app/[locale]/projects/page.tsx`
|
||||
- Triggers: `GET /<locale>/projects`
|
||||
- Responsibilities: Parse query-string filters, fetch initial browse data, and mount the projects client islands.
|
||||
|
||||
**Project Detail Route:**
|
||||
- Location: `src/app/[locale]/projects/[id]/page.tsx`
|
||||
- Triggers: `GET /<locale>/projects/:slug`
|
||||
- Responsibilities: Fetch one project, render detail/sidebar sections, derive related-project cards, and emit metadata.
|
||||
|
||||
**Signals Route:**
|
||||
- Location: `src/app/[locale]/signals/page.tsx`
|
||||
- Triggers: `GET /<locale>/signals`
|
||||
- Responsibilities: Render the signals page shell and mount the client-side feed loader.
|
||||
|
||||
**JSON API Surface:**
|
||||
- Location: `src/app/api/*`
|
||||
- Triggers: Browser fetches and automation/webhook requests.
|
||||
- Responsibilities: Read endpoints for projects/tags/signals, AI search proxying, authenticated tag maintenance, project-tag reset, and signal ingestion.
|
||||
|
||||
**n8n Context Generator:**
|
||||
- Location: `scripts/generate-n8n-context.mjs`
|
||||
- Triggers: `pnpm n8n:context`
|
||||
- Responsibilities: Read `docs/integrations/n8n/registry.json`, scan `src/` for n8n/webhook touchpoints, and regenerate `docs/integrations/n8n/CONTEXT.generated.md` plus `.planning/codebase/N8N-CONTEXT.md`.
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Strategy:** Validate inputs early, return structured JSON errors from route handlers, use `notFound()` for invalid route resources, and degrade selected read paths to fallback data rather than failing the whole page render.
|
||||
|
||||
**Patterns:**
|
||||
- Use shared Zod schemas from `src/lib/validations.ts` in `src/app/api/search/ai/route.ts`, `src/app/api/signals/route.ts`, `src/app/api/tags/maintenance/route.ts`, `src/app/api/tags/reset-projects/route.ts`, and `src/app/api/webhook/signals/route.ts`.
|
||||
- Use `notFound()` in `src/app/[locale]/layout.tsx`, `src/app/[locale]/projects/[id]/page.tsx`, and `src/app/[locale]/[...catchAll]/page.tsx`.
|
||||
- Retry transient project/tag DB reads in `src/hooks/useProjects.ts` through `withDbRetry`.
|
||||
- Degrade cached or aggregate reads in `src/hooks/useProjects.ts`, `src/hooks/useHome.ts`, and `src/lib/cache.ts` when the cache/runtime is unavailable.
|
||||
- Convert route-specific domain failures into `TagMaintenanceApiError` in `src/app/api/tags/maintenance/service.ts`.
|
||||
- Detect schema drift for signal hotness columns through `src/lib/signal-hotness.ts` and downgrade to fallback sorting/calculation.
|
||||
|
||||
## Cross-Cutting Concerns
|
||||
|
||||
**Logging:** Use `console.error` and `console.warn` directly in server code such as `src/app/api/search/ai/route.ts`, `src/app/api/signals/route.ts`, `src/app/api/webhook/signals/route.ts`, `src/hooks/useProjects.ts`, and `src/hooks/useHome.ts`.
|
||||
|
||||
**Validation:** Centralize public and internal payload/query contracts in `src/lib/validations.ts`.
|
||||
|
||||
**Authentication:** Protect mutation/webhook endpoints with shared API-key validation from `src/lib/auth.ts`. No user/session auth layer is present in the inspected app architecture.
|
||||
|
||||
**Internationalization:** Keep locale routing and messages in `src/middleware.ts`, `src/i18n/request.ts`, `src/messages/en.json`, and `src/messages/zh.json`.
|
||||
|
||||
**Caching & Revalidation:** Use `unstable_cache`, route-level `revalidate = 300`, and `revalidatePath` after tag mutations.
|
||||
|
||||
**SEO & Discovery:** Build `sitemap.xml` from `getProjects` in `src/app/sitemap.ts` and block `/api/*` from indexing in `src/app/robots.ts`.
|
||||
|
||||
**Observability:** Restrict Vercel Analytics and Speed Insights to locale-prefixed user-facing routes in `src/app/VercelMetrics.tsx`.
|
||||
|
||||
**External System Boundary:** Treat n8n workflows and discovery-task services as external systems unless a concrete route exists under `src/app/api/`. The repo documents those systems in `docs/integrations/n8n/*` and `.planning/codebase/N8N-*.md`, but does not execute the queue/task infrastructure itself.
|
||||
|
||||
---
|
||||
|
||||
*Architecture analysis: 2026-04-20*
|
||||
@@ -0,0 +1,216 @@
|
||||
# Codebase Concerns
|
||||
|
||||
**Analysis Date:** 2026-04-20
|
||||
|
||||
## Tech Debt
|
||||
|
||||
**Oversized mixed-responsibility modules:**
|
||||
- Issue: Database access, retry policy, cache fallback, query normalization, and response shaping are combined in the same modules instead of being split into smaller server-side layers.
|
||||
- Files: `src/hooks/useProjects.ts`, `src/hooks/useHome.ts`, `src/app/[locale]/projects/ProjectsResultsClient.tsx`, `src/components/project/TagFilterPanel.tsx`, `src/components/signals/SignalFeedClient.tsx`, `src/app/[locale]/layout.tsx`
|
||||
- Impact: Safe changes require understanding unrelated concerns at once, so regressions in caching, pagination, or rendering are easy to introduce.
|
||||
- Fix approach: Split server data access into `src/lib/` or `src/server/`, keep client state in client components only, and extract URL/query helpers plus cache wrappers into dedicated modules.
|
||||
|
||||
**Server-only data modules are mislabeled as hooks:**
|
||||
- Issue: `src/hooks/useProjects.ts` and `src/hooks/useHome.ts` are not React hooks. They contain Prisma queries, `unstable_cache`, retry logic, and server-only fallback behavior.
|
||||
- Files: `src/hooks/useProjects.ts`, `src/hooks/useHome.ts`
|
||||
- Impact: The naming invites accidental client imports and hides the real server/client boundary during refactors.
|
||||
- Fix approach: Move these modules to a server-oriented location and reserve `src/hooks/` for actual React hooks.
|
||||
|
||||
**n8n workflow state is documented, but executable exports are still missing:**
|
||||
- Issue: The repository now carries rich n8n metadata and generated context, but `docs/integrations/n8n/exports/` contains only `.gitkeep` and every workflow entry currently records `Export file: not recorded`.
|
||||
- Files: `docs/integrations/n8n/README.md`, `docs/integrations/n8n/registry.json`, `docs/integrations/n8n/CONTEXT.generated.md`, `docs/integrations/n8n/exports/.gitkeep`, `scripts/generate-n8n-context.mjs`
|
||||
- Impact: The repo captures contracts and touchpoints, but not the actual workflow logic. Incident response, review, and reproducibility still depend on external n8n access.
|
||||
- Fix approach: Commit workflow exports alongside `registry.json`, treat them as versioned artifacts, and keep `pnpm n8n:context` as a verification step instead of the primary source of truth.
|
||||
|
||||
**Repository instructions drift from the actual toolchain:**
|
||||
- Issue: `AGENTS.md` documents `pnpm test:e2e` and an `e2e/` suite, but `package.json` has no `test:e2e` script and the repo contains no `playwright.config.*` or `e2e/` directory.
|
||||
- Files: `AGENTS.md`, `package.json`
|
||||
- Impact: Contributors can assume browser-level regression coverage exists when it does not.
|
||||
- Fix approach: Either add the documented Playwright harness or remove the claim from `AGENTS.md` so verification expectations match reality.
|
||||
|
||||
## Known Bugs
|
||||
|
||||
**Related projects are picked from the newest three projects, not the actual related set:**
|
||||
- Symptoms: The project detail page fetches `getProjects({ limit: 3 })` and then filters that tiny subset for shared tags.
|
||||
- Files: `src/app/[locale]/projects/[id]/page.tsx`, `src/hooks/useProjects.ts`
|
||||
- Trigger: Open a project whose genuinely related items are not among the newest three active projects.
|
||||
- Workaround: None in code. The page simply shows fewer or zero related projects.
|
||||
|
||||
**AI search route crashes at module initialization when its env var is missing:**
|
||||
- Symptoms: Importing the route throws before handling a request because `process.env.N8N_AI_SEARCH_WEBHOOK` is read at module scope and hard-fails.
|
||||
- Files: `src/app/api/search/ai/route.ts`
|
||||
- Trigger: Start the app, build, or run tests without `N8N_AI_SEARCH_WEBHOOK` configured.
|
||||
- Workaround: Provide the env var in every environment that loads the route.
|
||||
|
||||
**Visible navigation and conversion entry points are placeholders:**
|
||||
- Symptoms: Submit-project, footer resource/legal links, social links, and the newsletter form do not connect to real destinations or handlers. The mobile menu button also has no behavior.
|
||||
- Files: `src/app/[locale]/layout.tsx`, `src/components/project/ProjectCard.tsx`
|
||||
- Trigger: Click the submit CTA, footer links, social buttons, or submit the newsletter form on any localized page.
|
||||
- Workaround: None in code. Users stay on the same page or submit to a no-op form target.
|
||||
|
||||
## Security Considerations
|
||||
|
||||
**Admin and ingestion endpoints depend on a single shared API key passed in request bodies:**
|
||||
- Risk: The code checks only equality against `WEBHOOK_API_KEY`. There is no request signature, timestamp, nonce, replay protection, or application-side rate limiting.
|
||||
- Files: `src/lib/auth.ts`, `src/app/api/webhook/signals/route.ts`, `src/app/api/tags/maintenance/route.ts`, `src/app/api/tags/reset-projects/route.ts`, `docs/integrations/n8n/CONTEXT.generated.md`, `docs/integrations/n8n/workflows/07-signals-aggregation.md`, `docs/integrations/n8n/workflows/08-project-tag-reset.md`
|
||||
- Current mitigation: `src/lib/auth.ts` uses `crypto.timingSafeEqual`, and the n8n docs call out the shared-secret problem explicitly.
|
||||
- Recommendations: Move to HMAC-signed requests or provider-native webhook verification, reject stale timestamps, and apply rate limiting at the app or edge layer.
|
||||
|
||||
**AI search forwards user queries to n8n in a GET query string:**
|
||||
- Risk: Search text and filters are serialized into the webhook URL, which is more likely to be logged by reverse proxies, platforms, and third-party tooling.
|
||||
- Files: `src/app/api/search/ai/route.ts`, `docs/integrations/n8n/workflows/06-rag-project-search.md`
|
||||
- Current mitigation: Not detected in code.
|
||||
- Recommendations: Switch to `POST`, move request data into the body, and document the retention/logging expectations for the n8n side.
|
||||
|
||||
**Runtime secret handling spreads certificate material and credentials outside the repo boundary:**
|
||||
- Risk: Database TLS assets are reconstructed from env vars onto disk at runtime and the client identity password is appended to the Prisma datasource URL. The n8n docs also confirm workflows that need app secrets or raw database access outside this repository.
|
||||
- Files: `src/lib/prisma-url.ts`, `src/lib/prisma.ts`, `docs/integrations/n8n/CONTEXT.generated.md`, `docs/integrations/n8n/workflows/04-github-star-refresh.md`, `docs/integrations/n8n/workflows/05-project-description-vectorization.md`
|
||||
- Current mitigation: `src/lib/prisma-url.ts` writes files with restrictive permissions and uses an environment-configurable certificate directory.
|
||||
- Recommendations: Prefer mounted secrets over reconstructing certs in temp storage, keep passwords out of DSN strings where possible, scope database users per workflow, and document secret ownership/rotation outside the app.
|
||||
|
||||
**Raw markdown content can load remote images from arbitrary hosts:**
|
||||
- Risk: Markdown rendering sanitizes HTML, but it still renders remote `<img>` URLs directly. If project content is not fully trusted, remote hosts can observe client IPs and referrers.
|
||||
- Files: `src/components/project/MarkdownContent.tsx`
|
||||
- Current mitigation: `rehype-sanitize` removes unsafe HTML and external links use `rel="noopener noreferrer"`.
|
||||
- Recommendations: Proxy images, restrict allowed hosts, or disable markdown image rendering for untrusted content.
|
||||
|
||||
## Performance Bottlenecks
|
||||
|
||||
**Signal ingestion performs per-item existence checks and upserts sequentially:**
|
||||
- Problem: Each signal performs validation, `findUnique`, and `upsert` inside a loop, with optional retry into a second upsert path when hotness columns are unavailable.
|
||||
- Files: `src/app/api/webhook/signals/route.ts`
|
||||
- Cause: The route is optimized for straightforward per-item error reporting, not for throughput.
|
||||
- Improvement path: Bulk-load existing keys, batch inserts/updates, and move high-volume ingestion to a queue or transactional batch writer.
|
||||
|
||||
**Project and signal search rely on case-insensitive `contains` scans instead of search-specific indexes:**
|
||||
- Problem: Search touches multiple text fields with `contains`, while `prisma/schema.prisma` indexes sorting/filter columns but no text-search structures.
|
||||
- Files: `src/hooks/useProjects.ts`, `src/app/api/signals/route.ts`, `prisma/schema.prisma`
|
||||
- Cause: Search is implemented as application-level substring matching over Prisma filters.
|
||||
- Improvement path: Add PostgreSQL full-text or trigram indexes, or move search to a dedicated retrieval service.
|
||||
|
||||
**AI search does extra in-memory filtering and O(n²) result hydration after the webhook call:**
|
||||
- Problem: `src/app/api/search/ai/route.ts` fetches candidate IDs from n8n, loads projects from the database, repeatedly calls `projects.find(...)` to restore ranking order, and then re-applies tag/domain/product-form filtering in memory.
|
||||
- Files: `src/app/api/search/ai/route.ts`, `src/hooks/useProjects.ts`
|
||||
- Cause: Responsibility is split awkwardly between n8n ranking and route-side post-processing.
|
||||
- Improvement path: Use an ID-to-project map, keep filtering ownership on one side of the contract, and avoid loading more rows than the final page needs.
|
||||
|
||||
**Graceful-degradation fallbacks can return empty or zero-shaped data under DB failure:**
|
||||
- Problem: Homepage and project metadata helpers catch database errors and degrade to empty arrays or zero counts.
|
||||
- Files: `src/hooks/useProjects.ts`, `src/hooks/useHome.ts`
|
||||
- Cause: The code prefers availability over surfacing failures and uses the same modules for cached and uncached access.
|
||||
- Improvement path: Separate failure-aware fetchers from UI fallback shaping, emit explicit telemetry, and avoid treating outage-shaped responses as normal product data.
|
||||
|
||||
## Fragile Areas
|
||||
|
||||
**Projects results UI maintains two overlapping state machines in one client component:**
|
||||
- Files: `src/app/[locale]/projects/ProjectsResultsClient.tsx`, `src/app/api/projects/route.ts`, `src/app/api/search/ai/route.ts`
|
||||
- Why fragile: Traditional search and AI search keep separate pagination, sort, loading, error, and URL-sync state inside the same file. The component also mutates the browser URL with `window.history.replaceState`, which is easy to desynchronize from server-rendered state.
|
||||
- Safe modification: Change only one search mode at a time, verify deep-linking after every edit, and extract shared query-state helpers before adding more filters or sorts.
|
||||
- Test coverage: No tests detected for `src/app/[locale]/projects/ProjectsResultsClient.tsx`, `src/app/api/projects/route.ts`, or `src/app/api/search/ai/route.ts`
|
||||
|
||||
**Direct database-writing n8n jobs bypass repository API routes and cache invalidation:**
|
||||
- Files: `docs/integrations/n8n/CONTEXT.generated.md`, `docs/integrations/n8n/workflows/04-github-star-refresh.md`, `docs/integrations/n8n/workflows/05-project-description-vectorization.md`, `prisma/schema.prisma`, `src/hooks/useProjects.ts`, `src/app/api/search/ai/route.ts`
|
||||
- Why fragile: The documented star-refresh and vectorization workflows write straight to Postgres instead of going through repository routes. That bypasses app-level validation, audit points, and any future route-based revalidation logic.
|
||||
- Safe modification: Treat the Prisma schema and n8n registry as one shared contract, and change database columns/indexes only with coordinated workflow updates plus runtime verification.
|
||||
- Test coverage: No contract or integration tests detected for these cross-system write paths
|
||||
|
||||
**The project ingestion loop is not self-contained in this repository:**
|
||||
- Files: `docs/integrations/n8n/DATAFLOW.md`, `docs/integrations/n8n/CONTEXT.generated.md`, `docs/integrations/n8n/workflows/01-topic-discovery.md`, `docs/integrations/n8n/workflows/02-github-trending-discovery.md`, `docs/integrations/n8n/workflows/03-project-ingestion-multi-source.md`
|
||||
- Why fragile: The docs explicitly depend on `/api/discovery/check-duplicates` and `/api/discovery/tasks*`, but there is no `src/app/api/discovery/` implementation in this repo. The repo depends on an upstream discovery service to stay operational.
|
||||
- Safe modification: Treat discovery endpoints as an external contract, version their request/response shapes, and avoid assuming local end-to-end reproducibility for ingestion work.
|
||||
- Test coverage: No in-repo tests can cover the full discovery-to-ingestion flow because the required service is absent here
|
||||
|
||||
**Bulk tag mutation endpoints couple validation, writes, and cache invalidation at the route layer:**
|
||||
- Files: `src/app/api/tags/maintenance/route.ts`, `src/app/api/tags/maintenance/service.ts`, `src/app/api/tags/reset-projects/route.ts`
|
||||
- Why fragile: A single request can mutate many tags or projects and immediately trigger localized page revalidation. Business rules, transactional writes, and cache invalidation are tightly coupled.
|
||||
- Safe modification: Preserve transaction boundaries, keep mutation rules covered with focused tests, and review every `revalidatePath` target before changing route semantics.
|
||||
- Test coverage: Unit-style route and service tests exist, but there are no broader integration tests across Prisma writes, cache invalidation, and localized page rendering
|
||||
|
||||
## Scaling Limits
|
||||
|
||||
**Offset pagination on projects degrades with table size:**
|
||||
- Current capacity: `src/hooks/useProjects.ts` uses `skip` and `take`, and `src/app/api/projects/route.ts` allows `limit` up to `100`.
|
||||
- Limit: Deep pages require larger offset scans in PostgreSQL, especially when combined with tag joins and text filters.
|
||||
- Scaling path: Move project listings to cursor pagination keyed by indexed sort fields or restrict deep paging.
|
||||
|
||||
**Signals pagination is ordered by mutable ranking fields:**
|
||||
- Current capacity: `src/app/api/signals/route.ts` sorts hot feeds by `isHot`, `hotScore`, `engagement`, `publishedAt`, and `id`, but cursors by `id` only.
|
||||
- Limit: As new signals arrive or hotness changes, clients can observe duplicates or skips between pages because the rank can move independently of the cursor key.
|
||||
- Scaling path: Use a stable compound cursor that includes the sort fields or snapshot the ranking inputs for pagination windows.
|
||||
|
||||
**Bulk tag reset scales linearly in both writes and cache invalidations:**
|
||||
- Current capacity: `src/app/api/tags/reset-projects/route.ts` can process many projects in one request and revalidate both locale list pages plus every updated detail page.
|
||||
- Limit: Large batch operations increase request time and invalidation fan-out.
|
||||
- Scaling path: Batch revalidation, shift large maintenance jobs to background execution, or use broader cache-tag invalidation when available.
|
||||
|
||||
## Dependencies at Risk
|
||||
|
||||
**External discovery services are mandatory but not versioned here:**
|
||||
- Risk: Project discovery, dedupe, and task lifecycle all depend on endpoints that are documented but not implemented in this repo.
|
||||
- Impact: Local development, replay, debugging, and disaster recovery are incomplete without a second system.
|
||||
- Migration plan: Either bring `src/app/api/discovery/*` into the repo or maintain a separately versioned API contract with tests and operational ownership.
|
||||
|
||||
**AI search depends on an external n8n webhook contract with no local fallback:**
|
||||
- Risk: `/api/search/ai` assumes the remote workflow exists, responds quickly, and preserves the `results[].id` plus `similarity` schema.
|
||||
- Impact: AI search becomes a single external point of failure and contract drift breaks the feature immediately.
|
||||
- Migration plan: Version the webhook contract in `docs/integrations/n8n/registry.json`, add contract tests around `src/app/api/search/ai/route.ts`, and return controlled degraded responses when the webhook is unavailable.
|
||||
|
||||
**Workflow metadata is committed, but workflow behavior still lives outside git:**
|
||||
- Risk: The repo carries registry entries and generated context, but not the executable n8n exports.
|
||||
- Impact: Reviewers can understand intent, but they still cannot reproduce or diff actual workflow logic from the repository alone.
|
||||
- Migration plan: Start committing workflow exports to `docs/integrations/n8n/exports/` and reference them from `docs/integrations/n8n/registry.json`.
|
||||
|
||||
**`unstable_cache` behavior still depends on runtime capabilities:**
|
||||
- Risk: The app uses `runWithCacheFallback` to catch environments where `unstable_cache` is unavailable.
|
||||
- Impact: Cache behavior differs across local development, tests, and deployed runtimes, which complicates debugging and performance expectations.
|
||||
- Migration plan: Centralize cache policy, document supported runtimes, and replace `unstable_cache` with stable APIs when the stack allows it.
|
||||
|
||||
## Missing Critical Features
|
||||
|
||||
**The repository does not contain a self-contained discovery ingestion loop:**
|
||||
- Problem: The n8n docs depend on discovery task endpoints that do not exist under `src/app/api/`, so the repo cannot run its own project-intake workflow end to end.
|
||||
- Blocks: End-to-end ingestion tests, local replay of failed discovery tasks, and full incident debugging from this repo alone.
|
||||
|
||||
**Browser-level regression coverage is missing:**
|
||||
- Problem: The repo ships no `playwright.config.*`, no `e2e/` directory, and no `test:e2e` script even though repository guidance claims they exist.
|
||||
- Blocks: Localized routing, multi-step filtering, AI search UX, and layout-level interaction regressions are not protected at the browser layer.
|
||||
|
||||
**User-facing submission and newsletter flows are still not implemented:**
|
||||
- Problem: The visible submit-project and newsletter UI does not connect to backend handlers or third-party providers.
|
||||
- Blocks: Users cannot submit projects, subscribe for updates, or rely on footer resource/legal/social destinations.
|
||||
|
||||
## Test Coverage Gaps
|
||||
|
||||
**Search and listing APIs remain untested:**
|
||||
- What's not tested: `GET /api/projects`, `GET /api/projects/[slug]`, `POST /api/search/ai`, and `GET /api/signals`
|
||||
- Files: `src/app/api/projects/route.ts`, `src/app/api/projects/[slug]/route.ts`, `src/app/api/search/ai/route.ts`, `src/app/api/signals/route.ts`
|
||||
- Risk: Pagination, sorting, env-missing behavior, cursor correctness, and external-service error handling can break unnoticed.
|
||||
- Priority: High
|
||||
|
||||
**Signal ingestion still has no route-level tests:**
|
||||
- What's not tested: Validation, per-item error accounting, hotness fallback, and write behavior in `POST /api/webhook/signals`
|
||||
- Files: `src/app/api/webhook/signals/route.ts`, `src/lib/signal-hotness.ts`
|
||||
- Risk: Ingestion regressions can silently drop, mis-rank, or partially write signals.
|
||||
- Priority: High
|
||||
|
||||
**n8n and discovery contracts have no automated verification in this repo:**
|
||||
- What's not tested: That `docs/integrations/n8n/registry.json`, `docs/integrations/n8n/CONTEXT.generated.md`, and route expectations stay aligned with live n8n workflows and the external discovery service.
|
||||
- Files: `docs/integrations/n8n/registry.json`, `docs/integrations/n8n/CONTEXT.generated.md`, `scripts/generate-n8n-context.mjs`, `src/app/api/search/ai/route.ts`, `src/app/api/tags/reset-projects/route.ts`, `src/app/api/webhook/signals/route.ts`
|
||||
- Risk: Cross-system contract drift is detected late, usually only after production failures.
|
||||
- Priority: High
|
||||
|
||||
**Large client components and shared layout have no regression tests:**
|
||||
- What's not tested: Filter toggling, URL synchronization, AI/traditional search mode switching, signal feed paging, and layout-level placeholder interactions
|
||||
- Files: `src/app/[locale]/projects/ProjectsResultsClient.tsx`, `src/components/project/TagFilterPanel.tsx`, `src/components/signals/SignalFeedClient.tsx`, `src/app/[locale]/layout.tsx`
|
||||
- Risk: UI regressions are likely because these files are state-heavy and branchy.
|
||||
- Priority: High
|
||||
|
||||
**Current tests focus on tag admin flows and low-level helpers, not main user journeys:**
|
||||
- What's not tested: Most database-backed pages and operational boundaries outside tag maintenance, auth comparison, validation schemas, and Prisma URL construction
|
||||
- Files: `src/app/api/tags/route.test.ts`, `src/app/api/tags/reset-projects/route.test.ts`, `src/app/api/tags/maintenance/route.test.ts`, `src/app/api/tags/maintenance/service.test.ts`, `src/lib/auth.test.ts`, `src/lib/validations.test.ts`, `src/lib/validations.tag-maintenance.test.ts`, `src/lib/prisma-url.test.ts`
|
||||
- Risk: The suite provides confidence for admin mutation helpers but not for the main product surfaces or external integration edges.
|
||||
- Priority: Medium
|
||||
|
||||
---
|
||||
|
||||
*Concerns audit: 2026-04-20*
|
||||
@@ -0,0 +1,126 @@
|
||||
# Coding Conventions
|
||||
|
||||
**Analysis Date:** 2026-04-20
|
||||
|
||||
## Naming Patterns
|
||||
|
||||
**Files:**
|
||||
- Use Next.js App Router filenames in `src/app`, including `page.tsx`, `layout.tsx`, and `route.ts`, as seen in `src/app/[locale]/page.tsx`, `src/app/[locale]/layout.tsx`, `src/app/api/projects/route.ts`, and `src/app/api/webhook/signals/route.ts`.
|
||||
- Use `PascalCase.tsx` for reusable components in `src/components`, for example `src/components/search/HomeSearchBar.tsx`, `src/components/project/ProjectDetail.tsx`, and `src/components/signals/SignalFeedClient.tsx`.
|
||||
- Use lower-case or kebab-case utility filenames in `src/lib`, for example `src/lib/auth.ts`, `src/lib/cache.ts`, `src/lib/signal-hotness.ts`, `src/lib/prisma-url.ts`, and `src/lib/tag-taxonomy.ts`.
|
||||
- Keep tests co-located and named `*.test.ts`, for example `src/lib/auth.test.ts`, `src/app/api/tags/route.test.ts`, and `src/app/api/tags/reset-projects/route.test.ts`.
|
||||
- Treat `src/hooks` as a mixed server query layer plus client hooks. `src/hooks/useProjects.ts` and `src/hooks/useHome.ts` are not React hooks despite the `use*` prefix.
|
||||
|
||||
**Functions:**
|
||||
- Use `camelCase` for helpers and query functions, such as `isValidApiKey` in `src/lib/auth.ts`, `normalizeProjectSort` in `src/hooks/useProjects.ts`, `parseSlugList` in `src/app/api/projects/route.ts`, and `getTimestamp` in `src/app/api/search/ai/route.ts`.
|
||||
- Reserve `PascalCase` for React components, prop interfaces, and domain error classes, such as `ProjectDetail` in `src/components/project/ProjectDetail.tsx` and `TagMaintenanceApiError` in `src/app/api/tags/maintenance/service.ts`.
|
||||
- Export route handlers as uppercase HTTP verbs from `src/app/api/**/route.ts`, for example `GET` in `src/app/api/tags/route.ts` and `POST` in `src/app/api/webhook/signals/route.ts`.
|
||||
|
||||
**Variables:**
|
||||
- Use `UPPER_SNAKE_CASE` for configuration constants and env-backed settings, such as `N8N_WEBHOOK_URL` in `src/app/api/search/ai/route.ts`, `TAGS_CACHE_REVALIDATE_SECONDS` in `src/app/api/tags/route.ts`, `DB_RETRY_DELAYS_MS` in `src/hooks/useProjects.ts`, and `ENV_KEYS` in `src/lib/prisma-url.test.ts`.
|
||||
- Use descriptive names for parsed and normalized input, such as `validatedQuery` in `src/app/api/projects/route.ts`, `validationResult` in `src/app/api/webhook/signals/route.ts`, and `normalizedTagSlugs` in `src/hooks/useProjects.ts`.
|
||||
|
||||
**Types:**
|
||||
- Prefer `type` aliases for Prisma payloads and request payload shapes, such as `ProjectWithFlatTags` in `src/hooks/useProjects.ts`, `SignalWebhookPayload` in `src/lib/validations.ts`, and `ResetProjectsRouteTxMock` in `src/app/api/tags/reset-projects/route.test.ts`.
|
||||
- Prefer `interface` for React props, such as `HomeSearchBarProps` in `src/components/search/HomeSearchBar.tsx` and `ProjectDetailProps` in `src/components/project/ProjectDetail.tsx`.
|
||||
|
||||
## Code Style
|
||||
|
||||
**Formatting:**
|
||||
- Follow `.prettierrc.json`: 2-space indentation, semicolons, double quotes, trailing commas `es5`, and `printWidth` 100.
|
||||
- `AGENTS.md` treats Prettier as authoritative and `pnpm` as the required package manager.
|
||||
- The codebase currently has mixed formatting. Files such as `src/lib/validations.ts`, `src/lib/prisma-url.ts`, and `src/app/api/tags/route.ts` match the configured double-quote style, while `src/lib/auth.ts`, `src/app/api/projects/route.ts`, and `src/app/api/search/ai/route.ts` still use single quotes and omit semicolons.
|
||||
- For new files, follow `.prettierrc.json`. When editing an existing file with a different quote style, either preserve the local file style for a surgical change or reformat the full file consistently.
|
||||
|
||||
**Linting:**
|
||||
- `.eslintrc.json` extends `next/core-web-vitals` and `prettier`.
|
||||
- Only `console.warn` and `console.error` are explicitly allowed by `no-console`. This matches the logging used in `src/app/api/tags/route.ts`, `src/app/api/search/ai/route.ts`, and `src/app/api/webhook/signals/route.ts`.
|
||||
- Current verification on 2026-04-20: `pnpm lint` passed with no warnings or errors.
|
||||
|
||||
## Import Organization
|
||||
|
||||
**Order:**
|
||||
1. Framework and platform imports first, such as `next/server`, `next/cache`, `zod`, `crypto`, `fs`, or `@prisma/client`, as seen in `src/app/api/webhook/signals/route.ts` and `src/lib/prisma-url.ts`.
|
||||
2. Internal alias imports from `@/` next, such as `@/lib/prisma`, `@/lib/validations`, and `@/hooks/useProjects`.
|
||||
3. Relative imports last, such as `./service` in `src/app/api/tags/maintenance/route.ts` and `./MarkdownContent` in `src/components/project/ProjectDetail.tsx`.
|
||||
|
||||
**Path Aliases:**
|
||||
- Use the `@/*` alias defined in `tsconfig.json` and mirrored in `vitest.config.ts`.
|
||||
- Prefer `@/` imports for anything under `src`, as seen throughout `src/app/api/tags/route.ts`, `src/app/api/tags/reset-projects/route.ts`, and `src/hooks/useProjects.ts`.
|
||||
|
||||
## API Validation and Auth
|
||||
|
||||
**Validation:**
|
||||
- Put shared Zod schemas in `src/lib/validations.ts`. Current examples include `ProjectInputSchema`, `SignalWebhookPayloadSchema`, `SignalQuerySchema`, `TagMaintenanceRequestSchema`, and `ProjectTagResetRequestSchema`.
|
||||
- Define route-local schemas only when the contract is route-specific, such as `ProjectsQuerySchema` in `src/app/api/projects/route.ts` and `N8NSearchResponseSchema` plus `AISearchRequestSchema` in `src/app/api/search/ai/route.ts`.
|
||||
- Use `.safeParse()` when the route should return a custom `400` response without exceptions, as in `src/app/api/tags/maintenance/route.ts`, `src/app/api/tags/reset-projects/route.ts`, and `src/app/api/webhook/signals/route.ts`.
|
||||
- Use `.parse()` when the route already catches `ZodError`, as in `src/app/api/projects/route.ts`, `src/app/api/signals/route.ts`, and `src/app/api/search/ai/route.ts`.
|
||||
- Use `z.coerce.number()` for query-string pagination and limits, as in `src/lib/validations.ts` and `src/app/api/projects/route.ts`.
|
||||
- Use `.superRefine()` for cross-record constraints such as duplicate tag IDs and self-merge prevention, as in `TagMergeSchema`, `TagMaintenanceRequestSchema`, and `ProjectTagResetRequestSchema` in `src/lib/validations.ts`.
|
||||
|
||||
**Webhook Auth:**
|
||||
- Use the timing-safe `isValidApiKey(...)` helper from `src/lib/auth.ts` for internal mutation and webhook routes.
|
||||
- Read the expected secret from `process.env.WEBHOOK_API_KEY` unless a test passes an explicit override.
|
||||
- Validate the API key format at schema level first, then authenticate with `isValidApiKey(...)`, as done in `src/app/api/tags/maintenance/route.ts`, `src/app/api/tags/reset-projects/route.ts`, and `src/app/api/webhook/signals/route.ts`.
|
||||
- Return `401` with a JSON body containing `success: false`, `error: "Unauthorized"`, and a `details` array on auth failure.
|
||||
|
||||
## Data Access
|
||||
|
||||
**Prisma:**
|
||||
- Use the shared Prisma client from `src/lib/prisma.ts`.
|
||||
- Keep read-heavy query composition in `src/hooks/useProjects.ts` and `src/hooks/useHome.ts`.
|
||||
- Keep write workflows transactional with `prisma.$transaction(...)`, as in `src/app/api/tags/maintenance/route.ts` and `src/app/api/tags/reset-projects/route.ts`.
|
||||
- Use explicit `include` and `select` clauses rather than broad model reads, as seen throughout `src/hooks/useProjects.ts` and `src/app/api/webhook/signals/route.ts`.
|
||||
|
||||
**Caching and Degrade Patterns:**
|
||||
- Use `unstable_cache` for repeatable server reads, as in `src/app/api/tags/route.ts` and `src/hooks/useProjects.ts`.
|
||||
- Route cached reads through `runWithCacheFallback` from `src/lib/cache.ts`.
|
||||
- Use retry helpers for transient DB reads, as in `withDbRetry(...)` and `isTransientDbError(...)` in `src/hooks/useProjects.ts`.
|
||||
- Degrade on schema drift where the route can still succeed, as in `src/app/api/webhook/signals/route.ts` falling back when hotness columns are unavailable.
|
||||
|
||||
## Error Handling
|
||||
|
||||
**Patterns:**
|
||||
- Wrap route handlers in `try/catch` and return JSON through `NextResponse.json(...)`, as seen in `src/app/api/projects/route.ts`, `src/app/api/search/ai/route.ts`, and `src/app/api/webhook/signals/route.ts`.
|
||||
- Return `400` for schema and query validation failures, `401` for invalid webhook keys, and `500` for unexpected failures.
|
||||
- Use route-local or domain-specific error classes when mutation logic needs structured status and details, as in `TagMaintenanceApiError` from `src/app/api/tags/maintenance/service.ts`.
|
||||
- Keep JSON error payloads stable enough for automation. Current routes usually emit `success`, `error`, `details`, and sometimes `message`, but the exact shape is not yet fully standardized across `src/app/api/**/route.ts`.
|
||||
|
||||
**Logging:**
|
||||
- Use `console.error` for failures and `console.warn` for operational summaries or degraded behavior, matching `.eslintrc.json` and examples in `src/app/api/tags/route.ts`, `src/hooks/useProjects.ts`, and `src/app/api/webhook/signals/route.ts`.
|
||||
- Avoid `console.log` in production code because lint warns on it.
|
||||
|
||||
## Comments
|
||||
|
||||
**When to Comment:**
|
||||
- Keep comments sparse and intent-focused. Existing comments mostly explain numbered handler steps, fallback rationale, or bilingual product context, as in `src/app/api/tags/maintenance/route.ts`, `src/hooks/useProjects.ts`, and `src/app/api/search/ai/route.ts`.
|
||||
- Use short JSDoc only where security or contract semantics matter, such as the timing-safe explanation above `isValidApiKey(...)` in `src/lib/auth.ts`.
|
||||
|
||||
## Function Design
|
||||
|
||||
**Size:**
|
||||
- The repository tolerates large query and route modules. Current examples include `src/hooks/useProjects.ts`, `src/app/api/signals/route.ts`, and `src/app/api/tags/reset-projects/route.ts`.
|
||||
- Keep helper functions close to the route or query layer before extracting a new module. Examples include `parseSlugList(...)` in `src/app/api/projects/route.ts`, `normalizeSlugList(...)` in `src/app/api/search/ai/route.ts`, and `toSectionsJson(...)` in `src/app/api/webhook/signals/route.ts`.
|
||||
|
||||
**Parameters:**
|
||||
- Prefer a single typed options object for non-trivial query helpers, as in `getProjects(...)` from `src/hooks/useProjects.ts`.
|
||||
- For route handlers, parse from `request.nextUrl.searchParams` or `await request.json()` once, then normalize into a validated object before passing deeper.
|
||||
|
||||
**Return Values:**
|
||||
- Return plain serializable objects from API routes and query helpers.
|
||||
- Flatten Prisma relation shapes before returning UI data, as done by `getProjects(...)` and `getProjectsByIds(...)` in `src/hooks/useProjects.ts`.
|
||||
|
||||
## Module Design
|
||||
|
||||
**Exports:**
|
||||
- Prefer named exports across shared modules and components. No barrel files were detected under `src`.
|
||||
- Use default exports mainly for App Router pages and layouts under `src/app`.
|
||||
|
||||
**Separation of Concerns:**
|
||||
- Keep Prisma-backed reads in `src/hooks/useProjects.ts` and `src/hooks/useHome.ts`.
|
||||
- Keep cross-cutting utilities in `src/lib`, such as `src/lib/auth.ts`, `src/lib/cache.ts`, `src/lib/prisma.ts`, and `src/lib/validations.ts`.
|
||||
- Keep mutation business logic in a local service file when the route would otherwise mix transport and domain rules. `src/app/api/tags/maintenance/route.ts` plus `src/app/api/tags/maintenance/service.ts` is the clearest existing pattern.
|
||||
|
||||
---
|
||||
|
||||
*Convention analysis: 2026-04-20*
|
||||
@@ -0,0 +1,153 @@
|
||||
# External Integrations
|
||||
|
||||
**Analysis Date:** 2026-04-20
|
||||
|
||||
## APIs & External Services
|
||||
|
||||
**In-Repo n8n Contracts:**
|
||||
- n8n AI search webhook - The repo-owned AI search proxy in `src/app/api/search/ai/route.ts` forwards validated search requests to the webhook URL in `N8N_AI_SEARCH_WEBHOOK`, then hydrates returned IDs from PostgreSQL via `src/hooks/useProjects.ts`.
|
||||
- SDK/Client: Native `fetch` in `src/app/api/search/ai/route.ts`
|
||||
- Auth: `N8N_AI_SEARCH_WEBHOOK`
|
||||
- Repo-side n8n contract registry - n8n workflow metadata and request/response contracts are committed in `docs/integrations/n8n/registry.json`, `docs/integrations/n8n/CONTEXT.generated.md`, `docs/integrations/n8n/DATAFLOW.md`, and per-workflow docs under `docs/integrations/n8n/workflows/*.md`.
|
||||
- SDK/Client: `scripts/generate-n8n-context.mjs`
|
||||
- Auth: None in repo; this is documentation and generation logic
|
||||
- GSD-facing n8n mirrors - Generated mirrors for planning tools live in `.planning/codebase/N8N-CONTEXT.md` and `.planning/codebase/N8N-DATAFLOW.md`.
|
||||
- SDK/Client: Generated by `pnpm n8n:context`
|
||||
- Auth: None in repo
|
||||
|
||||
**External-Upstream Workflow Boundaries:**
|
||||
- Discovery Task Service - The n8n docs explicitly describe external endpoints `GET/POST/PATCH /api/discovery/tasks`, `POST /api/discovery/check-duplicates`, and completion/failure callbacks, but this repo does not implement those handlers.
|
||||
- SDK/Client: No in-repo client package; boundary is documented in `docs/integrations/n8n/DATAFLOW.md` and `docs/integrations/n8n/workflows/03-project-ingestion-multi-source.md`
|
||||
- Auth: External to this repo
|
||||
- Topic Discovery and GitHub Trending Discovery - Upstream n8n workflows documented in `docs/integrations/n8n/CONTEXT.generated.md` and `docs/integrations/n8n/workflows/01-topic-discovery.md` / `02-github-trending-discovery.md` depend on GitHub Search and GitHub Trending, then enqueue tasks into the external discovery service.
|
||||
- SDK/Client: n8n runtime outside this repo
|
||||
- Auth: External n8n credentials / runtime env, not committed here
|
||||
- Project Ingestion (Multi-source) - Upstream n8n workflow documented in `docs/integrations/n8n/workflows/03-project-ingestion-multi-source.md` consumes discovery tasks, enriches data with browser/AI steps, and writes project results back outside this repo’s route layer.
|
||||
- SDK/Client: n8n runtime outside this repo
|
||||
- Auth: External n8n credentials / discovery-service auth
|
||||
|
||||
**External Data Sources Used Through n8n:**
|
||||
- GitHub Search API - Upstream dependency for topic discovery documented in `docs/integrations/n8n/CONTEXT.generated.md`.
|
||||
- SDK/Client: n8n workflow, not repository code
|
||||
- Auth: External GitHub credentials in n8n
|
||||
- GitHub Trending - Upstream source for trending discovery documented in `docs/integrations/n8n/CONTEXT.generated.md`.
|
||||
- SDK/Client: n8n scraping workflow
|
||||
- Auth: None implied for the public page
|
||||
- GitHub Repository API - Upstream source for star refresh documented in `docs/integrations/n8n/workflows/04-github-star-refresh.md`.
|
||||
- SDK/Client: n8n workflow, not repository code
|
||||
- Auth: External GitHub credentials in n8n
|
||||
- Hacker News, Reddit, arXiv, Product Hunt, and Hugging Face - Upstream sources for signals aggregation documented in `docs/integrations/n8n/workflows/07-signals-aggregation.md`.
|
||||
- SDK/Client: n8n workflow, not repository code
|
||||
- Auth: External n8n credentials as needed per source
|
||||
- SiliconFlow embeddings API - Upstream embedding provider referenced by the vectorization and RAG search docs in `docs/integrations/n8n/DATAFLOW.md` and `docs/integrations/n8n/CONTEXT.generated.md`.
|
||||
- SDK/Client: n8n workflow, not repository code
|
||||
- Auth: External embedding-service credentials in n8n
|
||||
|
||||
**Platform / Asset Services:**
|
||||
- Vercel Analytics - Client analytics are mounted in `src/app/VercelMetrics.tsx` and gated by `process.env.VERCEL_ENV` in `src/app/layout.tsx`.
|
||||
- SDK/Client: `@vercel/analytics`
|
||||
- Auth: Vercel-managed runtime integration
|
||||
- Vercel Speed Insights - Client performance telemetry is mounted beside analytics in `src/app/VercelMetrics.tsx`.
|
||||
- SDK/Client: `@vercel/speed-insights`
|
||||
- Auth: Vercel-managed runtime integration
|
||||
- Google Fonts and Material Icons - CSS imports in `src/app/globals.css` pull `Inter`, `Space Mono`, and Material Icons from `fonts.googleapis.com`.
|
||||
- SDK/Client: CSS `@import`
|
||||
- Auth: None
|
||||
- Shields.io - GitHub badge URLs are generated in `src/lib/github/badges.ts` and rendered in `src/components/project/ProjectCard.tsx`, `src/components/project/GitHubTextStatsCard.tsx`, and `src/components/project/ProjectSidebar.tsx`.
|
||||
- SDK/Client: URL construction only
|
||||
- Auth: None
|
||||
|
||||
## Data Storage
|
||||
|
||||
**Databases:**
|
||||
- PostgreSQL
|
||||
- Connection: `DATABASE_URL`
|
||||
- Client: Prisma via `src/lib/prisma.ts` and `@prisma/client`
|
||||
- Schema: `prisma/schema.prisma`
|
||||
- Connection hardening: `src/lib/prisma-url.ts` supports `PG_SSL_ROOT_CERT_B64`, `PG_SSL_IDENTITY_P12_B64`, `PG_SSL_IDENTITY_PASSWORD`, `PG_SSL_CERT_DIR`, and `PG_SSL_MODE`
|
||||
- Notes: `prisma/schema.prisma` stores a `vector` column on `Project.embedding`, and n8n docs state some workflows write directly to the database rather than calling repo routes
|
||||
|
||||
**File Storage:**
|
||||
- Local filesystem only
|
||||
- Evidence: No S3, Blob, Cloudinary, or object-storage SDK is declared in `package.json` or imported under `src/**/*`
|
||||
|
||||
**Caching:**
|
||||
- Built-in Next.js data cache
|
||||
- Service: `unstable_cache` in `src/hooks/useHome.ts`, `src/hooks/useProjects.ts`, and `src/app/api/tags/route.ts`
|
||||
- Client: Framework cache, not an external provider
|
||||
- External cache service: None detected
|
||||
|
||||
## Authentication & Identity
|
||||
|
||||
**Auth Provider:**
|
||||
- Custom shared-secret auth for machine-to-machine routes
|
||||
- Implementation: `src/lib/auth.ts` uses `crypto.timingSafeEqual` against `WEBHOOK_API_KEY`
|
||||
- Used by: `src/app/api/webhook/signals/route.ts`, `src/app/api/tags/maintenance/route.ts`, and `src/app/api/tags/reset-projects/route.ts`
|
||||
- End-user authentication: Not detected
|
||||
- Evidence: No session/auth provider package or auth middleware is committed in `package.json` or `src/**/*`
|
||||
|
||||
## Monitoring & Observability
|
||||
|
||||
**Error Tracking:**
|
||||
- None detected
|
||||
- Evidence: No Sentry, Datadog, Rollbar, Bugsnag, or OpenTelemetry package is declared in `package.json`
|
||||
|
||||
**Logs:**
|
||||
- Server logging uses `console.error` and `console.warn` in route handlers and server data code such as `src/app/api/search/ai/route.ts`, `src/app/api/webhook/signals/route.ts`, `src/app/api/tags/maintenance/route.ts`, `src/app/api/tags/reset-projects/route.ts`, and `src/hooks/useHome.ts`
|
||||
- Frontend telemetry uses Vercel Analytics and Speed Insights via `src/app/VercelMetrics.tsx`
|
||||
|
||||
## CI/CD & Deployment
|
||||
|
||||
**Hosting:**
|
||||
- Vercel
|
||||
- Evidence: `vercel.json` sets the `nextjs` framework, install/build commands, and region `hkg1`
|
||||
- Container deployment
|
||||
- Evidence: `Dockerfile`, `Dockerfile.runtime`, and `nixpacks.toml` define reproducible Node 22 builds and runtime startup
|
||||
|
||||
**CI Pipeline:**
|
||||
- Not detected in repo
|
||||
- Evidence: No committed `.github/` workflow directory and no other CI config file are present at repo root
|
||||
|
||||
## Environment Configuration
|
||||
|
||||
**Required env vars:**
|
||||
- `DATABASE_URL` - Prisma datasource in `prisma/schema.prisma`
|
||||
- `WEBHOOK_API_KEY` - Shared-secret validation in `src/lib/auth.ts`
|
||||
- `N8N_AI_SEARCH_WEBHOOK` - Outbound AI search proxy target in `src/app/api/search/ai/route.ts`
|
||||
- `NEXT_PUBLIC_SITE_URL` - Canonical URL base in `src/app/robots.ts` and `src/app/sitemap.ts`
|
||||
- `VERCEL_ENV` - Telemetry gating in `src/app/layout.tsx`
|
||||
- `PG_SSL_ROOT_CERT_B64`, `PG_SSL_IDENTITY_P12_B64`, `PG_SSL_IDENTITY_PASSWORD`, `PG_SSL_CERT_DIR`, `PG_SSL_MODE` - Optional Postgres SSL/mTLS parameters in `src/lib/prisma-url.ts`
|
||||
|
||||
**Secrets location:**
|
||||
- Local development secrets are expected in `.env.local` or `.env`; contents were not read
|
||||
- Production secrets are expected in deployment settings for Vercel or container hosting
|
||||
- n8n runtime credentials, upstream API tokens, and discovery-service secrets are external to this repo and are only described contractually in `docs/integrations/n8n/*.md`
|
||||
|
||||
## Webhooks & Callbacks
|
||||
|
||||
**Incoming:**
|
||||
- `POST /api/webhook/signals` in `src/app/api/webhook/signals/route.ts`
|
||||
- Purpose: Accept batched signal payloads from the signals aggregation workflow and upsert them into PostgreSQL
|
||||
- Auth: `WEBHOOK_API_KEY` in request body
|
||||
- `POST /api/tags/maintenance` in `src/app/api/tags/maintenance/route.ts`
|
||||
- Purpose: Apply tag updates/merges and revalidate list pages
|
||||
- Auth: `WEBHOOK_API_KEY` in request body
|
||||
- `POST /api/tags/reset-projects` in `src/app/api/tags/reset-projects/route.ts`
|
||||
- Purpose: Bulk-reset project tags by taxonomy category and revalidate list/detail pages
|
||||
- Auth: `WEBHOOK_API_KEY` in request body
|
||||
|
||||
**Outgoing:**
|
||||
- n8n AI search webhook
|
||||
- Source: `src/app/api/search/ai/route.ts`
|
||||
- Method: `GET`
|
||||
- Target: URL from `N8N_AI_SEARCH_WEBHOOK`
|
||||
- Browser-side external navigations and assets
|
||||
- Sources: `src/app/globals.css`, `src/lib/github/badges.ts`, `src/components/project/GitHubTextStatsCard.tsx`
|
||||
- Targets: `fonts.googleapis.com`, `img.shields.io`, and `github.com`
|
||||
- External-upstream discovery service calls
|
||||
- Source of truth: Documented in `docs/integrations/n8n/DATAFLOW.md` and `docs/integrations/n8n/workflows/03-project-ingestion-multi-source.md`
|
||||
- Status: Not implemented by this repo; treat as upstream integration boundary
|
||||
|
||||
---
|
||||
|
||||
*Integration audit: 2026-04-20*
|
||||
@@ -0,0 +1,375 @@
|
||||
# N8N Context
|
||||
|
||||
Generated at: 2026-04-20T11:04:30.112Z
|
||||
|
||||
This file is generated from `docs/integrations/n8n/registry.json` plus repository scanning.
|
||||
It exists so external n8n workflows become committed, reviewable context for AI agents and GSD.
|
||||
|
||||
## Workflow Inventory
|
||||
|
||||
## AI Search
|
||||
|
||||
- Status: `confirmed`
|
||||
- ID: `ai-search`
|
||||
- Purpose: Resolve semantic search candidates from n8n and hydrate them into project results.
|
||||
- n8n workflow id: `F5cQ06DykBfpeyfqL-pd7`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- webhook: GET ai-search
|
||||
|
||||
### Repository Touchpoints
|
||||
- src/app/api/search/ai/route.ts
|
||||
|
||||
### Environment
|
||||
- N8N_AI_SEARCH_WEBHOOK
|
||||
|
||||
### Contracts
|
||||
- Request fields: `desc`, `limit`, `page`, `offset`, `tags`, `domains`, `productForms`
|
||||
- Response fields: `results[].id`, `results[].similarity`, `pagination.total`, `pagination.totalPages`, `pagination.hasMore`
|
||||
|
||||
### Schemas
|
||||
- N8NSearchResponseSchema
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: RAG项目搜索, pgvector similarity search, SiliconFlow embeddings
|
||||
- Downstreams: src/hooks/useProjects.ts#getProjectsByIds, POST /api/search/ai response
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: webhook path is `ai-search` and the workflow returns `results[].id` plus `similarity`.
|
||||
|
||||
## Signals Aggregation
|
||||
|
||||
- Status: `confirmed`
|
||||
- ID: `signals-aggregation`
|
||||
- Purpose: Aggregate multi-source discussion signals, filter them with AI, and ingest them into the repository signal store.
|
||||
- n8n workflow id: `bAxNZKGq2ApUUiw9`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- schedule: multi-source discussion crawl
|
||||
|
||||
### Repository Touchpoints
|
||||
- src/lib/auth.ts
|
||||
- src/app/api/webhook/signals/route.ts
|
||||
- src/app/api/signals/route.ts
|
||||
- src/lib/validations.ts
|
||||
- prisma/schema.prisma
|
||||
|
||||
### Environment
|
||||
- WEBHOOK_API_KEY
|
||||
|
||||
### Contracts
|
||||
- Request fields: `apiKey`, `signals[].source`, `signals[].sourceUrl`, `signals[].title`, `signals[].titleEn`, `signals[].summary`, `signals[].summaryEn`, `signals[].topic`, `signals[].topicEn`, `signals[].tags`, `signals[].sections`, `signals[].engagement`, `signals[].hotScore`, `signals[].isHot`, `signals[].publishedAt`, `signals[].isActive`
|
||||
- Response fields: `success`, `processed`, `created`, `updated`, `failed`, `errors[].index`, `errors[].field`, `errors[].message`
|
||||
|
||||
### Schemas
|
||||
- SignalWebhookPayloadSchema
|
||||
- SignalIngestionInputSchema
|
||||
- SignalQuerySchema
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: Hacker News, GitHub, arXiv, Reddit, Product Hunt, Hugging Face
|
||||
- Downstreams: GET /api/signals, signals page feed, signal hotness computation
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: workflow posts to `/api/webhook/signals` and also triggers external discovery dedupe/task creation. Workflow currently embeds a shared secret in HTTP body and should move to credentials/env.
|
||||
|
||||
## Project Tag Reset
|
||||
|
||||
- Status: `confirmed`
|
||||
- ID: `tag-reset`
|
||||
- Purpose: Reset selected project tags in bulk from n8n classification results.
|
||||
- n8n workflow id: `8tIgBqLyWrBewJPs`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- manual: bulk tag reset
|
||||
|
||||
### Repository Touchpoints
|
||||
- src/lib/auth.ts
|
||||
- src/app/api/tags/reset-projects/route.ts
|
||||
- src/lib/validations.ts
|
||||
- prisma/schema.prisma
|
||||
|
||||
### Environment
|
||||
- WEBHOOK_API_KEY
|
||||
|
||||
### Contracts
|
||||
- Request fields: `apiKey`, `dryRun`, `replaceAllCategories`, `categories`, `projects[].projectSlug`, `projects[].selectedTagSlugsByCategory`
|
||||
- Response fields: `success`, `result.dryRun`, `result.categories`, `result.updatedCount`, `result.failedCount`, `result.results[].projectSlug`, `result.results[].status`, `result.results[].details`
|
||||
|
||||
### Schemas
|
||||
- ProjectTagResetRequestSchema
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: n8n tag classification
|
||||
- Downstreams: project tag relations, project detail page revalidation, project list revalidation
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: workflow reads `/api/tags` and `/api/projects`, then posts bulk updates into `/api/tags/reset-projects`. Workflow currently embeds a shared secret in HTTP body and should move to credentials/env.
|
||||
|
||||
## Project Ingestion (Multi-source)
|
||||
|
||||
- Status: `external-upstream`
|
||||
- ID: `project-ingestion-multi-source`
|
||||
- Purpose: Consume queued discovery tasks, enrich project metadata with AI/browser steps, and write final ingestion results back to Agent Park.
|
||||
- n8n workflow id: `1Ig1CyVMsGJFaHOe`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- schedule: every 10 minutes
|
||||
|
||||
### Repository Touchpoints
|
||||
- prisma/schema.prisma
|
||||
- src/app/api/projects/route.ts
|
||||
- src/app/api/projects/[slug]/route.ts
|
||||
|
||||
### Environment
|
||||
- none
|
||||
|
||||
### Contracts
|
||||
- Request fields: `task.status`, `task.sourceUrl`, `task.sourceType`
|
||||
- Response fields: `project content`, `tag assignments`, `task completion status`
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: discovery task queue, browser/AI extraction, tag catalog
|
||||
- Downstreams: project records visible in repository APIs, task completion callbacks, task failure callbacks
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: workflow polls `/api/discovery/tasks`, marks tasks `IN_PROGRESS`, enriches candidates, then completes or fails tasks. The current repo does not contain `/api/discovery/*` handlers, so this is an upstream system dependency rather than a route implemented here.
|
||||
|
||||
## GitHub Star Refresh
|
||||
|
||||
- Status: `confirmed`
|
||||
- ID: `github-star-refresh`
|
||||
- Purpose: Refresh `projects.githubStars` and `projects.githubStarsUpdatedAt` directly from GitHub repository metadata.
|
||||
- n8n workflow id: `ewx9Gs6cjrTXvwD0`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- schedule: daily at 04:00
|
||||
|
||||
### Repository Touchpoints
|
||||
- prisma/schema.prisma
|
||||
- src/app/api/search/ai/route.ts
|
||||
- src/hooks/useProjects.ts
|
||||
|
||||
### Environment
|
||||
- none
|
||||
|
||||
### Contracts
|
||||
- Request fields: `projects.id`, `projects.slug`, `external_links.url(type=GITHUB)`
|
||||
- Response fields: `projects.githubStars`, `projects.githubStarsUpdatedAt`
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: GitHub repository API, projects table, external_links table
|
||||
- Downstreams: project ranking, star sorting, home ranking display
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: workflow reads active project GitHub links from Postgres, fetches repository metadata from GitHub, then writes star counts directly back to Postgres. This bypasses repository API routes.
|
||||
|
||||
## Project Description Vectorization
|
||||
|
||||
- Status: `confirmed`
|
||||
- ID: `project-description-vectorization`
|
||||
- Purpose: Generate and persist project embeddings used by semantic search.
|
||||
- n8n workflow id: `1AvejnM5n-WPApU1vFt9C`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- schedule: every 30 minutes
|
||||
|
||||
### Repository Touchpoints
|
||||
- prisma/schema.prisma
|
||||
- prisma/migrations/20260126000000_add_project_embedding/migration.sql
|
||||
- src/app/api/search/ai/route.ts
|
||||
|
||||
### Environment
|
||||
- none
|
||||
|
||||
### Contracts
|
||||
- Request fields: `projects.id`, `projects.name`, `projects.nameEn`, `projects.description`, `projects.descriptionEn`, `projects.content`, `projects.contentEn`
|
||||
- Response fields: `projects.embedding`, `projects.embeddingUpdatedAt`
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: SiliconFlow embeddings API, projects table
|
||||
- Downstreams: RAG项目搜索, semantic search quality
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: workflow selects active projects with null embeddings, generates `BAAI/bge-m3` vectors, and writes them directly into the `vector` column. This is a direct DB maintenance job, not a repository API route.
|
||||
|
||||
## GitHub Trending Discovery
|
||||
|
||||
- Status: `external-upstream`
|
||||
- ID: `github-trending-discovery`
|
||||
- Purpose: Scrape GitHub Trending, dedupe candidates, filter them with AI, and enqueue project discovery tasks.
|
||||
- n8n workflow id: `hughGsWismCpk7jd`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- schedule: daily at 01:00
|
||||
|
||||
### Repository Touchpoints
|
||||
- src/app/api/projects/route.ts
|
||||
- src/app/api/projects/[slug]/route.ts
|
||||
|
||||
### Environment
|
||||
- none
|
||||
|
||||
### Contracts
|
||||
- Request fields: `GitHub trending repository URL`, `apiKey`, `tasks[].sourceUrl`, `tasks[].sourceType`
|
||||
- Response fields: `dedupe shouldCreate`, `task creation result`
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: https://github.com/trending, AI keep/discard filter
|
||||
- Downstreams: /api/discovery/check-duplicates, /api/discovery/tasks, project ingestion queue
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: workflow scrapes GitHub Trending, filters candidates with an LLM, then posts queued tasks into discovery webhook endpoints. The current repo does not implement `/api/discovery/*`, so treat this as upstream data intake.
|
||||
|
||||
## Topic Discovery
|
||||
|
||||
- Status: `external-upstream`
|
||||
- ID: `topic-discovery`
|
||||
- Purpose: Search GitHub topics and keywords for agent/LLM engineering repos, dedupe them, and enqueue discovery tasks.
|
||||
- n8n workflow id: `iw9vx9ih5Lt0Mobk`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- schedule: daily at 01:00
|
||||
|
||||
### Repository Touchpoints
|
||||
- src/app/api/projects/route.ts
|
||||
- src/app/api/projects/[slug]/route.ts
|
||||
|
||||
### Environment
|
||||
- none
|
||||
|
||||
### Contracts
|
||||
- Request fields: `GitHub search query`, `apiKey`, `tasks[].sourceUrl`, `tasks[].sourceType`
|
||||
- Response fields: `dedupe shouldCreate`, `task creation result`, `low recall alert`
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: GitHub Search API, AI keep/discard filter, topic watchlist
|
||||
- Downstreams: /api/discovery/check-duplicates, /api/discovery/tasks, project ingestion queue
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: workflow searches agent, infra, observability, evaluation, and MCP-related repositories, then posts accepted candidates into discovery webhooks. The current repo does not implement `/api/discovery/*`, so this is upstream context rather than in-repo routing.
|
||||
|
||||
## AI Chat Gateway
|
||||
|
||||
- Status: `adjacent`
|
||||
- ID: `ai-chat-gateway`
|
||||
- Purpose: Expose a chat-oriented webhook wrapper around `RAG项目搜索` and package search hits into chat blocks/citations.
|
||||
- n8n workflow id: `Rncc22jmHEaYOG58`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- webhook: POST agent-park-chat
|
||||
|
||||
### Repository Touchpoints
|
||||
- none
|
||||
|
||||
### Environment
|
||||
- none
|
||||
|
||||
### Contracts
|
||||
- Request fields: `requestId`, `sessionId`, `clientId`, `locale`, `mode`, `message`
|
||||
- Response fields: `message.blocks`, `message.citations`, `message.meta.source`, `progress.stage`
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: RAG项目搜索, n8n webhook `ai-search`
|
||||
- Downstreams: external chat clients, project detail URLs
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: this workflow is related to Agent Park but does not call a repository route directly. It wraps the RAG webhook and formats citations pointing at project pages.
|
||||
|
||||
## Exported Workflow Files
|
||||
|
||||
- none
|
||||
|
||||
## Detected Repository Touchpoints
|
||||
|
||||
- `src/app/api/search/ai/route.ts` (39 matches)
|
||||
- L6: `const N8N_WEBHOOK_URL = process.env.N8N_AI_SEARCH_WEBHOOK!`
|
||||
- L8: `if (!N8N_WEBHOOK_URL) {`
|
||||
- L9: `throw new Error('N8N_AI_SEARCH_WEBHOOK environment variable is not set')`
|
||||
- L12: `// n8n 返回的搜索结果 Schema(统一格式)`
|
||||
- L13: `const N8NSearchResponseSchema = z.object({`
|
||||
|
||||
- `src/app/api/tags/maintenance/route.test.ts` (1 matches)
|
||||
- L65: `process.env.WEBHOOK_API_KEY = validApiKey;`
|
||||
|
||||
- `src/app/api/tags/reset-projects/route.test.ts` (1 matches)
|
||||
- L78: `process.env.WEBHOOK_API_KEY = validApiKey;`
|
||||
|
||||
- `src/app/api/webhook/signals/route.ts` (7 matches)
|
||||
- L13: `SignalWebhookPayloadSchema,`
|
||||
- L15: `type SignalWebhookPayload,`
|
||||
- L39: `const validationResult = SignalWebhookPayloadSchema.safeParse(body)`
|
||||
- L51: `const payload = validationResult.data as SignalWebhookPayload`
|
||||
- L184: `console.error(\`[Webhook Signals] Error at index ${i}:\`, error)`
|
||||
|
||||
- `src/hooks/useProjects.ts` (2 matches)
|
||||
- L563: `// n8n 返回的简化搜索结果类型`
|
||||
- L564: `export type N8NSearchResult = {`
|
||||
|
||||
- `src/lib/auth.ts` (1 matches)
|
||||
- L9: `expectedApiKey: string | undefined = process.env.WEBHOOK_API_KEY`
|
||||
|
||||
- `src/lib/validations.ts` (5 matches)
|
||||
- L60: `export const WebhookAuthSchema = z.object({`
|
||||
- L106: `export const SignalWebhookPayloadSchema = WebhookAuthSchema.extend({`
|
||||
- L147: `export type SignalWebhookPayload = z.infer<typeof SignalWebhookPayloadSchema>;`
|
||||
|
||||
- `src/messages/en.json` (2 matches)
|
||||
- L220: `"contractTitle": "n8n Field Contract",`
|
||||
- L221: `"contractDescription": "Every visual element maps to fields that can be produced from n8n workflow outputs.",`
|
||||
|
||||
- `src/messages/zh.json` (2 matches)
|
||||
- L220: `"contractTitle": "n8n 字段契约",`
|
||||
- L221: `"contractDescription": "页面元素都对应可由 n8n 输出的字段,避免出现无法供数的设计组件。",`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- N8N_AI_SEARCH_WEBHOOK
|
||||
- WEBHOOK_API_KEY
|
||||
|
||||
## Gaps To Fill
|
||||
|
||||
- All detected repo touchpoints are mapped to documented workflows.
|
||||
|
||||
## Maintenance Rules
|
||||
|
||||
- When an n8n workflow changes, update `docs/integrations/n8n/registry.json` in the same PR.
|
||||
- If possible, export the workflow JSON into `docs/integrations/n8n/exports/` and reference it from the registry.
|
||||
- Re-run `pnpm n8n:context` after every workflow, contract, or route change.
|
||||
- Treat this file as generated output; edit the registry instead of editing this file directly.
|
||||
@@ -0,0 +1,220 @@
|
||||
# AgentPark n8n Dataflow
|
||||
|
||||
这份文档回答两个问题:
|
||||
|
||||
1. 这 8 条生产流程分别负责什么。
|
||||
2. 数据怎样从外部源头进入 AgentPark,再进入数据库、API 和页面展示。
|
||||
|
||||
## 范围
|
||||
|
||||
当前纳入范围的 8 条生产流程:
|
||||
|
||||
1. `Topic项目计划新增`
|
||||
2. `每日Github Trending项目计划新增`
|
||||
3. `项目分析入库(多源)`
|
||||
4. `GitHub Star 每日刷新`
|
||||
5. `项目描述向量化`
|
||||
6. `RAG项目搜索`
|
||||
7. `前沿信号聚合(多源+AI Agent过滤)`
|
||||
8. `项目标签重置`
|
||||
|
||||
不在这 8 条内,但已登记在仓库上下文中的旁路流程:
|
||||
|
||||
- `AI对话网关(Agent Park)`
|
||||
|
||||
## 系统边界
|
||||
|
||||
当前体系不是“单仓库闭环”,而是三层:
|
||||
|
||||
- 外部数据源层:GitHub Search API、GitHub Trending、Hacker News、Reddit、arXiv、Product Hunt、Hugging Face
|
||||
- n8n 编排层:抓取、去重、AI 过滤、标签重置、向量化、信号结构化
|
||||
- AgentPark 应用层:Postgres/Prisma、Next.js API、页面组件
|
||||
|
||||
还有一个明确存在但当前仓库里没有实现代码的外部服务边界:
|
||||
|
||||
- `Discovery Task Service`
|
||||
- `GET/POST/PATCH /api/discovery/tasks`
|
||||
- `POST /api/discovery/check-duplicates`
|
||||
|
||||
## 核心实体
|
||||
|
||||
- `projects`
|
||||
- `external_links`
|
||||
- `tags`
|
||||
- `project_tags`
|
||||
- `signals`
|
||||
- `projects.embedding`
|
||||
|
||||
## 总流图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Sources["External Sources"]
|
||||
GHSearch["GitHub Search API"]
|
||||
GHTrend["GitHub Trending"]
|
||||
GHRepo["GitHub Repository API"]
|
||||
HN["Hacker News"]
|
||||
Reddit["Reddit"]
|
||||
Arxiv["arXiv"]
|
||||
PH["Product Hunt"]
|
||||
HF["Hugging Face"]
|
||||
Silicon["SiliconFlow Embeddings"]
|
||||
end
|
||||
|
||||
subgraph N8N["n8n Workflows"]
|
||||
W1["1 Topic项目计划新增"]
|
||||
W2["2 每日Github Trending项目计划新增"]
|
||||
W3["3 项目分析入库(多源)"]
|
||||
W4["4 GitHub Star 每日刷新"]
|
||||
W5["5 项目描述向量化"]
|
||||
W6["6 RAG项目搜索"]
|
||||
W7["7 前沿信号聚合"]
|
||||
W8["8 项目标签重置"]
|
||||
end
|
||||
|
||||
subgraph Discovery["External Discovery Service"]
|
||||
Dedupe["/api/discovery/check-duplicates"]
|
||||
Tasks["/api/discovery/tasks"]
|
||||
Complete["/api/discovery/tasks/:id/complete"]
|
||||
end
|
||||
|
||||
subgraph App["AgentPark App + DB"]
|
||||
DBProjects["projects"]
|
||||
DBLinks["external_links"]
|
||||
DBTags["tags / project_tags"]
|
||||
DBSignals["signals"]
|
||||
APIProjects["/api/projects"]
|
||||
APISearch["/api/search/ai"]
|
||||
APISignals["/api/signals"]
|
||||
APITagReset["/api/tags/reset-projects"]
|
||||
UIProjects["Projects pages"]
|
||||
UISignals["Signals page"]
|
||||
UIHome["Home rankings"]
|
||||
end
|
||||
|
||||
GHSearch --> W1
|
||||
GHTrend --> W2
|
||||
W1 --> Dedupe
|
||||
W2 --> Dedupe
|
||||
Dedupe --> Tasks
|
||||
Tasks --> W3
|
||||
W3 --> Complete
|
||||
Complete --> DBProjects
|
||||
Complete --> DBLinks
|
||||
Complete --> DBTags
|
||||
|
||||
DBProjects --> W5
|
||||
Silicon --> W5
|
||||
W5 --> DBProjects
|
||||
|
||||
DBProjects --> W6
|
||||
Silicon --> W6
|
||||
W6 --> APISearch
|
||||
APISearch --> UIProjects
|
||||
|
||||
DBProjects --> W4
|
||||
DBLinks --> W4
|
||||
GHRepo --> W4
|
||||
W4 --> DBProjects
|
||||
DBProjects --> UIHome
|
||||
DBProjects --> UIProjects
|
||||
|
||||
HN --> W7
|
||||
Reddit --> W7
|
||||
Arxiv --> W7
|
||||
PH --> W7
|
||||
HF --> W7
|
||||
W7 --> DBSignals
|
||||
W7 --> Dedupe
|
||||
DBSignals --> APISignals
|
||||
APISignals --> UISignals
|
||||
|
||||
DBTags --> W8
|
||||
DBProjects --> W8
|
||||
W8 --> APITagReset
|
||||
APITagReset --> DBTags
|
||||
APITagReset --> UIProjects
|
||||
```
|
||||
|
||||
## 主链路拆解
|
||||
|
||||
### 1. 项目发现链路
|
||||
|
||||
- `Topic项目计划新增`
|
||||
- `每日Github Trending项目计划新增`
|
||||
|
||||
职责:
|
||||
|
||||
- 从 GitHub 搜索结果和 Trending 列表中找候选项目
|
||||
- 先走 discovery 去重
|
||||
- 再用 LLM 做保留/丢弃判断
|
||||
- 最后把可入库项目写进 discovery 任务队列
|
||||
|
||||
### 2. 项目入库链路
|
||||
|
||||
- `项目分析入库(多源)`
|
||||
|
||||
职责:
|
||||
|
||||
- 轮询 discovery 任务队列
|
||||
- 任务置为 `IN_PROGRESS`
|
||||
- 浏览器/AI 收集事实
|
||||
- 生成标准化项目内容、外链、标签候选
|
||||
- 调用 completion 接口完成入库
|
||||
- 失败时置为 `FAILED`
|
||||
|
||||
### 3. 项目检索链路
|
||||
|
||||
- `项目描述向量化`
|
||||
- `RAG项目搜索`
|
||||
|
||||
职责:
|
||||
|
||||
- 生成并更新 `projects.embedding`
|
||||
- 通过 webhook 对用户查询生成向量
|
||||
- 在 Postgres 中进行向量相似度搜索
|
||||
- 返回项目 ID 与相似度
|
||||
|
||||
### 4. Signals 展示链路
|
||||
|
||||
- `前沿信号聚合(多源+AI Agent过滤)`
|
||||
|
||||
职责:
|
||||
|
||||
- 从 6 类外部源抓取内容
|
||||
- 规则过滤、去重、AI 结构化
|
||||
- 写入 `signals`
|
||||
- 从保留信号中提取 GitHub 仓库,回流 discovery
|
||||
|
||||
### 5. 标签治理链路
|
||||
|
||||
- `项目标签重置`
|
||||
|
||||
职责:
|
||||
|
||||
- 拉取标签池和项目列表
|
||||
- 按 5 类标签做归类
|
||||
- 调用标签重置接口更新项目标签
|
||||
|
||||
### 6. Star 刷新链路
|
||||
|
||||
- `GitHub Star 每日刷新`
|
||||
|
||||
职责:
|
||||
|
||||
- 读取项目 GitHub 链接
|
||||
- 调 GitHub API 获取仓库信息
|
||||
- 更新 `githubStars` 与 `githubStarsUpdatedAt`
|
||||
|
||||
## 当前“就绪状态”定义
|
||||
|
||||
现在仓库已经具备:
|
||||
|
||||
- 8 条生产流程的仓库内登记
|
||||
- 代码触点与 workflow 的映射关系
|
||||
- 从“源头 -> n8n -> DB/API -> 页面”的主链路图
|
||||
|
||||
仍然不在当前仓库闭环的内容:
|
||||
|
||||
- discovery task service
|
||||
- n8n credentials / secrets / runtime env
|
||||
@@ -0,0 +1,101 @@
|
||||
# Technology Stack
|
||||
|
||||
**Analysis Date:** 2026-04-20
|
||||
|
||||
## Languages
|
||||
|
||||
**Primary:**
|
||||
- TypeScript 5.x - Main application, API, Prisma helpers, hooks, and tooling live in `src/**/*.ts`, `src/**/*.tsx`, `prisma/seed.ts`, `tailwind.config.ts`, and `vitest.config.ts`; the compiler is declared in `package.json`.
|
||||
|
||||
**Secondary:**
|
||||
- JavaScript - Runtime and framework config live in `next.config.js`, `postcss.config.mjs`, and `scripts/generate-n8n-context.mjs`.
|
||||
- CSS - Global styling and external font/icon imports live in `src/app/globals.css`.
|
||||
- JSON - Repo and content config live in `package.json`, `tsconfig.json`, `.eslintrc.json`, `.prettierrc.json`, `vercel.json`, `docs/integrations/n8n/registry.json`, and `src/messages/*.json`.
|
||||
- Prisma schema DSL - Database schema, datasource, enums, and vector column definitions live in `prisma/schema.prisma`.
|
||||
- SQL migrations - Prisma migration output lives in `prisma/migrations/*/migration.sql`.
|
||||
- Markdown - Integration contracts and workflow docs live in `docs/integrations/n8n/*.md`, `docs/integrations/n8n/workflows/*.md`, and mirrored GSD docs in `.planning/codebase/N8N-CONTEXT.md` and `.planning/codebase/N8N-DATAFLOW.md`.
|
||||
|
||||
## Runtime
|
||||
|
||||
**Environment:**
|
||||
- Node.js 22.x - Containerized runtime is pinned by `Dockerfile` and `Dockerfile.runtime`, both based on `node:22-bookworm`.
|
||||
- Server runtime is Next.js App Router with standalone output enabled in `next.config.js` and consumed by `Dockerfile.runtime`.
|
||||
- Local runtime version pin file is not committed. No `.nvmrc`, `.node-version`, or `.tool-versions` file is present at repo root.
|
||||
|
||||
**Package Manager:**
|
||||
- `pnpm` - All install/build/test scripts in `package.json`, `vercel.json`, `Dockerfile`, and `nixpacks.toml` use `pnpm`.
|
||||
- Lockfile: present in `pnpm-lock.yaml`.
|
||||
|
||||
## Frameworks
|
||||
|
||||
**Core:**
|
||||
- Next.js `15.1.11` - App Router pages, layouts, metadata routes, and route handlers live in `src/app/**/*`; framework config is in `next.config.js`.
|
||||
- React `19.0.0` and `react-dom` `19.0.0` - UI runtime for components in `src/components/**/*` and route segments in `src/app/**/*`.
|
||||
- `next-intl` `^4.0.2` - Locale middleware and message loading are wired through `next.config.js`, `src/middleware.ts`, `src/i18n/request.ts`, and localized routes under `src/app/[locale]`.
|
||||
- Prisma `^6.1.0` / `@prisma/client` `^6.1.0` - ORM and generated client used by `src/lib/prisma.ts`, `src/hooks/useProjects.ts`, `src/hooks/useHome.ts`, and route handlers under `src/app/api/**/*`.
|
||||
- Zod `^3.24.1` - Runtime validation is centralized in `src/lib/validations.ts` and consumed by API routes such as `src/app/api/search/ai/route.ts` and `src/app/api/webhook/signals/route.ts`.
|
||||
|
||||
**Testing:**
|
||||
- Vitest `^2.1.8` - Unit-style tests are configured in `vitest.config.ts` and run against `src/**/*.test.ts`.
|
||||
- Playwright: Not detected in committed dependencies or config. No `playwright.config.*` file and no committed `e2e/` directory are present.
|
||||
|
||||
**Build/Dev:**
|
||||
- Tailwind CSS `^3.4.17` - Utility styling is configured in `tailwind.config.ts` and loaded from `src/app/globals.css`.
|
||||
- `tailwindcss-animate` `^1.0.7` - Tailwind plugin registered in `tailwind.config.ts`.
|
||||
- PostCSS `^8` with `autoprefixer` `^10.4.20` - CSS processing is configured in `postcss.config.mjs`.
|
||||
- ESLint `^9` with `eslint-config-next` `15.1.11` and `eslint-config-prettier` `^9.1.0` - Linting rules are configured in `.eslintrc.json`.
|
||||
- Prettier `^3.4.2` - Formatting rules are configured in `.prettierrc.json`.
|
||||
- `ts-node` `^10.9.2` - Used by the Prisma seed command configured in `package.json`.
|
||||
|
||||
## Key Dependencies
|
||||
|
||||
**Critical:**
|
||||
- `next` `15.1.11` - Main web framework for `src/app/**/*`.
|
||||
- `react` `19.0.0` and `react-dom` `19.0.0` - Required by all React components in `src/components/**/*`.
|
||||
- `@prisma/client` `^6.1.0` - Data access layer instantiated in `src/lib/prisma.ts`.
|
||||
- `prisma` `^6.1.0` - Schema and migration tooling for `prisma/schema.prisma` and `prisma/migrations/*`.
|
||||
- `next-intl` `^4.0.2` - Locale-aware routing and translations for `src/middleware.ts`, `src/i18n/request.ts`, and `src/messages/*.json`.
|
||||
- `zod` `^3.24.1` - Request validation for search, tags, and webhook contracts in `src/lib/validations.ts`.
|
||||
|
||||
**Infrastructure:**
|
||||
- `@vercel/analytics` `^1.6.1` - Client analytics are mounted in `src/app/VercelMetrics.tsx`.
|
||||
- `@vercel/speed-insights` `^1.3.1` - Frontend performance telemetry is mounted in `src/app/VercelMetrics.tsx`.
|
||||
- `react-markdown` `^10.1.0`, `remark-gfm` `^4.0.1`, and `rehype-sanitize` `^6.0.0` - Markdown rendering and sanitization stack used by `src/components/project/MarkdownContent.tsx`.
|
||||
- `lucide-react` `^0.468.0` - Icon package optimized through `experimental.optimizePackageImports` in `next.config.js`.
|
||||
|
||||
## Configuration
|
||||
|
||||
**Environment:**
|
||||
- Database connectivity is anchored by `DATABASE_URL` in `prisma/schema.prisma` and normalized by `buildPrismaDataSourceUrl()` in `src/lib/prisma-url.ts`.
|
||||
- Optional Postgres mTLS/SSL overlay is configured in `src/lib/prisma-url.ts` using `PG_SSL_ROOT_CERT_B64`, `PG_SSL_IDENTITY_P12_B64`, `PG_SSL_IDENTITY_PASSWORD`, `PG_SSL_CERT_DIR`, and `PG_SSL_MODE`.
|
||||
- Internal machine-to-machine auth uses `WEBHOOK_API_KEY` in `src/lib/auth.ts`.
|
||||
- Outbound AI search proxying uses `N8N_AI_SEARCH_WEBHOOK` in `src/app/api/search/ai/route.ts`.
|
||||
- Canonical URL generation uses `NEXT_PUBLIC_SITE_URL` in `src/app/robots.ts` and `src/app/sitemap.ts`.
|
||||
- Vercel-only telemetry gating uses `VERCEL_ENV` in `src/app/layout.tsx`.
|
||||
- Template env files exist as `.env.example`, `.env.local`, and `.env`; contents were not read.
|
||||
|
||||
**Build:**
|
||||
- `next.config.js` enables `output: "standalone"`, wires `next-intl`, configures remote image hosts, and optimizes `lucide-react` imports.
|
||||
- `tsconfig.json` enables strict TypeScript, `noUncheckedIndexedAccess`, `noImplicitReturns`, `noFallthroughCasesInSwitch`, and the `@/*` alias.
|
||||
- `tailwind.config.ts` and `postcss.config.mjs` define the frontend styling pipeline.
|
||||
- `vercel.json` defines install/build commands, the `nextjs` framework target, and region `hkg1`.
|
||||
- `Dockerfile`, `Dockerfile.runtime`, and `nixpacks.toml` define container build and runtime packaging.
|
||||
- `scripts/generate-n8n-context.mjs` generates `docs/integrations/n8n/CONTEXT.generated.md` and the GSD mirror `.planning/codebase/N8N-CONTEXT.md`.
|
||||
|
||||
## Platform Requirements
|
||||
|
||||
**Development:**
|
||||
- Node.js 22-compatible runtime and `pnpm` are required to run `package.json` scripts.
|
||||
- PostgreSQL is required because `prisma/schema.prisma` uses the `postgresql` provider.
|
||||
- Prisma client generation is part of the build path in `vercel.json`, `Dockerfile`, and `nixpacks.toml`.
|
||||
- The n8n context refresh flow requires `pnpm n8n:context`, which runs `scripts/generate-n8n-context.mjs`.
|
||||
|
||||
**Production:**
|
||||
- Vercel is a first-class deployment target via `vercel.json` and the telemetry components in `src/app/VercelMetrics.tsx`.
|
||||
- Container deployment is also supported through `Dockerfile`, `Dockerfile.runtime`, and `nixpacks.toml`.
|
||||
- Production data storage is PostgreSQL via Prisma, with a `vector` column in `prisma/schema.prisma` used by semantic-search-related workflows documented under `docs/integrations/n8n/`.
|
||||
- Standalone Next.js output is expected by `Dockerfile.runtime`, which boots `.next/standalone/server.js`.
|
||||
|
||||
---
|
||||
|
||||
*Stack analysis: 2026-04-20*
|
||||
@@ -0,0 +1,297 @@
|
||||
# Codebase Structure
|
||||
|
||||
**Analysis Date:** 2026-04-20
|
||||
|
||||
## Directory Layout
|
||||
|
||||
```text
|
||||
agent-park/
|
||||
├── .planning/codebase/ # Generated codebase maps consumed by GSD
|
||||
├── docs/integrations/n8n/ # Source-of-truth docs for external n8n workflows and dataflow
|
||||
├── prisma/ # Prisma schema, migrations, and seed script
|
||||
├── public/ # Static assets served by Next.js
|
||||
├── scripts/ # Repo maintenance scripts
|
||||
├── src/app/ # App Router pages, layouts, metadata files, and API routes
|
||||
├── src/components/ # Reusable UI grouped by feature area
|
||||
├── src/hooks/ # Server-side query/read-model modules
|
||||
├── src/i18n/ # `next-intl` request configuration
|
||||
├── src/lib/ # Shared infrastructure and domain utilities
|
||||
├── src/messages/ # Locale dictionaries
|
||||
├── AGENTS.md # Repo-specific agent instructions
|
||||
├── next.config.js # Next.js config with `next-intl`
|
||||
├── package.json # App scripts and dependency manifest
|
||||
├── tailwind.config.ts # Tailwind theme and content scan config
|
||||
├── tsconfig.json # TypeScript config and `@/*` alias
|
||||
├── vercel.json # Deployment config
|
||||
└── vitest.config.ts # Unit test config
|
||||
```
|
||||
|
||||
## Directory Purposes
|
||||
|
||||
**`.planning/codebase/`:**
|
||||
- Purpose: Store generated architecture, stack, integration, testing, and concern maps for GSD.
|
||||
- Contains: `ARCHITECTURE.md`, `STRUCTURE.md`, `STACK.md`, `INTEGRATIONS.md`, `CONVENTIONS.md`, `TESTING.md`, `CONCERNS.md`, `N8N-CONTEXT.md`, `N8N-DATAFLOW.md`
|
||||
- Key files: `.planning/codebase/ARCHITECTURE.md`, `.planning/codebase/STRUCTURE.md`, `.planning/codebase/N8N-CONTEXT.md`, `.planning/codebase/N8N-DATAFLOW.md`
|
||||
|
||||
**`docs/integrations/n8n/`:**
|
||||
- Purpose: Keep external workflow contracts, workflow inventory, and end-to-end dataflow inside the repository.
|
||||
- Contains: `registry.json`, generated workflow inventory, cross-workflow dataflow docs, exported workflow placeholders, and per-workflow notes.
|
||||
- Key files: `docs/integrations/n8n/README.md`, `docs/integrations/n8n/registry.json`, `docs/integrations/n8n/DATAFLOW.md`, `docs/integrations/n8n/CONTEXT.generated.md`, `docs/integrations/n8n/workflows/03-project-ingestion-multi-source.md`
|
||||
|
||||
**`docs/integrations/n8n/workflows/`:**
|
||||
- Purpose: Describe individual external workflows separately from the app code.
|
||||
- Contains: `01-topic-discovery.md` through `08-project-tag-reset.md`, plus `README.md`.
|
||||
- Key files: `docs/integrations/n8n/workflows/01-topic-discovery.md`, `docs/integrations/n8n/workflows/02-github-trending-discovery.md`, `docs/integrations/n8n/workflows/03-project-ingestion-multi-source.md`, `docs/integrations/n8n/workflows/06-rag-project-search.md`, `docs/integrations/n8n/workflows/07-signals-aggregation.md`
|
||||
|
||||
**`prisma/`:**
|
||||
- Purpose: Own the database contract and local bootstrapping path.
|
||||
- Contains: `schema.prisma`, `seed.ts`, and migration directories under `prisma/migrations/`.
|
||||
- Key files: `prisma/schema.prisma`, `prisma/seed.ts`, `prisma/migrations/20260106122028_init/migration.sql`, `prisma/migrations/20260126000000_add_project_embedding/migration.sql`, `prisma/migrations/20260418191500_remove_discovery_pipeline/migration.sql`
|
||||
|
||||
**`scripts/`:**
|
||||
- Purpose: Hold repository maintenance scripts that generate or sync documentation.
|
||||
- Contains: `generate-n8n-context.mjs`
|
||||
- Key files: `scripts/generate-n8n-context.mjs`
|
||||
|
||||
**`src/app/`:**
|
||||
- Purpose: Own all App Router entry points, route-local client islands, global assets, metadata routes, and API handlers.
|
||||
- Contains: `layout.tsx`, `globals.css`, `robots.ts`, `sitemap.ts`, `VercelMetrics.tsx`, localized routes, and `/api/*`.
|
||||
- Key files: `src/app/layout.tsx`, `src/app/[locale]/layout.tsx`, `src/app/[locale]/page.tsx`, `src/app/[locale]/projects/page.tsx`, `src/app/[locale]/signals/page.tsx`, `src/app/api/projects/route.ts`, `src/app/api/signals/route.ts`
|
||||
|
||||
**`src/app/[locale]/`:**
|
||||
- Purpose: Group all locale-prefixed, user-facing routes under the same shell.
|
||||
- Contains: Home, about, projects, signals, `not-found.tsx`, and a catch-all fallback.
|
||||
- Key files: `src/app/[locale]/layout.tsx`, `src/app/[locale]/page.tsx`, `src/app/[locale]/about/page.tsx`, `src/app/[locale]/projects/page.tsx`, `src/app/[locale]/projects/[id]/page.tsx`, `src/app/[locale]/signals/page.tsx`
|
||||
|
||||
**`src/app/[locale]/projects/`:**
|
||||
- Purpose: Own the projects browse route plus the route-local browser islands that manage query-driven browsing.
|
||||
- Contains: `page.tsx`, `ProjectsPageClient.tsx`, `ProjectsResultsClient.tsx`, and the `[id]/` detail route subfolder.
|
||||
- Key files: `src/app/[locale]/projects/page.tsx`, `src/app/[locale]/projects/ProjectsPageClient.tsx`, `src/app/[locale]/projects/ProjectsResultsClient.tsx`, `src/app/[locale]/projects/[id]/page.tsx`
|
||||
|
||||
**`src/app/api/`:**
|
||||
- Purpose: Group JSON APIs and webhook handlers by resource area.
|
||||
- Contains: Project list/detail routes, AI search proxy, signals feed, tags endpoints, and signal ingestion webhook.
|
||||
- Key files: `src/app/api/projects/route.ts`, `src/app/api/projects/[slug]/route.ts`, `src/app/api/search/ai/route.ts`, `src/app/api/signals/route.ts`, `src/app/api/tags/route.ts`, `src/app/api/tags/maintenance/route.ts`, `src/app/api/tags/reset-projects/route.ts`, `src/app/api/webhook/signals/route.ts`
|
||||
|
||||
**`src/components/`:**
|
||||
- Purpose: Store reusable UI by product area instead of by primitive type.
|
||||
- Contains: `home`, `layout`, `locale`, `project`, `search`, `signals`, and an empty `ui` folder.
|
||||
- Key files: `src/components/home/HomeOverviewStats.tsx`, `src/components/project/ProjectCard.tsx`, `src/components/project/ProjectDetail.tsx`, `src/components/project/TagFilterPanel.tsx`, `src/components/search/AISearchBar.tsx`, `src/components/signals/SignalFeedClient.tsx`
|
||||
|
||||
**`src/hooks/`:**
|
||||
- Purpose: Hold server-side query/read-model modules.
|
||||
- Contains: `useProjects.ts` and `useHome.ts`
|
||||
- Key files: `src/hooks/useProjects.ts`, `src/hooks/useHome.ts`
|
||||
|
||||
**`src/i18n/`:**
|
||||
- Purpose: Configure `next-intl` request resolution and message loading.
|
||||
- Contains: `request.ts`
|
||||
- Key files: `src/i18n/request.ts`
|
||||
|
||||
**`src/lib/`:**
|
||||
- Purpose: Hold shared infrastructure, schema validation, and domain helpers used across pages and APIs.
|
||||
- Contains: Prisma bootstrap, API auth, cache fallback, slug generation, tag taxonomy, signal hotness helpers, Zod schemas, and GitHub badge utilities.
|
||||
- Key files: `src/lib/prisma.ts`, `src/lib/prisma-url.ts`, `src/lib/auth.ts`, `src/lib/cache.ts`, `src/lib/signal-hotness.ts`, `src/lib/tag-taxonomy.ts`, `src/lib/validations.ts`, `src/lib/github/badges.ts`
|
||||
|
||||
**`src/messages/`:**
|
||||
- Purpose: Store locale message bundles consumed by `next-intl`.
|
||||
- Contains: `en.json` and `zh.json`
|
||||
- Key files: `src/messages/en.json`, `src/messages/zh.json`
|
||||
|
||||
**`src/types/`:**
|
||||
- Purpose: Reserved location for shared type modules.
|
||||
- Contains: No files in the inspected tree.
|
||||
- Key files: Not applicable
|
||||
|
||||
## Key File Locations
|
||||
|
||||
**Entry Points:**
|
||||
- `src/app/layout.tsx`: Root HTML/body shell and Vercel metrics mounting.
|
||||
- `src/middleware.ts`: Locale middleware for all non-API, non-static requests.
|
||||
- `src/app/[locale]/layout.tsx`: Localized shell, navigation, footer, and translation provider.
|
||||
- `src/app/[locale]/page.tsx`: Home route entry.
|
||||
- `src/app/[locale]/projects/page.tsx`: Projects browse entry.
|
||||
- `src/app/[locale]/projects/[id]/page.tsx`: Project detail entry.
|
||||
- `src/app/[locale]/signals/page.tsx`: Signals route entry.
|
||||
- `src/app/sitemap.ts`: Dynamic sitemap generation from project data.
|
||||
- `src/app/robots.ts`: Robots rules and sitemap pointer.
|
||||
- `scripts/generate-n8n-context.mjs`: n8n context generation entry point.
|
||||
|
||||
**API Endpoints:**
|
||||
- `src/app/api/projects/route.ts`: Paginated projects list endpoint.
|
||||
- `src/app/api/projects/[slug]/route.ts`: Single-project JSON endpoint.
|
||||
- `src/app/api/search/ai/route.ts`: AI search proxy to the external n8n webhook plus DB hydration.
|
||||
- `src/app/api/signals/route.ts`: Cursor-paginated signals feed endpoint.
|
||||
- `src/app/api/tags/route.ts`: Cached tag list endpoint.
|
||||
- `src/app/api/tags/maintenance/route.ts`: Authenticated tag merge/update endpoint.
|
||||
- `src/app/api/tags/maintenance/service.ts`: Route-local service module for tag maintenance transactions.
|
||||
- `src/app/api/tags/reset-projects/route.ts`: Authenticated project-tag replacement endpoint.
|
||||
- `src/app/api/webhook/signals/route.ts`: Authenticated signal ingestion webhook.
|
||||
|
||||
**Configuration:**
|
||||
- `package.json`: App scripts, dependency graph, and `pnpm n8n:context`.
|
||||
- `next.config.js`: `next-intl` plugin wrapper, standalone output, and image host allowlist.
|
||||
- `tailwind.config.ts`: Theme extension and content scanning.
|
||||
- `tsconfig.json`: TypeScript settings and the `@/*` alias.
|
||||
- `vitest.config.ts`: Node test environment and `src/**/*.test.ts` include pattern.
|
||||
- `vercel.json`: Deployment/build settings.
|
||||
- `.env.example`: Template env file, including webhook-related variables. Real secrets stay outside committed files.
|
||||
- `docs/integrations/n8n/registry.json`: Editable source of truth for external workflow contracts.
|
||||
|
||||
**Core Logic:**
|
||||
- `src/hooks/useProjects.ts`: Project/tag read models, cache wrappers, filter logic, and DB retry handling.
|
||||
- `src/hooks/useHome.ts`: Homepage aggregate data builder.
|
||||
- `src/lib/validations.ts`: Shared Zod schemas for queries, webhooks, and internal mutation payloads.
|
||||
- `src/lib/tag-taxonomy.ts`: Tag category ordering, presets, and inference helpers.
|
||||
- `src/lib/signal-hotness.ts`: Hotness scoring and schema-capability detection.
|
||||
- `src/lib/auth.ts`: Shared internal API-key validation.
|
||||
- `src/app/api/tags/maintenance/service.ts`: Tag merge/update transaction logic.
|
||||
- `scripts/generate-n8n-context.mjs`: Registry-to-doc generation logic for n8n context.
|
||||
|
||||
**Testing:**
|
||||
- `src/lib/auth.test.ts`
|
||||
- `src/lib/prisma-url.test.ts`
|
||||
- `src/lib/validations.test.ts`
|
||||
- `src/lib/validations.tag-maintenance.test.ts`
|
||||
- `src/app/api/tags/route.test.ts`
|
||||
- `src/app/api/tags/maintenance/route.test.ts`
|
||||
- `src/app/api/tags/maintenance/service.test.ts`
|
||||
- `src/app/api/tags/reset-projects/route.test.ts`
|
||||
- `e2e/`: Not present in the inspected tree
|
||||
|
||||
## Naming Conventions
|
||||
|
||||
**Files:**
|
||||
- Use Next.js route conventions inside `src/app`, such as `page.tsx`, `layout.tsx`, `route.ts`, `not-found.tsx`, `robots.ts`, and `sitemap.ts`.
|
||||
- Use `PascalCase.tsx` for reusable components under `src/components/`, such as `ProjectCard.tsx`, `ProjectDetail.tsx`, `HomeOverviewStats.tsx`, and `SignalFeedClient.tsx`.
|
||||
- Keep route-local browser components adjacent to their route, such as `src/app/[locale]/projects/ProjectsPageClient.tsx` and `src/app/[locale]/projects/ProjectsResultsClient.tsx`.
|
||||
- Keep shared utility files lower-case or kebab-case under `src/lib/`, such as `prisma.ts`, `cache.ts`, `signal-hotness.ts`, and `tag-taxonomy.ts`.
|
||||
- Keep tests adjacent to the source file they exercise, using `*.test.ts`.
|
||||
|
||||
**Directories:**
|
||||
- Group user-facing routes by URL shape under `src/app/`.
|
||||
- Group reusable UI by feature area under `src/components/`, not by HTML primitive.
|
||||
- Keep shared server read-model logic flat in `src/hooks/`.
|
||||
- Keep cross-cutting utilities flat in `src/lib/`, with only narrow nested folders such as `src/lib/github/`.
|
||||
- Keep external automation documentation under `docs/integrations/n8n/`, not mixed into `src/`.
|
||||
|
||||
## Where to Add New Code
|
||||
|
||||
**New User-Facing Route:**
|
||||
- Primary code: add a route under `src/app/[locale]/...`, for example `src/app/[locale]/new-section/page.tsx`.
|
||||
- Route metadata: colocate `generateMetadata` in that route file.
|
||||
- Shared shell changes: edit `src/app/[locale]/layout.tsx` only if the new route needs global navigation/footer updates.
|
||||
|
||||
**New Route-Local Browser State:**
|
||||
- Implementation: colocate the client component under the route folder in `src/app/[locale]/...`.
|
||||
- Examples to follow: `src/app/[locale]/projects/ProjectsPageClient.tsx`, `src/app/[locale]/projects/ProjectsResultsClient.tsx`
|
||||
|
||||
**New Reusable UI Component:**
|
||||
- Implementation: place it in the matching feature folder under `src/components/`.
|
||||
- Examples:
|
||||
- `src/components/project/` for project cards, details, sidebars, filters, and related UI.
|
||||
- `src/components/search/` for search bars and AI-result renderers.
|
||||
- `src/components/signals/` for signal-specific reusable UI.
|
||||
|
||||
**New Read Query or Read Model:**
|
||||
- Project and taxonomy reads: extend `src/hooks/useProjects.ts`.
|
||||
- Home aggregate reads: extend `src/hooks/useHome.ts`.
|
||||
- New domain-specific read module: add another file under `src/hooks/` if the logic is large enough to stand alone.
|
||||
|
||||
**New Shared Utility or Domain Rule:**
|
||||
- Prisma/bootstrap changes: `src/lib/prisma.ts` or `src/lib/prisma-url.ts`.
|
||||
- Validation contracts: `src/lib/validations.ts`.
|
||||
- Tag/category rules: `src/lib/tag-taxonomy.ts`.
|
||||
- Signal hotness logic: `src/lib/signal-hotness.ts`.
|
||||
- Internal API auth helpers: `src/lib/auth.ts`.
|
||||
|
||||
**New API Endpoint:**
|
||||
- Primary code: add `route.ts` under `src/app/api/<resource>/`.
|
||||
- Route-specific service logic: colocate it beside the route, following `src/app/api/tags/maintenance/service.ts`.
|
||||
- Shared payload validation: add or extend schemas in `src/lib/validations.ts`.
|
||||
|
||||
**New n8n Workflow Mapping:**
|
||||
- Workflow contract/source of truth: update `docs/integrations/n8n/registry.json`.
|
||||
- Workflow note: add or update the matching file under `docs/integrations/n8n/workflows/`.
|
||||
- Generated mirrors: run `pnpm n8n:context` so `docs/integrations/n8n/CONTEXT.generated.md` and `.planning/codebase/N8N-CONTEXT.md` stay synchronized.
|
||||
|
||||
**New External Discovery Queue Logic:**
|
||||
- Do not place it under `src/app/api/` unless the discovery service is explicitly being absorbed into this repository.
|
||||
- Current queue/task endpoints described in `docs/integrations/n8n/DATAFLOW.md` are external boundaries, not missing route stubs to extend casually.
|
||||
|
||||
**New Locale Strings:**
|
||||
- Add keys to both `src/messages/en.json` and `src/messages/zh.json`.
|
||||
- Resolve them through `next-intl` in route or component code.
|
||||
|
||||
**New Database Model or Field:**
|
||||
- Schema: `prisma/schema.prisma`
|
||||
- Migration: add a new directory under `prisma/migrations/`
|
||||
- Seed changes: `prisma/seed.ts` if local bootstrapping requires the new data
|
||||
|
||||
**New Tests:**
|
||||
- Unit-style tests: colocate as `*.test.ts` beside the source file.
|
||||
- Browser/e2e tests: create a top-level `e2e/` directory because it is not present in the current tree.
|
||||
|
||||
## Special Directories
|
||||
|
||||
**`docs/integrations/n8n/`:**
|
||||
- Purpose: Document external workflows that feed or depend on this repo.
|
||||
- Generated: Partially
|
||||
- Committed: Yes
|
||||
|
||||
**`docs/integrations/n8n/exports/`:**
|
||||
- Purpose: Reserved location for exported workflow JSON files.
|
||||
- Generated: Yes
|
||||
- Committed: Yes
|
||||
|
||||
**`.planning/codebase/`:**
|
||||
- Purpose: Store generated repository maps for GSD orchestration and execution.
|
||||
- Generated: Yes
|
||||
- Committed: Yes
|
||||
|
||||
**`src/app/[locale]/projects/`:**
|
||||
- Purpose: Contains the projects browse route plus its route-local client islands and nested detail route.
|
||||
- Generated: No
|
||||
- Committed: Yes
|
||||
|
||||
**`src/app/api/tags/maintenance/`:**
|
||||
- Purpose: Contains a route handler, a route-local service module, and colocated tests for tag governance.
|
||||
- Generated: No
|
||||
- Committed: Yes
|
||||
|
||||
**`prisma/migrations/`:**
|
||||
- Purpose: Store schema migration history.
|
||||
- Generated: Yes
|
||||
- Committed: Yes
|
||||
|
||||
**`.next/`:**
|
||||
- Purpose: Next.js build output and development cache.
|
||||
- Generated: Yes
|
||||
- Committed: No
|
||||
|
||||
## Placement Rules
|
||||
|
||||
**Use `src/app` only for route ownership:**
|
||||
- Put code under `src/app` when it directly maps to a URL, metadata document, or route-local client island.
|
||||
|
||||
**Use `src/components` for cross-route reuse:**
|
||||
- Promote a route-local component into `src/components/` only when it is reused or clearly generic.
|
||||
|
||||
**Treat `src/hooks` as server query modules, not browser hooks:**
|
||||
- `src/hooks/useProjects.ts` and `src/hooks/useHome.ts` are imported by server components and route handlers.
|
||||
- Follow that pattern for future read-model modules.
|
||||
|
||||
**Keep Prisma concentrated away from most UI files:**
|
||||
- Direct Prisma access lives in `src/hooks/*`, `src/app/api/*`, and `src/lib/prisma.ts`.
|
||||
- Most UI should consume read models or JSON responses, not query the database directly.
|
||||
|
||||
**Keep external workflow context out of `src/`:**
|
||||
- n8n and discovery queue behavior belongs in `docs/integrations/n8n/*` and the generated `.planning/codebase/N8N-*.md` mirrors until runtime code is actually moved into this repository.
|
||||
|
||||
**Do not assume `/api/discovery/*` exists here:**
|
||||
- If a feature depends on discovery tasks, first verify whether it belongs to the external discovery service or the app itself.
|
||||
- The current inspected tree contains no `src/app/api/discovery/` implementation.
|
||||
|
||||
---
|
||||
|
||||
*Structure analysis: 2026-04-20*
|
||||
@@ -0,0 +1,213 @@
|
||||
# Testing Patterns
|
||||
|
||||
**Analysis Date:** 2026-04-20
|
||||
|
||||
## Test Framework
|
||||
|
||||
**Runner:**
|
||||
- Use `vitest` via `vitest.config.ts` and `pnpm test` from `package.json`.
|
||||
- `vitest.config.ts` sets `environment: "node"`, resolves `@` to `./src`, disables watch mode, and only includes `src/**/*.test.ts`.
|
||||
- Current verification on 2026-04-20: `pnpm test` passed with 8 test files and 26 tests.
|
||||
|
||||
**Assertion Library:**
|
||||
- Use Vitest built-ins from `vitest`, including `describe`, `it`, `expect`, `beforeEach`, `afterEach`, and `vi`, as seen in `src/lib/auth.test.ts`, `src/lib/prisma-url.test.ts`, and `src/app/api/tags/maintenance/route.test.ts`.
|
||||
|
||||
**Run Commands:**
|
||||
```bash
|
||||
pnpm test # Run all configured Vitest suites
|
||||
pnpm lint # Run ESLint quality checks
|
||||
pnpm build # Run production build checks
|
||||
```
|
||||
- `AGENTS.md` also reserves `pnpm test:e2e` for Playwright, but no `test:e2e` script or `playwright.config.*` file is present in the current workspace.
|
||||
|
||||
## Test File Organization
|
||||
|
||||
**Location:**
|
||||
- Keep tests co-located beside the code they exercise under `src`, for example `src/lib/auth.test.ts`, `src/lib/prisma-url.test.ts`, `src/app/api/tags/route.test.ts`, and `src/app/api/tags/maintenance/service.test.ts`.
|
||||
- No `e2e/` directory is present in the repository snapshot analyzed on 2026-04-20.
|
||||
|
||||
**Naming:**
|
||||
- Use `*.test.ts`. No `*.spec.ts` or `*.test.tsx` files were detected by `rg --files` or included by `vitest.config.ts`.
|
||||
- Keep route tests adjacent to `route.ts` or `service.ts` files, for example `src/app/api/tags/reset-projects/route.test.ts` next to `src/app/api/tags/reset-projects/route.ts`.
|
||||
|
||||
**Structure:**
|
||||
```text
|
||||
src/
|
||||
lib/
|
||||
auth.ts
|
||||
auth.test.ts
|
||||
prisma-url.ts
|
||||
prisma-url.test.ts
|
||||
app/api/tags/
|
||||
route.ts
|
||||
route.test.ts
|
||||
maintenance/
|
||||
route.ts
|
||||
route.test.ts
|
||||
service.ts
|
||||
service.test.ts
|
||||
```
|
||||
|
||||
## Test Structure
|
||||
|
||||
**Suite Organization:**
|
||||
```typescript
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { POST } from "./route";
|
||||
|
||||
const { transactionMock, revalidatePathMock, txMock } = vi.hoisted(() => {
|
||||
const tx = {
|
||||
projectTag: {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
transactionMock: vi.fn(async (callback) => callback(tx)),
|
||||
revalidatePathMock: vi.fn(),
|
||||
txMock: tx,
|
||||
};
|
||||
});
|
||||
```
|
||||
|
||||
**Patterns:**
|
||||
- Use one top-level `describe(...)` block per module or endpoint, with behavior-based test names such as `returns 401 for wrong API key` in `src/app/api/tags/maintenance/route.test.ts` and `returns 500 when prisma query fails` in `src/app/api/tags/route.test.ts`.
|
||||
- Build local request helpers for route tests, such as `buildRequest(...)` in `src/app/api/tags/maintenance/route.test.ts` and `src/app/api/tags/reset-projects/route.test.ts`.
|
||||
- Use inline fixture builders for reusable payloads, such as `buildValidPayload(...)` in `src/app/api/tags/reset-projects/route.test.ts` and `baseProjectInput` in `src/lib/validations.test.ts`.
|
||||
- Reset mocks and environment state in `beforeEach` or `afterEach`, as seen in `src/app/api/tags/route.test.ts`, `src/app/api/tags/maintenance/route.test.ts`, and `src/lib/prisma-url.test.ts`.
|
||||
|
||||
## Mocking
|
||||
|
||||
**Framework:**
|
||||
- Use Vitest mocks via `vi.fn`, `vi.mock`, `vi.hoisted`, `mockResolvedValue`, and `mockRejectedValue`.
|
||||
|
||||
**Patterns:**
|
||||
```typescript
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
tag: {
|
||||
findMany: findManyMock,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("next/cache", () => ({
|
||||
revalidatePath: revalidatePathMock,
|
||||
}));
|
||||
```
|
||||
- Hoist shared mocks before module import so route modules capture mocked dependencies, as in `src/app/api/tags/route.test.ts`, `src/app/api/tags/maintenance/route.test.ts`, and `src/app/api/tags/reset-projects/route.test.ts`.
|
||||
- Mock Prisma reads and writes rather than using a real database in route tests.
|
||||
- Mock `next/cache` side effects when mutation routes call `revalidatePath`, as in `src/app/api/tags/maintenance/route.test.ts` and `src/app/api/tags/reset-projects/route.test.ts`.
|
||||
|
||||
**What to Mock:**
|
||||
- Mock Prisma modules for handler and service tests, as in `src/app/api/tags/route.test.ts`, `src/app/api/tags/maintenance/route.test.ts`, and `src/app/api/tags/reset-projects/route.test.ts`.
|
||||
- Set `process.env.WEBHOOK_API_KEY` inline for auth-path tests, as in `src/app/api/tags/maintenance/route.test.ts` and `src/app/api/tags/reset-projects/route.test.ts`.
|
||||
- Mock cache revalidation side effects instead of asserting real filesystem or ISR behavior.
|
||||
|
||||
**What NOT to Mock:**
|
||||
- Test pure helpers directly without mocks when possible, as in `src/lib/auth.test.ts` and `src/lib/validations.tag-maintenance.test.ts`.
|
||||
- There is no current pattern for DOM, browser, or component mocking because no component tests are checked in.
|
||||
|
||||
## Fixtures and Environment Handling
|
||||
|
||||
**Test Data:**
|
||||
```typescript
|
||||
const baseProjectInput = {
|
||||
name: "Agent Park",
|
||||
description: "A curated list of practical AI agent tools.",
|
||||
tags: [{ name: "ai-agent" }],
|
||||
links: [{ type: "GITHUB" as const, url: "https://github.com/example/repo" }],
|
||||
};
|
||||
```
|
||||
```typescript
|
||||
const ENV_KEYS = [
|
||||
"DATABASE_URL",
|
||||
"PG_SSL_ROOT_CERT_B64",
|
||||
"PG_SSL_IDENTITY_P12_B64",
|
||||
"PG_SSL_IDENTITY_PASSWORD",
|
||||
"PG_SSL_MODE",
|
||||
"PG_SSL_CERT_DIR",
|
||||
] as const;
|
||||
```
|
||||
- `src/lib/prisma-url.test.ts` snapshots selected env vars and restores them in `afterEach`, which is the current pattern for tests that mutate `process.env`.
|
||||
- Fixtures are inline per file. No shared `test-utils` or factory directory exists.
|
||||
|
||||
## Coverage
|
||||
|
||||
**Requirements:**
|
||||
- No coverage thresholds or coverage command are configured in `package.json` or `vitest.config.ts`.
|
||||
- No CI workflow, coverage upload, or Playwright setup was detected under `.github/` or repository root.
|
||||
|
||||
**View Coverage:**
|
||||
```bash
|
||||
Not configured
|
||||
```
|
||||
|
||||
## Test Types
|
||||
|
||||
**Unit Tests:**
|
||||
- Pure utility coverage exists for `src/lib/auth.ts`, `src/lib/prisma-url.ts`, `src/lib/validations.ts`, and tag-maintenance schema rules in `src/lib/validations.tag-maintenance.test.ts`.
|
||||
|
||||
**Service Tests:**
|
||||
- Business logic in `src/app/api/tags/maintenance/service.ts` is tested separately in `src/app/api/tags/maintenance/service.test.ts`, including deduped tag migration and validation failure behavior.
|
||||
|
||||
**Route-Level Tests:**
|
||||
- Direct route-handler tests exist for `src/app/api/tags/route.ts`, `src/app/api/tags/maintenance/route.ts`, and `src/app/api/tags/reset-projects/route.ts`.
|
||||
- These tests assert status codes, JSON bodies, auth failures, validation failures, and `revalidatePath(...)` side effects.
|
||||
|
||||
**n8n-Related Coverage:**
|
||||
- `src/app/api/search/ai/route.ts` has no corresponding `src/app/api/search/ai/route.test.ts`.
|
||||
- `src/app/api/webhook/signals/route.ts` has no corresponding `src/app/api/webhook/signals/route.test.ts`.
|
||||
- This means the n8n-facing search proxy and the authenticated signals webhook currently rely on runtime behavior rather than automated handler tests.
|
||||
|
||||
**E2E Tests:**
|
||||
- Not detected. `AGENTS.md` reserves `e2e/` for Playwright, but there is no `e2e/` directory, no Playwright config, and no `pnpm test:e2e` script in `package.json`.
|
||||
|
||||
## Common Patterns
|
||||
|
||||
**Async Route Testing:**
|
||||
```typescript
|
||||
const response = await POST(buildRequest(payload));
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(200);
|
||||
expect(json.success).toBe(true);
|
||||
```
|
||||
**Error Testing:**
|
||||
```typescript
|
||||
findManyMock.mockRejectedValue(new Error("db unavailable"));
|
||||
const response = await GET();
|
||||
const json = await response.json();
|
||||
|
||||
expect(response.status).toBe(500);
|
||||
expect(json.error).toBe("Internal server error");
|
||||
expect(json.details).toContain("db unavailable");
|
||||
```
|
||||
|
||||
## Current Gaps
|
||||
|
||||
**Untested API Routes:**
|
||||
- No tests were detected for `src/app/api/projects/route.ts`, `src/app/api/projects/[slug]/route.ts`, `src/app/api/signals/route.ts`, `src/app/api/search/ai/route.ts`, or `src/app/api/webhook/signals/route.ts`.
|
||||
- The missing n8n-related tests are especially important because `src/app/api/search/ai/route.ts` depends on the `N8N_AI_SEARCH_WEBHOOK` contract and `src/app/api/webhook/signals/route.ts` handles authenticated ingestion plus Prisma upserts.
|
||||
|
||||
**Untested Query and UI Modules:**
|
||||
- No tests were detected for `src/hooks/useProjects.ts`, `src/hooks/useHome.ts`, `src/lib/cache.ts`, `src/lib/signal-hotness.ts`, `src/lib/tag-taxonomy.ts`, `src/lib/github/badges.ts`, or large client components under `src/components` and `src/app/[locale]`.
|
||||
|
||||
**Infrastructure Gaps:**
|
||||
- No shared fixture library, no DOM test environment, no Playwright harness, and no CI workflow files were detected.
|
||||
|
||||
## Verification Signals
|
||||
|
||||
**Current Quality Checks:**
|
||||
- `pnpm test` passed on 2026-04-20 with 8 test files and 26 tests.
|
||||
- `pnpm lint` passed on 2026-04-20 with no warnings or errors.
|
||||
|
||||
**Observed Output Notes:**
|
||||
- `pnpm test` prints expected stderr from the deliberate error-path assertion in `src/app/api/tags/route.test.ts` because `src/app/api/tags/route.ts` logs failures with `console.error`.
|
||||
- The current Vitest run emits a Vite deprecation notice about the CJS Node API. This is a tooling signal, not a failing test.
|
||||
|
||||
---
|
||||
|
||||
*Testing analysis: 2026-04-20*
|
||||
@@ -0,0 +1,27 @@
|
||||
# Repository Guidelines
|
||||
|
||||
## Project Structure & Module Organization
|
||||
This repository is a Next.js 15 app using the App Router and TypeScript. Route entry points live in `src/app`, with localized pages under `src/app/[locale]` and API handlers under `src/app/api`. Reusable UI belongs in `src/components`, shared hooks in `src/hooks`, and server/client utilities in `src/lib`. Internationalization files live in `src/messages` and `src/i18n`. Database schema, migrations, and seed data are in `prisma/`. End-to-end tests should live in `e2e/`; unit-style tests currently sit beside source files in `src/`.
|
||||
|
||||
## Build, Test, and Development Commands
|
||||
Use `pnpm`, not `npm`.
|
||||
|
||||
- `pnpm dev`: start the local Next.js dev server.
|
||||
- `pnpm build`: create a production build and catch type/runtime integration issues.
|
||||
- `pnpm start`: serve the production build locally.
|
||||
- `pnpm lint`: run Next.js ESLint rules.
|
||||
- `pnpm test`: run Vitest for `src/**/*.test.ts`.
|
||||
- `pnpm test:e2e`: run Playwright against a local app instance on port `3100` by default.
|
||||
- `pnpm prisma db seed`: seed the database from `prisma/seed.ts`.
|
||||
|
||||
## Coding Style & Naming Conventions
|
||||
Prettier is authoritative: 2-space indentation, semicolons, double quotes, trailing commas (`es5`), and `printWidth: 100`. ESLint extends `next/core-web-vitals`; `console.warn` and `console.error` are allowed, other `console` calls trigger warnings. Use strict TypeScript and prefer the `@/*` import alias over deep relative paths. Name React components in `PascalCase`, hooks as `useX`, and tests as `*.test.ts`. Keep route files in Next.js conventions such as `page.tsx`, `layout.tsx`, and `route.ts`.
|
||||
|
||||
## Testing Guidelines
|
||||
Write fast logic tests with Vitest next to the code they exercise, for example `src/lib/validations.test.ts`. Use Playwright for cross-page or API-driven flows under `e2e/`. Add tests for new behavior and for bug fixes, especially around API routes, validation, Prisma-backed queries, and localized routing. Run `pnpm test` and `pnpm lint` before opening a PR; add `pnpm test:e2e` for UI or routing changes.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
Recent history uses Conventional Commit prefixes such as `feat:`, `fix:`, `refactor:`, and `chore:`; keep that format and use concise summaries. PRs should describe the user-visible change, note schema or env updates, link the issue when available, and include screenshots for UI work. If a migration is added, mention the migration directory name and any seed or deployment steps reviewers must run.
|
||||
|
||||
## Security & Configuration Tips
|
||||
Copy from `.env.example` and keep real secrets only in `.env.local` or deployment settings. Never commit production credentials, webhook keys, or database URLs. Validate Prisma schema changes with migrations, not manual database edits.
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
FROM node:22-bookworm AS base
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
FROM base AS deps
|
||||
|
||||
COPY package.json pnpm-lock.yaml ./
|
||||
COPY prisma/schema.prisma ./prisma/schema.prisma
|
||||
RUN pnpm install --frozen-lockfile
|
||||
|
||||
FROM base AS builder
|
||||
|
||||
COPY --from=deps /app/node_modules ./node_modules
|
||||
COPY . .
|
||||
|
||||
RUN pnpm prisma generate && pnpm build
|
||||
|
||||
FROM node:22-bookworm AS runner
|
||||
|
||||
ENV PNPM_HOME="/pnpm"
|
||||
ENV PATH="$PNPM_HOME:$PATH"
|
||||
ENV NODE_ENV="production"
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV PORT="3000"
|
||||
|
||||
RUN corepack enable
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY --from=builder /app ./
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["pnpm", "exec", "next", "start", "--hostname", "0.0.0.0", "--port", "3000"]
|
||||
@@ -0,0 +1,14 @@
|
||||
FROM node:22-bookworm
|
||||
|
||||
ENV NODE_ENV="production"
|
||||
ENV HOSTNAME="0.0.0.0"
|
||||
ENV PORT="3000"
|
||||
|
||||
WORKDIR /app
|
||||
|
||||
COPY .next/standalone ./
|
||||
COPY .next/static ./.next/static
|
||||
|
||||
EXPOSE 3000
|
||||
|
||||
CMD ["node", "server.js"]
|
||||
@@ -1,17 +0,0 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "default",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "src/app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,375 @@
|
||||
# N8N Context
|
||||
|
||||
Generated at: 2026-04-20T11:04:30.112Z
|
||||
|
||||
This file is generated from `docs/integrations/n8n/registry.json` plus repository scanning.
|
||||
It exists so external n8n workflows become committed, reviewable context for AI agents and GSD.
|
||||
|
||||
## Workflow Inventory
|
||||
|
||||
## AI Search
|
||||
|
||||
- Status: `confirmed`
|
||||
- ID: `ai-search`
|
||||
- Purpose: Resolve semantic search candidates from n8n and hydrate them into project results.
|
||||
- n8n workflow id: `F5cQ06DykBfpeyfqL-pd7`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- webhook: GET ai-search
|
||||
|
||||
### Repository Touchpoints
|
||||
- src/app/api/search/ai/route.ts
|
||||
|
||||
### Environment
|
||||
- N8N_AI_SEARCH_WEBHOOK
|
||||
|
||||
### Contracts
|
||||
- Request fields: `desc`, `limit`, `page`, `offset`, `tags`, `domains`, `productForms`
|
||||
- Response fields: `results[].id`, `results[].similarity`, `pagination.total`, `pagination.totalPages`, `pagination.hasMore`
|
||||
|
||||
### Schemas
|
||||
- N8NSearchResponseSchema
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: RAG项目搜索, pgvector similarity search, SiliconFlow embeddings
|
||||
- Downstreams: src/hooks/useProjects.ts#getProjectsByIds, POST /api/search/ai response
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: webhook path is `ai-search` and the workflow returns `results[].id` plus `similarity`.
|
||||
|
||||
## Signals Aggregation
|
||||
|
||||
- Status: `confirmed`
|
||||
- ID: `signals-aggregation`
|
||||
- Purpose: Aggregate multi-source discussion signals, filter them with AI, and ingest them into the repository signal store.
|
||||
- n8n workflow id: `bAxNZKGq2ApUUiw9`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- schedule: multi-source discussion crawl
|
||||
|
||||
### Repository Touchpoints
|
||||
- src/lib/auth.ts
|
||||
- src/app/api/webhook/signals/route.ts
|
||||
- src/app/api/signals/route.ts
|
||||
- src/lib/validations.ts
|
||||
- prisma/schema.prisma
|
||||
|
||||
### Environment
|
||||
- WEBHOOK_API_KEY
|
||||
|
||||
### Contracts
|
||||
- Request fields: `apiKey`, `signals[].source`, `signals[].sourceUrl`, `signals[].title`, `signals[].titleEn`, `signals[].summary`, `signals[].summaryEn`, `signals[].topic`, `signals[].topicEn`, `signals[].tags`, `signals[].sections`, `signals[].engagement`, `signals[].hotScore`, `signals[].isHot`, `signals[].publishedAt`, `signals[].isActive`
|
||||
- Response fields: `success`, `processed`, `created`, `updated`, `failed`, `errors[].index`, `errors[].field`, `errors[].message`
|
||||
|
||||
### Schemas
|
||||
- SignalWebhookPayloadSchema
|
||||
- SignalIngestionInputSchema
|
||||
- SignalQuerySchema
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: Hacker News, GitHub, arXiv, Reddit, Product Hunt, Hugging Face
|
||||
- Downstreams: GET /api/signals, signals page feed, signal hotness computation
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: workflow posts to `/api/webhook/signals` and also triggers external discovery dedupe/task creation. Workflow currently embeds a shared secret in HTTP body and should move to credentials/env.
|
||||
|
||||
## Project Tag Reset
|
||||
|
||||
- Status: `confirmed`
|
||||
- ID: `tag-reset`
|
||||
- Purpose: Reset selected project tags in bulk from n8n classification results.
|
||||
- n8n workflow id: `8tIgBqLyWrBewJPs`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- manual: bulk tag reset
|
||||
|
||||
### Repository Touchpoints
|
||||
- src/lib/auth.ts
|
||||
- src/app/api/tags/reset-projects/route.ts
|
||||
- src/lib/validations.ts
|
||||
- prisma/schema.prisma
|
||||
|
||||
### Environment
|
||||
- WEBHOOK_API_KEY
|
||||
|
||||
### Contracts
|
||||
- Request fields: `apiKey`, `dryRun`, `replaceAllCategories`, `categories`, `projects[].projectSlug`, `projects[].selectedTagSlugsByCategory`
|
||||
- Response fields: `success`, `result.dryRun`, `result.categories`, `result.updatedCount`, `result.failedCount`, `result.results[].projectSlug`, `result.results[].status`, `result.results[].details`
|
||||
|
||||
### Schemas
|
||||
- ProjectTagResetRequestSchema
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: n8n tag classification
|
||||
- Downstreams: project tag relations, project detail page revalidation, project list revalidation
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: workflow reads `/api/tags` and `/api/projects`, then posts bulk updates into `/api/tags/reset-projects`. Workflow currently embeds a shared secret in HTTP body and should move to credentials/env.
|
||||
|
||||
## Project Ingestion (Multi-source)
|
||||
|
||||
- Status: `external-upstream`
|
||||
- ID: `project-ingestion-multi-source`
|
||||
- Purpose: Consume queued discovery tasks, enrich project metadata with AI/browser steps, and write final ingestion results back to Agent Park.
|
||||
- n8n workflow id: `1Ig1CyVMsGJFaHOe`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- schedule: every 10 minutes
|
||||
|
||||
### Repository Touchpoints
|
||||
- prisma/schema.prisma
|
||||
- src/app/api/projects/route.ts
|
||||
- src/app/api/projects/[slug]/route.ts
|
||||
|
||||
### Environment
|
||||
- none
|
||||
|
||||
### Contracts
|
||||
- Request fields: `task.status`, `task.sourceUrl`, `task.sourceType`
|
||||
- Response fields: `project content`, `tag assignments`, `task completion status`
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: discovery task queue, browser/AI extraction, tag catalog
|
||||
- Downstreams: project records visible in repository APIs, task completion callbacks, task failure callbacks
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: workflow polls `/api/discovery/tasks`, marks tasks `IN_PROGRESS`, enriches candidates, then completes or fails tasks. The current repo does not contain `/api/discovery/*` handlers, so this is an upstream system dependency rather than a route implemented here.
|
||||
|
||||
## GitHub Star Refresh
|
||||
|
||||
- Status: `confirmed`
|
||||
- ID: `github-star-refresh`
|
||||
- Purpose: Refresh `projects.githubStars` and `projects.githubStarsUpdatedAt` directly from GitHub repository metadata.
|
||||
- n8n workflow id: `ewx9Gs6cjrTXvwD0`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- schedule: daily at 04:00
|
||||
|
||||
### Repository Touchpoints
|
||||
- prisma/schema.prisma
|
||||
- src/app/api/search/ai/route.ts
|
||||
- src/hooks/useProjects.ts
|
||||
|
||||
### Environment
|
||||
- none
|
||||
|
||||
### Contracts
|
||||
- Request fields: `projects.id`, `projects.slug`, `external_links.url(type=GITHUB)`
|
||||
- Response fields: `projects.githubStars`, `projects.githubStarsUpdatedAt`
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: GitHub repository API, projects table, external_links table
|
||||
- Downstreams: project ranking, star sorting, home ranking display
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: workflow reads active project GitHub links from Postgres, fetches repository metadata from GitHub, then writes star counts directly back to Postgres. This bypasses repository API routes.
|
||||
|
||||
## Project Description Vectorization
|
||||
|
||||
- Status: `confirmed`
|
||||
- ID: `project-description-vectorization`
|
||||
- Purpose: Generate and persist project embeddings used by semantic search.
|
||||
- n8n workflow id: `1AvejnM5n-WPApU1vFt9C`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- schedule: every 30 minutes
|
||||
|
||||
### Repository Touchpoints
|
||||
- prisma/schema.prisma
|
||||
- prisma/migrations/20260126000000_add_project_embedding/migration.sql
|
||||
- src/app/api/search/ai/route.ts
|
||||
|
||||
### Environment
|
||||
- none
|
||||
|
||||
### Contracts
|
||||
- Request fields: `projects.id`, `projects.name`, `projects.nameEn`, `projects.description`, `projects.descriptionEn`, `projects.content`, `projects.contentEn`
|
||||
- Response fields: `projects.embedding`, `projects.embeddingUpdatedAt`
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: SiliconFlow embeddings API, projects table
|
||||
- Downstreams: RAG项目搜索, semantic search quality
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: workflow selects active projects with null embeddings, generates `BAAI/bge-m3` vectors, and writes them directly into the `vector` column. This is a direct DB maintenance job, not a repository API route.
|
||||
|
||||
## GitHub Trending Discovery
|
||||
|
||||
- Status: `external-upstream`
|
||||
- ID: `github-trending-discovery`
|
||||
- Purpose: Scrape GitHub Trending, dedupe candidates, filter them with AI, and enqueue project discovery tasks.
|
||||
- n8n workflow id: `hughGsWismCpk7jd`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- schedule: daily at 01:00
|
||||
|
||||
### Repository Touchpoints
|
||||
- src/app/api/projects/route.ts
|
||||
- src/app/api/projects/[slug]/route.ts
|
||||
|
||||
### Environment
|
||||
- none
|
||||
|
||||
### Contracts
|
||||
- Request fields: `GitHub trending repository URL`, `apiKey`, `tasks[].sourceUrl`, `tasks[].sourceType`
|
||||
- Response fields: `dedupe shouldCreate`, `task creation result`
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: https://github.com/trending, AI keep/discard filter
|
||||
- Downstreams: /api/discovery/check-duplicates, /api/discovery/tasks, project ingestion queue
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: workflow scrapes GitHub Trending, filters candidates with an LLM, then posts queued tasks into discovery webhook endpoints. The current repo does not implement `/api/discovery/*`, so treat this as upstream data intake.
|
||||
|
||||
## Topic Discovery
|
||||
|
||||
- Status: `external-upstream`
|
||||
- ID: `topic-discovery`
|
||||
- Purpose: Search GitHub topics and keywords for agent/LLM engineering repos, dedupe them, and enqueue discovery tasks.
|
||||
- n8n workflow id: `iw9vx9ih5Lt0Mobk`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- schedule: daily at 01:00
|
||||
|
||||
### Repository Touchpoints
|
||||
- src/app/api/projects/route.ts
|
||||
- src/app/api/projects/[slug]/route.ts
|
||||
|
||||
### Environment
|
||||
- none
|
||||
|
||||
### Contracts
|
||||
- Request fields: `GitHub search query`, `apiKey`, `tasks[].sourceUrl`, `tasks[].sourceType`
|
||||
- Response fields: `dedupe shouldCreate`, `task creation result`, `low recall alert`
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: GitHub Search API, AI keep/discard filter, topic watchlist
|
||||
- Downstreams: /api/discovery/check-duplicates, /api/discovery/tasks, project ingestion queue
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: workflow searches agent, infra, observability, evaluation, and MCP-related repositories, then posts accepted candidates into discovery webhooks. The current repo does not implement `/api/discovery/*`, so this is upstream context rather than in-repo routing.
|
||||
|
||||
## AI Chat Gateway
|
||||
|
||||
- Status: `adjacent`
|
||||
- ID: `ai-chat-gateway`
|
||||
- Purpose: Expose a chat-oriented webhook wrapper around `RAG项目搜索` and package search hits into chat blocks/citations.
|
||||
- n8n workflow id: `Rncc22jmHEaYOG58`
|
||||
- Export file: `not recorded`
|
||||
|
||||
### Entrypoints
|
||||
- webhook: POST agent-park-chat
|
||||
|
||||
### Repository Touchpoints
|
||||
- none
|
||||
|
||||
### Environment
|
||||
- none
|
||||
|
||||
### Contracts
|
||||
- Request fields: `requestId`, `sessionId`, `clientId`, `locale`, `mode`, `message`
|
||||
- Response fields: `message.blocks`, `message.citations`, `message.meta.source`, `progress.stage`
|
||||
|
||||
### Related Systems
|
||||
- Upstreams: RAG项目搜索, n8n webhook `ai-search`
|
||||
- Downstreams: external chat clients, project detail URLs
|
||||
|
||||
### Ownership
|
||||
- none
|
||||
|
||||
### Notes
|
||||
- Confirmed against live n8n MCP: this workflow is related to Agent Park but does not call a repository route directly. It wraps the RAG webhook and formats citations pointing at project pages.
|
||||
|
||||
## Exported Workflow Files
|
||||
|
||||
- none
|
||||
|
||||
## Detected Repository Touchpoints
|
||||
|
||||
- `src/app/api/search/ai/route.ts` (39 matches)
|
||||
- L6: `const N8N_WEBHOOK_URL = process.env.N8N_AI_SEARCH_WEBHOOK!`
|
||||
- L8: `if (!N8N_WEBHOOK_URL) {`
|
||||
- L9: `throw new Error('N8N_AI_SEARCH_WEBHOOK environment variable is not set')`
|
||||
- L12: `// n8n 返回的搜索结果 Schema(统一格式)`
|
||||
- L13: `const N8NSearchResponseSchema = z.object({`
|
||||
|
||||
- `src/app/api/tags/maintenance/route.test.ts` (1 matches)
|
||||
- L65: `process.env.WEBHOOK_API_KEY = validApiKey;`
|
||||
|
||||
- `src/app/api/tags/reset-projects/route.test.ts` (1 matches)
|
||||
- L78: `process.env.WEBHOOK_API_KEY = validApiKey;`
|
||||
|
||||
- `src/app/api/webhook/signals/route.ts` (7 matches)
|
||||
- L13: `SignalWebhookPayloadSchema,`
|
||||
- L15: `type SignalWebhookPayload,`
|
||||
- L39: `const validationResult = SignalWebhookPayloadSchema.safeParse(body)`
|
||||
- L51: `const payload = validationResult.data as SignalWebhookPayload`
|
||||
- L184: `console.error(\`[Webhook Signals] Error at index ${i}:\`, error)`
|
||||
|
||||
- `src/hooks/useProjects.ts` (2 matches)
|
||||
- L563: `// n8n 返回的简化搜索结果类型`
|
||||
- L564: `export type N8NSearchResult = {`
|
||||
|
||||
- `src/lib/auth.ts` (1 matches)
|
||||
- L9: `expectedApiKey: string | undefined = process.env.WEBHOOK_API_KEY`
|
||||
|
||||
- `src/lib/validations.ts` (5 matches)
|
||||
- L60: `export const WebhookAuthSchema = z.object({`
|
||||
- L106: `export const SignalWebhookPayloadSchema = WebhookAuthSchema.extend({`
|
||||
- L147: `export type SignalWebhookPayload = z.infer<typeof SignalWebhookPayloadSchema>;`
|
||||
|
||||
- `src/messages/en.json` (2 matches)
|
||||
- L220: `"contractTitle": "n8n Field Contract",`
|
||||
- L221: `"contractDescription": "Every visual element maps to fields that can be produced from n8n workflow outputs.",`
|
||||
|
||||
- `src/messages/zh.json` (2 matches)
|
||||
- L220: `"contractTitle": "n8n 字段契约",`
|
||||
- L221: `"contractDescription": "页面元素都对应可由 n8n 输出的字段,避免出现无法供数的设计组件。",`
|
||||
|
||||
## Environment Variables
|
||||
|
||||
- N8N_AI_SEARCH_WEBHOOK
|
||||
- WEBHOOK_API_KEY
|
||||
|
||||
## Gaps To Fill
|
||||
|
||||
- All detected repo touchpoints are mapped to documented workflows.
|
||||
|
||||
## Maintenance Rules
|
||||
|
||||
- When an n8n workflow changes, update `docs/integrations/n8n/registry.json` in the same PR.
|
||||
- If possible, export the workflow JSON into `docs/integrations/n8n/exports/` and reference it from the registry.
|
||||
- Re-run `pnpm n8n:context` after every workflow, contract, or route change.
|
||||
- Treat this file as generated output; edit the registry instead of editing this file directly.
|
||||
@@ -0,0 +1,321 @@
|
||||
# AgentPark n8n Dataflow
|
||||
|
||||
这份文档回答两个问题:
|
||||
|
||||
1. 这 8 条生产流程分别负责什么。
|
||||
2. 数据怎样从外部源头进入 AgentPark,再进入数据库、API 和页面展示。
|
||||
|
||||
## 范围
|
||||
|
||||
当前纳入范围的 8 条生产流程:
|
||||
|
||||
1. `Topic项目计划新增`
|
||||
2. `每日Github Trending项目计划新增`
|
||||
3. `项目分析入库(多源)`
|
||||
4. `GitHub Star 每日刷新`
|
||||
5. `项目描述向量化`
|
||||
6. `RAG项目搜索`
|
||||
7. `前沿信号聚合(多源+AI Agent过滤)`
|
||||
8. `项目标签重置`
|
||||
|
||||
不在这 8 条内,但已登记在 [registry.json](D:/Code/AI/agent-park/docs/integrations/n8n/registry.json) 的旁路流程:
|
||||
|
||||
- `AI对话网关(Agent Park)`
|
||||
|
||||
## 系统边界
|
||||
|
||||
当前体系不是“单仓库闭环”,而是三层:
|
||||
|
||||
- 外部数据源层:GitHub Search API、GitHub Trending、Hacker News、Reddit、arXiv、Product Hunt、Hugging Face
|
||||
- n8n 编排层:抓取、去重、AI 过滤、标签重置、向量化、信号结构化
|
||||
- AgentPark 应用层:Postgres/Prisma、Next.js API、页面组件
|
||||
|
||||
还有一个明确存在但当前仓库里没有实现代码的外部服务边界:
|
||||
|
||||
- `Discovery Task Service`
|
||||
- `GET/POST/PATCH /api/discovery/tasks`
|
||||
- `POST /api/discovery/check-duplicates`
|
||||
|
||||
也就是说,项目发现与入库链路的“队列与去重接口”不在当前 Next.js 仓库中实现,但其输出最终进入当前仓库使用的数据库与项目展示链路。
|
||||
|
||||
## 核心实体
|
||||
|
||||
- `projects`
|
||||
- 项目主体记录,供 `/api/projects`、详情页、搜索页、首页排行使用
|
||||
- `external_links`
|
||||
- 项目外链,尤其是 GitHub 链接,供 Star 刷新流程使用
|
||||
- `tags`
|
||||
- 标签池,供项目筛选、标签重置、入库分类使用
|
||||
- `project_tags`
|
||||
- 项目与标签的关系表
|
||||
- `signals`
|
||||
- 前沿讨论信号,供 `/api/signals` 和 Signals 页面使用
|
||||
- `projects.embedding`
|
||||
- 项目向量,供语义检索工作流使用
|
||||
|
||||
## 总流图
|
||||
|
||||
```mermaid
|
||||
flowchart TD
|
||||
subgraph Sources["External Sources"]
|
||||
GHSearch["GitHub Search API"]
|
||||
GHTrend["GitHub Trending"]
|
||||
GHRepo["GitHub Repository API"]
|
||||
HN["Hacker News"]
|
||||
Reddit["Reddit"]
|
||||
Arxiv["arXiv"]
|
||||
PH["Product Hunt"]
|
||||
HF["Hugging Face"]
|
||||
Silicon["SiliconFlow Embeddings"]
|
||||
end
|
||||
|
||||
subgraph N8N["n8n Workflows"]
|
||||
W1["1 Topic项目计划新增"]
|
||||
W2["2 每日Github Trending项目计划新增"]
|
||||
W3["3 项目分析入库(多源)"]
|
||||
W4["4 GitHub Star 每日刷新"]
|
||||
W5["5 项目描述向量化"]
|
||||
W6["6 RAG项目搜索"]
|
||||
W7["7 前沿信号聚合"]
|
||||
W8["8 项目标签重置"]
|
||||
end
|
||||
|
||||
subgraph Discovery["External Discovery Service"]
|
||||
Dedupe["/api/discovery/check-duplicates"]
|
||||
Tasks["/api/discovery/tasks"]
|
||||
Complete["/api/discovery/tasks/:id/complete"]
|
||||
end
|
||||
|
||||
subgraph App["AgentPark App + DB"]
|
||||
DBProjects["projects"]
|
||||
DBLinks["external_links"]
|
||||
DBTags["tags / project_tags"]
|
||||
DBSignals["signals"]
|
||||
APIProjects["/api/projects"]
|
||||
APISearch["/api/search/ai"]
|
||||
APISignals["/api/signals"]
|
||||
APITagReset["/api/tags/reset-projects"]
|
||||
UIProjects["Projects pages"]
|
||||
UISignals["Signals page"]
|
||||
UIHome["Home rankings"]
|
||||
end
|
||||
|
||||
GHSearch --> W1
|
||||
GHTrend --> W2
|
||||
W1 --> Dedupe
|
||||
W2 --> Dedupe
|
||||
Dedupe --> Tasks
|
||||
Tasks --> W3
|
||||
W3 --> Complete
|
||||
Complete --> DBProjects
|
||||
Complete --> DBLinks
|
||||
Complete --> DBTags
|
||||
|
||||
DBProjects --> W5
|
||||
Silicon --> W5
|
||||
W5 --> DBProjects
|
||||
|
||||
DBProjects --> W6
|
||||
Silicon --> W6
|
||||
W6 --> APISearch
|
||||
APISearch --> UIProjects
|
||||
|
||||
DBProjects --> W4
|
||||
DBLinks --> W4
|
||||
GHRepo --> W4
|
||||
W4 --> DBProjects
|
||||
DBProjects --> UIHome
|
||||
DBProjects --> UIProjects
|
||||
|
||||
HN --> W7
|
||||
Reddit --> W7
|
||||
Arxiv --> W7
|
||||
PH --> W7
|
||||
HF --> W7
|
||||
W7 --> DBSignals
|
||||
W7 --> Dedupe
|
||||
DBSignals --> APISignals
|
||||
APISignals --> UISignals
|
||||
|
||||
DBTags --> W8
|
||||
DBProjects --> W8
|
||||
W8 --> APITagReset
|
||||
APITagReset --> DBTags
|
||||
APITagReset --> UIProjects
|
||||
```
|
||||
|
||||
## 主链路拆解
|
||||
|
||||
### 1. 项目发现链路
|
||||
|
||||
入口流程:
|
||||
|
||||
- `Topic项目计划新增`
|
||||
- `每日Github Trending项目计划新增`
|
||||
|
||||
职责:
|
||||
|
||||
- 从 GitHub 搜索结果和 Trending 列表中找候选项目
|
||||
- 先走 discovery 去重
|
||||
- 再用 LLM 做保留/丢弃判断
|
||||
- 最后把可入库项目写进 discovery 任务队列
|
||||
|
||||
注意:
|
||||
|
||||
- 这两条流程不会直接写 `projects` 表
|
||||
- 它们只负责“造任务”
|
||||
|
||||
### 2. 项目入库链路
|
||||
|
||||
核心流程:
|
||||
|
||||
- `项目分析入库(多源)`
|
||||
|
||||
职责:
|
||||
|
||||
- 轮询 discovery 任务队列
|
||||
- 把任务置为 `IN_PROGRESS`
|
||||
- 用浏览器/AI 工具从入口 URL 收集事实
|
||||
- 生成标准化项目内容、外链、标签候选
|
||||
- 调用 completion 接口完成入库
|
||||
- 失败时把任务置为 `FAILED`
|
||||
|
||||
当前仓库边界:
|
||||
|
||||
- 当前仓库没有 `/api/discovery/*` 的实现
|
||||
- 但入库后的结果最终会出现在:
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/projects/route.ts)
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/projects/[slug]/route.ts)
|
||||
- [useProjects.ts](D:/Code/AI/agent-park/src/hooks/useProjects.ts)
|
||||
|
||||
### 3. 项目检索链路
|
||||
|
||||
核心流程:
|
||||
|
||||
- `项目描述向量化`
|
||||
- `RAG项目搜索`
|
||||
|
||||
职责分工:
|
||||
|
||||
- `项目描述向量化`
|
||||
- 扫描 `embedding IS NULL` 的活跃项目
|
||||
- 用 `BAAI/bge-m3` 生成向量
|
||||
- 写回 `projects.embedding` 和 `embeddingUpdatedAt`
|
||||
- `RAG项目搜索`
|
||||
- 接收 `ai-search` webhook
|
||||
- 对用户输入生成向量
|
||||
- 直接在 Postgres 中做向量相似度搜索
|
||||
- 返回 `results[].id` 和 `similarity`
|
||||
|
||||
仓库接点:
|
||||
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/search/ai/route.ts)
|
||||
- 转发到 n8n webhook
|
||||
- 再根据 ID 批量回库拿完整项目
|
||||
- [useProjects.ts](D:/Code/AI/agent-park/src/hooks/useProjects.ts)
|
||||
- `getProjectsByIds()` 负责补全项目详情
|
||||
|
||||
最终展示:
|
||||
|
||||
- 项目搜索页
|
||||
- 项目列表筛选结果
|
||||
|
||||
### 4. Signals 展示链路
|
||||
|
||||
核心流程:
|
||||
|
||||
- `前沿信号聚合(多源+AI Agent过滤)`
|
||||
|
||||
职责:
|
||||
|
||||
- 从 6 类外部源抓取讨论或发布内容
|
||||
- 先做规则过滤和去重
|
||||
- 再用 LLM 判断是否属于 AI Agent 相关信号
|
||||
- 输出中英双语结构化字段
|
||||
- 计算热度字段
|
||||
- 通过 webhook 写入 `signals`
|
||||
|
||||
仓库接点:
|
||||
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/webhook/signals/route.ts)
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/signals/route.ts)
|
||||
- [validations.ts](D:/Code/AI/agent-park/src/lib/validations.ts)
|
||||
|
||||
最终展示:
|
||||
|
||||
- Signals 页数据流
|
||||
- 讨论聚合展示卡片
|
||||
|
||||
副作用:
|
||||
|
||||
- 该流程还会从已保留信号中提取 GitHub 仓库链接,回流到 discovery 任务系统
|
||||
|
||||
### 5. 标签治理链路
|
||||
|
||||
核心流程:
|
||||
|
||||
- `项目标签重置`
|
||||
|
||||
职责:
|
||||
|
||||
- 拉取标签池与项目列表
|
||||
- 逐项目调用 LLM 做 5 类标签归类
|
||||
- 调用仓库内的标签重置接口
|
||||
|
||||
仓库接点:
|
||||
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/tags/reset-projects/route.ts)
|
||||
- [auth.ts](D:/Code/AI/agent-park/src/lib/auth.ts)
|
||||
- [validations.ts](D:/Code/AI/agent-park/src/lib/validations.ts)
|
||||
|
||||
最终影响:
|
||||
|
||||
- 项目筛选
|
||||
- 详情页标签
|
||||
- 入库后标签整洁度
|
||||
|
||||
### 6. Star 刷新链路
|
||||
|
||||
核心流程:
|
||||
|
||||
- `GitHub Star 每日刷新`
|
||||
|
||||
职责:
|
||||
|
||||
- 从 `projects` + `external_links` 找出 GitHub 仓库
|
||||
- 调 GitHub API 拉仓库详情
|
||||
- 更新 `githubStars` 与 `githubStarsUpdatedAt`
|
||||
|
||||
最终影响:
|
||||
|
||||
- 首页排行
|
||||
- 项目列表星标排序
|
||||
- AI 搜索结果里的 `stars_desc` / `stars_asc`
|
||||
|
||||
## 当前“就绪状态”定义
|
||||
|
||||
现在仓库已经具备:
|
||||
|
||||
- 8 条生产流程的仓库内登记
|
||||
- 代码触点与 workflow 的映射关系
|
||||
- 从“源头 -> n8n -> DB/API -> 页面”的主链路图
|
||||
- `.planning/codebase` 中可供 GSD 读取的 n8n 总览
|
||||
|
||||
但仍有 2 个外部依赖不在当前仓库闭环:
|
||||
|
||||
- discovery task service
|
||||
- n8n credentials / secrets / runtime env
|
||||
|
||||
因此,“代码库就绪”应理解为:
|
||||
|
||||
- AI 和 GSD 已经能正确理解全局结构与边界
|
||||
- 但不能假设当前仓库单独包含所有后端实现
|
||||
|
||||
## 建议维护规则
|
||||
|
||||
- 任何一条生产 workflow 变更时,优先更新:
|
||||
- [registry.json](D:/Code/AI/agent-park/docs/integrations/n8n/registry.json)
|
||||
- 对应 `workflows/*.md`
|
||||
- 更新后执行:
|
||||
- `pnpm n8n:context`
|
||||
- 如果 discovery 服务代码后续被并入仓库,优先补齐 `/api/discovery/*` 的实现文档与路由映射
|
||||
@@ -0,0 +1,58 @@
|
||||
# n8n Context
|
||||
|
||||
This directory is the repository-side source of truth for n8n workflows that feed or depend on this app.
|
||||
|
||||
Why this exists:
|
||||
|
||||
- n8n workflows live outside the application repository, so AI agents only see partial context from code scanning.
|
||||
- The fix is to commit workflow metadata, contracts, and touchpoint mapping into the repo.
|
||||
- Generated context is mirrored into `.planning/codebase/N8N-CONTEXT.md` so GSD can read it without guessing.
|
||||
|
||||
Key files:
|
||||
|
||||
- [registry.json](D:/Code/AI/agent-park/docs/integrations/n8n/registry.json): editable workflow registry and contract source of truth
|
||||
- [CONTEXT.generated.md](D:/Code/AI/agent-park/docs/integrations/n8n/CONTEXT.generated.md): generated inventory and repo touchpoint report
|
||||
- [DATAFLOW.md](D:/Code/AI/agent-park/docs/integrations/n8n/DATAFLOW.md): end-to-end source to UI dataflow
|
||||
- [workflows/README.md](D:/Code/AI/agent-park/docs/integrations/n8n/workflows/README.md): per-workflow documentation index
|
||||
- [N8N-CONTEXT.md](D:/Code/AI/agent-park/.planning/codebase/N8N-CONTEXT.md): GSD-facing generated mirror
|
||||
- [N8N-DATAFLOW.md](D:/Code/AI/agent-park/.planning/codebase/N8N-DATAFLOW.md): GSD-facing dataflow overview
|
||||
|
||||
Expected workflow:
|
||||
|
||||
1. Add or update an entry in `registry.json` for every n8n workflow that touches this repository.
|
||||
2. If possible, export the workflow JSON from n8n into `docs/integrations/n8n/exports/`.
|
||||
3. Run `pnpm n8n:context`.
|
||||
4. Commit the registry change together with the generated context file.
|
||||
5. If runtime behavior changed, also update `DATAFLOW.md` and the affected `workflows/*.md`.
|
||||
|
||||
Rules:
|
||||
|
||||
- `registry.json` is the editable source of truth.
|
||||
- `CONTEXT.generated.md` is generated output.
|
||||
- `DATAFLOW.md` is the cross-workflow end-to-end view.
|
||||
- `workflows/*.md` are the single-workflow execution notes.
|
||||
- Keep repository file paths repo-relative, for example `src/app/api/search/ai/route.ts`.
|
||||
- Record request and response fields at the contract level, not only business descriptions.
|
||||
- If a repo touchpoint is not linked to any workflow, the generated file will report it as a gap.
|
||||
|
||||
Minimum fields for each workflow entry:
|
||||
|
||||
- `id`
|
||||
- `status`
|
||||
- `name`
|
||||
- `purpose`
|
||||
- `n8n.entrypoints`
|
||||
- `repository.consumers`
|
||||
- `repository.env`
|
||||
- `contracts.requestFields`
|
||||
- `contracts.responseFields`
|
||||
|
||||
Recommended:
|
||||
|
||||
- `n8n.workflowId`
|
||||
- `n8n.exportFile`
|
||||
- `repository.schemas`
|
||||
- `upstreams`
|
||||
- `downstreams`
|
||||
- `owners`
|
||||
- `notes`
|
||||
@@ -0,0 +1,498 @@
|
||||
{
|
||||
"meta": {
|
||||
"lastReviewed": "2026-04-20",
|
||||
"instructions": [
|
||||
"Add one workflow entry for every n8n workflow that feeds or is triggered by this repository.",
|
||||
"Keep repository.consumers paths repo-relative.",
|
||||
"Optional: export workflow JSON into docs/integrations/n8n/exports/ and reference it from n8n.exportFile."
|
||||
]
|
||||
},
|
||||
"workflows": [
|
||||
{
|
||||
"id": "ai-search",
|
||||
"status": "confirmed",
|
||||
"name": "AI Search",
|
||||
"purpose": "Resolve semantic search candidates from n8n and hydrate them into project results.",
|
||||
"n8n": {
|
||||
"workflowId": "F5cQ06DykBfpeyfqL-pd7",
|
||||
"exportFile": "",
|
||||
"entrypoints": [
|
||||
{
|
||||
"kind": "webhook",
|
||||
"method": "GET",
|
||||
"path": "ai-search"
|
||||
}
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
"consumers": [
|
||||
"src/app/api/search/ai/route.ts"
|
||||
],
|
||||
"env": [
|
||||
"N8N_AI_SEARCH_WEBHOOK"
|
||||
],
|
||||
"schemas": [
|
||||
"N8NSearchResponseSchema"
|
||||
]
|
||||
},
|
||||
"contracts": {
|
||||
"requestFields": [
|
||||
"desc",
|
||||
"limit",
|
||||
"page",
|
||||
"offset",
|
||||
"tags",
|
||||
"domains",
|
||||
"productForms"
|
||||
],
|
||||
"responseFields": [
|
||||
"results[].id",
|
||||
"results[].similarity",
|
||||
"pagination.total",
|
||||
"pagination.totalPages",
|
||||
"pagination.hasMore"
|
||||
]
|
||||
},
|
||||
"upstreams": [
|
||||
"RAG项目搜索",
|
||||
"pgvector similarity search",
|
||||
"SiliconFlow embeddings"
|
||||
],
|
||||
"downstreams": [
|
||||
"src/hooks/useProjects.ts#getProjectsByIds",
|
||||
"POST /api/search/ai response"
|
||||
],
|
||||
"owners": [],
|
||||
"notes": "Confirmed against live n8n MCP: webhook path is `ai-search` and the workflow returns `results[].id` plus `similarity`."
|
||||
},
|
||||
{
|
||||
"id": "signals-aggregation",
|
||||
"status": "confirmed",
|
||||
"name": "Signals Aggregation",
|
||||
"purpose": "Aggregate multi-source discussion signals, filter them with AI, and ingest them into the repository signal store.",
|
||||
"n8n": {
|
||||
"workflowId": "bAxNZKGq2ApUUiw9",
|
||||
"exportFile": "",
|
||||
"entrypoints": [
|
||||
{
|
||||
"kind": "schedule",
|
||||
"path": "multi-source discussion crawl"
|
||||
}
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
"consumers": [
|
||||
"src/lib/auth.ts",
|
||||
"src/app/api/webhook/signals/route.ts",
|
||||
"src/app/api/signals/route.ts",
|
||||
"src/lib/validations.ts",
|
||||
"prisma/schema.prisma"
|
||||
],
|
||||
"env": [
|
||||
"WEBHOOK_API_KEY"
|
||||
],
|
||||
"schemas": [
|
||||
"SignalWebhookPayloadSchema",
|
||||
"SignalIngestionInputSchema",
|
||||
"SignalQuerySchema"
|
||||
]
|
||||
},
|
||||
"contracts": {
|
||||
"requestFields": [
|
||||
"apiKey",
|
||||
"signals[].source",
|
||||
"signals[].sourceUrl",
|
||||
"signals[].title",
|
||||
"signals[].titleEn",
|
||||
"signals[].summary",
|
||||
"signals[].summaryEn",
|
||||
"signals[].topic",
|
||||
"signals[].topicEn",
|
||||
"signals[].tags",
|
||||
"signals[].sections",
|
||||
"signals[].engagement",
|
||||
"signals[].hotScore",
|
||||
"signals[].isHot",
|
||||
"signals[].publishedAt",
|
||||
"signals[].isActive"
|
||||
],
|
||||
"responseFields": [
|
||||
"success",
|
||||
"processed",
|
||||
"created",
|
||||
"updated",
|
||||
"failed",
|
||||
"errors[].index",
|
||||
"errors[].field",
|
||||
"errors[].message"
|
||||
]
|
||||
},
|
||||
"upstreams": [
|
||||
"Hacker News",
|
||||
"GitHub",
|
||||
"arXiv",
|
||||
"Reddit",
|
||||
"Product Hunt",
|
||||
"Hugging Face"
|
||||
],
|
||||
"downstreams": [
|
||||
"GET /api/signals",
|
||||
"signals page feed",
|
||||
"signal hotness computation"
|
||||
],
|
||||
"owners": [],
|
||||
"notes": "Confirmed against live n8n MCP: workflow posts to `/api/webhook/signals` and also triggers external discovery dedupe/task creation. Workflow currently embeds a shared secret in HTTP body and should move to credentials/env."
|
||||
},
|
||||
{
|
||||
"id": "tag-reset",
|
||||
"status": "confirmed",
|
||||
"name": "Project Tag Reset",
|
||||
"purpose": "Reset selected project tags in bulk from n8n classification results.",
|
||||
"n8n": {
|
||||
"workflowId": "8tIgBqLyWrBewJPs",
|
||||
"exportFile": "",
|
||||
"entrypoints": [
|
||||
{
|
||||
"kind": "manual",
|
||||
"path": "bulk tag reset"
|
||||
}
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
"consumers": [
|
||||
"src/lib/auth.ts",
|
||||
"src/app/api/tags/reset-projects/route.ts",
|
||||
"src/lib/validations.ts",
|
||||
"prisma/schema.prisma"
|
||||
],
|
||||
"env": [
|
||||
"WEBHOOK_API_KEY"
|
||||
],
|
||||
"schemas": [
|
||||
"ProjectTagResetRequestSchema"
|
||||
]
|
||||
},
|
||||
"contracts": {
|
||||
"requestFields": [
|
||||
"apiKey",
|
||||
"dryRun",
|
||||
"replaceAllCategories",
|
||||
"categories",
|
||||
"projects[].projectSlug",
|
||||
"projects[].selectedTagSlugsByCategory"
|
||||
],
|
||||
"responseFields": [
|
||||
"success",
|
||||
"result.dryRun",
|
||||
"result.categories",
|
||||
"result.updatedCount",
|
||||
"result.failedCount",
|
||||
"result.results[].projectSlug",
|
||||
"result.results[].status",
|
||||
"result.results[].details"
|
||||
]
|
||||
},
|
||||
"upstreams": [
|
||||
"n8n tag classification"
|
||||
],
|
||||
"downstreams": [
|
||||
"project tag relations",
|
||||
"project detail page revalidation",
|
||||
"project list revalidation"
|
||||
],
|
||||
"owners": [],
|
||||
"notes": "Confirmed against live n8n MCP: workflow reads `/api/tags` and `/api/projects`, then posts bulk updates into `/api/tags/reset-projects`. Workflow currently embeds a shared secret in HTTP body and should move to credentials/env."
|
||||
},
|
||||
{
|
||||
"id": "project-ingestion-multi-source",
|
||||
"status": "external-upstream",
|
||||
"name": "Project Ingestion (Multi-source)",
|
||||
"purpose": "Consume queued discovery tasks, enrich project metadata with AI/browser steps, and write final ingestion results back to Agent Park.",
|
||||
"n8n": {
|
||||
"workflowId": "1Ig1CyVMsGJFaHOe",
|
||||
"exportFile": "",
|
||||
"entrypoints": [
|
||||
{
|
||||
"kind": "schedule",
|
||||
"path": "every 10 minutes"
|
||||
}
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
"consumers": [
|
||||
"prisma/schema.prisma",
|
||||
"src/app/api/projects/route.ts",
|
||||
"src/app/api/projects/[slug]/route.ts"
|
||||
],
|
||||
"env": [],
|
||||
"schemas": []
|
||||
},
|
||||
"contracts": {
|
||||
"requestFields": [
|
||||
"task.status",
|
||||
"task.sourceUrl",
|
||||
"task.sourceType"
|
||||
],
|
||||
"responseFields": [
|
||||
"project content",
|
||||
"tag assignments",
|
||||
"task completion status"
|
||||
]
|
||||
},
|
||||
"upstreams": [
|
||||
"discovery task queue",
|
||||
"browser/AI extraction",
|
||||
"tag catalog"
|
||||
],
|
||||
"downstreams": [
|
||||
"project records visible in repository APIs",
|
||||
"task completion callbacks",
|
||||
"task failure callbacks"
|
||||
],
|
||||
"owners": [],
|
||||
"notes": "Confirmed against live n8n MCP: workflow polls `/api/discovery/tasks`, marks tasks `IN_PROGRESS`, enriches candidates, then completes or fails tasks. The current repo does not contain `/api/discovery/*` handlers, so this is an upstream system dependency rather than a route implemented here."
|
||||
},
|
||||
{
|
||||
"id": "github-star-refresh",
|
||||
"status": "confirmed",
|
||||
"name": "GitHub Star Refresh",
|
||||
"purpose": "Refresh `projects.githubStars` and `projects.githubStarsUpdatedAt` directly from GitHub repository metadata.",
|
||||
"n8n": {
|
||||
"workflowId": "ewx9Gs6cjrTXvwD0",
|
||||
"exportFile": "",
|
||||
"entrypoints": [
|
||||
{
|
||||
"kind": "schedule",
|
||||
"path": "daily at 04:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
"consumers": [
|
||||
"prisma/schema.prisma",
|
||||
"src/app/api/search/ai/route.ts",
|
||||
"src/hooks/useProjects.ts"
|
||||
],
|
||||
"env": [],
|
||||
"schemas": []
|
||||
},
|
||||
"contracts": {
|
||||
"requestFields": [
|
||||
"projects.id",
|
||||
"projects.slug",
|
||||
"external_links.url(type=GITHUB)"
|
||||
],
|
||||
"responseFields": [
|
||||
"projects.githubStars",
|
||||
"projects.githubStarsUpdatedAt"
|
||||
]
|
||||
},
|
||||
"upstreams": [
|
||||
"GitHub repository API",
|
||||
"projects table",
|
||||
"external_links table"
|
||||
],
|
||||
"downstreams": [
|
||||
"project ranking",
|
||||
"star sorting",
|
||||
"home ranking display"
|
||||
],
|
||||
"owners": [],
|
||||
"notes": "Confirmed against live n8n MCP: workflow reads active project GitHub links from Postgres, fetches repository metadata from GitHub, then writes star counts directly back to Postgres. This bypasses repository API routes."
|
||||
},
|
||||
{
|
||||
"id": "project-description-vectorization",
|
||||
"status": "confirmed",
|
||||
"name": "Project Description Vectorization",
|
||||
"purpose": "Generate and persist project embeddings used by semantic search.",
|
||||
"n8n": {
|
||||
"workflowId": "1AvejnM5n-WPApU1vFt9C",
|
||||
"exportFile": "",
|
||||
"entrypoints": [
|
||||
{
|
||||
"kind": "schedule",
|
||||
"path": "every 30 minutes"
|
||||
}
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
"consumers": [
|
||||
"prisma/schema.prisma",
|
||||
"prisma/migrations/20260126000000_add_project_embedding/migration.sql",
|
||||
"src/app/api/search/ai/route.ts"
|
||||
],
|
||||
"env": [],
|
||||
"schemas": []
|
||||
},
|
||||
"contracts": {
|
||||
"requestFields": [
|
||||
"projects.id",
|
||||
"projects.name",
|
||||
"projects.nameEn",
|
||||
"projects.description",
|
||||
"projects.descriptionEn",
|
||||
"projects.content",
|
||||
"projects.contentEn"
|
||||
],
|
||||
"responseFields": [
|
||||
"projects.embedding",
|
||||
"projects.embeddingUpdatedAt"
|
||||
]
|
||||
},
|
||||
"upstreams": [
|
||||
"SiliconFlow embeddings API",
|
||||
"projects table"
|
||||
],
|
||||
"downstreams": [
|
||||
"RAG项目搜索",
|
||||
"semantic search quality"
|
||||
],
|
||||
"owners": [],
|
||||
"notes": "Confirmed against live n8n MCP: workflow selects active projects with null embeddings, generates `BAAI/bge-m3` vectors, and writes them directly into the `vector` column. This is a direct DB maintenance job, not a repository API route."
|
||||
},
|
||||
{
|
||||
"id": "github-trending-discovery",
|
||||
"status": "external-upstream",
|
||||
"name": "GitHub Trending Discovery",
|
||||
"purpose": "Scrape GitHub Trending, dedupe candidates, filter them with AI, and enqueue project discovery tasks.",
|
||||
"n8n": {
|
||||
"workflowId": "hughGsWismCpk7jd",
|
||||
"exportFile": "",
|
||||
"entrypoints": [
|
||||
{
|
||||
"kind": "schedule",
|
||||
"path": "daily at 01:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
"consumers": [
|
||||
"src/app/api/projects/route.ts",
|
||||
"src/app/api/projects/[slug]/route.ts"
|
||||
],
|
||||
"env": [],
|
||||
"schemas": []
|
||||
},
|
||||
"contracts": {
|
||||
"requestFields": [
|
||||
"GitHub trending repository URL",
|
||||
"apiKey",
|
||||
"tasks[].sourceUrl",
|
||||
"tasks[].sourceType"
|
||||
],
|
||||
"responseFields": [
|
||||
"dedupe shouldCreate",
|
||||
"task creation result"
|
||||
]
|
||||
},
|
||||
"upstreams": [
|
||||
"https://github.com/trending",
|
||||
"AI keep/discard filter"
|
||||
],
|
||||
"downstreams": [
|
||||
"/api/discovery/check-duplicates",
|
||||
"/api/discovery/tasks",
|
||||
"project ingestion queue"
|
||||
],
|
||||
"owners": [],
|
||||
"notes": "Confirmed against live n8n MCP: workflow scrapes GitHub Trending, filters candidates with an LLM, then posts queued tasks into discovery webhook endpoints. The current repo does not implement `/api/discovery/*`, so treat this as upstream data intake."
|
||||
},
|
||||
{
|
||||
"id": "topic-discovery",
|
||||
"status": "external-upstream",
|
||||
"name": "Topic Discovery",
|
||||
"purpose": "Search GitHub topics and keywords for agent/LLM engineering repos, dedupe them, and enqueue discovery tasks.",
|
||||
"n8n": {
|
||||
"workflowId": "iw9vx9ih5Lt0Mobk",
|
||||
"exportFile": "",
|
||||
"entrypoints": [
|
||||
{
|
||||
"kind": "schedule",
|
||||
"path": "daily at 01:00"
|
||||
}
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
"consumers": [
|
||||
"src/app/api/projects/route.ts",
|
||||
"src/app/api/projects/[slug]/route.ts"
|
||||
],
|
||||
"env": [],
|
||||
"schemas": []
|
||||
},
|
||||
"contracts": {
|
||||
"requestFields": [
|
||||
"GitHub search query",
|
||||
"apiKey",
|
||||
"tasks[].sourceUrl",
|
||||
"tasks[].sourceType"
|
||||
],
|
||||
"responseFields": [
|
||||
"dedupe shouldCreate",
|
||||
"task creation result",
|
||||
"low recall alert"
|
||||
]
|
||||
},
|
||||
"upstreams": [
|
||||
"GitHub Search API",
|
||||
"AI keep/discard filter",
|
||||
"topic watchlist"
|
||||
],
|
||||
"downstreams": [
|
||||
"/api/discovery/check-duplicates",
|
||||
"/api/discovery/tasks",
|
||||
"project ingestion queue"
|
||||
],
|
||||
"owners": [],
|
||||
"notes": "Confirmed against live n8n MCP: workflow searches agent, infra, observability, evaluation, and MCP-related repositories, then posts accepted candidates into discovery webhooks. The current repo does not implement `/api/discovery/*`, so this is upstream context rather than in-repo routing."
|
||||
},
|
||||
{
|
||||
"id": "ai-chat-gateway",
|
||||
"status": "adjacent",
|
||||
"name": "AI Chat Gateway",
|
||||
"purpose": "Expose a chat-oriented webhook wrapper around `RAG项目搜索` and package search hits into chat blocks/citations.",
|
||||
"n8n": {
|
||||
"workflowId": "Rncc22jmHEaYOG58",
|
||||
"exportFile": "",
|
||||
"entrypoints": [
|
||||
{
|
||||
"kind": "webhook",
|
||||
"method": "POST",
|
||||
"path": "agent-park-chat"
|
||||
}
|
||||
]
|
||||
},
|
||||
"repository": {
|
||||
"consumers": [],
|
||||
"env": [],
|
||||
"schemas": []
|
||||
},
|
||||
"contracts": {
|
||||
"requestFields": [
|
||||
"requestId",
|
||||
"sessionId",
|
||||
"clientId",
|
||||
"locale",
|
||||
"mode",
|
||||
"message"
|
||||
],
|
||||
"responseFields": [
|
||||
"message.blocks",
|
||||
"message.citations",
|
||||
"message.meta.source",
|
||||
"progress.stage"
|
||||
]
|
||||
},
|
||||
"upstreams": [
|
||||
"RAG项目搜索",
|
||||
"n8n webhook `ai-search`"
|
||||
],
|
||||
"downstreams": [
|
||||
"external chat clients",
|
||||
"project detail URLs"
|
||||
],
|
||||
"owners": [],
|
||||
"notes": "Confirmed against live n8n MCP: this workflow is related to Agent Park but does not call a repository route directly. It wraps the RAG webhook and formats citations pointing at project pages."
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
# Topic项目计划新增
|
||||
|
||||
- Registry ID: `topic-discovery`
|
||||
- n8n Workflow ID: `iw9vx9ih5Lt0Mobk`
|
||||
- Status: `external-upstream`
|
||||
- 角色: 从 GitHub Search API 按 topic 和关键词发现候选仓库,经过去重与 AI 筛选后,写入 discovery 任务队列。
|
||||
|
||||
## 触发方式
|
||||
|
||||
- 定时触发
|
||||
- 当前已核查行为:每日约 `01:00` 运行
|
||||
|
||||
## 外部输入
|
||||
|
||||
- GitHub Search API
|
||||
- 预设 topic / keyword watchlist
|
||||
- AI 保留或丢弃判断
|
||||
|
||||
## 主流程
|
||||
|
||||
1. 组合 topic 与关键词查询 GitHub 仓库。
|
||||
2. 提取候选仓库 URL、基础描述和来源信息。
|
||||
3. 调用 discovery 去重接口判断是否应该继续创建任务。
|
||||
4. 使用 LLM 对候选项目做保留或丢弃判断。
|
||||
5. 把保留结果写入 discovery 任务队列。
|
||||
6. 当召回偏低时发出低召回告警。
|
||||
|
||||
## 输出结果
|
||||
|
||||
- `tasks[].sourceUrl`
|
||||
- `tasks[].sourceType`
|
||||
- `dedupe shouldCreate`
|
||||
- `task creation result`
|
||||
- `low recall alert`
|
||||
|
||||
## 与仓库的关系
|
||||
|
||||
这条流程不直接写当前仓库的 `projects` 表。它的作用是“发现项目并造任务”,后续由 `项目分析入库(多源)` 消费任务并完成入库。
|
||||
|
||||
当前仓库内最终会消费它产出的结果:
|
||||
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/projects/route.ts)
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/projects/[slug]/route.ts)
|
||||
|
||||
## 系统边界
|
||||
|
||||
当前仓库没有实现以下接口,这部分应视为外部上游系统:
|
||||
|
||||
- `POST /api/discovery/check-duplicates`
|
||||
- `POST /api/discovery/tasks`
|
||||
|
||||
因此,这条流程在仓库侧属于 `external-upstream`,不是应用内路由。
|
||||
|
||||
## 已确认要点
|
||||
|
||||
- 已通过 live n8n MCP 核查 workflow 元数据与职责。
|
||||
- 该流程围绕 agent / infra / observability / evaluation / MCP 等方向搜索仓库。
|
||||
- 任务入队后,真实入库并不在当前流程内完成。
|
||||
|
||||
## 维护要求
|
||||
|
||||
- GitHub 搜索 query、topic watchlist、保留规则变更时,同步更新 [registry.json](D:/Code/AI/agent-park/docs/integrations/n8n/registry.json)。
|
||||
- 若未来 discovery 服务代码并入当前仓库,应把这里的外部边界改成具体路由映射。
|
||||
@@ -0,0 +1,62 @@
|
||||
# 每日Github Trending项目计划新增
|
||||
|
||||
- Registry ID: `github-trending-discovery`
|
||||
- n8n Workflow ID: `hughGsWismCpk7jd`
|
||||
- Status: `external-upstream`
|
||||
- 角色: 抓取 GitHub Trending,筛出值得跟踪的新项目,并写入 discovery 任务队列。
|
||||
|
||||
## 触发方式
|
||||
|
||||
- 定时触发
|
||||
- 当前已核查行为:每日约 `01:00` 运行
|
||||
|
||||
## 外部输入
|
||||
|
||||
- [GitHub Trending](https://github.com/trending)
|
||||
- 页面抓取结果
|
||||
- AI 保留或丢弃判断
|
||||
|
||||
## 主流程
|
||||
|
||||
1. 抓取 GitHub Trending 页面,提取仓库 URL 和基础描述。
|
||||
2. 规范化候选项目数据。
|
||||
3. 调用 discovery 去重接口,判断当前候选是否已经存在。
|
||||
4. 使用 LLM 对候选项目做保留或丢弃判断。
|
||||
5. 把保留候选写入 discovery 任务队列。
|
||||
|
||||
## 输出结果
|
||||
|
||||
- `GitHub trending repository URL`
|
||||
- `tasks[].sourceUrl`
|
||||
- `tasks[].sourceType`
|
||||
- `dedupe shouldCreate`
|
||||
- `task creation result`
|
||||
|
||||
## 与仓库的关系
|
||||
|
||||
这条流程不直接写当前仓库数据库。它只生成“待分析任务”,真正的项目详情写入由 `项目分析入库(多源)` 完成。
|
||||
|
||||
最终影响到当前仓库的展示结果:
|
||||
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/projects/route.ts)
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/projects/[slug]/route.ts)
|
||||
|
||||
## 系统边界
|
||||
|
||||
当前仓库没有实现以下接口:
|
||||
|
||||
- `POST /api/discovery/check-duplicates`
|
||||
- `POST /api/discovery/tasks`
|
||||
|
||||
因此它是当前仓库的外部上游,而不是仓库内闭环的一部分。
|
||||
|
||||
## 已确认要点
|
||||
|
||||
- 已通过 live n8n MCP 核查 workflow 元数据与职责。
|
||||
- 该流程从 Trending 抓取候选,再由 AI 过滤,避免把纯噪声仓库直接入库。
|
||||
- 当前仓库只能看到最终被入库后的项目,不能独立重放这条发现链路。
|
||||
|
||||
## 维护要求
|
||||
|
||||
- Trending 抓取逻辑、筛选规则、入队字段变更时,同步更新 [registry.json](D:/Code/AI/agent-park/docs/integrations/n8n/registry.json)。
|
||||
- 若后续保留了 workflow 导出文件,应在 registry 中补上 `exportFile`。
|
||||
@@ -0,0 +1,67 @@
|
||||
# 项目分析入库(多源)
|
||||
|
||||
- Registry ID: `project-ingestion-multi-source`
|
||||
- n8n Workflow ID: `1Ig1CyVMsGJFaHOe`
|
||||
- Status: `external-upstream`
|
||||
- 角色: 消费 discovery 任务队列,补齐项目事实、外链和标签,再把结果写回 AgentPark 的项目主数据。
|
||||
|
||||
## 触发方式
|
||||
|
||||
- 定时触发
|
||||
- 当前已核查行为:约每 `10` 分钟轮询一次
|
||||
|
||||
## 外部输入
|
||||
|
||||
- discovery task queue
|
||||
- 浏览器抓取与页面解析
|
||||
- AI 提取和结构化能力
|
||||
- 标签池 / 分类规则
|
||||
|
||||
## 主流程
|
||||
|
||||
1. 轮询 discovery 任务队列,拉取待处理项目。
|
||||
2. 把任务置为 `IN_PROGRESS`。
|
||||
3. 基于 `sourceUrl` 和 `sourceType` 打开外部页面,采集项目事实。
|
||||
4. 生成标准化项目资料,如标题、描述、正文、分类、外链、标签候选。
|
||||
5. 写回任务完成接口,完成项目入库。
|
||||
6. 出错时写回失败状态。
|
||||
|
||||
## 输出结果
|
||||
|
||||
- `project content`
|
||||
- `tag assignments`
|
||||
- `task completion status`
|
||||
|
||||
## 与仓库的关系
|
||||
|
||||
这是“项目从发现到落库”的核心桥梁,但它不通过当前仓库中的显式 `/api/discovery/*` 路由实现。当前仓库能确认的消费面主要是项目表结构和展示接口:
|
||||
|
||||
- [schema.prisma](D:/Code/AI/agent-park/prisma/schema.prisma)
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/projects/route.ts)
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/projects/[slug]/route.ts)
|
||||
|
||||
## 系统边界
|
||||
|
||||
当前仓库没有以下接口实现:
|
||||
|
||||
- `GET /api/discovery/tasks`
|
||||
- `PATCH /api/discovery/tasks/:id`
|
||||
- `POST /api/discovery/tasks/:id/complete`
|
||||
- 失败回写相关接口
|
||||
|
||||
这说明项目入库队列服务是外部系统。当前仓库只能消费最终入库结果,而不能独立运行整条入库工作流。
|
||||
|
||||
## 已确认要点
|
||||
|
||||
- 已通过 live n8n MCP 核查 workflow 元数据与职责。
|
||||
- 该流程会先标记 `IN_PROGRESS`,再进行浏览器与 AI 分析。
|
||||
- 当前仓库看到的是结果表和查询接口,不是入库执行器本身。
|
||||
|
||||
## 对 AI/GSD 的意义
|
||||
|
||||
后续如果要梳理“项目数据源头 -> 展示”,必须把这条流程当作核心中间层,而不是假设项目直接从 GitHub 写入 `projects`。
|
||||
|
||||
## 维护要求
|
||||
|
||||
- 任务字段、回写结构、标签落库策略变化时,同步更新 [registry.json](D:/Code/AI/agent-park/docs/integrations/n8n/registry.json)。
|
||||
- 若 discovery 服务未来并入仓库,应第一时间把这里改成具体路由与 schema 映射。
|
||||
@@ -0,0 +1,54 @@
|
||||
# GitHub Star 每日刷新
|
||||
|
||||
- Registry ID: `github-star-refresh`
|
||||
- n8n Workflow ID: `ewx9Gs6cjrTXvwD0`
|
||||
- Status: `confirmed`
|
||||
- 角色: 每日刷新项目的 GitHub Star 数,保证排序、排行和展示的时效性。
|
||||
|
||||
## 触发方式
|
||||
|
||||
- 定时触发
|
||||
- 当前已核查行为:每日约 `04:00` 运行
|
||||
|
||||
## 数据来源
|
||||
|
||||
- `projects`
|
||||
- `external_links`
|
||||
- GitHub Repository API
|
||||
|
||||
## 主流程
|
||||
|
||||
1. 从数据库读取活跃项目及其 GitHub 外链。
|
||||
2. 解析 GitHub 仓库 owner/repo。
|
||||
3. 调用 GitHub Repository API 获取最新 star 数。
|
||||
4. 直接写回 `projects.githubStars` 与 `projects.githubStarsUpdatedAt`。
|
||||
|
||||
## 直接写入字段
|
||||
|
||||
- `projects.githubStars`
|
||||
- `projects.githubStarsUpdatedAt`
|
||||
|
||||
## 与仓库的关系
|
||||
|
||||
这条流程不是调用仓库 API,而是直接维护数据库中的展示字段。当前仓库内受其影响的消费面包括:
|
||||
|
||||
- [schema.prisma](D:/Code/AI/agent-park/prisma/schema.prisma)
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/search/ai/route.ts)
|
||||
- [useProjects.ts](D:/Code/AI/agent-park/src/hooks/useProjects.ts)
|
||||
|
||||
## 下游影响
|
||||
|
||||
- 首页排行
|
||||
- 项目列表按 star 排序
|
||||
- AI 搜索结果中的 `stars_desc` / `stars_asc`
|
||||
|
||||
## 已确认要点
|
||||
|
||||
- 已通过 live n8n MCP 核查 workflow 元数据与职责。
|
||||
- 该流程直接读写 Postgres,而不是走 Next.js API。
|
||||
- 所以如果星标不更新,优先排查 n8n 与数据库连接,而不是先看前端排序代码。
|
||||
|
||||
## 维护要求
|
||||
|
||||
- 如果 `external_links` 的 GitHub 链接筛选规则改变,要同步更新这里和 [registry.json](D:/Code/AI/agent-park/docs/integrations/n8n/registry.json)。
|
||||
- 如果后续改成走仓库 API 写入,应把“直接 DB 写”改成“仓库 API 触点”。
|
||||
@@ -0,0 +1,55 @@
|
||||
# 项目描述向量化
|
||||
|
||||
- Registry ID: `project-description-vectorization`
|
||||
- n8n Workflow ID: `1AvejnM5n-WPApU1vFt9C`
|
||||
- Status: `confirmed`
|
||||
- 角色: 为项目生成 embedding,供语义检索工作流使用。
|
||||
|
||||
## 触发方式
|
||||
|
||||
- 定时触发
|
||||
- 当前已核查行为:约每 `30` 分钟运行一次
|
||||
|
||||
## 数据来源
|
||||
|
||||
- `projects` 表中的活跃项目
|
||||
- SiliconFlow Embeddings API
|
||||
- 模型:`BAAI/bge-m3`
|
||||
|
||||
## 主流程
|
||||
|
||||
1. 选出 `embedding IS NULL` 或需要补算的活跃项目。
|
||||
2. 读取项目的中英文名称、描述和正文。
|
||||
3. 拼接向量化输入文本。
|
||||
4. 调用 SiliconFlow Embeddings API 生成向量。
|
||||
5. 直接写回 `projects.embedding` 和 `embeddingUpdatedAt`。
|
||||
|
||||
## 直接写入字段
|
||||
|
||||
- `projects.embedding`
|
||||
- `projects.embeddingUpdatedAt`
|
||||
|
||||
## 与仓库的关系
|
||||
|
||||
这条流程直接维护数据库向量列,而不是通过仓库 API 写入。仓库侧主要消费点:
|
||||
|
||||
- [schema.prisma](D:/Code/AI/agent-park/prisma/schema.prisma)
|
||||
- [migration.sql](D:/Code/AI/agent-park/prisma/migrations/20260126000000_add_project_embedding/migration.sql)
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/search/ai/route.ts)
|
||||
|
||||
## 下游影响
|
||||
|
||||
- `RAG项目搜索`
|
||||
- 语义搜索命中质量
|
||||
- 以自然语言搜索项目的相关性
|
||||
|
||||
## 已确认要点
|
||||
|
||||
- 已通过 live n8n MCP 核查 workflow 元数据与职责。
|
||||
- 当前实现是直连数据库维护向量列。
|
||||
- 所以如果 AI 搜索结果变差,既要查 webhook 搜索流程,也要查这条补向量流程是否落后或失败。
|
||||
|
||||
## 维护要求
|
||||
|
||||
- 向量输入字段、模型、补算规则变化时,同步更新 [registry.json](D:/Code/AI/agent-park/docs/integrations/n8n/registry.json)。
|
||||
- 如果后续 embedding 改成异步队列或应用内任务,需要把这里的数据库直写描述同步改掉。
|
||||
@@ -0,0 +1,66 @@
|
||||
# RAG项目搜索
|
||||
|
||||
- Registry ID: `ai-search`
|
||||
- n8n Workflow ID: `F5cQ06DykBfpeyfqL-pd7`
|
||||
- Status: `confirmed`
|
||||
- 角色: 把用户查询转成向量相似度检索结果,再把候选项目 ID 返回给仓库 API 做二次补全。
|
||||
|
||||
## 触发方式
|
||||
|
||||
- webhook 触发
|
||||
- 已核查 webhook path: `ai-search`
|
||||
|
||||
## 输入契约
|
||||
|
||||
- `desc`
|
||||
- `limit`
|
||||
- `page`
|
||||
- `offset`
|
||||
- `tags`
|
||||
- `domains`
|
||||
- `productForms`
|
||||
|
||||
## 主流程
|
||||
|
||||
1. 仓库 API 接收搜索请求。
|
||||
2. API 把请求转发给 n8n webhook `ai-search`。
|
||||
3. n8n 为查询文本生成 embedding。
|
||||
4. n8n 在 Postgres 中执行向量相似度搜索。
|
||||
5. n8n 返回候选项目 ID 和相似度。
|
||||
6. 仓库 API 再按 ID 回库查询完整项目数据并返回前端。
|
||||
|
||||
## 输出契约
|
||||
|
||||
- `results[].id`
|
||||
- `results[].similarity`
|
||||
- `pagination.total`
|
||||
- `pagination.totalPages`
|
||||
- `pagination.hasMore`
|
||||
|
||||
## 与仓库的关系
|
||||
|
||||
这是当前仓库里最直接可见的 n8n 搜索接点:
|
||||
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/search/ai/route.ts)
|
||||
- [useProjects.ts](D:/Code/AI/agent-park/src/hooks/useProjects.ts)
|
||||
|
||||
## 关键实现边界
|
||||
|
||||
- n8n 负责“召回候选 ID”
|
||||
- 仓库 API 负责“按 ID 补全项目字段”
|
||||
- 前端不直接信任 n8n 返回完整项目对象,而是以仓库数据库为准
|
||||
|
||||
这个分层是正确的,因为它避免把页面展示完全绑死到 n8n 返回结构。
|
||||
|
||||
## 已确认要点
|
||||
|
||||
- 已通过 live n8n MCP 核查 workflow 元数据、webhook path 和返回字段。
|
||||
- 当前搜索依赖 `projects.embedding`,因此和 `项目描述向量化` 强耦合。
|
||||
- 如果 `N8N_AI_SEARCH_WEBHOOK` 缺失或返回结构变化,搜索 API 会直接受影响。
|
||||
|
||||
## 维护要求
|
||||
|
||||
- 搜索输入字段、分页规则、n8n 返回结构变化时,必须同步更新:
|
||||
- [registry.json](D:/Code/AI/agent-park/docs/integrations/n8n/registry.json)
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/search/ai/route.ts)
|
||||
- 如未来增加 rerank 或 hybrid search,也应先更新这里,再调整 API 契约。
|
||||
@@ -0,0 +1,89 @@
|
||||
# 前沿信号聚合(多源+AI Agent过滤)
|
||||
|
||||
- Registry ID: `signals-aggregation`
|
||||
- n8n Workflow ID: `bAxNZKGq2ApUUiw9`
|
||||
- Status: `confirmed`
|
||||
- 角色: 聚合多源讨论与发布内容,筛出 AI Agent 相关信号,结构化后写入 AgentPark 的 `signals` 数据流。
|
||||
|
||||
## 触发方式
|
||||
|
||||
- 定时触发
|
||||
- 当前已核查行为:多源周期抓取
|
||||
|
||||
## 数据来源
|
||||
|
||||
- Hacker News
|
||||
- GitHub
|
||||
- arXiv
|
||||
- Reddit
|
||||
- Product Hunt
|
||||
- Hugging Face
|
||||
|
||||
## 主流程
|
||||
|
||||
1. 从 6 类外部源抓取候选讨论或发布内容。
|
||||
2. 做基础规则过滤与去重。
|
||||
3. 使用 LLM 判断是否属于 AI Agent 相关前沿信号。
|
||||
4. 生成中英双语标题、摘要、主题、标签、sections 和热度字段。
|
||||
5. 调用仓库 webhook 写入 `signals`。
|
||||
6. 从保留结果中抽取 GitHub 仓库链接,回流 discovery 系统继续发现项目。
|
||||
|
||||
## 输入契约
|
||||
|
||||
- `apiKey`
|
||||
- `signals[].source`
|
||||
- `signals[].sourceUrl`
|
||||
- `signals[].title`
|
||||
- `signals[].titleEn`
|
||||
- `signals[].summary`
|
||||
- `signals[].summaryEn`
|
||||
- `signals[].topic`
|
||||
- `signals[].topicEn`
|
||||
- `signals[].tags`
|
||||
- `signals[].sections`
|
||||
- `signals[].engagement`
|
||||
- `signals[].hotScore`
|
||||
- `signals[].isHot`
|
||||
- `signals[].publishedAt`
|
||||
- `signals[].isActive`
|
||||
|
||||
## 输出契约
|
||||
|
||||
- `success`
|
||||
- `processed`
|
||||
- `created`
|
||||
- `updated`
|
||||
- `failed`
|
||||
- `errors[].index`
|
||||
- `errors[].field`
|
||||
- `errors[].message`
|
||||
|
||||
## 与仓库的关系
|
||||
|
||||
仓库内直接接点:
|
||||
|
||||
- [auth.ts](D:/Code/AI/agent-park/src/lib/auth.ts)
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/webhook/signals/route.ts)
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/signals/route.ts)
|
||||
- [validations.ts](D:/Code/AI/agent-park/src/lib/validations.ts)
|
||||
- [schema.prisma](D:/Code/AI/agent-park/prisma/schema.prisma)
|
||||
|
||||
## 下游影响
|
||||
|
||||
- Signals 页面内容
|
||||
- 热门信号排序与过滤
|
||||
- 从信号反向发现 GitHub 项目的回流链路
|
||||
|
||||
## 已确认要点
|
||||
|
||||
- 已通过 live n8n MCP 核查 workflow 元数据与 webhook 写入方向。
|
||||
- 该流程会向 `/api/webhook/signals` 写入结构化 signals。
|
||||
- 它还会触发外部 discovery 去重与任务创建,因此不只是“信号展示流”,也是项目发现的旁路入口。
|
||||
- workflow 当前在 HTTP body 中携带共享密钥,仓库侧应视为待治理项,后续改为 n8n credential 或环境变量注入。
|
||||
|
||||
## 维护要求
|
||||
|
||||
- 字段结构、验证 schema、热度算法变更时,同步更新:
|
||||
- [registry.json](D:/Code/AI/agent-park/docs/integrations/n8n/registry.json)
|
||||
- [validations.ts](D:/Code/AI/agent-park/src/lib/validations.ts)
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/webhook/signals/route.ts)
|
||||
@@ -0,0 +1,73 @@
|
||||
# 项目标签重置
|
||||
|
||||
- Registry ID: `tag-reset`
|
||||
- n8n Workflow ID: `8tIgBqLyWrBewJPs`
|
||||
- Status: `confirmed`
|
||||
- 角色: 基于 n8n 分类结果批量重置项目标签,保证项目标签体系的一致性。
|
||||
|
||||
## 触发方式
|
||||
|
||||
- 手动触发
|
||||
- 当前已核查行为:面向批量标签治理任务
|
||||
|
||||
## 数据来源
|
||||
|
||||
- `GET /api/tags`
|
||||
- `GET /api/projects`
|
||||
- n8n 内的 LLM 标签分类
|
||||
|
||||
## 主流程
|
||||
|
||||
1. 从仓库读取标签池和项目列表。
|
||||
2. 在 n8n 内对项目做多分类标签判断。
|
||||
3. 组装批量标签重置请求。
|
||||
4. 调用仓库 API `/api/tags/reset-projects`。
|
||||
5. 返回更新结果、失败信息和 dry-run 结果。
|
||||
|
||||
## 输入契约
|
||||
|
||||
- `apiKey`
|
||||
- `dryRun`
|
||||
- `replaceAllCategories`
|
||||
- `categories`
|
||||
- `projects[].projectSlug`
|
||||
- `projects[].selectedTagSlugsByCategory`
|
||||
|
||||
## 输出契约
|
||||
|
||||
- `success`
|
||||
- `result.dryRun`
|
||||
- `result.categories`
|
||||
- `result.updatedCount`
|
||||
- `result.failedCount`
|
||||
- `result.results[].projectSlug`
|
||||
- `result.results[].status`
|
||||
- `result.results[].details`
|
||||
|
||||
## 与仓库的关系
|
||||
|
||||
仓库内直接接点:
|
||||
|
||||
- [auth.ts](D:/Code/AI/agent-park/src/lib/auth.ts)
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/tags/reset-projects/route.ts)
|
||||
- [validations.ts](D:/Code/AI/agent-park/src/lib/validations.ts)
|
||||
- [schema.prisma](D:/Code/AI/agent-park/prisma/schema.prisma)
|
||||
|
||||
## 下游影响
|
||||
|
||||
- 项目列表筛选
|
||||
- 项目详情标签展示
|
||||
- 标签一致性与后续搜索效果
|
||||
|
||||
## 已确认要点
|
||||
|
||||
- 已通过 live n8n MCP 核查 workflow 元数据与请求方向。
|
||||
- 该流程会先读取仓库标签和项目,再把批量结果回写到仓库 API。
|
||||
- workflow 当前在请求体中携带共享密钥,仓库侧应视为待治理项,后续改为 n8n credential 或环境变量注入。
|
||||
|
||||
## 维护要求
|
||||
|
||||
- 标签分类规则、分类维度、批量请求结构变更时,同步更新:
|
||||
- [registry.json](D:/Code/AI/agent-park/docs/integrations/n8n/registry.json)
|
||||
- [route.ts](D:/Code/AI/agent-park/src/app/api/tags/reset-projects/route.ts)
|
||||
- [validations.ts](D:/Code/AI/agent-park/src/lib/validations.ts)
|
||||
@@ -0,0 +1,32 @@
|
||||
# Workflow Specs
|
||||
|
||||
这里存放 8 条已核查生产流程的仓库内说明文件。
|
||||
|
||||
作用:
|
||||
|
||||
- 给 AI 和工程协作者提供“单流程级别”的说明,而不是只看总表
|
||||
- 固定每条流程的触发方式、数据源、关键节点、写入位置、仓库触点和边界
|
||||
- 当 n8n 流程有变更时,可以快速定位应该更新哪一份说明
|
||||
|
||||
## 索引
|
||||
|
||||
1. [1 Topic项目计划新增](D:/Code/AI/agent-park/docs/integrations/n8n/workflows/01-topic-discovery.md)
|
||||
2. [2 每日Github Trending项目计划新增](D:/Code/AI/agent-park/docs/integrations/n8n/workflows/02-github-trending-discovery.md)
|
||||
3. [3 项目分析入库(多源)](D:/Code/AI/agent-park/docs/integrations/n8n/workflows/03-project-ingestion-multi-source.md)
|
||||
4. [4 GitHub Star 每日刷新](D:/Code/AI/agent-park/docs/integrations/n8n/workflows/04-github-star-refresh.md)
|
||||
5. [5 项目描述向量化](D:/Code/AI/agent-park/docs/integrations/n8n/workflows/05-project-description-vectorization.md)
|
||||
6. [6 RAG项目搜索](D:/Code/AI/agent-park/docs/integrations/n8n/workflows/06-rag-project-search.md)
|
||||
7. [7 前沿信号聚合(多源+AI Agent过滤)](D:/Code/AI/agent-park/docs/integrations/n8n/workflows/07-signals-aggregation.md)
|
||||
8. [8 项目标签重置](D:/Code/AI/agent-park/docs/integrations/n8n/workflows/08-project-tag-reset.md)
|
||||
|
||||
## 使用规则
|
||||
|
||||
- `registry.json` 记录的是映射和契约
|
||||
- `workflows/*.md` 记录的是流程结构和职责
|
||||
- [DATAFLOW.md](D:/Code/AI/agent-park/docs/integrations/n8n/DATAFLOW.md) 记录的是全链路视图
|
||||
|
||||
建议在每次生产流程改动后同时更新:
|
||||
|
||||
1. 对应 `workflows/*.md`
|
||||
2. [registry.json](D:/Code/AI/agent-park/docs/integrations/n8n/registry.json)
|
||||
3. `pnpm n8n:context`
|
||||
@@ -4,6 +4,7 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')
|
||||
|
||||
/** @type {import('next').NextConfig} */
|
||||
const nextConfig = {
|
||||
output: 'standalone',
|
||||
images: {
|
||||
remotePatterns: [
|
||||
{ hostname: 'localhost' },
|
||||
|
||||
@@ -0,0 +1,8 @@
|
||||
[phases.install]
|
||||
cmds = ["corepack enable", "pnpm install --frozen-lockfile"]
|
||||
|
||||
[phases.build]
|
||||
cmds = ["pnpm prisma generate", "pnpm build"]
|
||||
|
||||
[start]
|
||||
cmd = "pnpm exec next start --hostname 0.0.0.0 --port 3000"
|
||||
+3
-17
@@ -6,42 +6,28 @@
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"db:generate": "prisma generate",
|
||||
"lint": "next lint",
|
||||
"postinstall": "prisma generate",
|
||||
"test": "vitest",
|
||||
"test:e2e": "playwright test"
|
||||
"n8n:context": "node scripts/generate-n8n-context.mjs"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/client": "^6.1.0",
|
||||
"@radix-ui/react-dropdown-menu": "^2.1.4",
|
||||
"@radix-ui/react-navigation-menu": "^1.2.2",
|
||||
"@radix-ui/react-separator": "^1.1.1",
|
||||
"@radix-ui/react-slot": "^1.1.1",
|
||||
"@vercel/analytics": "^1.6.1",
|
||||
"@vercel/speed-insights": "^1.3.1",
|
||||
"class-variance-authority": "^0.7.1",
|
||||
"clsx": "^2.1.1",
|
||||
"lucide-react": "^0.468.0",
|
||||
"next": "15.1.11",
|
||||
"next-intl": "^4.0.2",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"react-markdown": "^10.1.0",
|
||||
"rehype-raw": "^7.0.0",
|
||||
"rehype-sanitize": "^6.0.0",
|
||||
"rehype-shiki": "^0.0.9",
|
||||
"remark-gfm": "^4.0.1",
|
||||
"shiki": "^3.20.0",
|
||||
"tailwind-merge": "^2.6.0",
|
||||
"zod": "^3.24.1"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@playwright/test": "^1.49.1",
|
||||
"@testing-library/jest-dom": "^6.6.3",
|
||||
"@testing-library/react": "^16.1.0",
|
||||
"@types/node": "^22",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"autoprefixer": "^10.4.20",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "15.1.11",
|
||||
|
||||
@@ -1,32 +0,0 @@
|
||||
import { defineConfig, devices } from '@playwright/test'
|
||||
|
||||
const PORT = Number(process.env.PLAYWRIGHT_PORT || 3100)
|
||||
const BASE_URL = process.env.PLAYWRIGHT_BASE_URL || `http://127.0.0.1:${PORT}`
|
||||
|
||||
export default defineConfig({
|
||||
testDir: './e2e',
|
||||
timeout: 120_000,
|
||||
fullyParallel: false,
|
||||
forbidOnly: !!process.env.CI,
|
||||
retries: process.env.CI ? 1 : 0,
|
||||
reporter: [['list']],
|
||||
use: {
|
||||
baseURL: BASE_URL,
|
||||
trace: 'on-first-retry',
|
||||
screenshot: 'only-on-failure',
|
||||
video: 'retain-on-failure',
|
||||
},
|
||||
projects: [
|
||||
{
|
||||
name: 'chromium',
|
||||
use: { ...devices['Desktop Chrome'] },
|
||||
},
|
||||
],
|
||||
webServer: {
|
||||
command: `pnpm dev --port ${PORT}`,
|
||||
url: BASE_URL,
|
||||
reuseExistingServer: !process.env.CI,
|
||||
timeout: 120_000,
|
||||
},
|
||||
})
|
||||
|
||||
Generated
+11
-1703
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,51 @@
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_type
|
||||
WHERE typname = 'TaskStatus'
|
||||
) THEN
|
||||
CREATE TYPE "TaskStatus" AS ENUM ('PENDING', 'IN_PROGRESS', 'COMPLETED', 'FAILED');
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
|
||||
CREATE TABLE IF NOT EXISTS "project_discovery_tasks" (
|
||||
"id" TEXT NOT NULL,
|
||||
"status" "TaskStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"sourceUrl" TEXT NOT NULL,
|
||||
"sourceType" TEXT NOT NULL DEFAULT 'manual',
|
||||
"explorationData" JSONB,
|
||||
"explorationSummary" TEXT,
|
||||
"errorMessage" TEXT,
|
||||
"retryCount" INTEGER NOT NULL DEFAULT 0,
|
||||
"lastRetryAt" TIMESTAMP(3),
|
||||
"projectId" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"startedAt" TIMESTAMP(3),
|
||||
"completedAt" TIMESTAMP(3),
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
|
||||
CONSTRAINT "project_discovery_tasks_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE INDEX IF NOT EXISTS "idx_task_project_id" ON "project_discovery_tasks"("projectId");
|
||||
CREATE INDEX IF NOT EXISTS "idx_task_source_url" ON "project_discovery_tasks"("sourceUrl");
|
||||
CREATE INDEX IF NOT EXISTS "idx_task_status_created" ON "project_discovery_tasks"("status", "createdAt");
|
||||
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM pg_constraint
|
||||
WHERE conname = 'project_discovery_tasks_projectId_fkey'
|
||||
) THEN
|
||||
ALTER TABLE "project_discovery_tasks"
|
||||
ADD CONSTRAINT "project_discovery_tasks_projectId_fkey"
|
||||
FOREIGN KEY ("projectId")
|
||||
REFERENCES "projects"("id")
|
||||
ON DELETE SET NULL
|
||||
ON UPDATE CASCADE;
|
||||
END IF;
|
||||
END
|
||||
$$;
|
||||
@@ -0,0 +1,21 @@
|
||||
CREATE TYPE "ProjectSubmissionStatus" AS ENUM ('PENDING', 'APPROVED', 'REJECTED', 'IMPORTED');
|
||||
|
||||
CREATE TABLE "project_submissions" (
|
||||
"id" TEXT NOT NULL,
|
||||
"url" TEXT NOT NULL,
|
||||
"normalizedUrl" TEXT NOT NULL,
|
||||
"projectName" TEXT,
|
||||
"description" TEXT,
|
||||
"submitterName" TEXT,
|
||||
"submitterEmail" TEXT,
|
||||
"locale" TEXT NOT NULL DEFAULT 'zh',
|
||||
"status" "ProjectSubmissionStatus" NOT NULL DEFAULT 'PENDING',
|
||||
"reviewNotes" TEXT,
|
||||
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
"updatedAt" TIMESTAMP(3) NOT NULL,
|
||||
CONSTRAINT "project_submissions_pkey" PRIMARY KEY ("id")
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX "project_submissions_normalizedUrl_key" ON "project_submissions"("normalizedUrl");
|
||||
CREATE INDEX "idx_project_submission_status_createdAt" ON "project_submissions"("status", "createdAt");
|
||||
CREATE INDEX "idx_project_submission_locale" ON "project_submissions"("locale");
|
||||
+76
-18
@@ -1,5 +1,6 @@
|
||||
generator client {
|
||||
provider = "prisma-client-js"
|
||||
binaryTargets = ["native", "debian-openssl-3.0.x", "debian-openssl-1.1.x"]
|
||||
previewFeatures = ["postgresqlExtensions"]
|
||||
}
|
||||
|
||||
@@ -24,6 +25,29 @@ model ExternalLink {
|
||||
@@map("external_links")
|
||||
}
|
||||
|
||||
model ProjectDiscoveryTask {
|
||||
id String @id @default(cuid())
|
||||
status TaskStatus @default(PENDING)
|
||||
sourceUrl String
|
||||
sourceType String @default("manual")
|
||||
explorationData Json?
|
||||
explorationSummary String?
|
||||
errorMessage String?
|
||||
retryCount Int @default(0)
|
||||
lastRetryAt DateTime?
|
||||
projectId String?
|
||||
createdAt DateTime @default(now())
|
||||
startedAt DateTime?
|
||||
completedAt DateTime?
|
||||
updatedAt DateTime @updatedAt
|
||||
project Project? @relation(fields: [projectId], references: [id])
|
||||
|
||||
@@index([projectId], map: "idx_task_project_id")
|
||||
@@index([sourceUrl], map: "idx_task_source_url")
|
||||
@@index([status, createdAt], map: "idx_task_status_created")
|
||||
@@map("project_discovery_tasks")
|
||||
}
|
||||
|
||||
model ProjectTag {
|
||||
projectId String
|
||||
tagId String
|
||||
@@ -36,24 +60,25 @@ model ProjectTag {
|
||||
}
|
||||
|
||||
model Project {
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
nameEn String?
|
||||
slug String @unique
|
||||
description String
|
||||
descriptionEn String?
|
||||
content String?
|
||||
contentEn String?
|
||||
githubStars Int @default(0)
|
||||
githubStarsUpdatedAt DateTime?
|
||||
status ProjectStatus @default(ACTIVE)
|
||||
source String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
embedding Unsupported("vector")?
|
||||
embeddingUpdatedAt DateTime?
|
||||
links ExternalLink[]
|
||||
tags ProjectTag[]
|
||||
id String @id @default(cuid())
|
||||
name String
|
||||
nameEn String?
|
||||
slug String @unique
|
||||
description String
|
||||
descriptionEn String?
|
||||
content String?
|
||||
contentEn String?
|
||||
githubStars Int @default(0)
|
||||
githubStarsUpdatedAt DateTime?
|
||||
status ProjectStatus @default(ACTIVE)
|
||||
source String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
embedding Unsupported("vector")?
|
||||
embeddingUpdatedAt DateTime?
|
||||
links ExternalLink[]
|
||||
projectDiscoveryTasks ProjectDiscoveryTask[]
|
||||
tags ProjectTag[]
|
||||
|
||||
@@index([embedding], map: "idx_project_embedding_cosine")
|
||||
@@index([slug], map: "idx_project_slug")
|
||||
@@ -105,6 +130,25 @@ model Signal {
|
||||
@@map("signals")
|
||||
}
|
||||
|
||||
model ProjectSubmission {
|
||||
id String @id @default(cuid())
|
||||
url String
|
||||
normalizedUrl String @unique
|
||||
projectName String?
|
||||
description String?
|
||||
submitterName String?
|
||||
submitterEmail String?
|
||||
locale String @default("zh")
|
||||
status ProjectSubmissionStatus @default(PENDING)
|
||||
reviewNotes String?
|
||||
createdAt DateTime @default(now())
|
||||
updatedAt DateTime @updatedAt
|
||||
|
||||
@@index([status, createdAt], map: "idx_project_submission_status_createdAt")
|
||||
@@index([locale], map: "idx_project_submission_locale")
|
||||
@@map("project_submissions")
|
||||
}
|
||||
|
||||
enum LinkType {
|
||||
WEBSITE
|
||||
GITHUB
|
||||
@@ -117,6 +161,20 @@ enum ProjectStatus {
|
||||
ARCHIVED
|
||||
}
|
||||
|
||||
enum TaskStatus {
|
||||
PENDING
|
||||
IN_PROGRESS
|
||||
COMPLETED
|
||||
FAILED
|
||||
}
|
||||
|
||||
enum ProjectSubmissionStatus {
|
||||
PENDING
|
||||
APPROVED
|
||||
REJECTED
|
||||
IMPORTED
|
||||
}
|
||||
|
||||
enum TagCategory {
|
||||
FIXED_PROJECT_TYPE
|
||||
TECH_STACK
|
||||
|
||||
@@ -0,0 +1,329 @@
|
||||
import fs from "node:fs";
|
||||
import path from "node:path";
|
||||
|
||||
const ROOT = process.cwd();
|
||||
const REGISTRY_PATH = path.join(ROOT, "docs", "integrations", "n8n", "registry.json");
|
||||
const DOC_OUTPUT_PATH = path.join(
|
||||
ROOT,
|
||||
"docs",
|
||||
"integrations",
|
||||
"n8n",
|
||||
"CONTEXT.generated.md"
|
||||
);
|
||||
const PLANNING_OUTPUT_PATH = path.join(ROOT, ".planning", "codebase", "N8N-CONTEXT.md");
|
||||
const SOURCE_DIR = path.join(ROOT, "src");
|
||||
const ENV_EXAMPLE_PATH = path.join(ROOT, ".env.example");
|
||||
const WORKFLOW_EXPORT_DIR = path.join(ROOT, "docs", "integrations", "n8n", "exports");
|
||||
|
||||
const TOUCHPOINT_PATTERN = /\bN8N_[A-Z0-9_]+\b|n8n|webhook/gi;
|
||||
const TOUCHPOINT_LINE_PATTERN = /\bN8N_[A-Z0-9_]+\b|n8n|webhook/i;
|
||||
const CODE_EXTENSIONS = new Set([".ts", ".tsx", ".js", ".jsx", ".mjs", ".cjs", ".json"]);
|
||||
|
||||
function ensureDir(targetPath) {
|
||||
fs.mkdirSync(path.dirname(targetPath), { recursive: true });
|
||||
}
|
||||
|
||||
function readJsonIfExists(targetPath, fallback) {
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
return fallback;
|
||||
}
|
||||
|
||||
try {
|
||||
return JSON.parse(fs.readFileSync(targetPath, "utf8"));
|
||||
} catch (error) {
|
||||
throw new Error(`Failed to parse JSON at ${targetPath}: ${error.message}`);
|
||||
}
|
||||
}
|
||||
|
||||
function readTextIfExists(targetPath) {
|
||||
if (!fs.existsSync(targetPath)) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return fs.readFileSync(targetPath, "utf8");
|
||||
}
|
||||
|
||||
function walkFiles(dirPath) {
|
||||
if (!fs.existsSync(dirPath)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
const entries = fs.readdirSync(dirPath, { withFileTypes: true });
|
||||
const files = [];
|
||||
|
||||
for (const entry of entries) {
|
||||
const absolutePath = path.join(dirPath, entry.name);
|
||||
if (entry.isDirectory()) {
|
||||
files.push(...walkFiles(absolutePath));
|
||||
continue;
|
||||
}
|
||||
|
||||
if (!CODE_EXTENSIONS.has(path.extname(entry.name))) {
|
||||
continue;
|
||||
}
|
||||
|
||||
files.push(absolutePath);
|
||||
}
|
||||
|
||||
return files;
|
||||
}
|
||||
|
||||
function isPrimaryTouchpoint(relativePath) {
|
||||
if (relativePath.includes(".test.")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (relativePath.startsWith("src/messages/")) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function collectEnvVars() {
|
||||
const content = readTextIfExists(ENV_EXAMPLE_PATH);
|
||||
const envVars = new Set();
|
||||
|
||||
for (const line of content.split(/\r?\n/)) {
|
||||
const match = line.match(/^([A-Z0-9_]+)=/);
|
||||
if (!match) {
|
||||
continue;
|
||||
}
|
||||
if (match[1].includes("N8N") || match[1].includes("WEBHOOK")) {
|
||||
envVars.add(match[1]);
|
||||
}
|
||||
}
|
||||
|
||||
return [...envVars].sort();
|
||||
}
|
||||
|
||||
function detectTouchpoints() {
|
||||
const files = walkFiles(SOURCE_DIR);
|
||||
const touchpoints = [];
|
||||
|
||||
for (const filePath of files) {
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
const matches = [...content.matchAll(TOUCHPOINT_PATTERN)];
|
||||
|
||||
if (matches.length === 0) {
|
||||
continue;
|
||||
}
|
||||
|
||||
const relativePath = path.relative(ROOT, filePath).replaceAll("\\", "/");
|
||||
const lines = content.split(/\r?\n/);
|
||||
const highlights = [];
|
||||
const seenLineNumbers = new Set();
|
||||
|
||||
lines.forEach((line, index) => {
|
||||
if (!TOUCHPOINT_LINE_PATTERN.test(line)) {
|
||||
return;
|
||||
}
|
||||
|
||||
const lineNumber = index + 1;
|
||||
if (seenLineNumbers.has(lineNumber)) {
|
||||
return;
|
||||
}
|
||||
seenLineNumbers.add(lineNumber);
|
||||
highlights.push({
|
||||
lineNumber,
|
||||
text: line.trim(),
|
||||
});
|
||||
});
|
||||
|
||||
touchpoints.push({
|
||||
path: relativePath,
|
||||
matchCount: matches.length,
|
||||
highlights: highlights.slice(0, 5),
|
||||
});
|
||||
}
|
||||
|
||||
return touchpoints.sort((a, b) => a.path.localeCompare(b.path));
|
||||
}
|
||||
|
||||
function listExports() {
|
||||
if (!fs.existsSync(WORKFLOW_EXPORT_DIR)) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return fs
|
||||
.readdirSync(WORKFLOW_EXPORT_DIR, { withFileTypes: true })
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
||||
.map((entry) => entry.name)
|
||||
.sort();
|
||||
}
|
||||
|
||||
function toList(value) {
|
||||
return Array.isArray(value) ? value.filter(Boolean) : [];
|
||||
}
|
||||
|
||||
function formatBullets(items, emptyText = "- none") {
|
||||
if (items.length === 0) {
|
||||
return [emptyText];
|
||||
}
|
||||
|
||||
return items.map((item) => `- ${item}`);
|
||||
}
|
||||
|
||||
function quoteInline(value) {
|
||||
return String(value).replaceAll("`", "\\`");
|
||||
}
|
||||
|
||||
function renderWorkflow(workflow) {
|
||||
const entrypoints = toList(workflow?.n8n?.entrypoints).map((entrypoint) => {
|
||||
const method = entrypoint.method ? `${entrypoint.method} ` : "";
|
||||
const pathValue = entrypoint.path || "(missing path)";
|
||||
return `${entrypoint.kind || "entrypoint"}: ${method}${pathValue}`.trim();
|
||||
});
|
||||
|
||||
const consumers = toList(workflow?.repository?.consumers);
|
||||
const envVars = toList(workflow?.repository?.env);
|
||||
const schemas = toList(workflow?.repository?.schemas);
|
||||
const requestFields = toList(workflow?.contracts?.requestFields).map((item) => `\`${item}\``);
|
||||
const responseFields = toList(workflow?.contracts?.responseFields).map((item) => `\`${item}\``);
|
||||
const upstreams = toList(workflow?.upstreams);
|
||||
const downstreams = toList(workflow?.downstreams);
|
||||
const owners = toList(workflow?.owners);
|
||||
const notes = workflow?.notes ? [`- ${workflow.notes}`] : ["- none"];
|
||||
|
||||
const lines = [
|
||||
`## ${workflow.name || workflow.id || "Unnamed workflow"}`,
|
||||
"",
|
||||
`- Status: \`${workflow.status || "unknown"}\``,
|
||||
`- ID: \`${quoteInline(workflow.id || "missing-id")}\``,
|
||||
`- Purpose: ${workflow.purpose || "missing purpose"}`,
|
||||
`- n8n workflow id: \`${quoteInline(workflow?.n8n?.workflowId || "not recorded")}\``,
|
||||
`- Export file: \`${quoteInline(workflow?.n8n?.exportFile || "not recorded")}\``,
|
||||
"",
|
||||
"### Entrypoints",
|
||||
...formatBullets(entrypoints),
|
||||
"",
|
||||
"### Repository Touchpoints",
|
||||
...formatBullets(consumers),
|
||||
"",
|
||||
"### Environment",
|
||||
...formatBullets(envVars),
|
||||
"",
|
||||
"### Contracts",
|
||||
`- Request fields: ${requestFields.length > 0 ? requestFields.join(", ") : "none"}`,
|
||||
`- Response fields: ${responseFields.length > 0 ? responseFields.join(", ") : "none"}`,
|
||||
"",
|
||||
"### Related Systems",
|
||||
`- Upstreams: ${upstreams.length > 0 ? upstreams.join(", ") : "none"}`,
|
||||
`- Downstreams: ${downstreams.length > 0 ? downstreams.join(", ") : "none"}`,
|
||||
"",
|
||||
"### Ownership",
|
||||
...formatBullets(owners),
|
||||
"",
|
||||
"### Notes",
|
||||
...notes,
|
||||
"",
|
||||
];
|
||||
|
||||
if (schemas.length > 0) {
|
||||
lines.splice(lines.indexOf("### Related Systems"), 0, "### Schemas", ...formatBullets(schemas), "");
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function renderTouchpoint(touchpoint) {
|
||||
const lines = [
|
||||
`- \`${touchpoint.path}\` (${touchpoint.matchCount} matches)`,
|
||||
];
|
||||
|
||||
for (const highlight of touchpoint.highlights) {
|
||||
lines.push(` - L${highlight.lineNumber}: \`${quoteInline(highlight.text)}\``);
|
||||
}
|
||||
|
||||
return lines.join("\n");
|
||||
}
|
||||
|
||||
function buildMarkdown(registry, touchpoints, envVars, exportsList) {
|
||||
const workflows = toList(registry?.workflows);
|
||||
const documentedConsumers = new Set(
|
||||
workflows.flatMap((workflow) => toList(workflow?.repository?.consumers))
|
||||
);
|
||||
const undocumentedTouchpoints = touchpoints.filter(
|
||||
(touchpoint) => isPrimaryTouchpoint(touchpoint.path) && !documentedConsumers.has(touchpoint.path)
|
||||
);
|
||||
|
||||
const sections = [
|
||||
"# N8N Context",
|
||||
"",
|
||||
`Generated at: ${new Date().toISOString()}`,
|
||||
"",
|
||||
"This file is generated from `docs/integrations/n8n/registry.json` plus repository scanning.",
|
||||
"It exists so external n8n workflows become committed, reviewable context for AI agents and GSD.",
|
||||
"",
|
||||
"## Workflow Inventory",
|
||||
"",
|
||||
];
|
||||
|
||||
if (workflows.length === 0) {
|
||||
sections.push("No workflows documented in `docs/integrations/n8n/registry.json` yet.", "");
|
||||
} else {
|
||||
for (const workflow of workflows) {
|
||||
sections.push(renderWorkflow(workflow));
|
||||
}
|
||||
}
|
||||
|
||||
sections.push("## Exported Workflow Files", "");
|
||||
sections.push(...formatBullets(exportsList.map((fileName) => `docs/integrations/n8n/exports/${fileName}`)));
|
||||
sections.push("");
|
||||
|
||||
sections.push("## Detected Repository Touchpoints", "");
|
||||
if (touchpoints.length === 0) {
|
||||
sections.push("No n8n or webhook references detected under `src/`.", "");
|
||||
} else {
|
||||
for (const touchpoint of touchpoints) {
|
||||
sections.push(renderTouchpoint(touchpoint), "");
|
||||
}
|
||||
}
|
||||
|
||||
sections.push("## Environment Variables", "");
|
||||
sections.push(...formatBullets(envVars), "");
|
||||
|
||||
sections.push("## Gaps To Fill", "");
|
||||
if (undocumentedTouchpoints.length === 0) {
|
||||
sections.push("- All detected repo touchpoints are mapped to documented workflows.", "");
|
||||
} else {
|
||||
sections.push(
|
||||
"- These files mention n8n or webhook logic but are not mapped in `docs/integrations/n8n/registry.json`:"
|
||||
);
|
||||
sections.push(...formatBullets(undocumentedTouchpoints.map((item) => item.path)), "");
|
||||
}
|
||||
|
||||
sections.push("## Maintenance Rules", "");
|
||||
sections.push("- When an n8n workflow changes, update `docs/integrations/n8n/registry.json` in the same PR.");
|
||||
sections.push("- If possible, export the workflow JSON into `docs/integrations/n8n/exports/` and reference it from the registry.");
|
||||
sections.push("- Re-run `pnpm n8n:context` after every workflow, contract, or route change.");
|
||||
sections.push("- Treat this file as generated output; edit the registry instead of editing this file directly.", "");
|
||||
|
||||
return sections.join("\n");
|
||||
}
|
||||
|
||||
function writeOutput(outputPath, content) {
|
||||
ensureDir(outputPath);
|
||||
fs.writeFileSync(outputPath, content, "utf8");
|
||||
}
|
||||
|
||||
function main() {
|
||||
const registry = readJsonIfExists(REGISTRY_PATH, { workflows: [] });
|
||||
const touchpoints = detectTouchpoints();
|
||||
const envVars = collectEnvVars();
|
||||
const exportsList = listExports();
|
||||
const markdown = buildMarkdown(registry, touchpoints, envVars, exportsList);
|
||||
|
||||
writeOutput(DOC_OUTPUT_PATH, markdown);
|
||||
|
||||
if (fs.existsSync(path.dirname(PLANNING_OUTPUT_PATH))) {
|
||||
writeOutput(PLANNING_OUTPUT_PATH, markdown);
|
||||
}
|
||||
|
||||
console.log(`Generated ${path.relative(ROOT, DOC_OUTPUT_PATH)}`);
|
||||
if (fs.existsSync(path.dirname(PLANNING_OUTPUT_PATH))) {
|
||||
console.log(`Generated ${path.relative(ROOT, PLANNING_OUTPUT_PATH)}`);
|
||||
}
|
||||
}
|
||||
|
||||
main();
|
||||
@@ -0,0 +1,50 @@
|
||||
import Script from "next/script";
|
||||
|
||||
const beforeSendScript = `
|
||||
window.umamiBeforeSend = function (_type, payload) {
|
||||
try {
|
||||
var rawUrl = payload && typeof payload.url === "string" ? payload.url : window.location.href;
|
||||
var pathname = new URL(rawUrl, window.location.origin).pathname;
|
||||
var isTrackable =
|
||||
pathname === "/zh" ||
|
||||
pathname === "/en" ||
|
||||
pathname.startsWith("/zh/") ||
|
||||
pathname.startsWith("/en/");
|
||||
|
||||
return isTrackable ? payload : false;
|
||||
} catch (_error) {
|
||||
return false;
|
||||
}
|
||||
};
|
||||
`;
|
||||
|
||||
export function UmamiMetrics() {
|
||||
const hostUrl = process.env.NEXT_PUBLIC_UMAMI_HOST_URL?.replace(/\/+$/, "");
|
||||
const websiteId = process.env.NEXT_PUBLIC_UMAMI_WEBSITE_ID;
|
||||
const domains = process.env.NEXT_PUBLIC_UMAMI_DOMAINS;
|
||||
|
||||
if (!hostUrl || !websiteId) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<Script id="umami-before-send" strategy="beforeInteractive">
|
||||
{beforeSendScript}
|
||||
</Script>
|
||||
<Script
|
||||
id="umami-script"
|
||||
src={`${hostUrl}/script.js`}
|
||||
strategy="afterInteractive"
|
||||
data-website-id={websiteId}
|
||||
data-host-url={hostUrl}
|
||||
data-domains={domains || undefined}
|
||||
data-before-send="umamiBeforeSend"
|
||||
data-exclude-search="true"
|
||||
data-exclude-hash="true"
|
||||
data-do-not-track="true"
|
||||
data-performance="true"
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -1,24 +0,0 @@
|
||||
"use client"
|
||||
|
||||
import { Analytics } from "@vercel/analytics/next"
|
||||
import { SpeedInsights } from "@vercel/speed-insights/next"
|
||||
|
||||
const isTrackablePath = (url: string): boolean => {
|
||||
try {
|
||||
const pathname = new URL(url).pathname
|
||||
|
||||
// Only keep user-facing locale pages to control usage on the Hobby plan.
|
||||
return pathname === "/zh" || pathname === "/en" || pathname.startsWith("/zh/") || pathname.startsWith("/en/")
|
||||
} catch {
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
export function VercelMetrics() {
|
||||
return (
|
||||
<>
|
||||
<Analytics mode="production" beforeSend={(event) => (isTrackablePath(event.url) ? event : null)} />
|
||||
<SpeedInsights sampleRate={0.2} beforeSend={(data) => (isTrackablePath(data.url) ? data : null)} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -88,7 +88,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
|
||||
<section className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 pt-14 md:pt-20 pb-12 text-center">
|
||||
<p className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-black border-2 border-black font-display text-xs font-bold uppercase tracking-wide shadow-neo-sm">
|
||||
<span className="material-icons text-base">hub</span>
|
||||
<span className="material-icons text-base" aria-hidden="true">hub</span>
|
||||
{t('heroEyebrow')}
|
||||
</p>
|
||||
<h1 className="mt-6 font-display text-4xl md:text-6xl font-bold leading-tight tracking-tight">
|
||||
@@ -109,7 +109,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
{t('ctaProjects')}
|
||||
</Link>
|
||||
<Link
|
||||
href={`/${locale}/projects`}
|
||||
href={`/${locale}/submit`}
|
||||
className="neo-btn bg-white dark:bg-surface-dark px-6 py-3 text-sm"
|
||||
>
|
||||
{t('ctaSubmit')}
|
||||
@@ -135,7 +135,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{capabilityCards.map((card) => (
|
||||
<article key={card.title} className="neo-card p-6 md:p-7">
|
||||
<span className="material-icons text-3xl mb-4">{card.icon}</span>
|
||||
<span className="material-icons text-3xl mb-4" aria-hidden="true">{card.icon}</span>
|
||||
<h3 className="font-display text-lg font-bold mb-2">{card.title}</h3>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{card.description}</p>
|
||||
</article>
|
||||
@@ -151,7 +151,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
{workflowSteps.map((step, index) => (
|
||||
<article key={step.title} className="border-2 border-black dark:border-gray-600 bg-white dark:bg-surface-dark p-4">
|
||||
<div className="font-display text-xs font-bold uppercase mb-2">0{index + 1}</div>
|
||||
<span className="material-icons text-2xl mb-2">{step.icon}</span>
|
||||
<span className="material-icons text-2xl mb-2" aria-hidden="true">{step.icon}</span>
|
||||
<h3 className="font-display text-base font-bold mb-1">{step.title}</h3>
|
||||
<p className="text-xs text-gray-700 dark:text-gray-300 leading-relaxed">{step.description}</p>
|
||||
</article>
|
||||
@@ -165,7 +165,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
<div className="grid gap-6 md:grid-cols-3">
|
||||
{principles.map((principle) => (
|
||||
<article key={principle.title} className="neo-card p-6 md:p-7">
|
||||
<span className="material-icons text-3xl mb-4">{principle.icon}</span>
|
||||
<span className="material-icons text-3xl mb-4" aria-hidden="true">{principle.icon}</span>
|
||||
<h3 className="font-display text-lg font-bold mb-2">{principle.title}</h3>
|
||||
<p className="text-sm text-gray-700 dark:text-gray-300 leading-relaxed">{principle.description}</p>
|
||||
</article>
|
||||
@@ -177,7 +177,7 @@ export default async function AboutPage({ params }: AboutPageProps) {
|
||||
<div className="neo-card bg-primary text-black p-8 md:p-10 text-center">
|
||||
<h2 className="font-display text-2xl md:text-3xl font-bold mb-3">{t('closingTitle')}</h2>
|
||||
<p className="max-w-2xl mx-auto mb-6 leading-relaxed">{t('closingDescription')}</p>
|
||||
<Link href={`/${locale}/projects`} className="neo-btn inline-flex bg-white text-black px-6 py-3 text-sm">
|
||||
<Link href={`/${locale}/submit`} className="neo-btn inline-flex bg-white text-black px-6 py-3 text-sm">
|
||||
{t('closingCta')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StaticInfoPage } from "@/components/static/StaticInfoPage";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "staticPages.docs" });
|
||||
return { title: t("title"), description: t("description") };
|
||||
}
|
||||
|
||||
export default async function DocsPage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "staticPages.docs" });
|
||||
const sections = t.raw("sections") as Array<{ title: string; body: string }>;
|
||||
|
||||
return (
|
||||
<StaticInfoPage
|
||||
eyebrow="Agent Park"
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
}
|
||||
+31
-106
@@ -2,8 +2,9 @@ import { notFound } from "next/navigation"
|
||||
import { NextIntlClientProvider } from 'next-intl'
|
||||
import { setRequestLocale, getMessages, getTranslations } from 'next-intl/server'
|
||||
import Link from "next/link"
|
||||
import { LocaleSwitcher } from "@/components/locale/LocaleSwitcher"
|
||||
import { AnnouncementBar } from "@/components/layout/AnnouncementBar"
|
||||
import { NewsletterSignup } from "@/components/layout/NewsletterSignup"
|
||||
import { SiteHeader } from "@/components/layout/SiteHeader"
|
||||
import type { Metadata } from "next"
|
||||
|
||||
const locales = ['zh', 'en']
|
||||
@@ -13,11 +14,10 @@ export async function generateStaticParams() {
|
||||
}
|
||||
|
||||
export async function generateMetadata({
|
||||
params
|
||||
params: _params
|
||||
}: {
|
||||
params: Promise<{ locale: string }>
|
||||
}): Promise<Metadata> {
|
||||
const { locale } = await params
|
||||
const t = await getTranslations('home')
|
||||
|
||||
return {
|
||||
@@ -43,7 +43,6 @@ export default async function LocaleLayout({
|
||||
// Get translations
|
||||
const t = await getTranslations('layout')
|
||||
const tNav = await getTranslations('navigation')
|
||||
const tHome = await getTranslations('home')
|
||||
const messages = await getMessages()
|
||||
return (
|
||||
<NextIntlClientProvider messages={messages}>
|
||||
@@ -56,69 +55,18 @@ export default async function LocaleLayout({
|
||||
closeLabel={t('closeAnnouncement')}
|
||||
/>
|
||||
|
||||
{/* Navigation */}
|
||||
<header className="w-full border-b-2 border-black dark:border-gray-600 bg-background-light dark:bg-background-dark sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
{/* Logo */}
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
<span className="material-icons text-3xl">smart_toy</span>
|
||||
<Link href={`/${locale}`} className="font-display font-bold text-xl tracking-tight">
|
||||
Agent Park
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Desktop Nav */}
|
||||
<nav className="hidden md:flex space-x-8 items-center">
|
||||
<Link
|
||||
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
|
||||
href={`/${locale}`}
|
||||
>
|
||||
{tNav('home')}
|
||||
</Link>
|
||||
<Link
|
||||
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
|
||||
href={`/${locale}/projects`}
|
||||
>
|
||||
{tNav('projects')}
|
||||
</Link>
|
||||
<Link
|
||||
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
|
||||
href={`/${locale}/signals`}
|
||||
>
|
||||
{tNav('signals')}
|
||||
</Link>
|
||||
<Link
|
||||
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
|
||||
href={`/${locale}/about`}
|
||||
>
|
||||
{tNav('about')}
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Right side */}
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
<LocaleSwitcher
|
||||
currentLocale={locale}
|
||||
switchUrl={`/${locale === 'zh' ? 'en' : 'zh'}`}
|
||||
/>
|
||||
<Link
|
||||
className="bg-white dark:bg-surface-dark border-2 border-black dark:border-white px-4 py-2 font-display text-sm font-bold shadow-neo-sm hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all"
|
||||
href="#"
|
||||
>
|
||||
{tNav('submitProject')}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu button */}
|
||||
<div className="md:hidden flex items-center">
|
||||
<button className="text-text-light dark:text-text-dark hover:text-gray-600 focus:outline-none">
|
||||
<span className="material-icons">menu</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
<SiteHeader
|
||||
locale={locale}
|
||||
labels={{
|
||||
home: tNav('home'),
|
||||
projects: tNav('projects'),
|
||||
signals: tNav('signals'),
|
||||
about: tNav('about'),
|
||||
submitProject: tNav('submitProject'),
|
||||
menu: tNav('menu'),
|
||||
closeMenu: tNav('closeMenu'),
|
||||
}}
|
||||
/>
|
||||
|
||||
<main className="flex-1 relative">{children}</main>
|
||||
|
||||
@@ -130,36 +78,21 @@ export default async function LocaleLayout({
|
||||
<p className="font-sans text-black mb-8 max-w-md">
|
||||
{t('stayUpdatedDesc')}
|
||||
</p>
|
||||
<form className="space-y-4 max-w-md">
|
||||
<input
|
||||
className="w-full bg-background-light border-2 border-black p-3 font-display text-sm placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
placeholder={t('email')}
|
||||
type="email"
|
||||
id="newsletter-email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input className="w-4 h-4 border-2 border-black text-black focus:ring-0" id="consent" name="consent" type="checkbox" required />
|
||||
<label className="text-xs font-bold font-display text-black" htmlFor="consent">
|
||||
{t('subscribeConsent')}
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
className="bg-white text-black font-display font-bold py-3 px-8 border-2 border-black shadow-neo hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all"
|
||||
type="submit"
|
||||
>
|
||||
{t('subscribe')}
|
||||
</button>
|
||||
</form>
|
||||
<NewsletterSignup
|
||||
labels={{
|
||||
email: t('email'),
|
||||
subscribe: t('subscribe'),
|
||||
consent: t('subscribeConsent'),
|
||||
unavailable: t('newsletterUnavailable'),
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
<div className="bg-background-light dark:bg-background-dark p-12 md:p-20 relative overflow-hidden flex items-center justify-center">
|
||||
<div className="relative w-64 h-64">
|
||||
<div className="absolute inset-0 border-2 border-black dark:border-gray-500 bg-white dark:bg-surface-dark transform rotate-3"></div>
|
||||
<div className="absolute inset-0 border-2 border-black dark:border-gray-500 bg-primary dark:bg-primary transform -rotate-3 translate-x-4 translate-y-4 opacity-80"></div>
|
||||
<div className="absolute inset-0 flex items-center justify-center z-10">
|
||||
<span className="material-icons text-8xl">rocket_launch</span>
|
||||
<span className="material-icons text-8xl" aria-hidden="true">rocket_launch</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -169,10 +102,10 @@ export default async function LocaleLayout({
|
||||
{/* Footer */}
|
||||
<footer className="bg-background-light dark:bg-background-dark border-t-2 border-black dark:border-gray-700 py-12">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="grid grid-cols-2 md:grid-cols-4 gap-8 mb-12">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-8 mb-12">
|
||||
<div className="col-span-1 md:col-span-1">
|
||||
<Link className="flex items-center gap-2 mb-4" href={`/${locale}`}>
|
||||
<span className="material-icons text-2xl">smart_toy</span>
|
||||
<span className="material-icons text-2xl" aria-hidden="true">smart_toy</span>
|
||||
<span className="font-display font-bold text-lg tracking-tight">Agent Park</span>
|
||||
</Link>
|
||||
<p className="text-sm text-gray-600 dark:text-gray-400">
|
||||
@@ -182,26 +115,18 @@ export default async function LocaleLayout({
|
||||
<div>
|
||||
<h4 className="font-display font-bold text-lg mb-4">{t('resources')}</h4>
|
||||
<ul className="space-y-2 text-sm font-sans text-gray-600 dark:text-gray-400">
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href="#">{t('resourceNewsletter')}</Link></li>
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href="#">{t('resourceUpdates')}</Link></li>
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href="#">{t('resourceDocumentation')}</Link></li>
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href={`/${locale}/newsletter`}>{t('resourceNewsletter')}</Link></li>
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href={`/${locale}/updates`}>{t('resourceUpdates')}</Link></li>
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href={`/${locale}/docs`}>{t('resourceDocumentation')}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-display font-bold text-lg mb-4">{t('legal')}</h4>
|
||||
<ul className="space-y-2 text-sm font-sans text-gray-600 dark:text-gray-400">
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href="#">{t('privacyPolicy')}</Link></li>
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href="#">{t('termsOfService')}</Link></li>
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href={`/${locale}/privacy`}>{t('privacyPolicy')}</Link></li>
|
||||
<li><Link className="hover:text-black dark:hover:text-white" href={`/${locale}/terms`}>{t('termsOfService')}</Link></li>
|
||||
</ul>
|
||||
</div>
|
||||
<div>
|
||||
<h4 className="font-display font-bold text-lg mb-4">{t('followUs')}</h4>
|
||||
<div className="flex space-x-4">
|
||||
<Link className="w-10 h-10 border-2 border-black dark:border-gray-500 flex items-center justify-center hover:bg-primary hover:text-black transition-colors font-display font-bold" href="#">X</Link>
|
||||
<Link className="w-10 h-10 border-2 border-black dark:border-gray-500 flex items-center justify-center hover:bg-primary hover:text-black transition-colors font-display font-bold" href="#">Li</Link>
|
||||
<Link className="w-10 h-10 border-2 border-black dark:border-gray-500 flex items-center justify-center hover:bg-primary hover:text-black transition-colors font-display font-bold" href="#">Gh</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="border-t border-black dark:border-gray-700 pt-8 flex flex-col md:flex-row justify-between items-center">
|
||||
<p className="text-sm text-gray-500 font-display">{t('copyright')}</p>
|
||||
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StaticInfoPage } from "@/components/static/StaticInfoPage";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "staticPages.newsletter" });
|
||||
return { title: t("title"), description: t("description") };
|
||||
}
|
||||
|
||||
export default async function NewsletterPage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "staticPages.newsletter" });
|
||||
const sections = t.raw("sections") as Array<{ title: string; body: string }>;
|
||||
|
||||
return (
|
||||
<StaticInfoPage
|
||||
eyebrow="Agent Park"
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StaticInfoPage } from "@/components/static/StaticInfoPage";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "staticPages.privacy" });
|
||||
return { title: t("title"), description: t("description") };
|
||||
}
|
||||
|
||||
export default async function PrivacyPage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "staticPages.privacy" });
|
||||
const sections = t.raw("sections") as Array<{ title: string; body: string }>;
|
||||
|
||||
return (
|
||||
<StaticInfoPage
|
||||
eyebrow="Agent Park"
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -77,7 +77,7 @@ export function ProjectsPageClient({
|
||||
}
|
||||
if (selectedTags.length > 0) params.set('tags', selectedTags.join(','))
|
||||
if (sort !== 'latest') params.set('sort', sort)
|
||||
if (limit !== 20) params.set('limit', String(limit))
|
||||
if (limit !== 10) params.set('limit', String(limit))
|
||||
if (useAI) params.set('ai', '1')
|
||||
router.push(`/${locale}/projects?${params.toString()}`)
|
||||
},
|
||||
|
||||
@@ -24,6 +24,8 @@ type AISearchPagination = {
|
||||
hasMore: boolean
|
||||
}
|
||||
|
||||
type AIFallbackReason = 'error' | 'empty' | null
|
||||
|
||||
type ProjectsPagination = {
|
||||
total: number
|
||||
page: number
|
||||
@@ -69,6 +71,8 @@ interface ProjectsResultsClientProps {
|
||||
noProjects: string
|
||||
noResults: string
|
||||
searching: string
|
||||
aiSearchUnavailableFallback: string
|
||||
aiSearchEmptyFallback: string
|
||||
}
|
||||
}
|
||||
|
||||
@@ -168,6 +172,15 @@ export function ProjectsResultsClient({
|
||||
const [aiResults, setAiResults] = useState<AISearchResult[]>([])
|
||||
const [loadingAI, setLoadingAI] = useState(false)
|
||||
const [aiError, setAiError] = useState<string | null>(null)
|
||||
const [aiFallbackReason, setAiFallbackReason] = useState<AIFallbackReason>(null)
|
||||
const [aiFallbackResults, setAiFallbackResults] = useState<ProjectListItem[]>([])
|
||||
const [loadingAIFallback, setLoadingAIFallback] = useState(false)
|
||||
const [aiFallbackPagination, setAiFallbackPagination] = useState<ProjectsPagination>({
|
||||
total: 0,
|
||||
page: initialPage,
|
||||
limit: initialLimit,
|
||||
totalPages: 0,
|
||||
})
|
||||
const [aiCurrentPage, setAiCurrentPage] = useState(initialPage)
|
||||
const [aiSort, setAiSort] = useState<ProjectSortOption>(sort)
|
||||
const [aiLimit, setAiLimit] = useState<(typeof PAGE_SIZE_OPTIONS)[number]>(initialLimit)
|
||||
@@ -260,6 +273,9 @@ export function ProjectsResultsClient({
|
||||
setTraditionalLimit(nextLimit)
|
||||
setTraditionalError(null)
|
||||
setLoadingTraditional(false)
|
||||
setAiFallbackReason(null)
|
||||
setAiFallbackResults([])
|
||||
setLoadingAIFallback(false)
|
||||
}, [
|
||||
isAI,
|
||||
page,
|
||||
@@ -379,6 +395,92 @@ export function ProjectsResultsClient({
|
||||
[locale, projectType, replaceProjectsUrl, search, selectedDomains, selectedProductForms, selectedTags]
|
||||
)
|
||||
|
||||
const fetchAIFallbackResults = useCallback(
|
||||
async (
|
||||
nextPage: number,
|
||||
nextSort: ProjectSortOption,
|
||||
nextLimit: (typeof PAGE_SIZE_OPTIONS)[number],
|
||||
reason: Exclude<AIFallbackReason, null>
|
||||
): Promise<boolean> => {
|
||||
setLoadingAIFallback(true)
|
||||
|
||||
try {
|
||||
const query = new URLSearchParams({
|
||||
page: String(nextPage),
|
||||
limit: String(nextLimit),
|
||||
sort: nextSort,
|
||||
})
|
||||
|
||||
const normalizedSearch = search.trim()
|
||||
if (normalizedSearch) query.set('search', normalizedSearch)
|
||||
if (projectType) query.set('projectType', projectType)
|
||||
if (selectedDomains.length > 0) query.set('domains', selectedDomains.join(','))
|
||||
if (selectedProductForms.length > 0) {
|
||||
query.set('productForms', selectedProductForms.join(','))
|
||||
}
|
||||
if (selectedTags.length > 0) query.set('tags', selectedTags.join(','))
|
||||
|
||||
const response = await fetch(`/api/projects?${query.toString()}`, {
|
||||
method: 'GET',
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const projectsFetchFailedText = locale === 'en' ? 'Failed to load projects' : '项目加载失败'
|
||||
throw new Error(`${projectsFetchFailedText} (${response.status})`)
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
const incomingProjects = Array.isArray(data?.projects)
|
||||
? (data.projects as ProjectListItem[])
|
||||
: []
|
||||
const incomingPagination = data?.pagination as Partial<ProjectsPagination> | undefined
|
||||
|
||||
const normalizedTotal =
|
||||
typeof incomingPagination?.total === 'number'
|
||||
? incomingPagination.total
|
||||
: incomingProjects.length
|
||||
const normalizedTotalPages = Math.max(
|
||||
0,
|
||||
typeof incomingPagination?.totalPages === 'number'
|
||||
? incomingPagination.totalPages
|
||||
: normalizedTotal === 0
|
||||
? 0
|
||||
: Math.ceil(normalizedTotal / nextLimit)
|
||||
)
|
||||
const safePage = normalizedTotalPages === 0 ? 1 : Math.min(nextPage, normalizedTotalPages)
|
||||
|
||||
setAiFallbackResults(incomingProjects)
|
||||
setAiFallbackPagination({
|
||||
total: normalizedTotal,
|
||||
page: safePage,
|
||||
limit:
|
||||
typeof incomingPagination?.limit === 'number'
|
||||
? normalizePageLimit(incomingPagination.limit)
|
||||
: nextLimit,
|
||||
totalPages: normalizedTotalPages,
|
||||
})
|
||||
setAiFallbackReason(reason)
|
||||
setAiError(null)
|
||||
return true
|
||||
} catch (error) {
|
||||
const projectsFetchFailedText = locale === 'en' ? 'Failed to load projects' : '项目加载失败'
|
||||
setAiError(error instanceof Error ? error.message : projectsFetchFailedText)
|
||||
setAiFallbackReason(null)
|
||||
setAiFallbackResults([])
|
||||
setAiFallbackPagination({
|
||||
total: 0,
|
||||
page: 1,
|
||||
limit: nextLimit,
|
||||
totalPages: 0,
|
||||
})
|
||||
return false
|
||||
} finally {
|
||||
setLoadingAIFallback(false)
|
||||
}
|
||||
},
|
||||
[locale, projectType, search, selectedDomains, selectedProductForms, selectedTags]
|
||||
)
|
||||
|
||||
const handleTraditionalPageChange = useCallback(
|
||||
(nextPage: number) => {
|
||||
const safePage = Math.max(1, nextPage)
|
||||
@@ -415,6 +517,8 @@ export function ProjectsResultsClient({
|
||||
|
||||
setLoadingAI(true)
|
||||
setAiError(null)
|
||||
setAiFallbackReason(null)
|
||||
setAiFallbackResults([])
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/search/ai', {
|
||||
@@ -477,11 +581,23 @@ export function ProjectsResultsClient({
|
||||
setAiCurrentPage(safePage)
|
||||
replaceProjectsUrl(safePage, aiSort, true, aiLimit)
|
||||
}
|
||||
|
||||
if (normalizedResults.length === 0) {
|
||||
void fetchAIFallbackResults(1, aiSort, aiLimit, 'empty')
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
const aiSearchFailedText = locale === 'en' ? 'AI search failed' : 'AI 搜索失败'
|
||||
setAiError(error instanceof Error ? error.message : aiSearchFailedText)
|
||||
const fallbackWorked = await fetchAIFallbackResults(
|
||||
aiCurrentPage,
|
||||
aiSort,
|
||||
aiLimit,
|
||||
'error'
|
||||
)
|
||||
setAiError(
|
||||
fallbackWorked ? null : error instanceof Error ? error.message : aiSearchFailedText
|
||||
)
|
||||
setAiResults([])
|
||||
setAiPagination((prev) => ({
|
||||
...prev,
|
||||
@@ -506,6 +622,7 @@ export function ProjectsResultsClient({
|
||||
aiCurrentPage,
|
||||
aiLimit,
|
||||
aiSort,
|
||||
fetchAIFallbackResults,
|
||||
isAI,
|
||||
locale,
|
||||
replaceProjectsUrl,
|
||||
@@ -518,7 +635,8 @@ export function ProjectsResultsClient({
|
||||
selectedTagsKey,
|
||||
])
|
||||
|
||||
const loading = isAI ? loadingAI : loadingTraditional
|
||||
const isUsingAIFallback = isAI && aiFallbackReason !== null
|
||||
const loading = isAI ? loadingAI || loadingAIFallback : loadingTraditional
|
||||
const activeSort = isAI ? aiSort : traditionalSort
|
||||
const activePage = isAI ? aiCurrentPage : traditionalCurrentPage
|
||||
const activeLimit = isAI ? aiLimit : traditionalLimit
|
||||
@@ -528,16 +646,38 @@ export function ProjectsResultsClient({
|
||||
const nextStarSort: ProjectSortOption = currentStarSort === 'stars_desc' ? 'stars_asc' : 'stars_desc'
|
||||
const starSortTarget: ProjectSortOption = activeSort === 'latest' ? 'stars_desc' : nextStarSort
|
||||
|
||||
const totalCount = isAI ? aiPagination.total : traditionalPagination.total
|
||||
const totalCount = isAI
|
||||
? isUsingAIFallback
|
||||
? aiFallbackPagination.total
|
||||
: aiPagination.total
|
||||
: traditionalPagination.total
|
||||
const visibleProjectsLabel = loading ? '...' : String(totalCount)
|
||||
const projectsCountText =
|
||||
locale === 'en' ? `${visibleProjectsLabel} projects` : `${visibleProjectsLabel} 个项目`
|
||||
const currentPage = isAI ? aiPagination.page || activePage : traditionalPagination.page || activePage
|
||||
const activeTotalPages = isAI ? aiPagination.totalPages : traditionalPagination.totalPages
|
||||
const currentPage = isAI
|
||||
? isUsingAIFallback
|
||||
? aiFallbackPagination.page || activePage
|
||||
: aiPagination.page || activePage
|
||||
: traditionalPagination.page || activePage
|
||||
const activeTotalPages = isAI
|
||||
? isUsingAIFallback
|
||||
? aiFallbackPagination.totalPages
|
||||
: aiPagination.totalPages
|
||||
: traditionalPagination.totalPages
|
||||
const canGoPrev = currentPage > 1
|
||||
const canGoNext = currentPage < activeTotalPages
|
||||
const displayProjects = isAI ? [] : traditionalResults
|
||||
const displayProjects = isAI
|
||||
? isUsingAIFallback
|
||||
? aiFallbackResults
|
||||
: []
|
||||
: traditionalResults
|
||||
const activeError = isAI ? aiError : traditionalError
|
||||
const fallbackMessage =
|
||||
aiFallbackReason === 'error'
|
||||
? translations.aiSearchUnavailableFallback
|
||||
: aiFallbackReason === 'empty'
|
||||
? translations.aiSearchEmptyFallback
|
||||
: null
|
||||
|
||||
const totalPagesForSummary = totalCount === 0 ? 0 : Math.max(1, activeTotalPages)
|
||||
const currentPageForSummary = totalPagesForSummary === 0 ? 0 : Math.min(currentPage, totalPagesForSummary)
|
||||
@@ -607,11 +747,36 @@ export function ProjectsResultsClient({
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{fallbackMessage ? (
|
||||
<div className="mb-8 border-l-4 border-primary bg-yellow-50 px-4 py-3 dark:bg-yellow-900/20">
|
||||
<p className="font-display text-sm font-bold text-black dark:text-yellow-100">
|
||||
{fallbackMessage}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{isAI ? (
|
||||
loadingAI ? (
|
||||
loading ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400 font-display">{translations.searching}</p>
|
||||
</div>
|
||||
) : isUsingAIFallback ? (
|
||||
displayProjects.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<p className="text-gray-500 dark:text-gray-400 font-display">{translations.noProjects}</p>
|
||||
</div>
|
||||
) : (
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-8">
|
||||
{displayProjects.map((project) => (
|
||||
<ProjectCard
|
||||
key={project.id}
|
||||
project={project}
|
||||
locale={locale}
|
||||
translations={{ viewDetails: translations.viewDetails }}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
) : (
|
||||
<AISearchResults
|
||||
results={aiResults}
|
||||
|
||||
@@ -43,7 +43,7 @@ export default async function ProjectDetailPage({
|
||||
className="inline-flex items-center text-sm font-display font-bold text-gray-500 hover:text-black dark:text-gray-400 dark:hover:text-white transition-colors group"
|
||||
href={`/${locale}/projects`}
|
||||
>
|
||||
<span className="material-icons text-base mr-1 group-hover:-translate-x-1 transition-transform">
|
||||
<span className="material-icons text-base mr-1 group-hover:-translate-x-1 transition-transform" aria-hidden="true">
|
||||
arrow_back
|
||||
</span>
|
||||
{tProject('backToProjects')}
|
||||
|
||||
@@ -198,6 +198,8 @@ export default async function ProjectsPage({
|
||||
noProjects: tCommon('noProjects'),
|
||||
noResults: tCommon('noResults'),
|
||||
searching: tCommon('searching'),
|
||||
aiSearchUnavailableFallback: tProject('aiSearchUnavailableFallback'),
|
||||
aiSearchEmptyFallback: tProject('aiSearchEmptyFallback'),
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
|
||||
@@ -0,0 +1,55 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { ProjectSubmissionForm } from "@/components/submissions/ProjectSubmissionForm";
|
||||
|
||||
interface SubmitPageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: SubmitPageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "submit" });
|
||||
|
||||
return {
|
||||
title: t("metaTitle"),
|
||||
description: t("metaDescription"),
|
||||
};
|
||||
}
|
||||
|
||||
export default async function SubmitPage({ params }: SubmitPageProps) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations("submit");
|
||||
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-14 md:py-20">
|
||||
<section className="mb-8">
|
||||
<p className="inline-flex border-2 border-black bg-primary px-3 py-1 font-display text-xs font-bold uppercase text-black">
|
||||
{t("eyebrow")}
|
||||
</p>
|
||||
<h1 className="mt-5 font-display text-4xl md:text-6xl font-bold tracking-tight">
|
||||
{t("title")}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-3xl text-gray-700 dark:text-gray-300">
|
||||
{t("description")}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<ProjectSubmissionForm
|
||||
locale={locale}
|
||||
labels={{
|
||||
url: t("url"),
|
||||
projectName: t("projectName"),
|
||||
description: t("projectDescription"),
|
||||
submitterName: t("submitterName"),
|
||||
submitterEmail: t("submitterEmail"),
|
||||
submit: t("submit"),
|
||||
submitting: t("submitting"),
|
||||
success: t("success"),
|
||||
duplicate: t("duplicate"),
|
||||
invalid: t("invalid"),
|
||||
failed: t("failed"),
|
||||
}}
|
||||
/>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StaticInfoPage } from "@/components/static/StaticInfoPage";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "staticPages.terms" });
|
||||
return { title: t("title"), description: t("description") };
|
||||
}
|
||||
|
||||
export default async function TermsPage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "staticPages.terms" });
|
||||
const sections = t.raw("sections") as Array<{ title: string; body: string }>;
|
||||
|
||||
return (
|
||||
<StaticInfoPage
|
||||
eyebrow="Agent Park"
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,28 @@
|
||||
import type { Metadata } from "next";
|
||||
import { getTranslations } from "next-intl/server";
|
||||
import { StaticInfoPage } from "@/components/static/StaticInfoPage";
|
||||
|
||||
interface PageProps {
|
||||
params: Promise<{ locale: string }>;
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: PageProps): Promise<Metadata> {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "staticPages.updates" });
|
||||
return { title: t("title"), description: t("description") };
|
||||
}
|
||||
|
||||
export default async function UpdatesPage({ params }: PageProps) {
|
||||
const { locale } = await params;
|
||||
const t = await getTranslations({ locale, namespace: "staticPages.updates" });
|
||||
const sections = t.raw("sections") as Array<{ title: string; body: string }>;
|
||||
|
||||
return (
|
||||
<StaticInfoPage
|
||||
eyebrow="Agent Park"
|
||||
title={t("title")}
|
||||
description={t("description")}
|
||||
sections={sections}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,189 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { CheckTaskDuplicatesSchema } from "@/lib/validations";
|
||||
import { isValidApiKey } from "@/lib/auth";
|
||||
|
||||
type DuplicateCheckResult = {
|
||||
url: string;
|
||||
shouldCreate: boolean;
|
||||
reason: string;
|
||||
existingTask?: {
|
||||
id: string;
|
||||
status: string;
|
||||
sourceUrl: string;
|
||||
createdAt: Date;
|
||||
projectId?: string | null;
|
||||
};
|
||||
existingProject?: {
|
||||
id: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
};
|
||||
};
|
||||
|
||||
async function checkUrlDuplicate(url: string): Promise<DuplicateCheckResult> {
|
||||
const activeTask = await prisma.projectDiscoveryTask.findFirst({
|
||||
where: {
|
||||
sourceUrl: url,
|
||||
status: {
|
||||
in: ["PENDING", "IN_PROGRESS"],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
sourceUrl: true,
|
||||
createdAt: true,
|
||||
projectId: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (activeTask) {
|
||||
return {
|
||||
url,
|
||||
shouldCreate: false,
|
||||
reason: `Task already exists with status ${activeTask.status}`,
|
||||
existingTask: activeTask,
|
||||
};
|
||||
}
|
||||
|
||||
const finishedTask = await prisma.projectDiscoveryTask.findFirst({
|
||||
where: {
|
||||
sourceUrl: url,
|
||||
status: {
|
||||
in: ["COMPLETED", "FAILED"],
|
||||
},
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
sourceUrl: true,
|
||||
createdAt: true,
|
||||
projectId: true,
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: "desc",
|
||||
},
|
||||
});
|
||||
|
||||
if (finishedTask) {
|
||||
let projectInfo;
|
||||
|
||||
if (finishedTask.status === "COMPLETED" && finishedTask.projectId) {
|
||||
const project = await prisma.project.findUnique({
|
||||
where: { id: finishedTask.projectId },
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (project) {
|
||||
projectInfo = project;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
shouldCreate: false,
|
||||
reason:
|
||||
finishedTask.status === "COMPLETED" ? "Task already completed" : "Task already failed",
|
||||
existingTask: finishedTask,
|
||||
existingProject: projectInfo,
|
||||
};
|
||||
}
|
||||
|
||||
const existingLink = await prisma.externalLink.findFirst({
|
||||
where: {
|
||||
url,
|
||||
},
|
||||
select: {
|
||||
project: {
|
||||
select: {
|
||||
id: true,
|
||||
name: true,
|
||||
slug: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingLink) {
|
||||
return {
|
||||
url,
|
||||
shouldCreate: false,
|
||||
reason: "Project already exists with this URL",
|
||||
existingProject: existingLink.project,
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
url,
|
||||
shouldCreate: true,
|
||||
reason: "No existing task or project found",
|
||||
};
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const validationResult = CheckTaskDuplicatesSchema.safeParse(body);
|
||||
|
||||
if (!validationResult.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Validation error",
|
||||
details: validationResult.error.errors.map((error) => error.message),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { apiKey, urls } = validationResult.data;
|
||||
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Unauthorized",
|
||||
details: ["Invalid or missing API Key"],
|
||||
},
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
|
||||
const results = await Promise.all(urls.map((url) => checkUrlDuplicate(url)));
|
||||
const stats = {
|
||||
total: results.length,
|
||||
shouldCreate: results.filter((result) => result.shouldCreate).length,
|
||||
duplicate: results.filter((result) => !result.shouldCreate).length,
|
||||
};
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
|
||||
console.warn(
|
||||
`[CheckTaskDuplicates] Checked ${stats.total} URLs in ${duration}ms: ${stats.shouldCreate} should create, ${stats.duplicate} duplicate`
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
results,
|
||||
stats,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[CheckTaskDuplicates] Error:", error);
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Internal server error",
|
||||
details: [error instanceof Error ? error.message : "Unknown error"],
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,536 @@
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import type { ProjectInput } from "@/lib/validations";
|
||||
import { generateSlug } from "@/lib/slug";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import {
|
||||
FIXED_PROJECT_TYPE_TAGS,
|
||||
inferProjectTypeSlug,
|
||||
inferTagCategory,
|
||||
type FixedProjectTypeSlug,
|
||||
} from "@/lib/tag-taxonomy";
|
||||
|
||||
const PLURAL_NORMALIZATION_MAP = new Map([
|
||||
["agents", "agent"],
|
||||
["assistants", "assistant"],
|
||||
["tools", "tool"],
|
||||
["frameworks", "framework"],
|
||||
["models", "model"],
|
||||
["servers", "server"],
|
||||
["clients", "client"],
|
||||
["workflows", "workflow"],
|
||||
["plugins", "plugin"],
|
||||
["libraries", "library"],
|
||||
["datasets", "dataset"],
|
||||
["platforms", "platform"],
|
||||
["systems", "system"],
|
||||
["repositories", "repository"],
|
||||
["engines", "engine"],
|
||||
]);
|
||||
|
||||
const NOISE_TAG_KEYS = new Set([
|
||||
"ai",
|
||||
"artificial intelligence",
|
||||
"open source",
|
||||
"requires configuration",
|
||||
"requires basics",
|
||||
"low learning curve",
|
||||
"enterprise",
|
||||
"complex deployment",
|
||||
"cloud service",
|
||||
"cross platform",
|
||||
"tutorial",
|
||||
"academic research",
|
||||
"academic resource",
|
||||
"ai research resource",
|
||||
"multi language support",
|
||||
"self hosted",
|
||||
"user notification",
|
||||
"mit license",
|
||||
"apache 2 0",
|
||||
"开源",
|
||||
"需要配置",
|
||||
"需要基础",
|
||||
"低学习成本",
|
||||
"企业级",
|
||||
"复杂部署",
|
||||
"云端服务",
|
||||
"跨平台",
|
||||
"教程",
|
||||
"学术研究",
|
||||
"学术资源",
|
||||
"多语言支持",
|
||||
"自托管",
|
||||
"用户通知",
|
||||
"许可",
|
||||
]);
|
||||
|
||||
const TAG_FALLBACK = {
|
||||
name: "AI开发工具",
|
||||
nameEn: "AI Development Tool",
|
||||
} as const;
|
||||
|
||||
function normalizeWhitespace(value: string): string {
|
||||
return value.replace(/\s+/g, " ").trim();
|
||||
}
|
||||
|
||||
function isAsciiText(value: string): boolean {
|
||||
return /^[\x00-\x7f]+$/.test(value);
|
||||
}
|
||||
|
||||
function canonicalizeTagKey(value: string): string {
|
||||
const normalized = normalizeWhitespace(value)
|
||||
.normalize("NFKC")
|
||||
.toLowerCase()
|
||||
.replace(/[+/_&|-]+/g, " ")
|
||||
.replace(/[^a-z0-9\u4e00-\u9fa5\s]/g, " ")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
|
||||
if (!normalized) {
|
||||
return "";
|
||||
}
|
||||
|
||||
return normalized
|
||||
.split(" ")
|
||||
.map((word) => PLURAL_NORMALIZATION_MAP.get(word) || word)
|
||||
.join(" ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
function normalizeIncomingTag(tag: ProjectInput["tags"][number]) {
|
||||
const normalizedName = normalizeWhitespace(tag.name);
|
||||
const normalizedNameEn = normalizeWhitespace(tag.nameEn || "");
|
||||
const resolvedNameEn = normalizedNameEn || (isAsciiText(normalizedName) ? normalizedName : "");
|
||||
const canonicalNameKey = canonicalizeTagKey(normalizedName);
|
||||
const canonicalNameEnKey = canonicalizeTagKey(resolvedNameEn);
|
||||
|
||||
return {
|
||||
name: normalizedName,
|
||||
nameEn: resolvedNameEn || null,
|
||||
canonicalNameKey,
|
||||
canonicalNameEnKey,
|
||||
slug: generateSlug(normalizedName, resolvedNameEn || null),
|
||||
};
|
||||
}
|
||||
|
||||
function isMeaningfulTag(tag: ReturnType<typeof normalizeIncomingTag>): boolean {
|
||||
if (!tag.name) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (NOISE_TAG_KEYS.has(tag.canonicalNameKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (tag.canonicalNameEnKey && NOISE_TAG_KEYS.has(tag.canonicalNameEnKey)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
type TagWithProjectCount = Prisma.TagGetPayload<{
|
||||
include: {
|
||||
_count: { select: { projects: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
function chooseBestTag(candidates: TagWithProjectCount[]): TagWithProjectCount {
|
||||
return [...candidates].sort((a, b) => {
|
||||
const projectDiff = b._count.projects - a._count.projects;
|
||||
if (projectDiff !== 0) {
|
||||
return projectDiff;
|
||||
}
|
||||
|
||||
return a.createdAt.getTime() - b.createdAt.getTime();
|
||||
})[0]!;
|
||||
}
|
||||
|
||||
type IncomingNormalizedTag = ReturnType<typeof normalizeIncomingTag>;
|
||||
|
||||
type TagLookupMaps = {
|
||||
tagByExactName: Map<string, TagWithProjectCount[]>;
|
||||
tagByExactNameEn: Map<string, TagWithProjectCount[]>;
|
||||
tagBySlug: Map<string, TagWithProjectCount>;
|
||||
tagByCanonicalName: Map<string, TagWithProjectCount[]>;
|
||||
tagByCanonicalNameEn: Map<string, TagWithProjectCount[]>;
|
||||
};
|
||||
|
||||
function pushTagMapEntry(
|
||||
map: Map<string, TagWithProjectCount[]>,
|
||||
key: string,
|
||||
tag: TagWithProjectCount
|
||||
): void {
|
||||
if (!key) {
|
||||
return;
|
||||
}
|
||||
|
||||
const current = map.get(key);
|
||||
if (current) {
|
||||
current.push(tag);
|
||||
return;
|
||||
}
|
||||
|
||||
map.set(key, [tag]);
|
||||
}
|
||||
|
||||
function createTagLookupMaps(tags: TagWithProjectCount[]): TagLookupMaps {
|
||||
const tagByExactName = new Map<string, TagWithProjectCount[]>();
|
||||
const tagByExactNameEn = new Map<string, TagWithProjectCount[]>();
|
||||
const tagBySlug = new Map<string, TagWithProjectCount>();
|
||||
const tagByCanonicalName = new Map<string, TagWithProjectCount[]>();
|
||||
const tagByCanonicalNameEn = new Map<string, TagWithProjectCount[]>();
|
||||
|
||||
for (const tag of tags) {
|
||||
const exactNameKey = normalizeWhitespace(tag.name);
|
||||
const exactNameEnKey = normalizeWhitespace(tag.nameEn || "");
|
||||
const canonicalNameKey = canonicalizeTagKey(tag.name);
|
||||
const canonicalNameEnKey = canonicalizeTagKey(tag.nameEn || "");
|
||||
|
||||
pushTagMapEntry(tagByExactName, exactNameKey, tag);
|
||||
pushTagMapEntry(tagByExactNameEn, exactNameEnKey, tag);
|
||||
pushTagMapEntry(tagByCanonicalName, canonicalNameKey, tag);
|
||||
pushTagMapEntry(tagByCanonicalNameEn, canonicalNameEnKey, tag);
|
||||
tagBySlug.set(tag.slug, tag);
|
||||
}
|
||||
|
||||
return {
|
||||
tagByExactName,
|
||||
tagByExactNameEn,
|
||||
tagBySlug,
|
||||
tagByCanonicalName,
|
||||
tagByCanonicalNameEn,
|
||||
};
|
||||
}
|
||||
|
||||
function addTagToLookupMaps(lookups: TagLookupMaps, tag: TagWithProjectCount): void {
|
||||
const exactNameKey = normalizeWhitespace(tag.name);
|
||||
const exactNameEnKey = normalizeWhitespace(tag.nameEn || "");
|
||||
const canonicalNameKey = canonicalizeTagKey(tag.name);
|
||||
const canonicalNameEnKey = canonicalizeTagKey(tag.nameEn || "");
|
||||
|
||||
pushTagMapEntry(lookups.tagByExactName, exactNameKey, tag);
|
||||
pushTagMapEntry(lookups.tagByExactNameEn, exactNameEnKey, tag);
|
||||
pushTagMapEntry(lookups.tagByCanonicalName, canonicalNameKey, tag);
|
||||
pushTagMapEntry(lookups.tagByCanonicalNameEn, canonicalNameEnKey, tag);
|
||||
lookups.tagBySlug.set(tag.slug, tag);
|
||||
}
|
||||
|
||||
async function getCandidateTags(
|
||||
incomingTags: IncomingNormalizedTag[]
|
||||
): Promise<TagWithProjectCount[]> {
|
||||
const nameValues = Array.from(
|
||||
new Set(
|
||||
incomingTags.map((tag) => normalizeWhitespace(tag.name)).filter((name) => name.length > 0)
|
||||
)
|
||||
);
|
||||
const slugValues = Array.from(
|
||||
new Set(incomingTags.map((tag) => tag.slug).filter((slug) => slug.length > 0))
|
||||
);
|
||||
const nameEnValues = Array.from(
|
||||
new Set(
|
||||
incomingTags
|
||||
.map((tag) => normalizeWhitespace(tag.nameEn || ""))
|
||||
.filter((nameEn) => nameEn.length > 0)
|
||||
)
|
||||
);
|
||||
|
||||
const whereOr: Prisma.TagWhereInput[] = [];
|
||||
if (nameValues.length > 0) {
|
||||
whereOr.push({ name: { in: nameValues } });
|
||||
}
|
||||
if (slugValues.length > 0) {
|
||||
whereOr.push({ slug: { in: slugValues } });
|
||||
}
|
||||
if (nameEnValues.length > 0) {
|
||||
whereOr.push({ nameEn: { in: nameEnValues } });
|
||||
}
|
||||
|
||||
if (whereOr.length === 0) {
|
||||
return [];
|
||||
}
|
||||
|
||||
return prisma.tag.findMany({
|
||||
where: {
|
||||
OR: whereOr,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
async function getFallbackTag(): Promise<TagWithProjectCount> {
|
||||
const fallbackSlug = generateSlug(TAG_FALLBACK.name, TAG_FALLBACK.nameEn);
|
||||
const fallbackCategory = inferTagCategory({
|
||||
slug: fallbackSlug,
|
||||
name: TAG_FALLBACK.name,
|
||||
nameEn: TAG_FALLBACK.nameEn,
|
||||
});
|
||||
|
||||
return prisma.tag.upsert({
|
||||
where: { slug: fallbackSlug },
|
||||
update: {
|
||||
name: TAG_FALLBACK.name,
|
||||
nameEn: TAG_FALLBACK.nameEn,
|
||||
category: fallbackCategory,
|
||||
},
|
||||
create: {
|
||||
name: TAG_FALLBACK.name,
|
||||
nameEn: TAG_FALLBACK.nameEn,
|
||||
slug: fallbackSlug,
|
||||
category: fallbackCategory,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const FIXED_PROJECT_TYPE_MAP = new Map(
|
||||
FIXED_PROJECT_TYPE_TAGS.map((tag) => [tag.slug, tag] as const)
|
||||
);
|
||||
|
||||
export async function ensureFixedProjectTypeTag(
|
||||
slug: FixedProjectTypeSlug
|
||||
): Promise<TagWithProjectCount> {
|
||||
const fixedTag = FIXED_PROJECT_TYPE_MAP.get(slug);
|
||||
if (!fixedTag) {
|
||||
throw new Error(`Unsupported fixed project type slug: ${slug}`);
|
||||
}
|
||||
|
||||
return prisma.tag.upsert({
|
||||
where: { slug: fixedTag.slug },
|
||||
update: {
|
||||
name: fixedTag.name,
|
||||
nameEn: fixedTag.nameEn,
|
||||
category: "FIXED_PROJECT_TYPE",
|
||||
},
|
||||
create: {
|
||||
name: fixedTag.name,
|
||||
nameEn: fixedTag.nameEn,
|
||||
slug: fixedTag.slug,
|
||||
category: "FIXED_PROJECT_TYPE",
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function resolveFixedProjectTypeTag(
|
||||
projectData: Pick<ProjectInput, "name" | "nameEn" | "description" | "descriptionEn" | "tags">
|
||||
): Promise<TagWithProjectCount> {
|
||||
const projectTypeSlug = inferProjectTypeSlug({
|
||||
name: projectData.name,
|
||||
nameEn: projectData.nameEn,
|
||||
description: projectData.description,
|
||||
descriptionEn: projectData.descriptionEn,
|
||||
tags: projectData.tags.map((tag) => ({
|
||||
name: tag.name,
|
||||
nameEn: tag.nameEn,
|
||||
slug: generateSlug(tag.name, tag.nameEn || null),
|
||||
})),
|
||||
});
|
||||
|
||||
return ensureFixedProjectTypeTag(projectTypeSlug);
|
||||
}
|
||||
|
||||
export async function findExistingProject(projectData: ProjectInput) {
|
||||
const githubLink = projectData.links.find((link) => link.type === "GITHUB");
|
||||
if (githubLink) {
|
||||
const existingByGithub = await prisma.externalLink.findFirst({
|
||||
where: {
|
||||
type: "GITHUB",
|
||||
url: githubLink.url,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
links: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingByGithub) {
|
||||
console.warn(`[Discovery] Found existing project by GitHub URL: ${githubLink.url}`);
|
||||
return existingByGithub.project;
|
||||
}
|
||||
}
|
||||
|
||||
const websiteLink = projectData.links.find((link) => link.type === "WEBSITE");
|
||||
if (websiteLink) {
|
||||
const existingByWebsite = await prisma.externalLink.findFirst({
|
||||
where: {
|
||||
type: "WEBSITE",
|
||||
url: websiteLink.url,
|
||||
},
|
||||
include: {
|
||||
project: {
|
||||
include: {
|
||||
links: true,
|
||||
},
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (existingByWebsite) {
|
||||
console.warn(`[Discovery] Found existing project by Website URL: ${websiteLink.url}`);
|
||||
return existingByWebsite.project;
|
||||
}
|
||||
}
|
||||
|
||||
const slug = generateSlug(projectData.name, projectData.nameEn);
|
||||
const existingBySlug = await prisma.project.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
links: true,
|
||||
},
|
||||
});
|
||||
|
||||
if (existingBySlug) {
|
||||
console.warn(`[Discovery] Found existing project by slug: ${slug}`);
|
||||
return existingBySlug;
|
||||
}
|
||||
|
||||
console.warn("[Discovery] No existing project found, will create new one");
|
||||
return null;
|
||||
}
|
||||
|
||||
export async function upsertTags(tags: ProjectInput["tags"]) {
|
||||
const meaningfulTags = tags
|
||||
.map((tag) => normalizeIncomingTag(tag))
|
||||
.filter((tag) => isMeaningfulTag(tag));
|
||||
|
||||
const normalizedIncomingTags = Array.from(
|
||||
new Map(
|
||||
meaningfulTags.map((normalized) => [
|
||||
normalized.canonicalNameEnKey || normalized.canonicalNameKey || normalized.name,
|
||||
normalized,
|
||||
])
|
||||
).values()
|
||||
);
|
||||
|
||||
if (normalizedIncomingTags.length === 0) {
|
||||
return [await getFallbackTag()];
|
||||
}
|
||||
|
||||
const candidateTags = await getCandidateTags(normalizedIncomingTags);
|
||||
const lookups = createTagLookupMaps(candidateTags);
|
||||
const resolvedTags: TagWithProjectCount[] = [];
|
||||
|
||||
for (const incomingTag of normalizedIncomingTags) {
|
||||
const exactNameCandidates = lookups.tagByExactName.get(incomingTag.name) || [];
|
||||
let matchedTag = exactNameCandidates.length > 0 ? chooseBestTag(exactNameCandidates) : null;
|
||||
|
||||
if (!matchedTag && incomingTag.nameEn) {
|
||||
const exactNameEnCandidates = lookups.tagByExactNameEn.get(incomingTag.nameEn) || [];
|
||||
if (exactNameEnCandidates.length > 0) {
|
||||
matchedTag = chooseBestTag(exactNameEnCandidates);
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedTag && incomingTag.canonicalNameKey) {
|
||||
const canonicalNameCandidates =
|
||||
lookups.tagByCanonicalName.get(incomingTag.canonicalNameKey) || [];
|
||||
if (canonicalNameCandidates.length > 0) {
|
||||
matchedTag = chooseBestTag(canonicalNameCandidates);
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedTag && incomingTag.canonicalNameEnKey) {
|
||||
const canonicalNameEnCandidates =
|
||||
lookups.tagByCanonicalNameEn.get(incomingTag.canonicalNameEnKey) || [];
|
||||
if (canonicalNameEnCandidates.length > 0) {
|
||||
matchedTag = chooseBestTag(canonicalNameEnCandidates);
|
||||
}
|
||||
}
|
||||
|
||||
if (!matchedTag) {
|
||||
matchedTag = lookups.tagBySlug.get(incomingTag.slug) || null;
|
||||
}
|
||||
|
||||
if (matchedTag) {
|
||||
const inferredCategory = inferTagCategory({
|
||||
slug: incomingTag.slug,
|
||||
name: incomingTag.name,
|
||||
nameEn: incomingTag.nameEn,
|
||||
});
|
||||
const shouldUpdateNameEn = incomingTag.nameEn && !matchedTag.nameEn;
|
||||
const shouldUpdateCategory =
|
||||
matchedTag.category !== inferredCategory &&
|
||||
["FREE_TAG", "RESOURCE_TYPE", "PROTOCOL_INTERFACE"].includes(matchedTag.category);
|
||||
|
||||
if (shouldUpdateNameEn || shouldUpdateCategory) {
|
||||
const updatedTag = await prisma.tag.update({
|
||||
where: { id: matchedTag.id },
|
||||
data: {
|
||||
...(shouldUpdateNameEn ? { nameEn: incomingTag.nameEn } : {}),
|
||||
...(shouldUpdateCategory ? { category: inferredCategory } : {}),
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
matchedTag = updatedTag;
|
||||
addTagToLookupMaps(lookups, matchedTag);
|
||||
}
|
||||
|
||||
resolvedTags.push(matchedTag);
|
||||
continue;
|
||||
}
|
||||
|
||||
const inferredCategory = inferTagCategory({
|
||||
slug: incomingTag.slug,
|
||||
name: incomingTag.name,
|
||||
nameEn: incomingTag.nameEn,
|
||||
});
|
||||
|
||||
try {
|
||||
const createdTag = await prisma.tag.create({
|
||||
data: {
|
||||
name: incomingTag.name,
|
||||
nameEn: incomingTag.nameEn,
|
||||
slug: incomingTag.slug,
|
||||
category: inferredCategory,
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
resolvedTags.push(createdTag);
|
||||
addTagToLookupMaps(lookups, createdTag);
|
||||
} catch {
|
||||
const fallbackTag = await prisma.tag.findFirst({
|
||||
where: {
|
||||
OR: [{ name: incomingTag.name }, { slug: incomingTag.slug }],
|
||||
},
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
if (fallbackTag) {
|
||||
resolvedTags.push(fallbackTag);
|
||||
continue;
|
||||
}
|
||||
|
||||
throw new Error(`Failed to resolve tag: ${incomingTag.name}`);
|
||||
}
|
||||
}
|
||||
|
||||
return Array.from(new Map(resolvedTags.map((tag) => [tag.id, tag])).values());
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { generateSlug } from "@/lib/slug";
|
||||
import { isValidApiKey } from "@/lib/auth";
|
||||
import { ProjectInputSchema, type ProjectInput } from "@/lib/validations";
|
||||
import {
|
||||
findExistingProject,
|
||||
resolveFixedProjectTypeTag,
|
||||
upsertTags,
|
||||
} from "@/app/api/discovery/lib/discovery-service";
|
||||
|
||||
export async function POST(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
const startTime = Date.now();
|
||||
const { id: taskId } = await params;
|
||||
|
||||
try {
|
||||
const body = await request.json();
|
||||
const { apiKey, explorationData } = body;
|
||||
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const projectValidation = ProjectInputSchema.safeParse(explorationData);
|
||||
if (!projectValidation.success) {
|
||||
console.error(
|
||||
`[Discovery] Invalid exploration data for task ${taskId}:`,
|
||||
projectValidation.error.errors
|
||||
);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Invalid exploration data format",
|
||||
details: projectValidation.error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const projectData = projectValidation.data as ProjectInput;
|
||||
const existingProject = await findExistingProject(projectData);
|
||||
|
||||
const [dynamicTagConnections, fixedProjectTypeTag] = await Promise.all([
|
||||
upsertTags(projectData.tags),
|
||||
resolveFixedProjectTypeTag(projectData),
|
||||
]);
|
||||
const tagConnections = Array.from(
|
||||
new Map([...dynamicTagConnections, fixedProjectTypeTag].map((tag) => [tag.id, tag])).values()
|
||||
);
|
||||
|
||||
const slug = generateSlug(projectData.name, projectData.nameEn);
|
||||
|
||||
const result = await prisma.$transaction(async (tx) => {
|
||||
let projectId: string;
|
||||
|
||||
if (existingProject) {
|
||||
console.warn(
|
||||
`[Discovery] Updating existing project for task ${taskId}: ${projectData.name}`
|
||||
);
|
||||
|
||||
await tx.projectTag.deleteMany({
|
||||
where: { projectId: existingProject.id },
|
||||
});
|
||||
|
||||
await tx.project.update({
|
||||
where: { id: existingProject.id },
|
||||
data: {
|
||||
name: projectData.name,
|
||||
nameEn: projectData.nameEn || null,
|
||||
description: projectData.description,
|
||||
descriptionEn: projectData.descriptionEn || null,
|
||||
content: projectData.content || null,
|
||||
contentEn: projectData.contentEn || null,
|
||||
status: projectData.status,
|
||||
source: projectData.source || "discovery",
|
||||
tags: {
|
||||
create: tagConnections.map((tag) => ({
|
||||
tag: { connect: { id: tag.id } },
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
await tx.externalLink.deleteMany({
|
||||
where: { projectId: existingProject.id },
|
||||
});
|
||||
|
||||
await tx.externalLink.createMany({
|
||||
data: projectData.links.map((link) => ({
|
||||
type: link.type,
|
||||
url: link.url,
|
||||
title: link.title || null,
|
||||
projectId: existingProject.id,
|
||||
})),
|
||||
});
|
||||
|
||||
projectId = existingProject.id;
|
||||
} else {
|
||||
console.warn(`[Discovery] Creating new project for task ${taskId}: ${projectData.name}`);
|
||||
|
||||
const newProject = await tx.project.create({
|
||||
data: {
|
||||
name: projectData.name,
|
||||
nameEn: projectData.nameEn || null,
|
||||
slug,
|
||||
description: projectData.description,
|
||||
descriptionEn: projectData.descriptionEn || null,
|
||||
content: projectData.content || null,
|
||||
contentEn: projectData.contentEn || null,
|
||||
status: projectData.status,
|
||||
source: projectData.source || "discovery",
|
||||
tags: {
|
||||
create: tagConnections.map((tag) => ({
|
||||
tag: { connect: { id: tag.id } },
|
||||
})),
|
||||
},
|
||||
links: {
|
||||
create: projectData.links.map((link) => ({
|
||||
type: link.type,
|
||||
url: link.url,
|
||||
title: link.title || null,
|
||||
})),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
projectId = newProject.id;
|
||||
}
|
||||
|
||||
const updatedTask = await tx.projectDiscoveryTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: "COMPLETED",
|
||||
completedAt: new Date(),
|
||||
explorationData,
|
||||
projectId,
|
||||
},
|
||||
});
|
||||
|
||||
return { projectId, updatedTask };
|
||||
});
|
||||
|
||||
const duration = Date.now() - startTime;
|
||||
console.warn(
|
||||
`[Discovery] Completed task ${taskId} in ${duration}ms, project: ${result.projectId}`
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
taskId,
|
||||
projectId: result.projectId,
|
||||
action: existingProject ? "updated" : "created",
|
||||
duration,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Discovery] Error completing task:", error);
|
||||
|
||||
try {
|
||||
await prisma.projectDiscoveryTask.update({
|
||||
where: { id: taskId },
|
||||
data: {
|
||||
status: "FAILED",
|
||||
completedAt: new Date(),
|
||||
errorMessage: error instanceof Error ? error.message : "Unknown error",
|
||||
retryCount: { increment: 1 },
|
||||
},
|
||||
});
|
||||
} catch (updateError) {
|
||||
console.error("[Discovery] Failed to update task status:", updateError);
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Internal server error",
|
||||
details: [error instanceof Error ? error.message : "Unknown error"],
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import type { Prisma } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { UpdateDiscoveryTaskSchema, type TaskStatus } from "@/lib/validations";
|
||||
import { isValidApiKey } from "@/lib/auth";
|
||||
|
||||
const VALID_STATUS_TRANSITIONS: Record<TaskStatus, TaskStatus[]> = {
|
||||
PENDING: ["IN_PROGRESS"],
|
||||
IN_PROGRESS: ["COMPLETED", "FAILED"],
|
||||
COMPLETED: [],
|
||||
FAILED: ["PENDING", "IN_PROGRESS"],
|
||||
};
|
||||
|
||||
function isValidStatusTransition(from: TaskStatus, to: TaskStatus): boolean {
|
||||
return VALID_STATUS_TRANSITIONS[from].includes(to);
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const apiKey = request.headers.get("x-api-key") || request.nextUrl.searchParams.get("apiKey");
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id } = await params;
|
||||
const task = await prisma.projectDiscoveryTask.findUnique({
|
||||
where: { id },
|
||||
});
|
||||
|
||||
if (!task) {
|
||||
return NextResponse.json({ success: false, error: "Task not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
task,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Discovery] Error fetching task:", error);
|
||||
return NextResponse.json({ success: false, error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PATCH(request: NextRequest, { params }: { params: Promise<{ id: string }> }) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const body = await request.json();
|
||||
const validation = UpdateDiscoveryTaskSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Validation error",
|
||||
details: validation.error.errors.map((error) => error.message),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { apiKey, status, explorationData, explorationSummary, errorMessage } = validation.data;
|
||||
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const existingTask = await prisma.projectDiscoveryTask.findUnique({
|
||||
where: { id },
|
||||
select: { status: true },
|
||||
});
|
||||
|
||||
if (!existingTask) {
|
||||
return NextResponse.json({ success: false, error: "Task not found" }, { status: 404 });
|
||||
}
|
||||
|
||||
if (!isValidStatusTransition(existingTask.status, status)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Invalid status transition",
|
||||
details: [
|
||||
`Cannot transition from ${existingTask.status} to ${status}. Valid transitions: ${VALID_STATUS_TRANSITIONS[existingTask.status].join(", ")}`,
|
||||
],
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
interface TaskUpdateData {
|
||||
status: TaskStatus;
|
||||
startedAt?: Date;
|
||||
completedAt?: Date;
|
||||
explorationData?: Prisma.InputJsonValue;
|
||||
explorationSummary?: string | null;
|
||||
errorMessage?: string | null;
|
||||
}
|
||||
|
||||
const updateData: TaskUpdateData = { status };
|
||||
|
||||
if (status === "IN_PROGRESS") {
|
||||
updateData.startedAt = new Date();
|
||||
} else if (status === "COMPLETED" || status === "FAILED") {
|
||||
updateData.completedAt = new Date();
|
||||
}
|
||||
|
||||
if (explorationData !== undefined) {
|
||||
updateData.explorationData = explorationData as Prisma.InputJsonObject;
|
||||
}
|
||||
if (explorationSummary !== undefined) {
|
||||
updateData.explorationSummary = explorationSummary;
|
||||
}
|
||||
if (errorMessage !== undefined) {
|
||||
updateData.errorMessage = errorMessage;
|
||||
}
|
||||
|
||||
const task = await prisma.projectDiscoveryTask.update({
|
||||
where: { id },
|
||||
data: updateData,
|
||||
});
|
||||
|
||||
console.warn(`[Discovery] Updated task ${id} to status: ${status}`);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
task,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Discovery] Error updating task:", error);
|
||||
return NextResponse.json({ success: false, error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { BatchResetTasksSchema } from "@/lib/validations";
|
||||
import { isValidApiKey } from "@/lib/auth";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const validation = BatchResetTasksSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Validation error",
|
||||
details: validation.error.errors.map((error) => error.message),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { apiKey, taskIds, statuses } = validation.data;
|
||||
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const where = taskIds
|
||||
? { id: { in: taskIds } }
|
||||
: { status: { in: statuses || ["IN_PROGRESS", "FAILED"] } };
|
||||
|
||||
const result = await prisma.projectDiscoveryTask.updateMany({
|
||||
where,
|
||||
data: {
|
||||
status: "PENDING",
|
||||
startedAt: null,
|
||||
completedAt: null,
|
||||
errorMessage: null,
|
||||
},
|
||||
});
|
||||
|
||||
console.warn(
|
||||
`[Discovery] Batch reset ${result.count} tasks to PENDING. Condition: ${JSON.stringify(where)}`
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
reset: result.count,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Discovery] Error batch resetting tasks:", error);
|
||||
return NextResponse.json({ success: false, error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,127 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { CreateDiscoveryTaskSchema, GetDiscoveryTasksQuerySchema } from "@/lib/validations";
|
||||
import { isValidApiKey } from "@/lib/auth";
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const validation = CreateDiscoveryTaskSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Validation error",
|
||||
details: validation.error.errors.map((error) => error.message),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { apiKey, tasks } = validation.data;
|
||||
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const existingUrls = new Set(
|
||||
(
|
||||
await prisma.projectDiscoveryTask.findMany({
|
||||
where: { sourceUrl: { in: tasks.map((task) => task.sourceUrl) } },
|
||||
select: { sourceUrl: true },
|
||||
})
|
||||
).map((task) => task.sourceUrl)
|
||||
);
|
||||
|
||||
const newTasks = tasks.filter((task) => !existingUrls.has(task.sourceUrl));
|
||||
|
||||
if (newTasks.length === 0) {
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
created: 0,
|
||||
skipped: tasks.length,
|
||||
total: tasks.length,
|
||||
message: "All tasks already exist",
|
||||
});
|
||||
}
|
||||
|
||||
const created = await prisma.projectDiscoveryTask.createMany({
|
||||
data: newTasks.map((task) => ({
|
||||
sourceUrl: task.sourceUrl,
|
||||
sourceType: task.sourceType,
|
||||
status: "PENDING",
|
||||
})),
|
||||
});
|
||||
|
||||
console.warn(
|
||||
`[Discovery] Created ${created.count} tasks, skipped ${tasks.length - created.count} existing tasks`
|
||||
);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
created: created.count,
|
||||
skipped: tasks.length - created.count,
|
||||
total: tasks.length,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Discovery] Error creating tasks:", error);
|
||||
return NextResponse.json({ success: false, error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const apiKey = request.headers.get("x-api-key") || searchParams.get("apiKey");
|
||||
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json({ success: false, error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
|
||||
const validation = GetDiscoveryTasksQuerySchema.safeParse({
|
||||
status: searchParams.get("status") || undefined,
|
||||
limit: searchParams.get("limit") || "10",
|
||||
offset: searchParams.get("offset") || "0",
|
||||
});
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: "Validation error",
|
||||
details: validation.error.errors.map((error) => error.message),
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
const { status, limit, offset } = validation.data;
|
||||
|
||||
let whereClause = {};
|
||||
if (status) {
|
||||
whereClause = Array.isArray(status) ? { status: { in: status } } : { status };
|
||||
}
|
||||
|
||||
const tasks = await prisma.projectDiscoveryTask.findMany({
|
||||
where: whereClause,
|
||||
orderBy: { createdAt: "asc" },
|
||||
take: limit,
|
||||
skip: offset,
|
||||
});
|
||||
|
||||
const total = await prisma.projectDiscoveryTask.count({
|
||||
where: whereClause,
|
||||
});
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
tasks,
|
||||
total,
|
||||
hasMore: offset + tasks.length < total,
|
||||
});
|
||||
} catch (error) {
|
||||
console.error("[Discovery] Error fetching tasks:", error);
|
||||
return NextResponse.json({ success: false, error: "Internal server error" }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,91 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { POST } from "./route";
|
||||
|
||||
const { createMock } = vi.hoisted(() => ({
|
||||
createMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
projectSubmission: {
|
||||
create: createMock,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
function buildRequest(body: unknown): Request {
|
||||
return new Request("http://localhost:3000/api/project-submissions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/project-submissions", () => {
|
||||
beforeEach(() => {
|
||||
createMock.mockReset();
|
||||
});
|
||||
|
||||
it("creates a pending project submission", async () => {
|
||||
createMock.mockResolvedValueOnce({
|
||||
id: "submission_1",
|
||||
status: "PENDING",
|
||||
});
|
||||
|
||||
const response = await POST(
|
||||
buildRequest({
|
||||
url: "HTTPS://GitHub.com/Owner/Repo/?utm_source=agentpark#readme",
|
||||
projectName: "Agent Repo",
|
||||
locale: "en",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
ok: true,
|
||||
id: "submission_1",
|
||||
status: "PENDING",
|
||||
});
|
||||
expect(response.status).toBe(201);
|
||||
expect(createMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
data: expect.objectContaining({
|
||||
url: "HTTPS://GitHub.com/Owner/Repo/?utm_source=agentpark#readme",
|
||||
normalizedUrl: "https://github.com/Owner/Repo",
|
||||
projectName: "Agent Repo",
|
||||
locale: "en",
|
||||
}),
|
||||
})
|
||||
);
|
||||
});
|
||||
|
||||
it("returns 400 for invalid submissions", async () => {
|
||||
const response = await POST(buildRequest({ url: "mailto:test@example.com" }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(400);
|
||||
expect(body.ok).toBe(false);
|
||||
expect(body.error).toBe("invalid_submission");
|
||||
});
|
||||
|
||||
it("returns 409 for duplicate normalized URLs", async () => {
|
||||
createMock.mockRejectedValueOnce(
|
||||
new Prisma.PrismaClientKnownRequestError("Unique constraint failed", {
|
||||
code: "P2002",
|
||||
clientVersion: "test",
|
||||
})
|
||||
);
|
||||
|
||||
const response = await POST(
|
||||
buildRequest({
|
||||
url: "https://github.com/example/project",
|
||||
})
|
||||
);
|
||||
|
||||
await expect(response.json()).resolves.toEqual({
|
||||
ok: false,
|
||||
error: "duplicate_submission",
|
||||
});
|
||||
expect(response.status).toBe(409);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { NextResponse } from "next/server";
|
||||
import { ZodError } from "zod";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import {
|
||||
ProjectSubmissionInputSchema,
|
||||
normalizeSubmissionUrl,
|
||||
} from "@/lib/project-submissions";
|
||||
|
||||
export async function POST(request: Request) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
const validated = ProjectSubmissionInputSchema.parse(body);
|
||||
const normalizedUrl = normalizeSubmissionUrl(validated.url);
|
||||
|
||||
const submission = await prisma.projectSubmission.create({
|
||||
data: {
|
||||
url: validated.url,
|
||||
normalizedUrl,
|
||||
projectName: validated.projectName,
|
||||
description: validated.description,
|
||||
submitterName: validated.submitterName,
|
||||
submitterEmail: validated.submitterEmail,
|
||||
locale: validated.locale,
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
status: true,
|
||||
},
|
||||
});
|
||||
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: true,
|
||||
id: submission.id,
|
||||
status: submission.status,
|
||||
},
|
||||
{ status: 201 }
|
||||
);
|
||||
} catch (error) {
|
||||
if (
|
||||
error instanceof Prisma.PrismaClientKnownRequestError &&
|
||||
error.code === "P2002"
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "duplicate_submission",
|
||||
},
|
||||
{ status: 409 }
|
||||
);
|
||||
}
|
||||
|
||||
if (error instanceof ZodError) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "invalid_submission",
|
||||
details: error.errors,
|
||||
},
|
||||
{ status: 400 }
|
||||
);
|
||||
}
|
||||
|
||||
console.error("Project submission error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
ok: false,
|
||||
error: "submission_failed",
|
||||
message: error instanceof Error ? error.message : "Unknown error",
|
||||
},
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -6,7 +6,7 @@ import { prisma } from "@/lib/prisma";
|
||||
*
|
||||
* 根据项目的 slug 获取项目详情
|
||||
*/
|
||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
export async function GET(_request: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||
try {
|
||||
const { slug } = await params;
|
||||
|
||||
|
||||
@@ -0,0 +1,64 @@
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
async function loadPost(webhookUrl?: string) {
|
||||
vi.resetModules();
|
||||
vi.doMock("@/hooks/useProjects", () => ({
|
||||
getProjectsByIds: vi.fn(),
|
||||
}));
|
||||
|
||||
if (webhookUrl) {
|
||||
process.env.N8N_AI_SEARCH_WEBHOOK = webhookUrl;
|
||||
} else {
|
||||
delete process.env.N8N_AI_SEARCH_WEBHOOK;
|
||||
}
|
||||
|
||||
const route = await import("./route");
|
||||
return route.POST;
|
||||
}
|
||||
|
||||
function buildRequest(body: unknown): Request {
|
||||
return new Request("http://localhost:3000/api/search/ai", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
}
|
||||
|
||||
describe("POST /api/search/ai", () => {
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
vi.resetModules();
|
||||
delete process.env.N8N_AI_SEARCH_WEBHOOK;
|
||||
});
|
||||
|
||||
it("returns a fallback-recommended 503 when webhook is not configured", async () => {
|
||||
const POST = await loadPost();
|
||||
const response = await POST(buildRequest({ search: "claude", page: 1, limit: 10 }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(503);
|
||||
expect(body).toMatchObject({
|
||||
error: "ai_search_unavailable",
|
||||
fallbackRecommended: true,
|
||||
});
|
||||
});
|
||||
|
||||
it("returns a fallback-recommended 502 when the upstream workflow fails", async () => {
|
||||
vi.spyOn(global, "fetch").mockResolvedValueOnce(
|
||||
new Response('{"message":"Error in workflow"}', {
|
||||
status: 500,
|
||||
statusText: "Internal Server Error",
|
||||
})
|
||||
);
|
||||
|
||||
const POST = await loadPost("https://n8n.example.test/search");
|
||||
const response = await POST(buildRequest({ search: "claude", page: 1, limit: 10 }));
|
||||
const body = await response.json();
|
||||
|
||||
expect(response.status).toBe(502);
|
||||
expect(body).toMatchObject({
|
||||
error: "ai_search_upstream_failed",
|
||||
fallbackRecommended: true,
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -3,11 +3,7 @@ import { ZodError, z } from 'zod'
|
||||
import { ProjectQuerySchema } from '@/lib/validations'
|
||||
import { getProjectsByIds, type AISearchResultItem } from '@/hooks/useProjects'
|
||||
|
||||
const N8N_WEBHOOK_URL = process.env.N8N_AI_SEARCH_WEBHOOK!
|
||||
|
||||
if (!N8N_WEBHOOK_URL) {
|
||||
throw new Error('N8N_AI_SEARCH_WEBHOOK environment variable is not set')
|
||||
}
|
||||
const N8N_WEBHOOK_URL = process.env.N8N_AI_SEARCH_WEBHOOK
|
||||
|
||||
// n8n 返回的搜索结果 Schema(统一格式)
|
||||
const N8NSearchResponseSchema = z.object({
|
||||
@@ -55,6 +51,17 @@ export async function POST(request: Request) {
|
||||
|
||||
// 验证查询参数
|
||||
const validatedQuery = AISearchRequestSchema.parse(body)
|
||||
|
||||
if (!N8N_WEBHOOK_URL) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'ai_search_unavailable',
|
||||
message: 'AI search webhook is not configured',
|
||||
fallbackRecommended: true,
|
||||
},
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
const page = Math.max(1, validatedQuery.page)
|
||||
const limit = Math.max(1, validatedQuery.limit)
|
||||
const fetchLimit = Math.min(100, Math.max(page * limit + 1, limit + 1))
|
||||
@@ -91,7 +98,14 @@ export async function POST(request: Request) {
|
||||
|
||||
if (!n8nResponse.ok) {
|
||||
const errorText = await n8nResponse.text()
|
||||
throw new Error(`n8n webhook failed: ${n8nResponse.statusText} - ${errorText}`)
|
||||
return NextResponse.json(
|
||||
{
|
||||
error: 'ai_search_upstream_failed',
|
||||
message: `n8n webhook failed: ${n8nResponse.statusText} - ${errorText}`,
|
||||
fallbackRecommended: true,
|
||||
},
|
||||
{ status: 502 }
|
||||
)
|
||||
}
|
||||
|
||||
// n8n 返回数据
|
||||
@@ -217,8 +231,12 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
return NextResponse.json(
|
||||
{ error: 'AI search failed', message: error instanceof Error ? error.message : 'Unknown error' },
|
||||
{ status: 500 }
|
||||
{
|
||||
error: 'ai_search_failed',
|
||||
message: error instanceof Error ? error.message : 'Unknown error',
|
||||
fallbackRecommended: true,
|
||||
},
|
||||
{ status: 503 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -271,6 +271,10 @@ export async function GET(request: NextRequest) {
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
const facetWhere: Prisma.SignalWhereInput = {
|
||||
...where,
|
||||
source: undefined,
|
||||
}
|
||||
|
||||
const hasHotColumns = await supportsSignalHotColumns(prisma)
|
||||
const orderByWithHot: Prisma.SignalOrderByWithRelationInput[] =
|
||||
@@ -282,6 +286,30 @@ export async function GET(request: NextRequest) {
|
||||
? [{ engagement: 'desc' }, { publishedAt: 'desc' }, { id: 'desc' }]
|
||||
: [{ publishedAt: 'desc' }, { id: 'desc' }]
|
||||
|
||||
const [totalCount, newestSignal, sourceCountRows, hotCount] = await Promise.all([
|
||||
prisma.signal.count({ where }),
|
||||
prisma.signal.findFirst({
|
||||
where,
|
||||
orderBy: { publishedAt: 'desc' },
|
||||
select: { publishedAt: true },
|
||||
}),
|
||||
prisma.signal.groupBy({
|
||||
by: ['source'] as const,
|
||||
where: facetWhere,
|
||||
_count: {
|
||||
_all: true,
|
||||
},
|
||||
}),
|
||||
hasHotColumns
|
||||
? prisma.signal.count({
|
||||
where: {
|
||||
...where,
|
||||
isHot: true,
|
||||
},
|
||||
})
|
||||
: Promise.resolve(0),
|
||||
])
|
||||
|
||||
let rows: SignalQueryRow[]
|
||||
|
||||
if (hasHotColumns) {
|
||||
@@ -331,6 +359,19 @@ export async function GET(request: NextRequest) {
|
||||
items,
|
||||
nextCursor,
|
||||
hasMore,
|
||||
meta: {
|
||||
totalCount,
|
||||
hotCount,
|
||||
newestPublishedAt: newestSignal?.publishedAt.toISOString() || null,
|
||||
},
|
||||
facets: {
|
||||
sourceCounts: sourceCountRows.reduce<Record<string, number>>((acc, row) => {
|
||||
if (isSignalSource(row.source)) {
|
||||
acc[row.source] = row._count._all
|
||||
}
|
||||
return acc
|
||||
}, {}),
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
if (error instanceof ZodError) {
|
||||
|
||||
@@ -2,8 +2,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { POST } from "./route";
|
||||
|
||||
type MaintenanceRouteTxMock = {
|
||||
tag: {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
findUnique: ReturnType<typeof vi.fn>;
|
||||
update: ReturnType<typeof vi.fn>;
|
||||
create: ReturnType<typeof vi.fn>;
|
||||
deleteMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
projectTag: {
|
||||
findMany: ReturnType<typeof vi.fn>;
|
||||
createMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
const { transactionMock, revalidatePathMock, txMock } = vi.hoisted(() => {
|
||||
const tx = {
|
||||
const tx: MaintenanceRouteTxMock = {
|
||||
tag: {
|
||||
findMany: vi.fn(),
|
||||
findUnique: vi.fn(),
|
||||
@@ -18,7 +32,7 @@ const { transactionMock, revalidatePathMock, txMock } = vi.hoisted(() => {
|
||||
};
|
||||
|
||||
return {
|
||||
transactionMock: vi.fn(async (callback: (tx: typeof tx) => unknown) => callback(tx)),
|
||||
transactionMock: vi.fn(async (callback: (tx: MaintenanceRouteTxMock) => unknown) => callback(tx)),
|
||||
revalidatePathMock: vi.fn(),
|
||||
txMock: tx,
|
||||
};
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { executeTagMaintenance, TagMaintenanceApiError } from "./service";
|
||||
import { executeTagMaintenance } from "./service";
|
||||
|
||||
function createTxMock() {
|
||||
return {
|
||||
@@ -34,7 +34,7 @@ describe("executeTagMaintenance", () => {
|
||||
tx.projectTag.createMany.mockResolvedValue({ count: 2 });
|
||||
tx.tag.deleteMany.mockResolvedValue({ count: 2 });
|
||||
|
||||
const result = await executeTagMaintenance(tx as Parameters<typeof executeTagMaintenance>[0], {
|
||||
const result = await executeTagMaintenance(tx as unknown as Parameters<typeof executeTagMaintenance>[0], {
|
||||
updates: [],
|
||||
merges: [
|
||||
{
|
||||
@@ -70,11 +70,11 @@ describe("executeTagMaintenance", () => {
|
||||
tx.tag.findMany.mockResolvedValue([]);
|
||||
|
||||
await expect(
|
||||
executeTagMaintenance(tx as Parameters<typeof executeTagMaintenance>[0], {
|
||||
executeTagMaintenance(tx as unknown as Parameters<typeof executeTagMaintenance>[0], {
|
||||
updates: [{ tagId: "missing-tag", nameEn: "Missing" }],
|
||||
merges: [],
|
||||
})
|
||||
).rejects.toMatchObject<TagMaintenanceApiError>({
|
||||
).rejects.toMatchObject({
|
||||
status: 400,
|
||||
message: "Validation error",
|
||||
});
|
||||
|
||||
@@ -2,9 +2,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { NextRequest } from "next/server";
|
||||
import { POST } from "./route";
|
||||
|
||||
type ResetProjectsRouteTxMock = {
|
||||
projectTag: {
|
||||
deleteMany: ReturnType<typeof vi.fn>;
|
||||
createMany: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
};
|
||||
|
||||
const { revalidatePathMock, transactionMock, txMock, projectFindManyMock, tagFindManyMock } =
|
||||
vi.hoisted(() => {
|
||||
const tx = {
|
||||
const tx: ResetProjectsRouteTxMock = {
|
||||
projectTag: {
|
||||
deleteMany: vi.fn(),
|
||||
createMany: vi.fn(),
|
||||
@@ -13,7 +20,7 @@ const { revalidatePathMock, transactionMock, txMock, projectFindManyMock, tagFin
|
||||
|
||||
return {
|
||||
revalidatePathMock: vi.fn(),
|
||||
transactionMock: vi.fn(async (callback: (tx: typeof tx) => unknown) => callback(tx)),
|
||||
transactionMock: vi.fn(async (callback: (tx: ResetProjectsRouteTxMock) => unknown) => callback(tx)),
|
||||
txMock: tx,
|
||||
projectFindManyMock: vi.fn(),
|
||||
tagFindManyMock: vi.fn(),
|
||||
@@ -88,7 +95,7 @@ describe("POST /api/tags/reset-projects", () => {
|
||||
|
||||
it("returns 400 when FIXED_PROJECT_TYPE is missing", async () => {
|
||||
const payload = buildValidPayload(validApiKey);
|
||||
payload.projects[0].selectedTagSlugsByCategory.FIXED_PROJECT_TYPE = [];
|
||||
payload.projects[0]!.selectedTagSlugsByCategory.FIXED_PROJECT_TYPE = [];
|
||||
|
||||
const response = await POST(buildRequest(payload));
|
||||
const json = await response.json();
|
||||
|
||||
@@ -1,105 +1,93 @@
|
||||
import { NextRequest, NextResponse } from 'next/server'
|
||||
import crypto from 'crypto'
|
||||
import { revalidatePath } from 'next/cache'
|
||||
import type { TagCategory } from '@prisma/client'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { revalidatePath } from "next/cache";
|
||||
import type { TagCategory } from "@prisma/client";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { isValidApiKey } from "@/lib/auth";
|
||||
import {
|
||||
ProjectTagResetRequestSchema,
|
||||
type ProjectTagResetItem,
|
||||
type ResettableTagCategory,
|
||||
} from '@/lib/validations'
|
||||
} from "@/lib/validations";
|
||||
|
||||
type ResetResultItem = {
|
||||
projectSlug: string
|
||||
status: 'updated' | 'dry-run' | 'failed'
|
||||
selectedTagCount: number
|
||||
addedCount: number
|
||||
removedCount: number
|
||||
details: string[]
|
||||
}
|
||||
projectSlug: string;
|
||||
status: "updated" | "dry-run" | "failed";
|
||||
selectedTagCount: number;
|
||||
addedCount: number;
|
||||
removedCount: number;
|
||||
details: string[];
|
||||
};
|
||||
|
||||
const DEFAULT_RESET_CATEGORIES: ResettableTagCategory[] = [
|
||||
'FIXED_PROJECT_TYPE',
|
||||
'TECH_STACK',
|
||||
'AI_PARADIGM',
|
||||
'PRODUCT_FORM',
|
||||
'DOMAIN_SCENARIO',
|
||||
]
|
||||
|
||||
function isApiKeyValid(providedApiKey: string, expectedApiKey?: string): boolean {
|
||||
if (!expectedApiKey) {
|
||||
return false
|
||||
}
|
||||
const providedBuf = Buffer.from(providedApiKey)
|
||||
const expectedBuf = Buffer.from(expectedApiKey)
|
||||
return (
|
||||
providedBuf.length === expectedBuf.length &&
|
||||
crypto.timingSafeEqual(providedBuf, expectedBuf)
|
||||
)
|
||||
}
|
||||
"FIXED_PROJECT_TYPE",
|
||||
"TECH_STACK",
|
||||
"AI_PARADIGM",
|
||||
"PRODUCT_FORM",
|
||||
"DOMAIN_SCENARIO",
|
||||
];
|
||||
|
||||
function normalizeSlug(slug: string): string {
|
||||
return slug.trim().toLowerCase()
|
||||
return slug.trim().toLowerCase();
|
||||
}
|
||||
|
||||
function collectSelectedTagSlugs(
|
||||
item: ProjectTagResetItem,
|
||||
categories: ResettableTagCategory[]
|
||||
): string[] {
|
||||
const selectedTagSlugSet = new Set<string>()
|
||||
const selectedTagSlugSet = new Set<string>();
|
||||
|
||||
for (const category of categories) {
|
||||
const categorySlugs = item.selectedTagSlugsByCategory[category] || []
|
||||
const categorySlugs = item.selectedTagSlugsByCategory[category] || [];
|
||||
for (const slug of categorySlugs) {
|
||||
selectedTagSlugSet.add(normalizeSlug(slug))
|
||||
selectedTagSlugSet.add(normalizeSlug(slug));
|
||||
}
|
||||
}
|
||||
|
||||
return [...selectedTagSlugSet]
|
||||
return [...selectedTagSlugSet];
|
||||
}
|
||||
|
||||
function collectValidationErrorsForProjectItem(params: {
|
||||
item: ProjectTagResetItem
|
||||
categories: ResettableTagCategory[]
|
||||
existingTagBySlug: Map<string, { id: string; slug: string; category: TagCategory }>
|
||||
item: ProjectTagResetItem;
|
||||
categories: ResettableTagCategory[];
|
||||
existingTagBySlug: Map<string, { id: string; slug: string; category: TagCategory }>;
|
||||
}): string[] {
|
||||
const { item, categories, existingTagBySlug } = params
|
||||
const errors: string[] = []
|
||||
const { item, categories, existingTagBySlug } = params;
|
||||
const errors: string[] = [];
|
||||
|
||||
for (const category of categories) {
|
||||
for (const rawSlug of item.selectedTagSlugsByCategory[category] || []) {
|
||||
const normalizedSlug = normalizeSlug(rawSlug)
|
||||
const existingTag = existingTagBySlug.get(normalizedSlug)
|
||||
const normalizedSlug = normalizeSlug(rawSlug);
|
||||
const existingTag = existingTagBySlug.get(normalizedSlug);
|
||||
|
||||
if (!existingTag) {
|
||||
errors.push(`Unknown tag slug "${rawSlug}" in category ${category}`)
|
||||
continue
|
||||
errors.push(`Unknown tag slug "${rawSlug}" in category ${category}`);
|
||||
continue;
|
||||
}
|
||||
if (existingTag.category !== category) {
|
||||
errors.push(
|
||||
`Tag slug "${rawSlug}" belongs to ${existingTag.category}, expected ${category}`
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return errors
|
||||
return errors;
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
const validation = ProjectTagResetRequestSchema.safeParse(body)
|
||||
const body = await request.json();
|
||||
const validation = ProjectTagResetRequestSchema.safeParse(body);
|
||||
|
||||
if (!validation.success) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Validation error',
|
||||
error: "Validation error",
|
||||
details: validation.error.errors.map((issue) => issue.message),
|
||||
},
|
||||
{ status: 400 }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const {
|
||||
@@ -108,27 +96,26 @@ export async function POST(request: NextRequest) {
|
||||
replaceAllCategories,
|
||||
projects,
|
||||
categories: requestedCategories,
|
||||
} = validation.data
|
||||
} = validation.data;
|
||||
|
||||
if (!isApiKeyValid(apiKey, process.env.WEBHOOK_API_KEY)) {
|
||||
if (!isValidApiKey(apiKey)) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Unauthorized',
|
||||
details: ['Invalid or missing API Key'],
|
||||
error: "Unauthorized",
|
||||
details: ["Invalid or missing API Key"],
|
||||
},
|
||||
{ status: 401 }
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const categories = requestedCategories.length > 0
|
||||
? requestedCategories
|
||||
: DEFAULT_RESET_CATEGORIES
|
||||
const categories =
|
||||
requestedCategories.length > 0 ? requestedCategories : DEFAULT_RESET_CATEGORIES;
|
||||
|
||||
const normalizedProjectSlugs = projects.map((item) => normalizeSlug(item.projectSlug))
|
||||
const normalizedProjectSlugs = projects.map((item) => normalizeSlug(item.projectSlug));
|
||||
const normalizedSelectedTagSlugs = [
|
||||
...new Set(projects.flatMap((item) => collectSelectedTagSlugs(item, categories))),
|
||||
]
|
||||
];
|
||||
|
||||
const [existingProjects, existingTags] = await Promise.all([
|
||||
prisma.project.findMany({
|
||||
@@ -157,63 +144,67 @@ export async function POST(request: NextRequest) {
|
||||
category: true,
|
||||
},
|
||||
}),
|
||||
])
|
||||
]);
|
||||
|
||||
const projectBySlug = new Map(existingProjects.map((project) => [project.slug, project]))
|
||||
const existingTagBySlug = new Map(existingTags.map((tag) => [tag.slug, tag]))
|
||||
const projectBySlug = new Map(existingProjects.map((project) => [project.slug, project]));
|
||||
const existingTagBySlug = new Map(existingTags.map((tag) => [tag.slug, tag]));
|
||||
|
||||
const results: ResetResultItem[] = []
|
||||
const updatedProjectSlugs: string[] = []
|
||||
const results: ResetResultItem[] = [];
|
||||
const updatedProjectSlugs: string[] = [];
|
||||
|
||||
for (const item of projects) {
|
||||
const projectSlug = normalizeSlug(item.projectSlug)
|
||||
const project = projectBySlug.get(projectSlug)
|
||||
const projectSlug = normalizeSlug(item.projectSlug);
|
||||
const project = projectBySlug.get(projectSlug);
|
||||
|
||||
if (!project) {
|
||||
results.push({
|
||||
projectSlug,
|
||||
status: 'failed',
|
||||
status: "failed",
|
||||
selectedTagCount: 0,
|
||||
addedCount: 0,
|
||||
removedCount: 0,
|
||||
details: [`Project with slug "${projectSlug}" not found`],
|
||||
})
|
||||
continue
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const validationErrors = collectValidationErrorsForProjectItem({
|
||||
item,
|
||||
categories,
|
||||
existingTagBySlug,
|
||||
})
|
||||
});
|
||||
|
||||
if (validationErrors.length > 0) {
|
||||
results.push({
|
||||
projectSlug,
|
||||
status: 'failed',
|
||||
status: "failed",
|
||||
selectedTagCount: 0,
|
||||
addedCount: 0,
|
||||
removedCount: 0,
|
||||
details: validationErrors,
|
||||
})
|
||||
continue
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
const selectedTagIds = collectSelectedTagSlugs(item, categories)
|
||||
.map((slug) => existingTagBySlug.get(slug)?.id)
|
||||
.filter((id): id is string => Boolean(id))
|
||||
.filter((id): id is string => Boolean(id));
|
||||
|
||||
const previousCategoryTagIds = new Set(
|
||||
project.tags
|
||||
.filter((projectTag) => categories.includes(projectTag.tag.category as ResettableTagCategory))
|
||||
.filter((projectTag) =>
|
||||
categories.includes(projectTag.tag.category as ResettableTagCategory)
|
||||
)
|
||||
.map((projectTag) => projectTag.tag.id)
|
||||
)
|
||||
);
|
||||
|
||||
const nextTagIdSet = new Set(selectedTagIds)
|
||||
const nextTagIdSet = new Set(selectedTagIds);
|
||||
const removedCount = replaceAllCategories
|
||||
? [...previousCategoryTagIds].filter((tagId) => !nextTagIdSet.has(tagId)).length
|
||||
: 0
|
||||
const addedCount = [...nextTagIdSet].filter((tagId) => !previousCategoryTagIds.has(tagId)).length
|
||||
: 0;
|
||||
const addedCount = [...nextTagIdSet].filter(
|
||||
(tagId) => !previousCategoryTagIds.has(tagId)
|
||||
).length;
|
||||
|
||||
if (!dryRun) {
|
||||
await prisma.$transaction(async (tx) => {
|
||||
@@ -227,7 +218,7 @@ export async function POST(request: NextRequest) {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (selectedTagIds.length > 0) {
|
||||
@@ -237,32 +228,32 @@ export async function POST(request: NextRequest) {
|
||||
tagId,
|
||||
})),
|
||||
skipDuplicates: true,
|
||||
})
|
||||
});
|
||||
}
|
||||
})
|
||||
updatedProjectSlugs.push(projectSlug)
|
||||
});
|
||||
updatedProjectSlugs.push(projectSlug);
|
||||
}
|
||||
|
||||
results.push({
|
||||
projectSlug,
|
||||
status: dryRun ? 'dry-run' : 'updated',
|
||||
status: dryRun ? "dry-run" : "updated",
|
||||
selectedTagCount: selectedTagIds.length,
|
||||
addedCount,
|
||||
removedCount,
|
||||
details: [],
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
const updatedCount = results.filter((item) => item.status === 'updated').length
|
||||
const dryRunCount = results.filter((item) => item.status === 'dry-run').length
|
||||
const failedCount = results.filter((item) => item.status === 'failed').length
|
||||
const updatedCount = results.filter((item) => item.status === "updated").length;
|
||||
const dryRunCount = results.filter((item) => item.status === "dry-run").length;
|
||||
const failedCount = results.filter((item) => item.status === "failed").length;
|
||||
|
||||
if (!dryRun && updatedProjectSlugs.length > 0) {
|
||||
revalidatePath('/zh/projects', 'page')
|
||||
revalidatePath('/en/projects', 'page')
|
||||
revalidatePath("/zh/projects", "page");
|
||||
revalidatePath("/en/projects", "page");
|
||||
for (const projectSlug of updatedProjectSlugs) {
|
||||
revalidatePath(`/zh/projects/${projectSlug}`, 'page')
|
||||
revalidatePath(`/en/projects/${projectSlug}`, 'page')
|
||||
revalidatePath(`/zh/projects/${projectSlug}`, "page");
|
||||
revalidatePath(`/en/projects/${projectSlug}`, "page");
|
||||
}
|
||||
}
|
||||
|
||||
@@ -278,16 +269,16 @@ export async function POST(request: NextRequest) {
|
||||
failedCount,
|
||||
results,
|
||||
},
|
||||
})
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('[POST /api/tags/reset-projects] Error:', error)
|
||||
console.error("[POST /api/tags/reset-projects] Error:", error);
|
||||
return NextResponse.json(
|
||||
{
|
||||
success: false,
|
||||
error: 'Internal server error',
|
||||
details: [error instanceof Error ? error.message : 'Unknown error'],
|
||||
error: "Internal server error",
|
||||
details: [error instanceof Error ? error.message : "Unknown error"],
|
||||
},
|
||||
{ status: 500 }
|
||||
)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+18
-18
@@ -1,31 +1,31 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { runWithCacheFallback } from "@/lib/cache";
|
||||
|
||||
const TAGS_CACHE_REVALIDATE_SECONDS = 300;
|
||||
|
||||
const getCachedTags = unstable_cache(
|
||||
async () =>
|
||||
prisma.tag.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
async function getTagsFromDb() {
|
||||
return prisma.tag.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
select: { projects: true },
|
||||
},
|
||||
orderBy: {
|
||||
name: "asc",
|
||||
},
|
||||
}),
|
||||
["api-tags:v1"],
|
||||
{
|
||||
revalidate: TAGS_CACHE_REVALIDATE_SECONDS,
|
||||
tags: ["api-tags"],
|
||||
}
|
||||
);
|
||||
},
|
||||
orderBy: {
|
||||
name: "asc",
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
const getCachedTags = unstable_cache(getTagsFromDb, ["api-tags:v1"], {
|
||||
revalidate: TAGS_CACHE_REVALIDATE_SECONDS,
|
||||
tags: ["api-tags"],
|
||||
});
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const tags = await getCachedTags();
|
||||
const tags = await runWithCacheFallback(getCachedTags, getTagsFromDb);
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
|
||||
+2
-4
@@ -1,5 +1,5 @@
|
||||
import type { Metadata } from "next"
|
||||
import { VercelMetrics } from "./VercelMetrics"
|
||||
import { UmamiMetrics } from "./UmamiMetrics"
|
||||
import "./globals.css"
|
||||
|
||||
export const metadata: Metadata = {
|
||||
@@ -7,8 +7,6 @@ export const metadata: Metadata = {
|
||||
description: "发现和探索全网优质 AI 项目",
|
||||
}
|
||||
|
||||
const isVercelProduction = process.env.VERCEL_ENV === "production"
|
||||
|
||||
export default function RootLayout({
|
||||
children,
|
||||
}: Readonly<{
|
||||
@@ -18,7 +16,7 @@ export default function RootLayout({
|
||||
<html lang="zh" suppressHydrationWarning>
|
||||
<body className="font-sans antialiased">
|
||||
{children}
|
||||
{isVercelProduction ? <VercelMetrics /> : null}
|
||||
<UmamiMetrics />
|
||||
</body>
|
||||
</html>
|
||||
)
|
||||
|
||||
+1
-1
@@ -1,7 +1,7 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
|
||||
export default function robots(): MetadataRoute.Robots {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://agentpark.ai'
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://agentpark.fun'
|
||||
|
||||
return {
|
||||
rules: [
|
||||
|
||||
+39
-42
@@ -1,55 +1,52 @@
|
||||
import { MetadataRoute } from 'next'
|
||||
import { getProjects } from '@/hooks/useProjects'
|
||||
import { prisma } from '@/lib/prisma'
|
||||
|
||||
const locales = ['zh', 'en'] as const
|
||||
const staticPaths = ['', '/projects', '/signals', '/about', '/submit', '/newsletter', '/updates', '/docs', '/privacy', '/terms'] as const
|
||||
|
||||
export default async function sitemap(): Promise<MetadataRoute.Sitemap> {
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://agentpark.ai'
|
||||
const baseUrl = process.env.NEXT_PUBLIC_SITE_URL || 'https://agentpark.fun'
|
||||
|
||||
// 获取所有项目
|
||||
const { projects } = await getProjects({ limit: 1000 })
|
||||
let projects: Array<{ slug: string; updatedAt: Date }> = []
|
||||
try {
|
||||
projects = await prisma.project.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
},
|
||||
select: {
|
||||
slug: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: {
|
||||
updatedAt: 'desc',
|
||||
},
|
||||
})
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[sitemap] degraded to static pages:',
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
}
|
||||
|
||||
// 静态页面
|
||||
const staticPages: MetadataRoute.Sitemap = [
|
||||
{
|
||||
url: `${baseUrl}/zh`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/en`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily',
|
||||
priority: 1,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/zh/projects`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.9,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/en/projects`,
|
||||
lastModified: new Date(),
|
||||
changeFrequency: 'daily',
|
||||
priority: 0.9,
|
||||
},
|
||||
]
|
||||
const now = new Date()
|
||||
const staticPages: MetadataRoute.Sitemap = locales.flatMap((locale) =>
|
||||
staticPaths.map((path) => ({
|
||||
url: `${baseUrl}/${locale}${path}`,
|
||||
lastModified: now,
|
||||
changeFrequency: 'daily' as const,
|
||||
priority: path === '' ? 1 : path === '/projects' ? 0.9 : 0.7,
|
||||
}))
|
||||
)
|
||||
|
||||
// 项目详情页(中英文)
|
||||
const projectPages: MetadataRoute.Sitemap = projects.flatMap((project) => [
|
||||
{
|
||||
url: `${baseUrl}/zh/projects/${project.slug}`,
|
||||
const projectPages: MetadataRoute.Sitemap = projects.flatMap((project) =>
|
||||
locales.map((locale) => ({
|
||||
url: `${baseUrl}/${locale}/projects/${project.slug}`,
|
||||
lastModified: project.updatedAt,
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
},
|
||||
{
|
||||
url: `${baseUrl}/en/projects/${project.slug}`,
|
||||
lastModified: project.updatedAt,
|
||||
changeFrequency: 'weekly' as const,
|
||||
priority: 0.8,
|
||||
},
|
||||
])
|
||||
}))
|
||||
)
|
||||
|
||||
return [...staticPages, ...projectPages]
|
||||
}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from 'next/link'
|
||||
import type { HomeProjectSummary } from '@/hooks/useHome'
|
||||
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
|
||||
|
||||
interface HomeRecentTimelineProps {
|
||||
locale: string
|
||||
@@ -66,7 +67,7 @@ export function HomeRecentTimeline({
|
||||
href={`/${locale}/projects?tag=${tag.slug}`}
|
||||
className="px-2 py-1 border border-black dark:border-gray-600 text-[10px] uppercase font-display font-bold bg-white dark:bg-surface-dark"
|
||||
>
|
||||
{locale === 'en' && tag.nameEn ? tag.nameEn : tag.name}
|
||||
{getLocalizedTagName(tag, locale)}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import Link from 'next/link'
|
||||
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
|
||||
|
||||
interface HomeTagInsightsProps {
|
||||
locale: string
|
||||
@@ -48,7 +49,7 @@ export function HomeTagInsights({
|
||||
href={`/${locale}/projects?tag=${tag.slug}`}
|
||||
className="shrink-0 px-3 py-2 border-2 border-black dark:border-gray-600 bg-white dark:bg-surface-dark text-xs font-display font-bold uppercase shadow-neo-sm hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all"
|
||||
>
|
||||
<span>{locale === 'en' && tag.nameEn ? tag.nameEn : tag.name}</span>
|
||||
<span>{getLocalizedTagName(tag, locale)}</span>
|
||||
<span className="ml-2 text-gray-500 dark:text-gray-400">
|
||||
{formatNumber(tag.projectCount, locale)}
|
||||
</span>
|
||||
|
||||
@@ -38,7 +38,7 @@ export function AnnouncementBar({ text, href, locale, closeLabel }: Announcement
|
||||
className="bg-primary w-full py-2 px-4 border-b-2 border-black dark:border-gray-600 flex justify-center items-center text-xs font-bold font-display tracking-wide text-black hover:opacity-90 transition-opacity relative"
|
||||
>
|
||||
<span>{text}</span>
|
||||
<span className="material-icons text-sm align-middle ml-1">arrow_forward</span>
|
||||
<span className="material-icons text-sm align-middle ml-1" aria-hidden="true">arrow_forward</span>
|
||||
|
||||
{/* Close button */}
|
||||
<button
|
||||
@@ -46,7 +46,7 @@ export function AnnouncementBar({ text, href, locale, closeLabel }: Announcement
|
||||
className="absolute right-4 hover:bg-black/10 rounded-full p-1 transition-colors"
|
||||
aria-label={closeLabel}
|
||||
>
|
||||
<span className="material-icons text-sm">close</span>
|
||||
<span className="material-icons text-sm" aria-hidden="true">close</span>
|
||||
</button>
|
||||
</Link>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface NewsletterSignupProps {
|
||||
labels: {
|
||||
email: string;
|
||||
subscribe: string;
|
||||
consent: string;
|
||||
unavailable: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function NewsletterSignup({ labels }: NewsletterSignupProps) {
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
return (
|
||||
<form
|
||||
className="space-y-4 max-w-md"
|
||||
onSubmit={(event) => {
|
||||
event.preventDefault();
|
||||
setMessage(labels.unavailable);
|
||||
}}
|
||||
>
|
||||
<input
|
||||
className="w-full bg-background-light border-2 border-black p-3 font-display text-sm placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-black"
|
||||
placeholder={labels.email}
|
||||
type="email"
|
||||
id="newsletter-email"
|
||||
name="email"
|
||||
autoComplete="email"
|
||||
required
|
||||
/>
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
className="w-4 h-4 border-2 border-black text-black focus:ring-0"
|
||||
id="consent"
|
||||
name="consent"
|
||||
type="checkbox"
|
||||
required
|
||||
/>
|
||||
<label className="text-xs font-bold font-display text-black" htmlFor="consent">
|
||||
{labels.consent}
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
className="bg-white text-black font-display font-bold py-3 px-8 border-2 border-black shadow-neo hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all"
|
||||
type="submit"
|
||||
>
|
||||
{labels.subscribe}
|
||||
</button>
|
||||
{message ? (
|
||||
<p className="border-2 border-black bg-white px-3 py-2 font-display text-xs font-bold text-black">
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useState } from "react";
|
||||
import { LocaleSwitcher } from "@/components/locale/LocaleSwitcher";
|
||||
|
||||
interface SiteHeaderProps {
|
||||
locale: string;
|
||||
labels: {
|
||||
home: string;
|
||||
projects: string;
|
||||
signals: string;
|
||||
about: string;
|
||||
submitProject: string;
|
||||
menu: string;
|
||||
closeMenu: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function SiteHeader({ locale, labels }: SiteHeaderProps) {
|
||||
const [isMenuOpen, setIsMenuOpen] = useState(false);
|
||||
const mobileMenuId = "site-mobile-menu";
|
||||
const switchUrl = `/${locale === "zh" ? "en" : "zh"}`;
|
||||
const submitUrl = `/${locale}/submit`;
|
||||
|
||||
const navLinks = [
|
||||
{ href: `/${locale}`, label: labels.home },
|
||||
{ href: `/${locale}/projects`, label: labels.projects },
|
||||
{ href: `/${locale}/signals`, label: labels.signals },
|
||||
{ href: `/${locale}/about`, label: labels.about },
|
||||
];
|
||||
|
||||
return (
|
||||
<header className="w-full border-b-2 border-black dark:border-gray-600 bg-background-light dark:bg-background-dark sticky top-0 z-50">
|
||||
<div className="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8">
|
||||
<div className="flex justify-between items-center h-16">
|
||||
<div className="flex-shrink-0 flex items-center gap-2">
|
||||
<span className="material-icons text-3xl" aria-hidden="true">
|
||||
smart_toy
|
||||
</span>
|
||||
<Link href={`/${locale}`} className="font-display font-bold text-xl tracking-tight">
|
||||
Agent Park
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<nav className="hidden md:flex space-x-8 items-center">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
className="font-display text-sm font-bold hover:text-secondary dark:hover:text-primary transition-colors"
|
||||
href={link.href}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="hidden md:flex items-center space-x-4">
|
||||
<LocaleSwitcher currentLocale={locale} switchUrl={switchUrl} />
|
||||
<Link
|
||||
className="bg-white dark:bg-surface-dark border-2 border-black dark:border-white px-4 py-2 font-display text-sm font-bold shadow-neo-sm hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] transition-all"
|
||||
href={submitUrl}
|
||||
>
|
||||
{labels.submitProject}
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="md:hidden flex items-center">
|
||||
<button
|
||||
type="button"
|
||||
className="text-text-light dark:text-text-dark hover:text-gray-600 focus:outline-none"
|
||||
aria-label={isMenuOpen ? labels.closeMenu : labels.menu}
|
||||
aria-expanded={isMenuOpen}
|
||||
aria-controls={mobileMenuId}
|
||||
onClick={() => setIsMenuOpen((value) => !value)}
|
||||
>
|
||||
<span className="material-icons" aria-hidden="true">
|
||||
{isMenuOpen ? "close" : "menu"}
|
||||
</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isMenuOpen ? (
|
||||
<div
|
||||
id={mobileMenuId}
|
||||
className="md:hidden border-t-2 border-black dark:border-gray-600 bg-background-light dark:bg-background-dark"
|
||||
>
|
||||
<nav className="px-4 py-4 space-y-2">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className="block border-2 border-black dark:border-gray-600 bg-white dark:bg-surface-dark px-4 py-3 font-display text-sm font-bold"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
<div className="flex items-center justify-between gap-3 pt-2">
|
||||
<LocaleSwitcher currentLocale={locale} switchUrl={switchUrl} />
|
||||
<Link
|
||||
href={submitUrl}
|
||||
className="bg-primary text-black border-2 border-black px-4 py-2 font-display text-sm font-bold"
|
||||
onClick={() => setIsMenuOpen(false)}
|
||||
>
|
||||
{labels.submitProject}
|
||||
</Link>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
) : null}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
@@ -2,7 +2,6 @@
|
||||
|
||||
import Link from 'next/link'
|
||||
|
||||
const locales = ['zh', 'en'] as const
|
||||
const localeNames: Record<string, string> = {
|
||||
zh: '中文',
|
||||
en: 'EN'
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
import { ExternalLink, LinkType } from '@prisma/client'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
|
||||
interface ExternalLinkCardProps {
|
||||
links: ExternalLink[]
|
||||
locale: string
|
||||
}
|
||||
|
||||
export async function ExternalLinkCard({ links, locale }: ExternalLinkCardProps) {
|
||||
const t = await getTranslations('project')
|
||||
|
||||
if (links.length === 0) {
|
||||
return null
|
||||
}
|
||||
|
||||
// Build type map for all link types
|
||||
const typeMap: Record<LinkType, string> = {
|
||||
WEBSITE: t('website'),
|
||||
GITHUB: t('github'),
|
||||
HUGGINGFACE: t('huggingface'),
|
||||
PAPER: t('paper'),
|
||||
}
|
||||
|
||||
// Pre-resolve all link names
|
||||
const linkItems = await Promise.all(
|
||||
links.map(async (link) => ({
|
||||
...link,
|
||||
displayName: link.title || typeMap[link.type]
|
||||
}))
|
||||
)
|
||||
|
||||
return (
|
||||
<div className="border rounded-lg p-6">
|
||||
<h3 className="text-lg font-semibold mb-4">{t('externalLinks')}</h3>
|
||||
<div className="space-y-3">
|
||||
{linkItems.map((link) => (
|
||||
<a
|
||||
key={link.id}
|
||||
href={link.url}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="flex items-center justify-between p-3 border rounded-lg hover:bg-secondary transition-colors"
|
||||
>
|
||||
<div>
|
||||
<div className="font-medium">
|
||||
{link.displayName}
|
||||
</div>
|
||||
<div className="text-sm text-muted-foreground">{link.url}</div>
|
||||
</div>
|
||||
<span className="text-primary">→</span>
|
||||
</a>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,74 +0,0 @@
|
||||
import Image from 'next/image'
|
||||
|
||||
interface GitHubBadgesProps {
|
||||
starsUrl?: string | null
|
||||
size?: 'sm' | 'md' | 'lg'
|
||||
className?: string
|
||||
}
|
||||
|
||||
const sizes = {
|
||||
sm: { width: 80, height: 20 },
|
||||
md: { width: 100, height: 20 },
|
||||
lg: { width: 120, height: 20 }
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Stars 徽章组件 - 显示 GitHub Stars 数量
|
||||
* 适用于项目详情页
|
||||
*/
|
||||
export function GitHubBadges({
|
||||
starsUrl,
|
||||
size = 'md',
|
||||
className = ''
|
||||
}: GitHubBadgesProps) {
|
||||
if (!starsUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
const { width, height } = sizes[size]
|
||||
|
||||
return (
|
||||
<div className={className}>
|
||||
<Image
|
||||
src={starsUrl}
|
||||
alt="GitHub Stars"
|
||||
width={width}
|
||||
height={height}
|
||||
unoptimized
|
||||
className="hover:opacity-80 transition-opacity rounded"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* GitHub Stars 紧凑型组件 - 用于项目卡片
|
||||
* 在较小的空间内显示 GitHub Stars 数量
|
||||
*/
|
||||
export function GitHubStatsCompact({
|
||||
starsUrl,
|
||||
className = ''
|
||||
}: {
|
||||
starsUrl?: string | null
|
||||
className?: string
|
||||
}) {
|
||||
if (!starsUrl) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div className={`flex items-center gap-1 text-xs text-gray-600 dark:text-gray-400 ${className}`}>
|
||||
<svg className="w-3 h-3" fill="currentColor" viewBox="0 0 16 16">
|
||||
<path d="M8 .25a.75.75 0 01.673.418l1.882 3.815 4.21.612a.75.75 0 01.416 1.279l-3.046 2.97.719 4.192a.75.75 0 01-1.088.791L8 12.347l-3.766 1.98a.75.75 0 01-1.088-.79l.72-4.194L.818 6.374a.75.75 0 01.416-1.28l4.21-.611L7.327.668A.75.75 0 018 .25z"/>
|
||||
</svg>
|
||||
<Image
|
||||
src={starsUrl}
|
||||
alt="Stars"
|
||||
width={60}
|
||||
height={20}
|
||||
unoptimized
|
||||
className="rounded"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import { getGitHubBadgesFromLinks } from '@/lib/github/badges'
|
||||
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
|
||||
|
||||
interface ProjectCardProps {
|
||||
project: {
|
||||
@@ -54,11 +55,6 @@ export function ProjectCard({ project, locale, featured = false, translations }:
|
||||
// Generate GitHub badge URLs
|
||||
const badges = project.links ? getGitHubBadgesFromLinks(project.links) : { stars: null }
|
||||
|
||||
// Helper to get display name for tag based on locale
|
||||
const getTagName = (tag: { name: string; nameEn?: string | null }) => {
|
||||
return locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
|
||||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`bg-white dark:bg-surface-dark border-2 border-black dark:border-gray-600 p-6 ${
|
||||
@@ -85,7 +81,7 @@ export function ProjectCard({ project, locale, featured = false, translations }:
|
||||
key={tag.id}
|
||||
className="bg-gray-100 dark:bg-gray-800 px-2 py-1 text-[10px] uppercase font-display font-bold border border-gray-300 dark:border-gray-600 text-gray-600 dark:text-gray-300"
|
||||
>
|
||||
{getTagName(tag)}
|
||||
{getLocalizedTagName(tag, locale)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -150,7 +146,7 @@ export function SubmitProjectCard({ locale, translations }: SubmitProjectCardPro
|
||||
{submitProjectDescription}
|
||||
</p>
|
||||
<Link
|
||||
href="#"
|
||||
href={`/${locale}/submit`}
|
||||
className="bg-black text-white dark:bg-black dark:text-primary border-2 border-black dark:border-black px-6 py-2 font-display text-sm font-bold uppercase hover:bg-white hover:text-black dark:hover:bg-white dark:hover:text-black transition-colors"
|
||||
>
|
||||
{submitNow}
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { MarkdownContent } from './MarkdownContent'
|
||||
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
|
||||
import { isFixedProjectTypeSlug } from '@/lib/tag-taxonomy'
|
||||
|
||||
interface ProjectDetailProps {
|
||||
@@ -56,10 +57,8 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
|
||||
const fixedTypeTag = project.tags.find((tag) => isFixedProjectTypeSlug(tag.slug))
|
||||
const category = fixedTypeTag?.name || project.tags[0]?.name || 'AI Agent'
|
||||
const categoryEn =
|
||||
fixedTypeTag?.nameEn || fixedTypeTag?.name || project.tags[0]?.nameEn || project.tags[0]?.name || 'AI Agent'
|
||||
fixedTypeTag ? getLocalizedTagName(fixedTypeTag, 'en') : project.tags[0] ? getLocalizedTagName(project.tags[0], 'en') : 'AI Agent'
|
||||
const displayCategory = locale === 'en' ? categoryEn : category
|
||||
const getTagName = (tag: { name: string; nameEn?: string | null }) =>
|
||||
locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -78,17 +77,17 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
|
||||
{/* Metadata */}
|
||||
<div className="flex flex-wrap items-center gap-4 text-sm font-mono text-gray-600 dark:text-gray-400 mb-8 pb-8 border-b border-gray-300 dark:border-gray-700">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-icons text-base">calendar_today</span>
|
||||
<span className="material-icons text-base" aria-hidden="true">calendar_today</span>
|
||||
<span>{t('addedOn', { date: formatDate(project.createdAt, locale) })}</span>
|
||||
</div>
|
||||
<span className="hidden sm:inline text-gray-300">|</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-icons text-base">category</span>
|
||||
<span className="material-icons text-base" aria-hidden="true">category</span>
|
||||
<span>{displayCategory}</span>
|
||||
</div>
|
||||
<span className="hidden sm:inline text-gray-300">|</span>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="material-icons text-base">code</span>
|
||||
<span className="material-icons text-base" aria-hidden="true">code</span>
|
||||
<span>{t('openSource')}</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -100,7 +99,7 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
|
||||
key={tag.id}
|
||||
className="px-3 py-1 border border-black dark:border-gray-500 text-xs font-display font-bold uppercase bg-white dark:bg-gray-800"
|
||||
>
|
||||
{getTagName(tag)}
|
||||
{getLocalizedTagName(tag, locale)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
@@ -116,9 +115,6 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
|
||||
{/* Full content with Markdown rendering */}
|
||||
<MarkdownContent content={displayContent ?? ''} noContentText={t('noContentAvailable')} />
|
||||
</article>
|
||||
|
||||
{/* Share and Feedback Section - Temporarily disabled for debugging */}
|
||||
{/* <ShareButtons displayName={displayName} /> */}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -37,7 +37,6 @@ function getLinkIcon(type: string): string {
|
||||
|
||||
export async function ProjectSidebar({ project, locale }: ProjectSidebarProps) {
|
||||
const t = await getTranslations('project')
|
||||
const displayName = locale === 'en' && project.nameEn ? project.nameEn : project.name
|
||||
|
||||
// 获取 GitHub 统计数据
|
||||
const githubInfo = getGitHubInfoFromLinks(project.links)
|
||||
@@ -69,10 +68,10 @@ export async function ProjectSidebar({ project, locale }: ProjectSidebarProps) {
|
||||
className="flex items-center justify-between group p-3 border border-gray-200 dark:border-gray-700 hover:border-black dark:hover:border-white transition-colors bg-gray-50 dark:bg-gray-800"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="material-icons">{icon}</span>
|
||||
<span className="material-icons" aria-hidden="true">{icon}</span>
|
||||
<span className="font-bold text-sm">{link.title || label}</span>
|
||||
</div>
|
||||
<span className="material-icons text-sm group-hover:translate-x-1 transition-transform">
|
||||
<span className="material-icons text-sm group-hover:translate-x-1 transition-transform" aria-hidden="true">
|
||||
arrow_forward
|
||||
</span>
|
||||
</a>
|
||||
@@ -111,7 +110,7 @@ export async function ProjectSidebar({ project, locale }: ProjectSidebarProps) {
|
||||
<div className="absolute bottom-0 left-0 w-16 h-16 bg-black opacity-10 rounded-full transform -translate-x-8 translate-y-8"></div>
|
||||
<div className="relative z-10 text-center">
|
||||
<div className="w-12 h-12 bg-white rounded-full border-2 border-black flex items-center justify-center mx-auto mb-4 group-hover:scale-110 transition-transform">
|
||||
<span className="material-icons text-2xl">rocket_launch</span>
|
||||
<span className="material-icons text-2xl" aria-hidden="true">rocket_launch</span>
|
||||
</div>
|
||||
<h3 className="font-display font-bold text-xl mb-2">{t('buildYourOwnAgent')}</h3>
|
||||
<p className="text-sm font-medium mb-6">{t('buildAgentDesc')}</p>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import Link from 'next/link'
|
||||
import { getTranslations } from 'next-intl/server'
|
||||
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
|
||||
|
||||
interface RelatedProjectsProps {
|
||||
projects: Array<{
|
||||
@@ -47,7 +48,7 @@ export async function RelatedProjects({ projects, locale }: RelatedProjectsProps
|
||||
className="font-display text-xs font-bold uppercase hover:underline flex items-center gap-1 group"
|
||||
>
|
||||
{tProject('viewAll')}{' '}
|
||||
<span className="material-icons text-sm group-hover:translate-x-1 transition-transform">arrow_forward</span>
|
||||
<span className="material-icons text-sm group-hover:translate-x-1 transition-transform" aria-hidden="true">arrow_forward</span>
|
||||
</Link>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
|
||||
@@ -56,8 +57,6 @@ export async function RelatedProjects({ projects, locale }: RelatedProjectsProps
|
||||
const displayDescription =
|
||||
locale === 'en' && project.descriptionEn ? project.descriptionEn : project.description
|
||||
const icon = getProjectIcon(project.tags)
|
||||
const getTagName = (tag: { name: string; nameEn?: string | null }) =>
|
||||
locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -78,14 +77,14 @@ export async function RelatedProjects({ projects, locale }: RelatedProjectsProps
|
||||
key={tag.id}
|
||||
className="text-[10px] uppercase font-bold border border-gray-300 dark:border-gray-600 px-2 py-1"
|
||||
>
|
||||
{getTagName(tag)}
|
||||
{getLocalizedTagName(tag, locale)}
|
||||
</span>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-auto pt-4 border-t border-gray-100 dark:border-gray-700">
|
||||
<span className="text-xs font-display font-bold uppercase flex items-center">
|
||||
{tCommon('viewDetails')}{' '}
|
||||
<span className="material-icons text-sm ml-1 group-hover:translate-x-1 transition-transform">
|
||||
<span className="material-icons text-sm ml-1 group-hover:translate-x-1 transition-transform" aria-hidden="true">
|
||||
arrow_forward
|
||||
</span>
|
||||
</span>
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useTranslations } from 'next-intl'
|
||||
|
||||
interface ShareButtonsProps {
|
||||
displayName: string
|
||||
}
|
||||
|
||||
export function ShareButtons({ displayName }: ShareButtonsProps) {
|
||||
const t = useTranslations('project')
|
||||
|
||||
const handleShare = () => {
|
||||
const url = encodeURIComponent(window.location.href)
|
||||
const text = encodeURIComponent(t('shareTweetText', { name: displayName }))
|
||||
window.open(`https://twitter.com/intent/tweet?url=${url}&text=${text}`, '_blank')
|
||||
}
|
||||
|
||||
const handleCopyLink = () => {
|
||||
navigator.clipboard.writeText(window.location.href)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mt-12 pt-8 border-t border-gray-300 dark:border-gray-700 flex flex-col sm:flex-row justify-between items-center gap-6">
|
||||
<div className="flex gap-4">
|
||||
<button
|
||||
className="p-2 border border-black dark:border-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
title={t('shareOnX')}
|
||||
onClick={handleShare}
|
||||
id="share-button"
|
||||
name="share"
|
||||
>
|
||||
<span className="material-icons text-lg">share</span>
|
||||
</button>
|
||||
<button
|
||||
className="p-2 border border-black dark:border-gray-400 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
title={t('copyLink')}
|
||||
onClick={handleCopyLink}
|
||||
id="copy-link-button"
|
||||
name="copyLink"
|
||||
>
|
||||
<span className="material-icons text-lg">link</span>
|
||||
</button>
|
||||
</div>
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-display font-bold text-gray-500">{t('feedbackQuestion')}</span>
|
||||
<button
|
||||
className="px-4 py-2 bg-primary text-black font-display font-bold text-xs uppercase border border-black hover:bg-yellow-400 transition-colors shadow-[2px_2px_0px_0px_rgba(0,0,0,1)] active:shadow-none active:translate-x-[2px] active:translate-y-[2px]"
|
||||
id="feedback-button"
|
||||
name="feedback"
|
||||
>
|
||||
{t('feedbackYes')}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -1,131 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState, useMemo } from 'react'
|
||||
import Link from 'next/link'
|
||||
|
||||
interface TagCloudProps {
|
||||
tags: Array<{
|
||||
id: string
|
||||
name: string
|
||||
nameEn?: string | null
|
||||
slug: string
|
||||
_count?: {
|
||||
projects: number
|
||||
}
|
||||
}>
|
||||
allTags?: Array<{
|
||||
id: string
|
||||
name: string
|
||||
nameEn?: string | null
|
||||
slug: string
|
||||
_count?: {
|
||||
projects: number
|
||||
}
|
||||
}>
|
||||
locale: string
|
||||
activeTag?: string
|
||||
}
|
||||
|
||||
export function TagCloud({ tags, allTags, locale, activeTag }: TagCloudProps) {
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [showAll, setShowAll] = useState(false)
|
||||
|
||||
// 搜索过滤逻辑
|
||||
const filteredTags = useMemo(() => {
|
||||
const sourceTags = allTags || tags
|
||||
|
||||
if (!searchQuery.trim()) {
|
||||
return showAll ? sourceTags : tags
|
||||
}
|
||||
|
||||
const query = searchQuery.toLowerCase()
|
||||
return sourceTags.filter(tag =>
|
||||
tag.name.toLowerCase().includes(query) ||
|
||||
(tag.nameEn && tag.nameEn.toLowerCase().includes(query))
|
||||
)
|
||||
}, [searchQuery, showAll, tags, allTags])
|
||||
|
||||
const hasMoreTags = allTags && allTags.length > tags.length
|
||||
const displayName = (tag: typeof tags[0]) =>
|
||||
locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* 搜索框 */}
|
||||
<div className="relative">
|
||||
<input
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={locale === 'zh' ? '搜索标签...' : 'Search tags...'}
|
||||
className="w-full px-4 py-2 pl-10 bg-white dark:bg-surface-dark border-2 border-gray-300 dark:border-gray-600 focus:border-primary text-sm font-display focus:outline-none transition-colors"
|
||||
/>
|
||||
<svg
|
||||
className="absolute left-3 top-1/2 -translate-y-1/2 w-4 h-4 text-gray-400"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
{/* 标签列表 */}
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{filteredTags.length > 0 ? (
|
||||
filteredTags.map((tag) => {
|
||||
const count = tag._count?.projects || 0
|
||||
const isActive = activeTag === tag.slug
|
||||
|
||||
return (
|
||||
<Link
|
||||
key={tag.id}
|
||||
href={`/${locale}/projects?tag=${tag.slug}`}
|
||||
className={`px-3 py-1 border-2 font-display text-xs font-bold uppercase transition-all shadow-neo-sm hover:shadow-none hover:translate-x-[2px] hover:translate-y-[2px] flex items-center gap-2 group ${
|
||||
isActive
|
||||
? 'bg-black text-white border-black'
|
||||
: 'bg-white dark:bg-surface-dark border-black dark:border-gray-500 hover:bg-black hover:text-white dark:hover:bg-primary dark:hover:text-black'
|
||||
}`}
|
||||
>
|
||||
{displayName(tag)}
|
||||
<span className={`text-[10px] px-1.5 py-0.5 ${
|
||||
isActive
|
||||
? 'bg-gray-700 text-white'
|
||||
: 'bg-gray-200 dark:bg-gray-800 text-gray-600 dark:text-gray-300 group-hover:bg-white group-hover:text-black'
|
||||
}`}>
|
||||
{count}
|
||||
</span>
|
||||
</Link>
|
||||
)
|
||||
})
|
||||
) : (
|
||||
<div className="text-center py-4 text-gray-500 text-sm w-full">
|
||||
{locale === 'zh' ? '未找到匹配的标签' : 'No matching tags found'}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* 显示全部按钮 */}
|
||||
{!searchQuery && hasMoreTags && !showAll && (
|
||||
<button
|
||||
onClick={() => setShowAll(true)}
|
||||
className="w-full py-2 bg-gray-100 dark:bg-gray-800 border-2 border-dashed border-gray-300 dark:border-gray-600 font-display text-xs font-bold uppercase hover:bg-gray-200 dark:hover:bg-gray-700 transition-colors"
|
||||
>
|
||||
{locale === 'zh'
|
||||
? `显示全部标签 (+${allTags!.length - tags.length})`
|
||||
: `Show all tags (+${allTags!.length - tags.length})`}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* 收起按钮 */}
|
||||
{showAll && !searchQuery && (
|
||||
<button
|
||||
onClick={() => setShowAll(false)}
|
||||
className="text-xs font-display font-bold text-gray-500 hover:text-black"
|
||||
>
|
||||
{locale === 'zh' ? '↑ 收起' : '↑ Show less'}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import type {
|
||||
ProjectSortOption,
|
||||
TagWithProjectCount,
|
||||
} from '@/hooks/useProjects'
|
||||
import { getLocalizedTagName } from '@/lib/i18n/tag-display'
|
||||
|
||||
interface TagFilterPanelProps {
|
||||
locale: string
|
||||
@@ -267,8 +268,7 @@ export function TagFilterPanel({
|
||||
selectedTags,
|
||||
sort,
|
||||
})
|
||||
const displayName =
|
||||
locale === 'en' && typeOption.nameEn ? typeOption.nameEn : typeOption.name
|
||||
const displayName = getLocalizedTagName(typeOption, locale)
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -332,8 +332,7 @@ export function TagFilterPanel({
|
||||
selectedTags,
|
||||
sort,
|
||||
})
|
||||
const displayName =
|
||||
locale === 'en' && domainTag.nameEn ? domainTag.nameEn : domainTag.name
|
||||
const displayName = getLocalizedTagName(domainTag, locale)
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -363,10 +362,9 @@ export function TagFilterPanel({
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{selectedDomains.map((domainSlug) => {
|
||||
const matchedDomain = domainBySlug.get(domainSlug)
|
||||
const displayName =
|
||||
matchedDomain && locale === 'en' && matchedDomain.nameEn
|
||||
? matchedDomain.nameEn
|
||||
: matchedDomain?.name || domainSlug
|
||||
const displayName = matchedDomain
|
||||
? getLocalizedTagName(matchedDomain, locale)
|
||||
: domainSlug
|
||||
const href = buildProjectsUrl({
|
||||
locale,
|
||||
search,
|
||||
@@ -427,8 +425,7 @@ export function TagFilterPanel({
|
||||
selectedTags,
|
||||
sort,
|
||||
})
|
||||
const displayName =
|
||||
locale === 'en' && productFormTag.nameEn ? productFormTag.nameEn : productFormTag.name
|
||||
const displayName = getLocalizedTagName(productFormTag, locale)
|
||||
|
||||
return (
|
||||
<Link
|
||||
@@ -458,10 +455,9 @@ export function TagFilterPanel({
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{selectedProductForms.map((productFormSlug) => {
|
||||
const matchedProductForm = productFormBySlug.get(productFormSlug)
|
||||
const displayName =
|
||||
matchedProductForm && locale === 'en' && matchedProductForm.nameEn
|
||||
? matchedProductForm.nameEn
|
||||
: matchedProductForm?.name || productFormSlug
|
||||
const displayName = matchedProductForm
|
||||
? getLocalizedTagName(matchedProductForm, locale)
|
||||
: productFormSlug
|
||||
const href = buildProjectsUrl({
|
||||
locale,
|
||||
search,
|
||||
@@ -545,10 +541,7 @@ export function TagFilterPanel({
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{selectedTags.map((tagSlug) => {
|
||||
const matchedTag = tagBySlug.get(tagSlug)
|
||||
const displayName =
|
||||
matchedTag && locale === 'en' && matchedTag.nameEn
|
||||
? matchedTag.nameEn
|
||||
: matchedTag?.name || tagSlug
|
||||
const displayName = matchedTag ? getLocalizedTagName(matchedTag, locale) : tagSlug
|
||||
const href = buildProjectsUrl({
|
||||
locale,
|
||||
search,
|
||||
@@ -638,7 +631,7 @@ export function TagFilterPanel({
|
||||
selectedTags: toggleTag(tag.slug),
|
||||
sort,
|
||||
})
|
||||
const displayName = locale === 'en' && tag.nameEn ? tag.nameEn : tag.name
|
||||
const displayName = getLocalizedTagName(tag, locale)
|
||||
|
||||
return (
|
||||
<Link
|
||||
|
||||
@@ -1,56 +0,0 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
import { useRouter, useSearchParams } from 'next/navigation'
|
||||
|
||||
interface SearchBarProps {
|
||||
locale: string
|
||||
searchPlaceholder: string
|
||||
searchLabel: string
|
||||
}
|
||||
|
||||
export function SearchBar({ locale, searchPlaceholder, searchLabel }: SearchBarProps) {
|
||||
const router = useRouter()
|
||||
const searchParams = useSearchParams()
|
||||
const [query, setQuery] = useState(searchParams.get('search') || '')
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
e.preventDefault()
|
||||
const params = new URLSearchParams()
|
||||
if (query) params.set('search', query)
|
||||
router.push(`/${locale}/projects?${params.toString()}`)
|
||||
}
|
||||
|
||||
return (
|
||||
<form onSubmit={handleSubmit} className="max-w-2xl mx-auto">
|
||||
<div className="relative group">
|
||||
{/* Glow effect on hover */}
|
||||
<div className="absolute -inset-1 bg-black dark:bg-primary rounded-lg blur opacity-25 group-hover:opacity-50 transition duration-200"></div>
|
||||
|
||||
<div className="relative flex items-center">
|
||||
{/* Search icon */}
|
||||
<div className="absolute inset-y-0 left-0 pl-4 flex items-center pointer-events-none">
|
||||
<span className="text-gray-400">🔍</span>
|
||||
</div>
|
||||
|
||||
{/* Input */}
|
||||
<input
|
||||
type="text"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder={searchPlaceholder}
|
||||
className="block w-full pl-12 pr-32 py-4 bg-surface-light dark:bg-surface-dark border-2 border-black dark:border-gray-600 text-text-light dark:text-text-dark placeholder-gray-500 focus:ring-0 focus:border-black dark:focus:border-primary font-display shadow-neo transition-all"
|
||||
/>
|
||||
|
||||
{/* Search button */}
|
||||
<button
|
||||
type="submit"
|
||||
className="absolute inset-y-2 right-2 px-4 bg-primary text-black font-bold font-display text-sm border-2 border-black hover:bg-yellow-400 transition-colors shadow-neo-sm active:shadow-none active:translate-x-[2px] active:translate-y-[2px]"
|
||||
>
|
||||
{searchLabel}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
)
|
||||
}
|
||||
@@ -55,6 +55,14 @@ interface SignalsApiResponse {
|
||||
items: IdeaSignal[]
|
||||
nextCursor: string | null
|
||||
hasMore: boolean
|
||||
meta: {
|
||||
totalCount: number
|
||||
hotCount: number
|
||||
newestPublishedAt: string | null
|
||||
}
|
||||
facets: {
|
||||
sourceCounts: Partial<Record<SignalSource, number>>
|
||||
}
|
||||
}
|
||||
|
||||
type SortKey = 'latest' | 'hot'
|
||||
@@ -99,6 +107,7 @@ interface SourceMeta {
|
||||
|
||||
function formatDate(value: string, locale: string): string {
|
||||
return new Intl.DateTimeFormat(locale === 'en' ? 'en-US' : 'zh-CN', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
}).format(new Date(value))
|
||||
@@ -217,6 +226,18 @@ async function fetchSignals(params: {
|
||||
items: Array.isArray(data.items) ? data.items : [],
|
||||
nextCursor: typeof data.nextCursor === 'string' ? data.nextCursor : null,
|
||||
hasMore: Boolean(data.hasMore),
|
||||
meta: {
|
||||
totalCount: typeof data.meta?.totalCount === 'number' ? data.meta.totalCount : 0,
|
||||
hotCount: typeof data.meta?.hotCount === 'number' ? data.meta.hotCount : 0,
|
||||
newestPublishedAt:
|
||||
typeof data.meta?.newestPublishedAt === 'string' ? data.meta.newestPublishedAt : null,
|
||||
},
|
||||
facets: {
|
||||
sourceCounts:
|
||||
data.facets?.sourceCounts && typeof data.facets.sourceCounts === 'object'
|
||||
? data.facets.sourceCounts
|
||||
: {},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
@@ -231,6 +252,9 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
const [signals, setSignals] = useState<IdeaSignal[]>([])
|
||||
const [nextCursor, setNextCursor] = useState<string | null>(null)
|
||||
const [hasMore, setHasMore] = useState(false)
|
||||
const [totalCount, setTotalCount] = useState(0)
|
||||
const [hotCount, setHotCount] = useState(0)
|
||||
const [sourceCounts, setSourceCounts] = useState<Partial<Record<SignalSource, number>>>({})
|
||||
const [isLoading, setIsLoading] = useState(true)
|
||||
const [isLoadingMore, setIsLoadingMore] = useState(false)
|
||||
const [loadError, setLoadError] = useState(false)
|
||||
@@ -270,12 +294,18 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
setSignals(page.items)
|
||||
setNextCursor(page.nextCursor)
|
||||
setHasMore(page.hasMore)
|
||||
setTotalCount(page.meta.totalCount)
|
||||
setHotCount(page.meta.hotCount)
|
||||
setSourceCounts(page.facets.sourceCounts)
|
||||
} catch (error) {
|
||||
if (!cancelled) {
|
||||
console.error('[Signals] failed to load first page:', error)
|
||||
setSignals([])
|
||||
setNextCursor(null)
|
||||
setHasMore(false)
|
||||
setTotalCount(0)
|
||||
setHotCount(0)
|
||||
setSourceCounts({})
|
||||
setLoadError(true)
|
||||
}
|
||||
} finally {
|
||||
@@ -293,6 +323,12 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
}
|
||||
}, [debouncedSearch, locale, sort, source])
|
||||
|
||||
useEffect(() => {
|
||||
if (source !== 'all' && !isLoading && (sourceCounts[source] || 0) === 0) {
|
||||
setSource('all')
|
||||
}
|
||||
}, [isLoading, source, sourceCounts])
|
||||
|
||||
useEffect(() => {
|
||||
if (!isSortMenuOpen) {
|
||||
return
|
||||
@@ -342,6 +378,9 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
setSignals((previous) => [...previous, ...page.items])
|
||||
setNextCursor(page.nextCursor)
|
||||
setHasMore(page.hasMore)
|
||||
setTotalCount(page.meta.totalCount)
|
||||
setHotCount(page.meta.hotCount)
|
||||
setSourceCounts(page.facets.sourceCounts)
|
||||
} catch (error) {
|
||||
console.error('[Signals] failed to load more:', error)
|
||||
setLoadError(true)
|
||||
@@ -384,7 +423,12 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
}
|
||||
|
||||
const activeSortLabel = sortOptions.find((option) => option.key === sort)?.label || translations.sortLatest
|
||||
const hotCount = signals.filter((item) => item.isHot).length
|
||||
const visibleSourceOptions = sourceOptions.filter((option) => {
|
||||
if (option === 'all') {
|
||||
return true
|
||||
}
|
||||
return (sourceCounts[option] || 0) > 0 || option === source
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
@@ -457,11 +501,11 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
<div className="flex flex-wrap items-center justify-start gap-1.5 xl:justify-end">
|
||||
<span
|
||||
title={translations.totalCountHint}
|
||||
aria-label={`${translations.totalCountHint}: ${signals.length}`}
|
||||
aria-label={`${translations.totalCountHint}: ${totalCount}`}
|
||||
className="inline-flex h-8 items-center gap-1 border-2 border-black bg-primary px-2 py-1 font-display text-[10px] font-bold uppercase text-black"
|
||||
>
|
||||
<List className="h-3 w-3" aria-hidden="true" />
|
||||
{signals.length}
|
||||
{totalCount}
|
||||
</span>
|
||||
<span
|
||||
title={translations.hotCountHint}
|
||||
@@ -476,10 +520,11 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
|
||||
<div className="mt-2 min-w-0 overflow-x-auto pb-1">
|
||||
<div className="flex min-w-max gap-1.5">
|
||||
{sourceOptions.map((option) => {
|
||||
{visibleSourceOptions.map((option) => {
|
||||
const active = source === option
|
||||
const meta = option === 'all' ? allSourcesMeta : sourceMeta[option]
|
||||
const SourceIcon = meta.icon
|
||||
const count = option === 'all' ? totalCount : sourceCounts[option] || 0
|
||||
|
||||
return (
|
||||
<button
|
||||
@@ -492,6 +537,7 @@ export function SignalFeedClient({ locale, translations }: SignalFeedClientProps
|
||||
<span className="inline-flex items-center gap-1.5">
|
||||
<SourceIcon className="h-3.5 w-3.5" aria-hidden="true" />
|
||||
{sourceLabels[option]}
|
||||
{count > 0 ? <span className="opacity-70">{count}</span> : null}
|
||||
</span>
|
||||
</button>
|
||||
)
|
||||
|
||||
@@ -0,0 +1,38 @@
|
||||
interface StaticInfoPageProps {
|
||||
eyebrow: string;
|
||||
title: string;
|
||||
description: string;
|
||||
sections: Array<{
|
||||
title: string;
|
||||
body: string;
|
||||
}>;
|
||||
}
|
||||
|
||||
export function StaticInfoPage({ eyebrow, title, description, sections }: StaticInfoPageProps) {
|
||||
return (
|
||||
<main className="max-w-5xl mx-auto px-4 sm:px-6 lg:px-8 py-14 md:py-20">
|
||||
<section className="neo-card bg-white p-6 md:p-8 dark:bg-surface-dark">
|
||||
<p className="inline-flex border-2 border-black bg-primary px-3 py-1 font-display text-xs font-bold uppercase text-black">
|
||||
{eyebrow}
|
||||
</p>
|
||||
<h1 className="mt-5 font-display text-4xl md:text-5xl font-bold tracking-tight">
|
||||
{title}
|
||||
</h1>
|
||||
<p className="mt-4 max-w-3xl text-gray-700 dark:text-gray-300">
|
||||
{description}
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-8 grid gap-5">
|
||||
{sections.map((section) => (
|
||||
<article key={section.title} className="neo-card p-6 md:p-7">
|
||||
<h2 className="font-display text-xl font-bold">{section.title}</h2>
|
||||
<p className="mt-3 leading-relaxed text-gray-700 dark:text-gray-300">
|
||||
{section.body}
|
||||
</p>
|
||||
</article>
|
||||
))}
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,146 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
|
||||
interface ProjectSubmissionFormProps {
|
||||
locale: string;
|
||||
labels: {
|
||||
url: string;
|
||||
projectName: string;
|
||||
description: string;
|
||||
submitterName: string;
|
||||
submitterEmail: string;
|
||||
submit: string;
|
||||
submitting: string;
|
||||
success: string;
|
||||
duplicate: string;
|
||||
invalid: string;
|
||||
failed: string;
|
||||
};
|
||||
}
|
||||
|
||||
export function ProjectSubmissionForm({ locale, labels }: ProjectSubmissionFormProps) {
|
||||
const [status, setStatus] = useState<"idle" | "submitting" | "success" | "error">("idle");
|
||||
const [message, setMessage] = useState<string | null>(null);
|
||||
|
||||
async function handleSubmit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const form = event.currentTarget;
|
||||
const formData = new FormData(form);
|
||||
|
||||
setStatus("submitting");
|
||||
setMessage(null);
|
||||
|
||||
try {
|
||||
const response = await fetch("/api/project-submissions", {
|
||||
method: "POST",
|
||||
headers: { "content-type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
url: formData.get("url"),
|
||||
projectName: formData.get("projectName"),
|
||||
description: formData.get("description"),
|
||||
submitterName: formData.get("submitterName"),
|
||||
submitterEmail: formData.get("submitterEmail"),
|
||||
locale,
|
||||
}),
|
||||
});
|
||||
|
||||
const body = await response.json();
|
||||
|
||||
if (response.ok) {
|
||||
setStatus("success");
|
||||
setMessage(labels.success);
|
||||
form.reset();
|
||||
return;
|
||||
}
|
||||
|
||||
setStatus("error");
|
||||
if (response.status === 409 || body?.error === "duplicate_submission") {
|
||||
setMessage(labels.duplicate);
|
||||
} else if (response.status === 400 || body?.error === "invalid_submission") {
|
||||
setMessage(labels.invalid);
|
||||
} else {
|
||||
setMessage(labels.failed);
|
||||
}
|
||||
} catch {
|
||||
setStatus("error");
|
||||
setMessage(labels.failed);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<form className="neo-card p-6 md:p-8 space-y-5" onSubmit={handleSubmit}>
|
||||
<label className="block">
|
||||
<span className="font-display text-xs font-bold uppercase">{labels.url}</span>
|
||||
<input
|
||||
name="url"
|
||||
type="url"
|
||||
required
|
||||
maxLength={2000}
|
||||
placeholder="https://github.com/org/project"
|
||||
className="mt-2 w-full border-2 border-black bg-white px-3 py-3 font-sans text-sm outline-none focus:ring-2 focus:ring-black dark:border-gray-600 dark:bg-surface-dark"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="font-display text-xs font-bold uppercase">{labels.projectName}</span>
|
||||
<input
|
||||
name="projectName"
|
||||
maxLength={200}
|
||||
className="mt-2 w-full border-2 border-black bg-white px-3 py-3 font-sans text-sm outline-none focus:ring-2 focus:ring-black dark:border-gray-600 dark:bg-surface-dark"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<label className="block">
|
||||
<span className="font-display text-xs font-bold uppercase">{labels.description}</span>
|
||||
<textarea
|
||||
name="description"
|
||||
rows={5}
|
||||
maxLength={1000}
|
||||
className="mt-2 w-full resize-y border-2 border-black bg-white px-3 py-3 font-sans text-sm outline-none focus:ring-2 focus:ring-black dark:border-gray-600 dark:bg-surface-dark"
|
||||
/>
|
||||
</label>
|
||||
|
||||
<div className="grid gap-5 md:grid-cols-2">
|
||||
<label className="block">
|
||||
<span className="font-display text-xs font-bold uppercase">{labels.submitterName}</span>
|
||||
<input
|
||||
name="submitterName"
|
||||
maxLength={120}
|
||||
className="mt-2 w-full border-2 border-black bg-white px-3 py-3 font-sans text-sm outline-none focus:ring-2 focus:ring-black dark:border-gray-600 dark:bg-surface-dark"
|
||||
/>
|
||||
</label>
|
||||
<label className="block">
|
||||
<span className="font-display text-xs font-bold uppercase">{labels.submitterEmail}</span>
|
||||
<input
|
||||
name="submitterEmail"
|
||||
type="email"
|
||||
maxLength={254}
|
||||
className="mt-2 w-full border-2 border-black bg-white px-3 py-3 font-sans text-sm outline-none focus:ring-2 focus:ring-black dark:border-gray-600 dark:bg-surface-dark"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
disabled={status === "submitting"}
|
||||
className="neo-btn bg-primary px-6 py-3 text-sm text-black disabled:opacity-60"
|
||||
>
|
||||
{status === "submitting" ? labels.submitting : labels.submit}
|
||||
</button>
|
||||
|
||||
{message ? (
|
||||
<p
|
||||
className={`border-2 px-4 py-3 font-display text-sm font-bold ${
|
||||
status === "success"
|
||||
? "border-emerald-700 bg-emerald-50 text-emerald-900"
|
||||
: "border-red-700 bg-red-50 text-red-900"
|
||||
}`}
|
||||
role={status === "success" ? "status" : "alert"}
|
||||
>
|
||||
{message}
|
||||
</p>
|
||||
) : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import { buildHomePageData } from "./useHome";
|
||||
|
||||
const { projectCountMock, projectFindManyMock } = vi.hoisted(() => ({
|
||||
projectCountMock: vi.fn(),
|
||||
projectFindManyMock: vi.fn(),
|
||||
}));
|
||||
|
||||
vi.mock("@/lib/prisma", () => ({
|
||||
prisma: {
|
||||
project: {
|
||||
count: projectCountMock,
|
||||
findMany: projectFindManyMock,
|
||||
},
|
||||
},
|
||||
}));
|
||||
|
||||
vi.mock("@/hooks/useProjects", () => ({
|
||||
getTopTags: vi.fn(async () => []),
|
||||
}));
|
||||
|
||||
describe("buildHomePageData", () => {
|
||||
beforeEach(() => {
|
||||
projectCountMock.mockReset();
|
||||
projectFindManyMock.mockReset();
|
||||
projectCountMock.mockResolvedValue(0);
|
||||
projectFindManyMock.mockResolvedValue([]);
|
||||
});
|
||||
|
||||
it("counts total projects using only ACTIVE projects", async () => {
|
||||
await buildHomePageData();
|
||||
|
||||
expect(projectCountMock).toHaveBeenCalledWith({
|
||||
where: {
|
||||
status: "ACTIVE",
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
+106
-106
@@ -1,77 +1,82 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { getTopTags } from '@/hooks/useProjects'
|
||||
import { Prisma } from '@prisma/client'
|
||||
import { unstable_cache } from 'next/cache'
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { getTopTags } from "@/hooks/useProjects";
|
||||
import { Prisma } from "@prisma/client";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { runWithCacheFallback } from "@/lib/cache";
|
||||
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
||||
const DEFAULT_RANKING_LIMIT = 6
|
||||
const DEFAULT_TIMELINE_LIMIT = 8
|
||||
const DEFAULT_TOP_TAG_LIMIT = 12
|
||||
const HOME_PAGE_REVALIDATE_SECONDS = 300
|
||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||
const DEFAULT_RANKING_LIMIT = 6;
|
||||
const DEFAULT_TIMELINE_LIMIT = 8;
|
||||
const DEFAULT_TOP_TAG_LIMIT = 12;
|
||||
const HOME_PAGE_REVALIDATE_SECONDS = 300;
|
||||
|
||||
export type HomeProjectSummary = {
|
||||
id: string
|
||||
slug: string
|
||||
name: string
|
||||
nameEn: string | null
|
||||
description: string
|
||||
descriptionEn: string | null
|
||||
githubStars: number
|
||||
createdAt: string
|
||||
id: string;
|
||||
slug: string;
|
||||
name: string;
|
||||
nameEn: string | null;
|
||||
description: string;
|
||||
descriptionEn: string | null;
|
||||
githubStars: number;
|
||||
createdAt: string;
|
||||
tags: Array<{
|
||||
id: string
|
||||
name: string
|
||||
nameEn: string | null
|
||||
slug: string
|
||||
}>
|
||||
}
|
||||
id: string;
|
||||
name: string;
|
||||
nameEn: string | null;
|
||||
slug: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
export type HomePageData = {
|
||||
overview: {
|
||||
totalProjects: number
|
||||
newProjects30d: number
|
||||
newProjects7d: number
|
||||
newProjects24h: number
|
||||
}
|
||||
totalProjects: number;
|
||||
newProjects30d: number;
|
||||
newProjects7d: number;
|
||||
newProjects24h: number;
|
||||
};
|
||||
rankings: {
|
||||
latestByWindow: {
|
||||
'24h': HomeProjectSummary[]
|
||||
'7d': HomeProjectSummary[]
|
||||
'30d': HomeProjectSummary[]
|
||||
}
|
||||
topStars: HomeProjectSummary[]
|
||||
}
|
||||
"24h": HomeProjectSummary[];
|
||||
"7d": HomeProjectSummary[];
|
||||
"30d": HomeProjectSummary[];
|
||||
};
|
||||
topStars: HomeProjectSummary[];
|
||||
};
|
||||
tagInsights: {
|
||||
topTags: Array<{
|
||||
id: string
|
||||
name: string
|
||||
nameEn: string | null
|
||||
slug: string
|
||||
projectCount: number
|
||||
}>
|
||||
}
|
||||
timeline: HomeProjectSummary[]
|
||||
}
|
||||
id: string;
|
||||
name: string;
|
||||
nameEn: string | null;
|
||||
slug: string;
|
||||
projectCount: number;
|
||||
}>;
|
||||
};
|
||||
timeline: HomeProjectSummary[];
|
||||
};
|
||||
|
||||
type ProjectWithRelations = Prisma.ProjectGetPayload<{
|
||||
include: {
|
||||
tags: {
|
||||
include: {
|
||||
tag: true
|
||||
}
|
||||
}
|
||||
}
|
||||
}>
|
||||
tag: true;
|
||||
};
|
||||
};
|
||||
};
|
||||
}>;
|
||||
|
||||
async function safeQuery<T>(operationName: string, fallback: T, task: () => Promise<T>): Promise<T> {
|
||||
async function safeQuery<T>(
|
||||
operationName: string,
|
||||
fallback: T,
|
||||
task: () => Promise<T>
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await task()
|
||||
return await task();
|
||||
} catch (error) {
|
||||
console.error(
|
||||
`[db] ${operationName} degraded to fallback:`,
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
return fallback
|
||||
);
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -91,14 +96,17 @@ function mapProjectSummary(project: ProjectWithRelations): HomeProjectSummary {
|
||||
nameEn: projectTag.tag.nameEn,
|
||||
slug: projectTag.tag.slug,
|
||||
})),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
async function getLatestProjects(limit: number, createdAfter?: Date): Promise<HomeProjectSummary[]> {
|
||||
const projects = await safeQuery('getLatestProjects', [] as ProjectWithRelations[], () =>
|
||||
async function getLatestProjects(
|
||||
limit: number,
|
||||
createdAfter?: Date
|
||||
): Promise<HomeProjectSummary[]> {
|
||||
const projects = await safeQuery("getLatestProjects", [] as ProjectWithRelations[], () =>
|
||||
prisma.project.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
status: "ACTIVE",
|
||||
...(createdAfter ? { createdAt: { gte: createdAfter } } : {}),
|
||||
},
|
||||
include: {
|
||||
@@ -109,20 +117,20 @@ async function getLatestProjects(limit: number, createdAfter?: Date): Promise<Ho
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
createdAt: 'desc',
|
||||
createdAt: "desc",
|
||||
},
|
||||
take: limit,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return projects.map(mapProjectSummary)
|
||||
return projects.map(mapProjectSummary);
|
||||
}
|
||||
|
||||
async function getTopStarsProjects(limit: number): Promise<HomeProjectSummary[]> {
|
||||
const projects = await safeQuery('getTopStarsProjects', [] as ProjectWithRelations[], () =>
|
||||
const projects = await safeQuery("getTopStarsProjects", [] as ProjectWithRelations[], () =>
|
||||
prisma.project.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
status: "ACTIVE",
|
||||
},
|
||||
include: {
|
||||
tags: {
|
||||
@@ -131,12 +139,12 @@ async function getTopStarsProjects(limit: number): Promise<HomeProjectSummary[]>
|
||||
},
|
||||
},
|
||||
},
|
||||
orderBy: [{ githubStars: 'desc' }, { createdAt: 'desc' }],
|
||||
orderBy: [{ githubStars: "desc" }, { createdAt: "desc" }],
|
||||
take: limit,
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return projects.map(mapProjectSummary)
|
||||
return projects.map(mapProjectSummary);
|
||||
}
|
||||
|
||||
function getLatestProjectsByWindow(
|
||||
@@ -144,17 +152,15 @@ function getLatestProjectsByWindow(
|
||||
createdAfter: Date,
|
||||
limit: number
|
||||
): HomeProjectSummary[] {
|
||||
return projects
|
||||
.filter((project) => new Date(project.createdAt) >= createdAfter)
|
||||
.slice(0, limit)
|
||||
return projects.filter((project) => new Date(project.createdAt) >= createdAfter).slice(0, limit);
|
||||
}
|
||||
|
||||
async function buildHomePageData(): Promise<HomePageData> {
|
||||
const now = Date.now()
|
||||
const last24Hours = new Date(now - ONE_DAY_MS)
|
||||
const last7Days = new Date(now - ONE_DAY_MS * 7)
|
||||
const last30Days = new Date(now - ONE_DAY_MS * 30)
|
||||
const latestProjectsLimit = Math.max(DEFAULT_RANKING_LIMIT, DEFAULT_TIMELINE_LIMIT)
|
||||
export async function buildHomePageData(): Promise<HomePageData> {
|
||||
const now = Date.now();
|
||||
const last24Hours = new Date(now - ONE_DAY_MS);
|
||||
const last7Days = new Date(now - ONE_DAY_MS * 7);
|
||||
const last30Days = new Date(now - ONE_DAY_MS * 30);
|
||||
const latestProjectsLimit = Math.max(DEFAULT_RANKING_LIMIT, DEFAULT_TIMELINE_LIMIT);
|
||||
|
||||
const [
|
||||
totalProjects,
|
||||
@@ -165,31 +171,37 @@ async function buildHomePageData(): Promise<HomePageData> {
|
||||
topStars,
|
||||
topTags,
|
||||
] = await Promise.all([
|
||||
safeQuery('countTotalProjects', 0, () => prisma.project.count()),
|
||||
safeQuery('countNewProjects30d', 0, () =>
|
||||
safeQuery("countTotalProjects", 0, () =>
|
||||
prisma.project.count({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
status: "ACTIVE",
|
||||
},
|
||||
})
|
||||
),
|
||||
safeQuery("countNewProjects30d", 0, () =>
|
||||
prisma.project.count({
|
||||
where: {
|
||||
status: "ACTIVE",
|
||||
createdAt: {
|
||||
gte: last30Days,
|
||||
},
|
||||
},
|
||||
})
|
||||
),
|
||||
safeQuery('countNewProjects7d', 0, () =>
|
||||
safeQuery("countNewProjects7d", 0, () =>
|
||||
prisma.project.count({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
status: "ACTIVE",
|
||||
createdAt: {
|
||||
gte: last7Days,
|
||||
},
|
||||
},
|
||||
})
|
||||
),
|
||||
safeQuery('countNewProjects24h', 0, () =>
|
||||
safeQuery("countNewProjects24h", 0, () =>
|
||||
prisma.project.count({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
status: "ACTIVE",
|
||||
createdAt: {
|
||||
gte: last24Hours,
|
||||
},
|
||||
@@ -198,25 +210,13 @@ async function buildHomePageData(): Promise<HomePageData> {
|
||||
),
|
||||
getLatestProjects(latestProjectsLimit),
|
||||
getTopStarsProjects(DEFAULT_RANKING_LIMIT),
|
||||
getTopTags(DEFAULT_TOP_TAG_LIMIT),
|
||||
])
|
||||
safeQuery("getTopTags", [], () => getTopTags(DEFAULT_TOP_TAG_LIMIT)),
|
||||
]);
|
||||
|
||||
const latest24h = getLatestProjectsByWindow(
|
||||
latestProjects,
|
||||
last24Hours,
|
||||
DEFAULT_RANKING_LIMIT
|
||||
)
|
||||
const latest7d = getLatestProjectsByWindow(
|
||||
latestProjects,
|
||||
last7Days,
|
||||
DEFAULT_RANKING_LIMIT
|
||||
)
|
||||
const latest30d = getLatestProjectsByWindow(
|
||||
latestProjects,
|
||||
last30Days,
|
||||
DEFAULT_RANKING_LIMIT
|
||||
)
|
||||
const timeline = latestProjects.slice(0, DEFAULT_TIMELINE_LIMIT)
|
||||
const latest24h = getLatestProjectsByWindow(latestProjects, last24Hours, DEFAULT_RANKING_LIMIT);
|
||||
const latest7d = getLatestProjectsByWindow(latestProjects, last7Days, DEFAULT_RANKING_LIMIT);
|
||||
const latest30d = getLatestProjectsByWindow(latestProjects, last30Days, DEFAULT_RANKING_LIMIT);
|
||||
const timeline = latestProjects.slice(0, DEFAULT_TIMELINE_LIMIT);
|
||||
|
||||
return {
|
||||
overview: {
|
||||
@@ -227,9 +227,9 @@ async function buildHomePageData(): Promise<HomePageData> {
|
||||
},
|
||||
rankings: {
|
||||
latestByWindow: {
|
||||
'24h': latest24h,
|
||||
'7d': latest7d,
|
||||
'30d': latest30d,
|
||||
"24h": latest24h,
|
||||
"7d": latest7d,
|
||||
"30d": latest30d,
|
||||
},
|
||||
topStars,
|
||||
},
|
||||
@@ -243,14 +243,14 @@ async function buildHomePageData(): Promise<HomePageData> {
|
||||
})),
|
||||
},
|
||||
timeline,
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const getCachedHomePageData = unstable_cache(buildHomePageData, ['home-page-data:v1'], {
|
||||
const getCachedHomePageData = unstable_cache(buildHomePageData, ["home-page-data:v1"], {
|
||||
revalidate: HOME_PAGE_REVALIDATE_SECONDS,
|
||||
tags: ['home-page-data'],
|
||||
})
|
||||
tags: ["home-page-data"],
|
||||
});
|
||||
|
||||
export async function getHomePageData(): Promise<HomePageData> {
|
||||
return getCachedHomePageData()
|
||||
return runWithCacheFallback(getCachedHomePageData, buildHomePageData);
|
||||
}
|
||||
|
||||
+190
-198
@@ -1,52 +1,54 @@
|
||||
import { prisma } from '@/lib/prisma'
|
||||
import { Prisma, type TagCategory } from '@prisma/client'
|
||||
import { unstable_cache } from 'next/cache'
|
||||
import { prisma } from "@/lib/prisma";
|
||||
import { Prisma, type TagCategory } from "@prisma/client";
|
||||
import { unstable_cache } from "next/cache";
|
||||
import { runWithCacheFallback } from "@/lib/cache";
|
||||
import {
|
||||
FIXED_PROJECT_TYPE_TAGS,
|
||||
TAG_CATEGORY_META,
|
||||
getTagCategoryOrder,
|
||||
isFixedProjectTypeSlug,
|
||||
type FixedProjectTypeSlug,
|
||||
} from '@/lib/tag-taxonomy'
|
||||
} from "@/lib/tag-taxonomy";
|
||||
|
||||
const DB_RETRY_DELAYS_MS = [300, 900] as const
|
||||
const DB_RETRY_DELAYS_MS = [300, 900] as const;
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
setTimeout(resolve, ms)
|
||||
})
|
||||
setTimeout(resolve, ms);
|
||||
});
|
||||
}
|
||||
|
||||
function isTransientDbError(error: unknown): boolean {
|
||||
if (error instanceof Prisma.PrismaClientInitializationError) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
if (error instanceof Prisma.PrismaClientRustPanicError) {
|
||||
return true
|
||||
return true;
|
||||
}
|
||||
|
||||
const message = error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase()
|
||||
const message =
|
||||
error instanceof Error ? error.message.toLowerCase() : String(error).toLowerCase();
|
||||
return (
|
||||
message.includes("can't reach database server") ||
|
||||
message.includes('p1001') ||
|
||||
message.includes('connection terminated') ||
|
||||
message.includes('timeout') ||
|
||||
message.includes('econnreset')
|
||||
)
|
||||
message.includes("p1001") ||
|
||||
message.includes("connection terminated") ||
|
||||
message.includes("timeout") ||
|
||||
message.includes("econnreset")
|
||||
);
|
||||
}
|
||||
|
||||
async function withDbRetry<T>(operationName: string, task: () => Promise<T>): Promise<T> {
|
||||
let lastError: unknown
|
||||
let lastError: unknown;
|
||||
|
||||
for (let attempt = 0; attempt <= DB_RETRY_DELAYS_MS.length; attempt += 1) {
|
||||
try {
|
||||
return await task()
|
||||
return await task();
|
||||
} catch (error) {
|
||||
lastError = error
|
||||
lastError = error;
|
||||
if (!isTransientDbError(error) || attempt === DB_RETRY_DELAYS_MS.length) {
|
||||
break
|
||||
break;
|
||||
}
|
||||
await sleep(DB_RETRY_DELAYS_MS[attempt] ?? 0)
|
||||
await sleep(DB_RETRY_DELAYS_MS[attempt] ?? 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,56 +56,56 @@ async function withDbRetry<T>(operationName: string, task: () => Promise<T>): Pr
|
||||
`[db] ${operationName} failed: ${
|
||||
lastError instanceof Error ? lastError.message : String(lastError)
|
||||
}`
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// 定义带有标签和链接的项目类型
|
||||
export type ProjectWithTagsAndLinks = Prisma.ProjectGetPayload<{
|
||||
include: {
|
||||
tags: { include: { tag: true } }
|
||||
links: true
|
||||
}
|
||||
}>
|
||||
tags: { include: { tag: true } };
|
||||
links: true;
|
||||
};
|
||||
}>;
|
||||
|
||||
// 定义扁平化标签的项目类型
|
||||
export type ProjectWithFlatTags = Omit<ProjectWithTagsAndLinks, 'tags'> & {
|
||||
tags: Prisma.TagGetPayload<{}>[]
|
||||
}
|
||||
export type ProjectWithFlatTags = Omit<ProjectWithTagsAndLinks, "tags"> & {
|
||||
tags: Prisma.TagGetPayload<{}>[];
|
||||
};
|
||||
|
||||
// 定义标签计数类型
|
||||
export type TagWithProjectCount = Prisma.TagGetPayload<{
|
||||
include: {
|
||||
_count: { select: { projects: true } }
|
||||
}
|
||||
}>
|
||||
_count: { select: { projects: true } };
|
||||
};
|
||||
}>;
|
||||
|
||||
export type FilterTagCategoryGroup = {
|
||||
category: Exclude<TagCategory, 'FIXED_PROJECT_TYPE'>
|
||||
name: string
|
||||
nameEn: string
|
||||
tags: TagWithProjectCount[]
|
||||
}
|
||||
category: Exclude<TagCategory, "FIXED_PROJECT_TYPE">;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
tags: TagWithProjectCount[];
|
||||
};
|
||||
|
||||
export type FixedProjectTypeFilter = {
|
||||
slug: FixedProjectTypeSlug
|
||||
name: string
|
||||
nameEn: string
|
||||
projectCount: number
|
||||
}
|
||||
slug: FixedProjectTypeSlug;
|
||||
name: string;
|
||||
nameEn: string;
|
||||
projectCount: number;
|
||||
};
|
||||
|
||||
export const PROJECT_SORT_OPTIONS = ['latest', 'stars_desc', 'stars_asc'] as const
|
||||
export type ProjectSortOption = (typeof PROJECT_SORT_OPTIONS)[number]
|
||||
const DEFAULT_PAGE = 1
|
||||
const DEFAULT_LIMIT = 10
|
||||
const MAX_LIMIT = 100
|
||||
const DEFAULT_CACHE_REVALIDATE_SECONDS = 300
|
||||
export const PROJECT_SORT_OPTIONS = ["latest", "stars_desc", "stars_asc"] as const;
|
||||
export type ProjectSortOption = (typeof PROJECT_SORT_OPTIONS)[number];
|
||||
const DEFAULT_PAGE = 1;
|
||||
const DEFAULT_LIMIT = 10;
|
||||
const MAX_LIMIT = 100;
|
||||
const DEFAULT_CACHE_REVALIDATE_SECONDS = 300;
|
||||
|
||||
export function normalizeProjectSort(value?: string): ProjectSortOption {
|
||||
const candidate = String(value || '').trim()
|
||||
const candidate = String(value || "").trim();
|
||||
if (PROJECT_SORT_OPTIONS.includes(candidate as ProjectSortOption)) {
|
||||
return candidate as ProjectSortOption
|
||||
return candidate as ProjectSortOption;
|
||||
}
|
||||
return 'latest'
|
||||
return "latest";
|
||||
}
|
||||
|
||||
function normalizePositiveInteger(
|
||||
@@ -111,30 +113,30 @@ function normalizePositiveInteger(
|
||||
fallback: number,
|
||||
max?: number
|
||||
): number {
|
||||
const normalized = Number.isFinite(value) ? Math.trunc(value as number) : fallback
|
||||
const bounded = normalized > 0 ? normalized : fallback
|
||||
return typeof max === 'number' ? Math.min(bounded, max) : bounded
|
||||
const normalized = Number.isFinite(value) ? Math.trunc(value as number) : fallback;
|
||||
const bounded = normalized > 0 ? normalized : fallback;
|
||||
return typeof max === "number" ? Math.min(bounded, max) : bounded;
|
||||
}
|
||||
|
||||
export async function getProjects(options?: {
|
||||
search?: string
|
||||
tag?: string
|
||||
tags?: string[]
|
||||
domains?: string[]
|
||||
productForms?: string[]
|
||||
projectType?: string
|
||||
sort?: ProjectSortOption
|
||||
status?: 'ACTIVE' | 'ARCHIVED'
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string;
|
||||
tag?: string;
|
||||
tags?: string[];
|
||||
domains?: string[];
|
||||
productForms?: string[];
|
||||
projectType?: string;
|
||||
sort?: ProjectSortOption;
|
||||
status?: "ACTIVE" | "ARCHIVED";
|
||||
page?: number;
|
||||
limit?: number;
|
||||
}): Promise<{
|
||||
projects: ProjectWithFlatTags[]
|
||||
projects: ProjectWithFlatTags[];
|
||||
pagination: {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
}
|
||||
page: number;
|
||||
limit: number;
|
||||
total: number;
|
||||
totalPages: number;
|
||||
};
|
||||
}> {
|
||||
const {
|
||||
search,
|
||||
@@ -143,65 +145,57 @@ export async function getProjects(options?: {
|
||||
domains = [],
|
||||
productForms = [],
|
||||
projectType,
|
||||
sort = 'latest',
|
||||
status = 'ACTIVE',
|
||||
sort = "latest",
|
||||
status = "ACTIVE",
|
||||
page = DEFAULT_PAGE,
|
||||
limit = DEFAULT_LIMIT,
|
||||
} = options || {}
|
||||
} = options || {};
|
||||
|
||||
const safePage = normalizePositiveInteger(page, DEFAULT_PAGE)
|
||||
const safeLimit = normalizePositiveInteger(limit, DEFAULT_LIMIT, MAX_LIMIT)
|
||||
const safePage = normalizePositiveInteger(page, DEFAULT_PAGE);
|
||||
const safeLimit = normalizePositiveInteger(limit, DEFAULT_LIMIT, MAX_LIMIT);
|
||||
|
||||
const where: Prisma.ProjectWhereInput = {
|
||||
status,
|
||||
}
|
||||
};
|
||||
|
||||
// 添加搜索字符串长度验证
|
||||
if (search && search.length >= 2 && search.length <= 100) {
|
||||
where.OR = [
|
||||
{ name: { contains: search, mode: 'insensitive' } },
|
||||
{ nameEn: { contains: search, mode: 'insensitive' } },
|
||||
{ description: { contains: search, mode: 'insensitive' } },
|
||||
{ descriptionEn: { contains: search, mode: 'insensitive' } },
|
||||
]
|
||||
{ name: { contains: search, mode: "insensitive" } },
|
||||
{ nameEn: { contains: search, mode: "insensitive" } },
|
||||
{ description: { contains: search, mode: "insensitive" } },
|
||||
{ descriptionEn: { contains: search, mode: "insensitive" } },
|
||||
];
|
||||
}
|
||||
|
||||
const andFilters: Prisma.ProjectWhereInput[] = []
|
||||
const andFilters: Prisma.ProjectWhereInput[] = [];
|
||||
const normalizedDomainSlugs = Array.from(
|
||||
new Set(
|
||||
domains
|
||||
.map((value) => (value || '').trim())
|
||||
.filter((value) => value.length > 0)
|
||||
)
|
||||
)
|
||||
new Set(domains.map((value) => (value || "").trim()).filter((value) => value.length > 0))
|
||||
);
|
||||
const normalizedProductFormSlugs = Array.from(
|
||||
new Set(
|
||||
productForms
|
||||
.map((value) => (value || '').trim())
|
||||
.filter((value) => value.length > 0)
|
||||
)
|
||||
)
|
||||
new Set(productForms.map((value) => (value || "").trim()).filter((value) => value.length > 0))
|
||||
);
|
||||
const normalizedTagSlugs = Array.from(
|
||||
new Set(
|
||||
[tag, ...tags]
|
||||
.map((value) => (value || '').trim())
|
||||
.map((value) => (value || "").trim())
|
||||
.filter((value) => value.length > 0)
|
||||
.filter((value) => !normalizedDomainSlugs.includes(value))
|
||||
.filter((value) => !normalizedProductFormSlugs.includes(value))
|
||||
)
|
||||
)
|
||||
);
|
||||
|
||||
for (const domainSlug of normalizedDomainSlugs) {
|
||||
andFilters.push({
|
||||
tags: {
|
||||
some: {
|
||||
tag: {
|
||||
category: 'DOMAIN_SCENARIO',
|
||||
category: "DOMAIN_SCENARIO",
|
||||
slug: domainSlug,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
for (const tagSlug of normalizedTagSlugs) {
|
||||
@@ -213,7 +207,7 @@ export async function getProjects(options?: {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
for (const productFormSlug of normalizedProductFormSlugs) {
|
||||
@@ -221,12 +215,12 @@ export async function getProjects(options?: {
|
||||
tags: {
|
||||
some: {
|
||||
tag: {
|
||||
category: 'PRODUCT_FORM',
|
||||
category: "PRODUCT_FORM",
|
||||
slug: productFormSlug,
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (projectType && isFixedProjectTypeSlug(projectType)) {
|
||||
@@ -238,25 +232,25 @@ export async function getProjects(options?: {
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
});
|
||||
}
|
||||
|
||||
if (andFilters.length > 0) {
|
||||
where.AND = andFilters
|
||||
where.AND = andFilters;
|
||||
}
|
||||
|
||||
const orderBy: Prisma.ProjectOrderByWithRelationInput[] =
|
||||
sort === 'stars_desc'
|
||||
? [{ githubStars: 'desc' }, { createdAt: 'desc' }]
|
||||
: sort === 'stars_asc'
|
||||
? [{ githubStars: 'asc' }, { createdAt: 'desc' }]
|
||||
: [{ createdAt: 'desc' }]
|
||||
sort === "stars_desc"
|
||||
? [{ githubStars: "desc" }, { createdAt: "desc" }]
|
||||
: sort === "stars_asc"
|
||||
? [{ githubStars: "asc" }, { createdAt: "desc" }]
|
||||
: [{ createdAt: "desc" }];
|
||||
|
||||
let projects: ProjectWithTagsAndLinks[] = []
|
||||
let total = 0
|
||||
let projects: ProjectWithTagsAndLinks[] = [];
|
||||
let total = 0;
|
||||
|
||||
try {
|
||||
;[projects, total] = await withDbRetry('getProjects', () =>
|
||||
[projects, total] = await withDbRetry("getProjects", () =>
|
||||
Promise.all([
|
||||
prisma.project.findMany({
|
||||
where,
|
||||
@@ -274,19 +268,19 @@ export async function getProjects(options?: {
|
||||
}),
|
||||
prisma.project.count({ where }),
|
||||
])
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[db] getProjects degraded to empty result:',
|
||||
"[db] getProjects degraded to empty result:",
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
// Transform tags to flatten the structure
|
||||
const transformedProjects = projects.map((project) => ({
|
||||
...project,
|
||||
tags: project.tags.map((pt) => pt.tag),
|
||||
}))
|
||||
}));
|
||||
|
||||
return {
|
||||
projects: transformedProjects,
|
||||
@@ -296,11 +290,11 @@ export async function getProjects(options?: {
|
||||
total,
|
||||
totalPages: Math.ceil(total / safeLimit),
|
||||
},
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function getProjectBySlug(slug: string): Promise<ProjectWithFlatTags | null> {
|
||||
const project = await withDbRetry('getProjectBySlug', () =>
|
||||
const project = await withDbRetry("getProjectBySlug", () =>
|
||||
prisma.project.findUnique({
|
||||
where: { slug },
|
||||
include: {
|
||||
@@ -312,25 +306,25 @@ export async function getProjectBySlug(slug: string): Promise<ProjectWithFlatTag
|
||||
links: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
if (!project) {
|
||||
return null
|
||||
return null;
|
||||
}
|
||||
|
||||
// Transform tags to flatten the structure
|
||||
return {
|
||||
...project,
|
||||
tags: project.tags.map((pt) => pt.tag),
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
export async function getAllTags(): Promise<TagWithProjectCount[]> {
|
||||
return withDbRetry('getAllTags', () =>
|
||||
return withDbRetry("getAllTags", () =>
|
||||
prisma.tag.findMany({
|
||||
where: {
|
||||
category: {
|
||||
not: 'FIXED_PROJECT_TYPE',
|
||||
not: "FIXED_PROJECT_TYPE",
|
||||
},
|
||||
},
|
||||
include: {
|
||||
@@ -339,18 +333,18 @@ export async function getAllTags(): Promise<TagWithProjectCount[]> {
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
name: "asc",
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
export async function getTagsWithProjectCounts(): Promise<TagWithProjectCount[]> {
|
||||
const tags = await withDbRetry('getTagsWithProjectCounts', () =>
|
||||
const tags = await withDbRetry("getTagsWithProjectCounts", () =>
|
||||
prisma.tag.findMany({
|
||||
where: {
|
||||
category: {
|
||||
not: 'FIXED_PROJECT_TYPE',
|
||||
not: "FIXED_PROJECT_TYPE",
|
||||
},
|
||||
},
|
||||
include: {
|
||||
@@ -359,16 +353,16 @@ export async function getTagsWithProjectCounts(): Promise<TagWithProjectCount[]>
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
name: "asc",
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
return tags.filter(tag => tag._count.projects > 0)
|
||||
return tags.filter((tag) => tag._count.projects > 0);
|
||||
}
|
||||
|
||||
async function getTopTagsFromDb(limit: number): Promise<TagWithProjectCount[]> {
|
||||
return withDbRetry('getTopTags', () =>
|
||||
return withDbRetry("getTopTags", () =>
|
||||
prisma.tag.findMany({
|
||||
include: {
|
||||
_count: {
|
||||
@@ -377,49 +371,45 @@ async function getTopTagsFromDb(limit: number): Promise<TagWithProjectCount[]> {
|
||||
},
|
||||
where: {
|
||||
category: {
|
||||
not: 'FIXED_PROJECT_TYPE',
|
||||
not: "FIXED_PROJECT_TYPE",
|
||||
},
|
||||
projects: {
|
||||
some: {},
|
||||
},
|
||||
},
|
||||
orderBy: [{ projects: { _count: 'desc' } }, { name: 'asc' }],
|
||||
orderBy: [{ projects: { _count: "desc" } }, { name: "asc" }],
|
||||
take: limit,
|
||||
})
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const topTagsCache = new Map<number, () => Promise<TagWithProjectCount[]>>()
|
||||
const topTagsCache = new Map<number, () => Promise<TagWithProjectCount[]>>();
|
||||
|
||||
function getTopTagsCachedFetcher(limit: number): () => Promise<TagWithProjectCount[]> {
|
||||
const existing = topTagsCache.get(limit)
|
||||
const existing = topTagsCache.get(limit);
|
||||
if (existing) {
|
||||
return existing
|
||||
return existing;
|
||||
}
|
||||
|
||||
const fetcher = unstable_cache(
|
||||
async () => getTopTagsFromDb(limit),
|
||||
[`top-tags:${limit}`],
|
||||
{
|
||||
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
||||
tags: ['top-tags'],
|
||||
}
|
||||
)
|
||||
topTagsCache.set(limit, fetcher)
|
||||
return fetcher
|
||||
const fetcher = unstable_cache(async () => getTopTagsFromDb(limit), [`top-tags:${limit}`], {
|
||||
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
||||
tags: ["top-tags"],
|
||||
});
|
||||
topTagsCache.set(limit, fetcher);
|
||||
return fetcher;
|
||||
}
|
||||
|
||||
export async function getTopTags(limit: number = 10): Promise<TagWithProjectCount[]> {
|
||||
return getTopTagsCachedFetcher(limit)()
|
||||
return runWithCacheFallback(getTopTagsCachedFetcher(limit), () => getTopTagsFromDb(limit));
|
||||
}
|
||||
|
||||
async function getFixedProjectTypeFiltersFromDb(
|
||||
status: 'ACTIVE' | 'ARCHIVED'
|
||||
status: "ACTIVE" | "ARCHIVED"
|
||||
): Promise<FixedProjectTypeFilter[]> {
|
||||
let counts: number[] = []
|
||||
let counts: number[] = [];
|
||||
|
||||
try {
|
||||
counts = await withDbRetry('getFixedProjectTypeFilters', () =>
|
||||
counts = await withDbRetry("getFixedProjectTypeFilters", () =>
|
||||
Promise.all(
|
||||
FIXED_PROJECT_TYPE_TAGS.map((type) =>
|
||||
prisma.project.count({
|
||||
@@ -436,12 +426,12 @@ async function getFixedProjectTypeFiltersFromDb(
|
||||
})
|
||||
)
|
||||
)
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[db] getFixedProjectTypeFilters degraded to zero counts:',
|
||||
"[db] getFixedProjectTypeFilters degraded to zero counts:",
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
return FIXED_PROJECT_TYPE_TAGS.map((type, index) => ({
|
||||
@@ -449,20 +439,20 @@ async function getFixedProjectTypeFiltersFromDb(
|
||||
name: type.name,
|
||||
nameEn: type.nameEn,
|
||||
projectCount: counts[index] ?? 0,
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
const fixedProjectTypeFilterCache = new Map<
|
||||
'ACTIVE' | 'ARCHIVED',
|
||||
"ACTIVE" | "ARCHIVED",
|
||||
() => Promise<FixedProjectTypeFilter[]>
|
||||
>()
|
||||
>();
|
||||
|
||||
function getFixedProjectTypeFilterCachedFetcher(
|
||||
status: 'ACTIVE' | 'ARCHIVED'
|
||||
status: "ACTIVE" | "ARCHIVED"
|
||||
): () => Promise<FixedProjectTypeFilter[]> {
|
||||
const existing = fixedProjectTypeFilterCache.get(status)
|
||||
const existing = fixedProjectTypeFilterCache.get(status);
|
||||
if (existing) {
|
||||
return existing
|
||||
return existing;
|
||||
}
|
||||
|
||||
const fetcher = unstable_cache(
|
||||
@@ -470,28 +460,30 @@ function getFixedProjectTypeFilterCachedFetcher(
|
||||
[`fixed-project-type-filters:${status}`],
|
||||
{
|
||||
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
||||
tags: ['fixed-project-type-filters'],
|
||||
tags: ["fixed-project-type-filters"],
|
||||
}
|
||||
)
|
||||
fixedProjectTypeFilterCache.set(status, fetcher)
|
||||
return fetcher
|
||||
);
|
||||
fixedProjectTypeFilterCache.set(status, fetcher);
|
||||
return fetcher;
|
||||
}
|
||||
|
||||
export async function getFixedProjectTypeFilters(
|
||||
status: 'ACTIVE' | 'ARCHIVED' = 'ACTIVE'
|
||||
status: "ACTIVE" | "ARCHIVED" = "ACTIVE"
|
||||
): Promise<FixedProjectTypeFilter[]> {
|
||||
return getFixedProjectTypeFilterCachedFetcher(status)()
|
||||
return runWithCacheFallback(getFixedProjectTypeFilterCachedFetcher(status), () =>
|
||||
getFixedProjectTypeFiltersFromDb(status)
|
||||
);
|
||||
}
|
||||
|
||||
async function getTagCategoryGroupsFromDb(): Promise<FilterTagCategoryGroup[]> {
|
||||
let tags: TagWithProjectCount[] = []
|
||||
let tags: TagWithProjectCount[] = [];
|
||||
|
||||
try {
|
||||
tags = await withDbRetry('getTagCategoryGroups', () =>
|
||||
tags = await withDbRetry("getTagCategoryGroups", () =>
|
||||
prisma.tag.findMany({
|
||||
where: {
|
||||
category: {
|
||||
not: 'FIXED_PROJECT_TYPE',
|
||||
not: "FIXED_PROJECT_TYPE",
|
||||
},
|
||||
projects: {
|
||||
some: {},
|
||||
@@ -503,35 +495,35 @@ async function getTagCategoryGroupsFromDb(): Promise<FilterTagCategoryGroup[]> {
|
||||
},
|
||||
},
|
||||
orderBy: {
|
||||
name: 'asc',
|
||||
name: "asc",
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
} catch (error) {
|
||||
console.error(
|
||||
'[db] getTagCategoryGroups degraded to empty groups:',
|
||||
"[db] getTagCategoryGroups degraded to empty groups:",
|
||||
error instanceof Error ? error.message : String(error)
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
const groups = new Map<Exclude<TagCategory, 'FIXED_PROJECT_TYPE'>, TagWithProjectCount[]>()
|
||||
const groups = new Map<Exclude<TagCategory, "FIXED_PROJECT_TYPE">, TagWithProjectCount[]>();
|
||||
for (const category of getTagCategoryOrder()) {
|
||||
if (category === 'FIXED_PROJECT_TYPE') {
|
||||
continue
|
||||
if (category === "FIXED_PROJECT_TYPE") {
|
||||
continue;
|
||||
}
|
||||
groups.set(category, [])
|
||||
groups.set(category, []);
|
||||
}
|
||||
|
||||
for (const tag of tags) {
|
||||
if (tag.category === 'FIXED_PROJECT_TYPE') {
|
||||
continue
|
||||
if (tag.category === "FIXED_PROJECT_TYPE") {
|
||||
continue;
|
||||
}
|
||||
const current = groups.get(tag.category)
|
||||
const current = groups.get(tag.category);
|
||||
if (!current) {
|
||||
groups.set(tag.category, [tag])
|
||||
continue
|
||||
groups.set(tag.category, [tag]);
|
||||
continue;
|
||||
}
|
||||
current.push(tag)
|
||||
current.push(tag);
|
||||
}
|
||||
|
||||
return Array.from(groups.entries())
|
||||
@@ -540,39 +532,39 @@ async function getTagCategoryGroupsFromDb(): Promise<FilterTagCategoryGroup[]> {
|
||||
name: TAG_CATEGORY_META[category].name,
|
||||
nameEn: TAG_CATEGORY_META[category].nameEn,
|
||||
tags: categoryTags.sort((a, b) => {
|
||||
const countDiff = b._count.projects - a._count.projects
|
||||
const countDiff = b._count.projects - a._count.projects;
|
||||
if (countDiff !== 0) {
|
||||
return countDiff
|
||||
return countDiff;
|
||||
}
|
||||
return a.name.localeCompare(b.name, 'zh')
|
||||
return a.name.localeCompare(b.name, "zh");
|
||||
}),
|
||||
}))
|
||||
.filter((group) => group.tags.length > 0)
|
||||
.filter((group) => group.tags.length > 0);
|
||||
}
|
||||
|
||||
const getCachedTagCategoryGroups = unstable_cache(
|
||||
getTagCategoryGroupsFromDb,
|
||||
['tag-category-groups:v1'],
|
||||
["tag-category-groups:v1"],
|
||||
{
|
||||
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
||||
tags: ['tag-category-groups'],
|
||||
tags: ["tag-category-groups"],
|
||||
}
|
||||
)
|
||||
);
|
||||
|
||||
export async function getTagCategoryGroups(): Promise<FilterTagCategoryGroup[]> {
|
||||
return getCachedTagCategoryGroups()
|
||||
return runWithCacheFallback(getCachedTagCategoryGroups, getTagCategoryGroupsFromDb);
|
||||
}
|
||||
|
||||
// 定义 AI 搜索结果类型
|
||||
export type AISearchResultItem = ProjectWithFlatTags & {
|
||||
similarity: number
|
||||
}
|
||||
similarity: number;
|
||||
};
|
||||
|
||||
// n8n 返回的简化搜索结果类型
|
||||
export type N8NSearchResult = {
|
||||
id: string
|
||||
similarity: number
|
||||
}
|
||||
id: string;
|
||||
similarity: number;
|
||||
};
|
||||
|
||||
/**
|
||||
* 根据 ID 列表批量获取项目(用于 AI 搜索结果组装)
|
||||
@@ -581,10 +573,10 @@ export type N8NSearchResult = {
|
||||
*/
|
||||
export async function getProjectsByIds(ids: string[]): Promise<ProjectWithFlatTags[]> {
|
||||
if (ids.length === 0) {
|
||||
return []
|
||||
return [];
|
||||
}
|
||||
|
||||
const projects = await withDbRetry('getProjectsByIds', () =>
|
||||
const projects = await withDbRetry("getProjectsByIds", () =>
|
||||
prisma.project.findMany({
|
||||
where: {
|
||||
id: {
|
||||
@@ -600,11 +592,11 @@ export async function getProjectsByIds(ids: string[]): Promise<ProjectWithFlatTa
|
||||
links: true,
|
||||
},
|
||||
})
|
||||
)
|
||||
);
|
||||
|
||||
// Transform tags to flatten the structure
|
||||
return projects.map((project) => ({
|
||||
...project,
|
||||
tags: project.tags.map((pt) => pt.tag),
|
||||
}))
|
||||
}));
|
||||
}
|
||||
|
||||
@@ -1,28 +0,0 @@
|
||||
import { useSearchParams } from 'next/navigation'
|
||||
|
||||
export function useSearch() {
|
||||
const searchParams = useSearchParams()
|
||||
|
||||
const tags = (searchParams.get('tags') || '')
|
||||
.split(',')
|
||||
.map((tag) => tag.trim())
|
||||
.filter((tag) => tag.length > 0)
|
||||
const domains = (searchParams.get('domains') || '')
|
||||
.split(',')
|
||||
.map((domain) => domain.trim())
|
||||
.filter((domain) => domain.length > 0)
|
||||
const productForms = (searchParams.get('productForms') || '')
|
||||
.split(',')
|
||||
.map((productForm) => productForm.trim())
|
||||
.filter((productForm) => productForm.length > 0)
|
||||
|
||||
return {
|
||||
search: searchParams.get('search') || '',
|
||||
tag: searchParams.get('tag') || '',
|
||||
tags,
|
||||
domains,
|
||||
productForms,
|
||||
projectType: searchParams.get('projectType') || '',
|
||||
page: Number(searchParams.get('page')) || 1,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
export function isUnstableCacheUnavailableError(error: unknown): boolean {
|
||||
if (!(error instanceof Error)) {
|
||||
return false;
|
||||
}
|
||||
|
||||
return error.message.toLowerCase().includes("incrementalcache missing in unstable_cache");
|
||||
}
|
||||
|
||||
export async function runWithCacheFallback<T>(
|
||||
cachedFetcher: () => Promise<T>,
|
||||
fallbackFetcher: () => Promise<T>
|
||||
): Promise<T> {
|
||||
try {
|
||||
return await cachedFetcher();
|
||||
} catch (error) {
|
||||
if (!isUnstableCacheUnavailableError(error)) {
|
||||
throw error;
|
||||
}
|
||||
|
||||
return fallbackFetcher();
|
||||
}
|
||||
}
|
||||
@@ -1,92 +0,0 @@
|
||||
/**
|
||||
* GitHub API 服务
|
||||
* 获取仓库的统计数据(stars, forks, issues, license 等)
|
||||
*/
|
||||
|
||||
export interface GitHubStats {
|
||||
stargazers_count: number
|
||||
forks_count: number
|
||||
open_issues_count: number
|
||||
license: { key: string; name: string } | null
|
||||
pushed_at: string
|
||||
}
|
||||
|
||||
/**
|
||||
* 从 GitHub API 获取仓库统计信息
|
||||
* @param owner - 仓库所有者
|
||||
* @param repo - 仓库名称
|
||||
* @returns GitHub 统计数据或 null
|
||||
*/
|
||||
export async function getGitHubStats(
|
||||
owner: string,
|
||||
repo: string
|
||||
): Promise<GitHubStats | null> {
|
||||
try {
|
||||
const response = await fetch(`https://api.github.com/repos/${owner}/${repo}`, {
|
||||
headers: {
|
||||
Accept: 'application/vnd.github.v3+json',
|
||||
// 如果需要更高的速率限制,可以添加 GitHub token
|
||||
// Authorization: `token ${process.env.GITHUB_TOKEN}`,
|
||||
},
|
||||
next: { revalidate: 300 } // 缓存 5 分钟
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
console.error(`GitHub API error: ${response.status}`)
|
||||
return null
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return {
|
||||
stargazers_count: data.stargazers_count || 0,
|
||||
forks_count: data.forks_count || 0,
|
||||
open_issues_count: data.open_issues_count || 0,
|
||||
license: data.license || null,
|
||||
pushed_at: data.pushed_at || ''
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch GitHub stats:', error)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化数字(如 142000 -> 142k)
|
||||
*/
|
||||
export function formatNumber(num: number): string {
|
||||
if (num >= 1000000) {
|
||||
return (num / 1000000).toFixed(1).replace(/\.0$/, '') + 'M'
|
||||
}
|
||||
if (num >= 1000) {
|
||||
return (num / 1000).toFixed(1).replace(/\.0$/, '') + 'k'
|
||||
}
|
||||
return num.toString()
|
||||
}
|
||||
|
||||
/**
|
||||
* 格式化相对时间(如 "2 days ago")
|
||||
*/
|
||||
export function formatRelativeTime(dateString: string, locale: string = 'zh'): string {
|
||||
if (!dateString) return ''
|
||||
|
||||
const date = new Date(dateString)
|
||||
const now = new Date()
|
||||
const diffMs = now.getTime() - date.getTime()
|
||||
const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24))
|
||||
|
||||
if (locale === 'en') {
|
||||
if (diffDays === 0) return 'today'
|
||||
if (diffDays === 1) return 'yesterday'
|
||||
if (diffDays < 7) return `${diffDays} days ago`
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} weeks ago`
|
||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)} months ago`
|
||||
return `${Math.floor(diffDays / 365)} years ago`
|
||||
} else {
|
||||
if (diffDays === 0) return '今天'
|
||||
if (diffDays === 1) return '昨天'
|
||||
if (diffDays < 7) return `${diffDays} 天前`
|
||||
if (diffDays < 30) return `${Math.floor(diffDays / 7)} 周前`
|
||||
if (diffDays < 365) return `${Math.floor(diffDays / 30)} 月前`
|
||||
return `${Math.floor(diffDays / 365)} 年前`
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
const ENGLISH_TAG_FALLBACKS: Record<string, string> = {
|
||||
"ai-agents": "AI Agents",
|
||||
"code-dev": "Developer Tools & Coding",
|
||||
python: "Python",
|
||||
"workflow-automation": "Workflow Automation",
|
||||
"automation-workflow": "Automation, Workflow & RPA",
|
||||
"api-integration": "Protocol, API & Integration",
|
||||
cli: "CLI",
|
||||
"model-context-protocol": "Model Context Protocol",
|
||||
"agent-framework": "Agent Framework",
|
||||
typescript: "TypeScript",
|
||||
"multi-agent-system": "Multi-Agent System",
|
||||
"大语言模型": "Large Language Models",
|
||||
"桌面应用": "Desktop Apps",
|
||||
"知识库": "Knowledge Base",
|
||||
"浏览器自动化": "Browser Automation",
|
||||
"多模态": "Multimodal",
|
||||
"安全-隐私": "Security & Privacy",
|
||||
};
|
||||
|
||||
export function getLocalizedTagName(
|
||||
tag: { name: string; nameEn?: string | null; slug?: string | null },
|
||||
locale: string
|
||||
): string {
|
||||
if (locale !== "en") {
|
||||
return tag.name;
|
||||
}
|
||||
|
||||
if (tag.nameEn && tag.nameEn.trim().length > 0) {
|
||||
return tag.nameEn;
|
||||
}
|
||||
|
||||
const slugFallback = tag.slug ? ENGLISH_TAG_FALLBACKS[tag.slug] : undefined;
|
||||
if (slugFallback) {
|
||||
return slugFallback;
|
||||
}
|
||||
|
||||
return ENGLISH_TAG_FALLBACKS[tag.name] || tag.name;
|
||||
}
|
||||
@@ -0,0 +1,64 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
import { afterEach, describe, expect, it, vi } from "vitest";
|
||||
|
||||
import { buildPrismaDataSourceUrl } from "./prisma-url";
|
||||
|
||||
const ENV_KEYS = [
|
||||
"DATABASE_URL",
|
||||
"PG_SSL_ROOT_CERT_B64",
|
||||
"PG_SSL_IDENTITY_P12_B64",
|
||||
"PG_SSL_IDENTITY_PASSWORD",
|
||||
"PG_SSL_MODE",
|
||||
"PG_SSL_CERT_DIR",
|
||||
] as const;
|
||||
|
||||
const envSnapshot = Object.fromEntries(ENV_KEYS.map((key) => [key, process.env[key]]));
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks();
|
||||
for (const key of ENV_KEYS) {
|
||||
const value = envSnapshot[key];
|
||||
if (value === undefined) {
|
||||
delete process.env[key];
|
||||
} else {
|
||||
process.env[key] = value;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
describe("buildPrismaDataSourceUrl", () => {
|
||||
it("returns the original url when TLS env vars are absent", () => {
|
||||
const url = "postgresql://root:rootroot@103.112.185.248:6432/agent_park";
|
||||
|
||||
delete process.env.PG_SSL_ROOT_CERT_B64;
|
||||
delete process.env.PG_SSL_IDENTITY_P12_B64;
|
||||
|
||||
expect(buildPrismaDataSourceUrl(url)).toBe(url);
|
||||
});
|
||||
|
||||
it("writes decoded TLS artifacts and appends Prisma SSL params", () => {
|
||||
const certDir = fs.mkdtempSync(path.join(os.tmpdir(), "agent-park-prisma-test-"));
|
||||
const baseUrl = "postgresql://root:rootroot@103.112.185.248:6432/agent_park";
|
||||
|
||||
process.env.PG_SSL_CERT_DIR = certDir;
|
||||
process.env.PG_SSL_ROOT_CERT_B64 = Buffer.from("root-cert").toString("base64");
|
||||
process.env.PG_SSL_IDENTITY_P12_B64 = Buffer.from("identity-p12").toString("base64");
|
||||
process.env.PG_SSL_IDENTITY_PASSWORD = "topsecret";
|
||||
|
||||
const builtUrl = buildPrismaDataSourceUrl(baseUrl);
|
||||
const parsed = new URL(builtUrl!);
|
||||
|
||||
const rootCertPath = path.join(certDir, "ca.crt");
|
||||
const identityPath = path.join(certDir, "client-identity.p12");
|
||||
|
||||
expect(fs.readFileSync(rootCertPath, "utf8")).toBe("root-cert");
|
||||
expect(fs.readFileSync(identityPath, "utf8")).toBe("identity-p12");
|
||||
expect(parsed.searchParams.get("sslmode")).toBe("verify-full");
|
||||
expect(parsed.searchParams.get("sslrootcert")).toBe(rootCertPath);
|
||||
expect(parsed.searchParams.get("sslidentity")).toBe(identityPath);
|
||||
expect(parsed.searchParams.get("sslpassword")).toBe("topsecret");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,53 @@
|
||||
import fs from "fs";
|
||||
import os from "os";
|
||||
import path from "path";
|
||||
|
||||
const DEFAULT_CERT_DIR = path.join(os.tmpdir(), "agent-park-db-mtls");
|
||||
|
||||
function writeFileIfChanged(filePath: string, content: Buffer | string, mode?: number) {
|
||||
fs.mkdirSync(path.dirname(filePath), { recursive: true, mode: 0o700 });
|
||||
|
||||
const nextContent = Buffer.isBuffer(content) ? content : Buffer.from(content, "utf8");
|
||||
const currentContent = fs.existsSync(filePath) ? fs.readFileSync(filePath) : null;
|
||||
|
||||
if (!currentContent || !currentContent.equals(nextContent)) {
|
||||
fs.writeFileSync(filePath, nextContent);
|
||||
}
|
||||
|
||||
if (mode !== undefined) {
|
||||
fs.chmodSync(filePath, mode);
|
||||
}
|
||||
}
|
||||
|
||||
export function buildPrismaDataSourceUrl(baseUrl = process.env.DATABASE_URL): string | undefined {
|
||||
if (!baseUrl) {
|
||||
return undefined;
|
||||
}
|
||||
|
||||
const rootCertB64 = process.env.PG_SSL_ROOT_CERT_B64;
|
||||
const identityP12B64 = process.env.PG_SSL_IDENTITY_P12_B64;
|
||||
|
||||
if (!rootCertB64 || !identityP12B64) {
|
||||
return baseUrl;
|
||||
}
|
||||
|
||||
const certDir = process.env.PG_SSL_CERT_DIR || DEFAULT_CERT_DIR;
|
||||
const rootCertPath = path.join(certDir, "ca.crt");
|
||||
const identityPath = path.join(certDir, "client-identity.p12");
|
||||
|
||||
writeFileIfChanged(rootCertPath, Buffer.from(rootCertB64, "base64"), 0o600);
|
||||
writeFileIfChanged(identityPath, Buffer.from(identityP12B64, "base64"), 0o600);
|
||||
|
||||
const url = new URL(baseUrl);
|
||||
|
||||
url.searchParams.set("sslmode", process.env.PG_SSL_MODE || "verify-full");
|
||||
url.searchParams.set("sslrootcert", rootCertPath);
|
||||
url.searchParams.set("sslidentity", identityPath);
|
||||
|
||||
const identityPassword = process.env.PG_SSL_IDENTITY_PASSWORD;
|
||||
if (identityPassword) {
|
||||
url.searchParams.set("sslpassword", identityPassword);
|
||||
}
|
||||
|
||||
return url.toString();
|
||||
}
|
||||
+21
-5
@@ -1,9 +1,25 @@
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
import { PrismaClient } from "@prisma/client";
|
||||
|
||||
import { buildPrismaDataSourceUrl } from "@/lib/prisma-url";
|
||||
|
||||
const globalForPrisma = globalThis as unknown as {
|
||||
prisma: PrismaClient | undefined
|
||||
}
|
||||
prisma: PrismaClient | undefined;
|
||||
};
|
||||
|
||||
export const prisma = globalForPrisma.prisma ?? new PrismaClient()
|
||||
const datasourceUrl = buildPrismaDataSourceUrl();
|
||||
|
||||
if (process.env.NODE_ENV !== 'production') globalForPrisma.prisma = prisma
|
||||
export const prisma =
|
||||
globalForPrisma.prisma ??
|
||||
new PrismaClient(
|
||||
datasourceUrl
|
||||
? {
|
||||
datasources: {
|
||||
db: {
|
||||
url: datasourceUrl,
|
||||
},
|
||||
},
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
|
||||
if (process.env.NODE_ENV !== "production") globalForPrisma.prisma = prisma;
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user