Compare commits
10
Commits
bc93755f87
...
5b64422dfb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
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
|
||||||
@@ -1,5 +1,10 @@
|
|||||||
# Database
|
# Database
|
||||||
DATABASE_URL="postgresql://postgres:password@localhost:5432/agent_park"
|
DATABASE_URL="postgresql://postgres:password@localhost:5432/agent_park"
|
||||||
|
PG_SSL_ROOT_CERT_B64=""
|
||||||
|
PG_SSL_IDENTITY_P12_B64=""
|
||||||
|
PG_SSL_IDENTITY_PASSWORD=""
|
||||||
|
# Optional override when your proxy hostname or certificate policy differs
|
||||||
|
# PG_SSL_MODE="verify-full"
|
||||||
|
|
||||||
# Webhook API - Generate a secure key for production
|
# Webhook API - Generate a secure key for production
|
||||||
WEBHOOK_API_KEY="sk_live_your_secure_api_key_min_32_chars"
|
WEBHOOK_API_KEY="sk_live_your_secure_api_key_min_32_chars"
|
||||||
|
|||||||
@@ -0,0 +1,232 @@
|
|||||||
|
# Architecture
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-18
|
||||||
|
|
||||||
|
## Pattern Overview
|
||||||
|
|
||||||
|
**Overall:** Server-first Next.js App Router monolith with localized page routes, thin API handlers, Prisma-backed data access, and small client-side interaction islands.
|
||||||
|
|
||||||
|
**Key Characteristics:**
|
||||||
|
- Use `src/app/layout.tsx` and `src/app/[locale]/layout.tsx` as the primary composition roots, with most route files implemented as async server components.
|
||||||
|
- Keep browser-only interaction in explicit client components such as `src/app/[locale]/projects/ProjectsPageClient.tsx`, `src/app/[locale]/projects/ProjectsResultsClient.tsx`, `src/components/search/AISearchBar.tsx`, and `src/components/signals/SignalFeedClient.tsx`.
|
||||||
|
- Centralize most project and home-page reads in `src/hooks/useProjects.ts` and `src/hooks/useHome.ts`, even though these modules are named like React hooks.
|
||||||
|
- Validate external input with Zod schemas from `src/lib/validations.ts` before querying or mutating the database.
|
||||||
|
- Use Prisma as the only persistence client through `src/lib/prisma.ts`, with schema ownership in `prisma/schema.prisma`.
|
||||||
|
|
||||||
|
## Layers
|
||||||
|
|
||||||
|
**Routing and Layout Layer:**
|
||||||
|
- Purpose: Resolve locale-aware routes, shared layout chrome, SEO metadata, and global CSS.
|
||||||
|
- 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`, `src/i18n/request.ts`
|
||||||
|
- Contains: App Router entry points, `generateMetadata`, `generateStaticParams`, `revalidate` exports, locale middleware, and SEO documents.
|
||||||
|
- Depends on: `next-intl`, server query modules in `src/hooks`, UI components in `src/components`, and shared helpers in `src/lib`.
|
||||||
|
- Used by: The Next.js runtime.
|
||||||
|
|
||||||
|
**UI Composition Layer:**
|
||||||
|
- Purpose: Render reusable view fragments for pages and route-specific UI sections.
|
||||||
|
- Location: `src/components/home/*`, `src/components/layout/*`, `src/components/locale/*`, `src/components/project/*`, `src/components/search/*`, `src/components/signals/*`
|
||||||
|
- Contains: Mostly presentational components, plus a few server components that call translations or helpers directly, such as `src/components/project/ProjectList.tsx` and `src/components/project/ProjectSidebar.tsx`.
|
||||||
|
- Depends on: Translation APIs, route props, and typed data returned by `src/hooks/useProjects.ts` or `src/hooks/useHome.ts`.
|
||||||
|
- Used by: Route entry points under `src/app/[locale]`.
|
||||||
|
|
||||||
|
**Client Interaction Layer:**
|
||||||
|
- Purpose: Own browser-only state, URL mutation, fetch loops, local storage, and interactive filters/search.
|
||||||
|
- Location: `src/app/VercelMetrics.tsx`, `src/app/[locale]/projects/ProjectsPageClient.tsx`, `src/app/[locale]/projects/ProjectsResultsClient.tsx`, `src/components/layout/AnnouncementBar.tsx`, `src/components/locale/LocaleSwitcher.tsx`, `src/components/project/TagFilterPanel.tsx`, `src/components/search/AISearchBar.tsx`, `src/components/search/HomeSearchBar.tsx`, `src/components/search/AISearchResults.tsx`, `src/components/signals/SignalFeedClient.tsx`
|
||||||
|
- Contains: `useState`/`useEffect`-driven UI state, `window.history.replaceState`, `window.location.href`, `localStorage`, and client-side `fetch` calls to `/api/*`.
|
||||||
|
- Depends on: Page props, Next navigation APIs, and JSON APIs implemented under `src/app/api`.
|
||||||
|
- Used by: Server-rendered pages and layouts.
|
||||||
|
|
||||||
|
**Query and Read Model Layer:**
|
||||||
|
- Purpose: Encapsulate database reads, filtering, retry logic, shape transformations, and cached read models for projects, tags, and home-page aggregates.
|
||||||
|
- Location: `src/hooks/useProjects.ts`, `src/hooks/useHome.ts`
|
||||||
|
- Contains: Prisma queries, pagination logic, tag-category grouping, home-page aggregate builders, and result-type definitions such as `ProjectWithFlatTags`, `FilterTagCategoryGroup`, and `HomePageData`.
|
||||||
|
- Depends on: `src/lib/prisma.ts`, `src/lib/cache.ts`, `src/lib/tag-taxonomy.ts`, and Prisma types from `@prisma/client`.
|
||||||
|
- Used by: Page routes such as `src/app/[locale]/page.tsx` and list APIs such as `src/app/api/projects/route.ts`.
|
||||||
|
|
||||||
|
**API and Mutation Layer:**
|
||||||
|
- Purpose: Expose JSON endpoints for list/detail reads, AI search proxying, signals feed reads, authenticated tag maintenance, project-tag resets, and signal ingestion.
|
||||||
|
- 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: `GET` and `POST` route handlers, request parsing, validation, response shaping, cache revalidation, and transaction-scoped maintenance logic.
|
||||||
|
- Depends on: `src/lib/validations.ts`, `src/lib/auth.ts`, `src/lib/prisma.ts`, `src/lib/signal-hotness.ts`, and the query layer in `src/hooks`.
|
||||||
|
- Used by: Client components, external webhook callers, and automation clients.
|
||||||
|
|
||||||
|
**Domain Utility Layer:**
|
||||||
|
- Purpose: Hold cross-route domain logic that is not tied to a single route.
|
||||||
|
- Location: `src/lib/auth.ts`, `src/lib/cache.ts`, `src/lib/prisma.ts`, `src/lib/signal-hotness.ts`, `src/lib/slug.ts`, `src/lib/tag-taxonomy.ts`, `src/lib/validations.ts`, `src/lib/github/badges.ts`
|
||||||
|
- Contains: API-key verification, cache fallback helpers, Prisma singleton setup, signal hotness scoring, slug generation, tag taxonomy inference, schema validation, and GitHub link parsing.
|
||||||
|
- Depends on: Standard library APIs, Prisma, and Zod.
|
||||||
|
- Used by: Both pages and API handlers.
|
||||||
|
|
||||||
|
**Persistence Layer:**
|
||||||
|
- Purpose: Define and migrate the database model and seed baseline data.
|
||||||
|
- Location: `prisma/schema.prisma`, `prisma/migrations/*`, `prisma/seed.ts`
|
||||||
|
- Contains: PostgreSQL schema for `Project`, `Tag`, `ProjectTag`, `ExternalLink`, and `Signal`, plus migrations and seed data.
|
||||||
|
- Depends on: Prisma CLI and environment-provided `DATABASE_URL`.
|
||||||
|
- Used by: `src/lib/prisma.ts` at runtime and Prisma tooling during migrations/seed.
|
||||||
|
|
||||||
|
## Data Flow
|
||||||
|
|
||||||
|
**Localized Page Render:**
|
||||||
|
|
||||||
|
1. `src/middleware.ts` forces a locale-prefixed pathname and excludes `/api`, `/_next`, and static assets from locale routing.
|
||||||
|
2. `src/app/[locale]/layout.tsx` validates the locale, calls `setRequestLocale(locale)`, loads messages through `next-intl`, and wraps the tree with `NextIntlClientProvider`.
|
||||||
|
3. Route pages such as `src/app/[locale]/page.tsx`, `src/app/[locale]/projects/page.tsx`, and `src/app/[locale]/projects/[id]/page.tsx` fetch data from `src/hooks/useProjects.ts` or `src/hooks/useHome.ts`.
|
||||||
|
4. Reusable server and client components render the data, with ISR enabled through `export const revalidate = 300` on the main content routes.
|
||||||
|
|
||||||
|
**Traditional Projects Browse Flow:**
|
||||||
|
|
||||||
|
1. `src/app/[locale]/projects/page.tsx` reads `searchParams`, normalizes filters, and calls `getProjects`, `getFixedProjectTypeFilters`, and `getTagCategoryGroups` from `src/hooks/useProjects.ts`.
|
||||||
|
2. `src/app/[locale]/projects/ProjectsPageClient.tsx` and `src/components/project/TagFilterPanel.tsx` manage filter-panel visibility and query-string updates in the browser.
|
||||||
|
3. `src/app/[locale]/projects/ProjectsResultsClient.tsx` fetches updated pages from `src/app/api/projects/route.ts` for client-side pagination and sorting.
|
||||||
|
4. `src/app/api/projects/route.ts` validates query parameters with Zod, then delegates to `getProjects`.
|
||||||
|
|
||||||
|
**AI Search Flow:**
|
||||||
|
|
||||||
|
1. `src/components/search/AISearchBar.tsx` toggles between traditional and AI mode and forwards the query back to the page-level handler.
|
||||||
|
2. `src/app/[locale]/projects/ProjectsResultsClient.tsx` requests `POST /api/search/ai`.
|
||||||
|
3. `src/app/api/search/ai/route.ts` validates the request with `ProjectQuerySchema`, forwards a GET request to the external N8N webhook URL from `N8N_AI_SEARCH_WEBHOOK`, then hydrates returned IDs by calling `getProjectsByIds` from `src/hooks/useProjects.ts`.
|
||||||
|
4. The route sorts and filters the hydrated projects again before returning JSON to the client.
|
||||||
|
|
||||||
|
**Signals Feed Flow:**
|
||||||
|
|
||||||
|
1. `src/app/[locale]/signals/page.tsx` renders shell content and mounts `src/components/signals/SignalFeedClient.tsx`.
|
||||||
|
2. `SignalFeedClient` holds search text, debounce state, source filter, sort mode, and pagination cursor in component state.
|
||||||
|
3. The client calls `GET /api/signals` on `src/app/api/signals/route.ts`.
|
||||||
|
4. `src/app/api/signals/route.ts` validates the query, queries Prisma, computes or reads hotness metadata via `src/lib/signal-hotness.ts`, and returns cursor-based pagination.
|
||||||
|
|
||||||
|
**Signal Ingestion Flow:**
|
||||||
|
|
||||||
|
1. External automation posts to `src/app/api/webhook/signals/route.ts`.
|
||||||
|
2. The handler validates the top-level payload and each item with `SignalWebhookPayloadSchema` and `SignalIngestionInputSchema` from `src/lib/validations.ts`.
|
||||||
|
3. `src/lib/auth.ts` verifies `apiKey` using `crypto.timingSafeEqual`.
|
||||||
|
4. The handler upserts signals in Prisma, computing fallback hotness when needed and downgrading if `hotScore`/`isHot` columns are absent.
|
||||||
|
|
||||||
|
**Tag Maintenance Flow:**
|
||||||
|
|
||||||
|
1. Clients call `POST /api/tags/maintenance` or `POST /api/tags/reset-projects`.
|
||||||
|
2. The route validates input, authenticates with `isValidApiKey`, and runs Prisma mutations.
|
||||||
|
3. `src/app/api/tags/maintenance/route.ts` delegates transactional merge/update logic to `src/app/api/tags/maintenance/service.ts`.
|
||||||
|
4. Successful mutations call `revalidatePath` for affected localized pages.
|
||||||
|
|
||||||
|
## State Management
|
||||||
|
|
||||||
|
**Server State:**
|
||||||
|
- Persistent application state lives in PostgreSQL, modeled in `prisma/schema.prisma` and accessed only through Prisma in `src/lib/prisma.ts`.
|
||||||
|
- Page-level read models are built in `src/hooks/useProjects.ts` and `src/hooks/useHome.ts`.
|
||||||
|
|
||||||
|
**Cached Server State:**
|
||||||
|
- `unstable_cache` is used in `src/hooks/useProjects.ts`, `src/hooks/useHome.ts`, and `src/app/api/tags/route.ts`.
|
||||||
|
- `src/lib/cache.ts` provides `runWithCacheFallback` so cached fetchers can fall back to direct database reads when Incremental Cache is unavailable.
|
||||||
|
|
||||||
|
**Client UI State:**
|
||||||
|
- Local component state, not a global store, drives interactivity.
|
||||||
|
- `src/app/[locale]/projects/ProjectsPageClient.tsx` stores filter-panel expansion.
|
||||||
|
- `src/app/[locale]/projects/ProjectsResultsClient.tsx` stores AI/traditional pagination, loading flags, and sort/limit state.
|
||||||
|
- `src/components/signals/SignalFeedClient.tsx` stores debounced search, source filters, sort mode, cursor, and loading state.
|
||||||
|
- `src/components/layout/AnnouncementBar.tsx` stores dismissal state in `localStorage`.
|
||||||
|
|
||||||
|
**URL State:**
|
||||||
|
- Search, pagination, sort, AI mode, and taxonomy filters are encoded in the query string on `src/app/[locale]/projects/page.tsx`.
|
||||||
|
- The projects UI treats the URL as the source of truth and synchronizes local state to it through `router.push` and `window.history.replaceState`.
|
||||||
|
|
||||||
|
**Translation State:**
|
||||||
|
- Locale selection is path-based and enforced by `src/middleware.ts`.
|
||||||
|
- Message bundles load from `src/messages/en.json` and `src/messages/zh.json` via `src/i18n/request.ts`.
|
||||||
|
|
||||||
|
## Key Abstractions
|
||||||
|
|
||||||
|
**Project Read Model:**
|
||||||
|
- Purpose: Present projects with flattened tags and related links, independent of Prisma join-table shape.
|
||||||
|
- Examples: `ProjectWithFlatTags` and `getProjects` in `src/hooks/useProjects.ts`, `getProjectBySlug` in `src/hooks/useProjects.ts`
|
||||||
|
- Pattern: Read-model transformation from Prisma includes to page/API-friendly objects.
|
||||||
|
|
||||||
|
**Tag Taxonomy:**
|
||||||
|
- Purpose: Normalize and classify project tags into fixed categories used by filters and maintenance APIs.
|
||||||
|
- Examples: `src/lib/tag-taxonomy.ts`, `getTagCategoryGroups` in `src/hooks/useProjects.ts`
|
||||||
|
- Pattern: Central domain vocabulary with inference helpers shared across pages and mutations.
|
||||||
|
|
||||||
|
**Signal View Model:**
|
||||||
|
- Purpose: Convert raw `Signal` rows, localized text fields, JSON sections, and hotness metadata into feed-ready objects.
|
||||||
|
- Examples: `toSignalView` and `parseSections` in `src/app/api/signals/route.ts`
|
||||||
|
- Pattern: API-specific presentation mapping on top of Prisma rows.
|
||||||
|
|
||||||
|
**Maintenance Service:**
|
||||||
|
- Purpose: Keep complex tag merge/update rules out of the route handler.
|
||||||
|
- Examples: `executeTagMaintenance` and `TagMaintenanceApiError` in `src/app/api/tags/maintenance/service.ts`
|
||||||
|
- Pattern: Route delegates orchestration to a transaction-aware service module.
|
||||||
|
|
||||||
|
**Auth Guard for Internal APIs:**
|
||||||
|
- Purpose: Reuse constant-time API-key validation across maintenance and webhook routes.
|
||||||
|
- 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 guard function, not middleware-based auth.
|
||||||
|
|
||||||
|
## Entry Points
|
||||||
|
|
||||||
|
**Root App Shell:**
|
||||||
|
- Location: `src/app/layout.tsx`
|
||||||
|
- Triggers: Every page render.
|
||||||
|
- Responsibilities: Global HTML/body shell, global CSS import, and conditional Vercel analytics injection through `src/app/VercelMetrics.tsx`.
|
||||||
|
|
||||||
|
**Localized App Shell:**
|
||||||
|
- Location: `src/app/[locale]/layout.tsx`
|
||||||
|
- Triggers: Every localized page render under `/<locale>/*`.
|
||||||
|
- Responsibilities: Locale validation, translations, site navigation, announcement bar, footer, and `NextIntlClientProvider`.
|
||||||
|
|
||||||
|
**Home Route:**
|
||||||
|
- Location: `src/app/[locale]/page.tsx`
|
||||||
|
- Triggers: `GET /zh` and `GET /en`
|
||||||
|
- Responsibilities: Render home hero, stats, rankings, tag insights, recent timeline, and featured projects.
|
||||||
|
|
||||||
|
**Projects Route:**
|
||||||
|
- Location: `src/app/[locale]/projects/page.tsx`
|
||||||
|
- Triggers: `GET /<locale>/projects`
|
||||||
|
- Responsibilities: Parse URL filters, render filter/search UI, fetch paginated project data, and mount the projects results client island.
|
||||||
|
|
||||||
|
**Project Detail Route:**
|
||||||
|
- Location: `src/app/[locale]/projects/[id]/page.tsx`
|
||||||
|
- Triggers: `GET /<locale>/projects/:slug`
|
||||||
|
- Responsibilities: Fetch a single project, derive related projects, render sidebar/details, and generate route metadata.
|
||||||
|
|
||||||
|
**Signals Route:**
|
||||||
|
- Location: `src/app/[locale]/signals/page.tsx`
|
||||||
|
- Triggers: `GET /<locale>/signals`
|
||||||
|
- Responsibilities: Render the signals feed shell and mount the client-side feed loader.
|
||||||
|
|
||||||
|
**API Routes:**
|
||||||
|
- Location: `src/app/api/*`
|
||||||
|
- Triggers: Browser fetches and external webhook clients.
|
||||||
|
- Responsibilities: JSON read endpoints, AI search proxying, signals feed pagination, authenticated tag maintenance, project tag resets, and signal ingestion.
|
||||||
|
|
||||||
|
**Locale Middleware:**
|
||||||
|
- Location: `src/middleware.ts`
|
||||||
|
- Triggers: All non-API, non-static requests.
|
||||||
|
- Responsibilities: Apply the `next-intl` locale prefix policy and route matching.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
**Strategy:** Validate early, return structured JSON for API errors, use `notFound()` for invalid page resources, and degrade some read paths to empty or fallback results instead of hard-failing the page.
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- Use Zod validation in `src/app/api/projects/route.ts`, `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` for route-level misses.
|
||||||
|
- Retry transient database errors in `src/hooks/useProjects.ts` via `withDbRetry`.
|
||||||
|
- Degrade to empty or zero-count results in `src/hooks/useProjects.ts` and `src/hooks/useHome.ts` when some DB reads fail.
|
||||||
|
- Wrap service-specific failures in `TagMaintenanceApiError` in `src/app/api/tags/maintenance/service.ts`.
|
||||||
|
|
||||||
|
## Cross-Cutting Concerns
|
||||||
|
|
||||||
|
**Logging:** Use `console.error` and `console.warn` directly in route handlers and server query modules, for example in `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:** Use shared Zod schemas from `src/lib/validations.ts` for external request bodies and query parameters.
|
||||||
|
|
||||||
|
**Authentication:** Protect internal mutation/webhook endpoints with API-key checks from `src/lib/auth.ts`. No session or user-account auth layer is present in the inspected files.
|
||||||
|
|
||||||
|
**Internationalization:** Use `next-intl` with locale-prefixed routing through `src/middleware.ts`, request config in `src/i18n/request.ts`, and message bundles in `src/messages/*.json`.
|
||||||
|
|
||||||
|
**Caching and Revalidation:** Use ISR-style route revalidation (`revalidate = 300`), `unstable_cache`, and `revalidatePath` after tag mutations.
|
||||||
|
|
||||||
|
**Observability:** The only built-in runtime instrumentation found is Vercel analytics in `src/app/VercelMetrics.tsx`. No separate tracing or background job framework was detected in inspected files.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Architecture analysis: 2026-04-18*
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
# Codebase Concerns
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-18
|
||||||
|
|
||||||
|
## Tech Debt
|
||||||
|
|
||||||
|
**Oversized mixed-responsibility modules:**
|
||||||
|
- Issue: Data access, cache policy, retry logic, query construction, and response shaping are combined in single files instead of being split into smaller modules.
|
||||||
|
- 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 many unrelated branches at once; regressions are likely because query behavior, cache behavior, and UI state are tightly coupled.
|
||||||
|
- Fix approach: Split server-side data access from presentation and client state; isolate URL/query builders, Prisma queries, cache wrappers, and presentational subcomponents into separate files before adding more behavior.
|
||||||
|
|
||||||
|
**Misleading server utilities under `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 behavior.
|
||||||
|
- Files: `src/hooks/useProjects.ts`, `src/hooks/useHome.ts`
|
||||||
|
- Impact: The naming invites accidental client imports and makes server/client boundaries harder to reason about during refactors.
|
||||||
|
- Fix approach: Move these modules to a server-oriented location such as `src/lib/` or `src/server/`, then keep React hooks in `src/hooks/` only.
|
||||||
|
|
||||||
|
**Schema-drift tolerance around signal hotness columns:**
|
||||||
|
- Issue: The application carries compatibility code for `hotScore` and `isHot` column absence even though the Prisma schema declares both fields.
|
||||||
|
- Files: `src/lib/signal-hotness.ts`, `src/app/api/signals/route.ts`, `src/app/api/webhook/signals/route.ts`, `prisma/schema.prisma`, `prisma/migrations/20260224120000_add_signal_hot_fields/migration.sql`
|
||||||
|
- Impact: Deployments can run with partially applied migrations without failing fast. That reduces blast radius in production but also hides schema drift and makes behavior environment-dependent.
|
||||||
|
- Fix approach: Treat missing columns as deployment errors after rollout is stable, or move the compatibility branch behind an explicit feature flag with clear removal criteria.
|
||||||
|
|
||||||
|
**Boot-time failure for AI search route configuration:**
|
||||||
|
- Issue: The AI search route reads `process.env.N8N_AI_SEARCH_WEBHOOK!` at module scope and throws immediately if the variable is missing.
|
||||||
|
- Files: `src/app/api/search/ai/route.ts`
|
||||||
|
- Impact: A missing env var breaks route initialization instead of returning a controlled runtime error. This is fragile in local setup, preview deployments, and tests.
|
||||||
|
- Fix approach: Read the env var inside the request handler, return a structured `500`, and cover the missing-env path with tests.
|
||||||
|
|
||||||
|
## Known Bugs
|
||||||
|
|
||||||
|
**Related projects are not selected from the actual related set:**
|
||||||
|
- Symptoms: The project detail page fetches only the latest three projects and then filters that tiny set for shared tags.
|
||||||
|
- Files: `src/app/[locale]/projects/[id]/page.tsx`, `src/hooks/useProjects.ts`
|
||||||
|
- Trigger: Open a project whose related items are not in the newest three active projects.
|
||||||
|
- Workaround: None in code. The page simply renders fewer or zero related projects.
|
||||||
|
|
||||||
|
**Interactive navigation and CTA elements are placeholders:**
|
||||||
|
- Symptoms: Several visible controls navigate to `#`, the mobile menu button has no behavior, and the newsletter form has no action handler.
|
||||||
|
- Files: `src/app/[locale]/layout.tsx`, `src/components/project/ProjectCard.tsx`
|
||||||
|
- Trigger: Click `submitProject`, footer/legal/social links, the mobile menu button, or submit the newsletter form.
|
||||||
|
- Workaround: None in code. Users stay on the same page or submit a form with no integration.
|
||||||
|
|
||||||
|
## Security Considerations
|
||||||
|
|
||||||
|
**Admin and ingestion endpoints rely on a single shared API key in request bodies:**
|
||||||
|
- Risk: The code checks only `apiKey` equality. There is no request signature, timestamp, nonce, replay protection, or rate limiting in application code.
|
||||||
|
- 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`
|
||||||
|
- Current mitigation: Timing-safe comparison via `crypto.timingSafeEqual` in `src/lib/auth.ts`
|
||||||
|
- Recommendations: Prefer HMAC-signed requests or provider-native webhook signatures, reject stale timestamps, add rate limiting, and keep network allowlisting outside the app if that is part of deployment.
|
||||||
|
|
||||||
|
**AI search leaks query text through URL-based webhook forwarding:**
|
||||||
|
- Risk: The route forwards user search text and filters to an external service with a `GET` request query string.
|
||||||
|
- Files: `src/app/api/search/ai/route.ts`
|
||||||
|
- Current mitigation: Not detected in code.
|
||||||
|
- Recommendations: Send the payload with `POST`, avoid placing search text in URLs, and document the data handling expectations for the external n8n workflow.
|
||||||
|
|
||||||
|
**Raw project markdown can load third-party images:**
|
||||||
|
- Risk: Markdown rendering sanitizes HTML, but image URLs still render through plain `<img>` elements. If project content is not fully trusted, remote images can leak user IPs and referrers to arbitrary hosts.
|
||||||
|
- 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 image hosts, or disable markdown images for untrusted content. This concern depends on whether project content is curated or user-submitted; the code alone does not establish that trust boundary.
|
||||||
|
|
||||||
|
## Performance Bottlenecks
|
||||||
|
|
||||||
|
**Signal ingestion performs per-item existence checks and upserts sequentially:**
|
||||||
|
- Problem: Each signal performs validation, `findUnique`, and `upsert` inside a loop. The route processes up to 100 signals per request and does not batch database writes.
|
||||||
|
- Files: `src/app/api/webhook/signals/route.ts`
|
||||||
|
- Cause: The ingestion path is written for straightforward correctness and partial failure reporting, not throughput.
|
||||||
|
- Improvement path: Preload existing records in bulk, batch inserts/updates where possible, and use a transaction or job queue if partial writes are unacceptable.
|
||||||
|
|
||||||
|
**Project and signal text search use `contains` scans without matching full-text indexes:**
|
||||||
|
- Problem: Search endpoints query multiple text fields with case-insensitive `contains`, but the Prisma schema indexes only sorting/filter columns such as `slug`, `status`, `createdAt`, `githubStars`, `publishedAt`, `hotScore`, and `engagement`.
|
||||||
|
- Files: `src/hooks/useProjects.ts`, `src/app/api/signals/route.ts`, `prisma/schema.prisma`
|
||||||
|
- Cause: Search logic is application-level string matching with no dedicated full-text search index.
|
||||||
|
- Improvement path: Add PostgreSQL full-text search or trigram indexes for project and signal search fields, or move search to a dedicated search service.
|
||||||
|
|
||||||
|
**Silent fallback paths can cache outage-shaped responses:**
|
||||||
|
- Problem: Several server-side queries catch database errors and return empty arrays or zero counts instead of surfacing failure.
|
||||||
|
- Files: `src/hooks/useProjects.ts`, `src/hooks/useHome.ts`
|
||||||
|
- Cause: The code prefers graceful degradation, combined with `unstable_cache` wrappers in the same modules.
|
||||||
|
- Improvement path: Separate fallback behavior from cached fetchers, emit structured telemetry, and avoid caching degraded empty results for homepage and listing data.
|
||||||
|
|
||||||
|
**AI search hydrates external results with local DB lookups and in-memory reordering:**
|
||||||
|
- Problem: The AI route requests candidate IDs from n8n, fetches projects from the database, then matches records back to the remote order with repeated `find` calls.
|
||||||
|
- Files: `src/app/api/search/ai/route.ts`, `src/hooks/useProjects.ts`
|
||||||
|
- Cause: Remote ranking and local hydration are stitched together in the route layer instead of a dedicated search service.
|
||||||
|
- Improvement path: Preserve order with an ID-to-project map, keep pagination logic on one side, and avoid fetching more rows than the final page requires.
|
||||||
|
|
||||||
|
## Fragile Areas
|
||||||
|
|
||||||
|
**Projects results UI has duplicated URL, pagination, and fetch state machines:**
|
||||||
|
- 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 maintain separate state, pagination, URL sync, and fetch flows inside one client component. The file also manually uses `window.history.replaceState`, which is easy to desynchronize from server-rendered state.
|
||||||
|
- Safe modification: Change one mode at a time, verify deep-linking and pagination after every edit, and extract shared query/pagination logic 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`
|
||||||
|
|
||||||
|
**Tag maintenance endpoints mix validation, mutation, and cache invalidation in request handlers:**
|
||||||
|
- 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: Business rules, Prisma writes, per-project iteration, and `revalidatePath` calls are tightly coupled. Bulk operations can change many rows and many pages in one request.
|
||||||
|
- Safe modification: Keep schema validation and mutation rules under tests, preserve transaction boundaries, and review all `revalidatePath` targets before changing route semantics.
|
||||||
|
- Test coverage: Route and service tests exist for these files, but there are no broader integration tests across Prisma, cache invalidation, and localized page rendering.
|
||||||
|
|
||||||
|
**Shared layout contains production UI plus unfinished placeholders:**
|
||||||
|
- Files: `src/app/[locale]/layout.tsx`
|
||||||
|
- Why fragile: The same layout file owns metadata, locale setup, navigation, announcement bar, newsletter section, footer, and placeholder interactions.
|
||||||
|
- Safe modification: Extract navigation, newsletter, and footer into dedicated components before wiring real integrations.
|
||||||
|
- Test coverage: No tests detected for this file.
|
||||||
|
|
||||||
|
## Scaling Limits
|
||||||
|
|
||||||
|
**Offset pagination on projects will degrade as data grows:**
|
||||||
|
- Current capacity: `src/hooks/useProjects.ts` uses `skip` and `take`; `src/app/api/projects/route.ts` allows `limit` up to `100`.
|
||||||
|
- Limit: High page numbers require larger offset scans in PostgreSQL, especially when combined with multi-join tag filters and text search.
|
||||||
|
- Scaling path: Move to cursor-based pagination for project listings or restrict deep paging with indexed sort keys.
|
||||||
|
|
||||||
|
**Signal search and sort scale with table growth, not just page size:**
|
||||||
|
- Current capacity: `src/app/api/signals/route.ts` limits pages to `50` items, but search still scans multiple text columns and sort-by-hot depends on computed/indexed metadata.
|
||||||
|
- Limit: Query latency rises as the `signals` table grows because there is no text-search index for `q`.
|
||||||
|
- Scaling path: Add dedicated search indexes and keep cursor pagination tied to indexed sort orders only.
|
||||||
|
|
||||||
|
**Bulk tag reset revalidates pages per updated project:**
|
||||||
|
- Current capacity: `src/app/api/tags/reset-projects/route.ts` accepts up to `100` projects and revalidates both locale list pages plus two detail pages per updated slug.
|
||||||
|
- Limit: A large batch creates many cache invalidations and can amplify request time.
|
||||||
|
- Scaling path: Batch revalidation, use broader tag-based invalidation if available, or move bulk operations to a background job.
|
||||||
|
|
||||||
|
## Dependencies at Risk
|
||||||
|
|
||||||
|
**`next/cache` `unstable_cache` behavior is runtime-sensitive:**
|
||||||
|
- Risk: Both server data modules include custom fallback logic for missing incremental cache support, which means cache behavior is not consistent across every execution context.
|
||||||
|
- Impact: The same function can behave differently in local development, tests, and production-like runtimes.
|
||||||
|
- Migration plan: Centralize cache wrappers in one server utility, document expected runtimes, and replace `unstable_cache` usage with stable APIs when the project upgrades to a supported alternative.
|
||||||
|
|
||||||
|
**External AI search depends on an n8n webhook contract with no local fallback:**
|
||||||
|
- Risk: The project assumes an external response shape and throws or fails requests when the webhook is unavailable or changes shape.
|
||||||
|
- Impact: `/api/search/ai` becomes a single external point of failure for AI search.
|
||||||
|
- Migration plan: Version the webhook contract, add contract tests, and consider a local adapter layer that can degrade more predictably.
|
||||||
|
|
||||||
|
## Missing Critical Features
|
||||||
|
|
||||||
|
**End-to-end test harness is absent from the repository:**
|
||||||
|
- Problem: `package.json` exposes only `pnpm test` for Vitest. No `playwright.config.*`, no `e2e/` directory, and no `test:e2e` script are present in the repository.
|
||||||
|
- Blocks: Critical user flows such as localized routing, project filtering, AI search, signals pagination, and webhook-backed content updates have no browser-level regression protection.
|
||||||
|
|
||||||
|
**User-facing submission and newsletter flows are not implemented:**
|
||||||
|
- Problem: The visible submission CTA and newsletter UI are placeholders with no connected backend or external provider.
|
||||||
|
- Blocks: Users cannot actually submit projects, subscribe to updates, or access legal/resource destinations from the shipped UI.
|
||||||
|
|
||||||
|
## Test Coverage Gaps
|
||||||
|
|
||||||
|
**Search and listing APIs are 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, filtering, sort correctness, env-missing behavior, and external-service failure handling can break unnoticed.
|
||||||
|
- Priority: High
|
||||||
|
|
||||||
|
**Signal ingestion path is untested:**
|
||||||
|
- What's not tested: Validation, per-item failure accounting, schema-drift 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
|
||||||
|
|
||||||
|
**Large client components and shared layout have no regression tests:**
|
||||||
|
- What's not tested: Filter toggling, URL synchronization, pagination controls, AI/traditional mode switching, signal feed interactions, and layout placeholders
|
||||||
|
- 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 have many interaction branches.
|
||||||
|
- Priority: High
|
||||||
|
|
||||||
|
**Current tests focus narrowly on tag maintenance and schema validation:**
|
||||||
|
- What's not tested: Most database-backed pages and non-tag APIs outside a few route/service units
|
||||||
|
- Files: `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`, `src/app/api/tags/route.test.ts`, `src/lib/auth.test.ts`, `src/lib/validations.test.ts`, `src/lib/validations.tag-maintenance.test.ts`
|
||||||
|
- Risk: The test suite gives confidence for tag admin flows but not for the main product surfaces.
|
||||||
|
- Priority: Medium
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Concerns audit: 2026-04-18*
|
||||||
@@ -0,0 +1,148 @@
|
|||||||
|
# Coding Conventions
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-18
|
||||||
|
|
||||||
|
## Naming Patterns
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Use Next.js route filenames in `src/app`: `page.tsx`, `layout.tsx`, `route.ts`, `not-found.tsx`, `robots.ts`, and `sitemap.ts` as seen in `src/app/[locale]/page.tsx`, `src/app/[locale]/layout.tsx`, `src/app/api/projects/route.ts`, and `src/app/[locale]/not-found.tsx`.
|
||||||
|
- Use `PascalCase.tsx` for reusable components in `src/components`, for example `src/components/project/ProjectCard.tsx`, `src/components/project/TagFilterPanel.tsx`, and `src/components/signals/SignalFeedClient.tsx`.
|
||||||
|
- Use lower-case utility filenames in `src/lib`, for example `src/lib/auth.ts`, `src/lib/cache.ts`, `src/lib/signal-hotness.ts`, and `src/lib/validations.ts`.
|
||||||
|
- `src/hooks` is not limited to React hooks. `src/hooks/useProjects.ts` and `src/hooks/useHome.ts` export server-side data access and aggregation functions, not hook APIs. Extend those files only when adding the same kind of server query layer.
|
||||||
|
|
||||||
|
**Functions:**
|
||||||
|
- Use `camelCase` for functions and helpers: `isValidApiKey` in `src/lib/auth.ts`, `normalizeProjectSort` in `src/hooks/useProjects.ts`, `runWithCacheFallback` in `src/lib/cache.ts`, and `collectSelectedTagSlugs` in `src/app/api/tags/reset-projects/route.ts`.
|
||||||
|
- Use `PascalCase` only for React components and error classes: `ProjectCard` in `src/components/project/ProjectCard.tsx`, `ProjectsResultsClient` in `src/app/[locale]/projects/ProjectsResultsClient.tsx`, and `TagMaintenanceApiError` in `src/app/api/tags/maintenance/service.ts`.
|
||||||
|
- Use `GET` and `POST` named exports for route handlers in `src/app/api/**/route.ts`.
|
||||||
|
|
||||||
|
**Variables:**
|
||||||
|
- Use `UPPER_SNAKE_CASE` for constants and configuration knobs, for example `DB_RETRY_DELAYS_MS` in `src/hooks/useProjects.ts`, `DEFAULT_RESET_CATEGORIES` in `src/app/api/tags/reset-projects/route.ts`, `TAGS_CACHE_REVALIDATE_SECONDS` in `src/app/api/tags/route.ts`, and `N8N_WEBHOOK_URL` in `src/app/api/search/ai/route.ts`.
|
||||||
|
- Use descriptive typed local variables for parsed or normalized input, such as `validatedQuery` in `src/app/api/projects/route.ts`, `normalizedTagSlugs` in `src/hooks/useProjects.ts`, and `validationResult` in `src/app/api/webhook/signals/route.ts`.
|
||||||
|
|
||||||
|
**Types:**
|
||||||
|
- Prefer `type` aliases for data shapes and Prisma payloads, for example `ProjectWithFlatTags` in `src/hooks/useProjects.ts`, `HomePageData` in `src/hooks/useHome.ts`, and `ResetResultItem` in `src/app/api/tags/reset-projects/route.ts`.
|
||||||
|
- Use `interface` for component props, for example `ProjectCardProps` in `src/components/project/ProjectCard.tsx`, `ProjectsPageClientProps` in `src/app/[locale]/projects/ProjectsPageClient.tsx`, and `SignalFeedClientProps` in `src/components/signals/SignalFeedClient.tsx`.
|
||||||
|
|
||||||
|
## Code Style
|
||||||
|
|
||||||
|
**Formatting:**
|
||||||
|
- Prettier is configured in `.prettierrc.json` for 2-space indentation, semicolons, double quotes, trailing commas set to `es5`, and `printWidth` 100.
|
||||||
|
- The repository is not uniformly formatted to that config. Files such as `src/lib/validations.ts`, `src/hooks/useProjects.ts`, and `src/app/api/tags/maintenance/route.ts` match the configured double-quote and semicolon style, while `src/app/api/projects/route.ts`, `src/app/api/search/ai/route.ts`, `src/lib/auth.ts`, and `src/components/project/ProjectCard.tsx` use single quotes and omit semicolons.
|
||||||
|
- For new files, follow `.prettierrc.json`. When editing existing files, preserve the file-local style unless the whole file is reformatted.
|
||||||
|
|
||||||
|
**Linting:**
|
||||||
|
- ESLint extends `next/core-web-vitals` and `prettier` in `.eslintrc.json`.
|
||||||
|
- `console.warn` and `console.error` are allowed; other `console` calls are warned by `no-console`.
|
||||||
|
- Quality checks on 2026-04-18: `pnpm lint` passed with `✔ No ESLint warnings or errors`.
|
||||||
|
|
||||||
|
## Import Organization
|
||||||
|
|
||||||
|
**Order:**
|
||||||
|
1. Framework and platform imports first, for example `next/server`, `next/cache`, `zod`, `@prisma/client`, or `react`.
|
||||||
|
2. Internal alias imports from `@/`, for example `@/lib/prisma`, `@/hooks/useProjects`, and `@/lib/validations`.
|
||||||
|
3. Relative imports last, for example `./service` in `src/app/api/tags/maintenance/route.ts`.
|
||||||
|
|
||||||
|
**Path Aliases:**
|
||||||
|
- Use the `@/*` alias from `tsconfig.json` and `vitest.config.ts` for internal imports.
|
||||||
|
- Prefer `@/` over deep relative paths across `src`, for example `@/lib/prisma` in `src/app/api/tags/route.ts` and `@/lib/tag-taxonomy` in `src/hooks/useProjects.ts`.
|
||||||
|
|
||||||
|
## Component and Module Design
|
||||||
|
|
||||||
|
**React Components:**
|
||||||
|
- Default-export only route-level pages and layouts, for example `src/app/[locale]/page.tsx`, `src/app/[locale]/projects/page.tsx`, and `src/app/layout.tsx`.
|
||||||
|
- Use named exports for reusable components, for example `ProjectCard` in `src/components/project/ProjectCard.tsx`, `HomeRankings` in `src/components/home/HomeRankings.tsx`, and `AnnouncementBar` in `src/components/layout/AnnouncementBar.tsx`.
|
||||||
|
- Mark interactive components with `'use client'`, as seen in `src/app/[locale]/projects/ProjectsResultsClient.tsx`, `src/app/[locale]/projects/ProjectsPageClient.tsx`, `src/components/project/TagFilterPanel.tsx`, and `src/components/signals/SignalFeedClient.tsx`.
|
||||||
|
- Keep server components async and free of client hooks, as seen in `src/app/[locale]/projects/[id]/page.tsx`, `src/app/[locale]/signals/page.tsx`, and `src/components/project/ProjectSidebar.tsx`.
|
||||||
|
- Type component props explicitly with a local `interface ...Props`.
|
||||||
|
|
||||||
|
**Service and Query Modules:**
|
||||||
|
- Centralize Prisma-backed read logic in `src/hooks/useProjects.ts` and `src/hooks/useHome.ts`. Despite the directory name, these modules act as query services for routes and server components.
|
||||||
|
- Keep route handlers thin where possible and delegate business rules to local services for mutations. The clearest example is `src/app/api/tags/maintenance/route.ts` delegating to `src/app/api/tags/maintenance/service.ts`.
|
||||||
|
- Reuse shared utility modules in `src/lib` for cross-cutting concerns: `src/lib/auth.ts`, `src/lib/cache.ts`, `src/lib/prisma.ts`, `src/lib/signal-hotness.ts`, `src/lib/slug.ts`, and `src/lib/tag-taxonomy.ts`.
|
||||||
|
|
||||||
|
**Exports:**
|
||||||
|
- Prefer named exports across shared modules. No barrel files were detected under `src` on 2026-04-18.
|
||||||
|
|
||||||
|
## Validation
|
||||||
|
|
||||||
|
**Schema Placement:**
|
||||||
|
- Put broadly shared Zod schemas in `src/lib/validations.ts`. Examples include `ProjectInputSchema`, `SignalWebhookPayloadSchema`, `SignalQuerySchema`, and `TagMaintenanceRequestSchema`.
|
||||||
|
- Define route-local Zod schemas only when the contract is tightly coupled to a single endpoint, as in `ProjectsQuerySchema` in `src/app/api/projects/route.ts` and `N8NSearchResponseSchema` in `src/app/api/search/ai/route.ts`.
|
||||||
|
|
||||||
|
**Validation Flow:**
|
||||||
|
- Use `.safeParse()` when the route needs to return a custom `400` payload 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 has a `ZodError` catch branch, as in `src/app/api/projects/route.ts`, `src/app/api/search/ai/route.ts`, and `src/app/api/signals/route.ts`.
|
||||||
|
- Use `z.coerce` for query-string number parsing in shared schemas, as in `SignalQuerySchema` and `ProjectQuerySchema` in `src/lib/validations.ts`.
|
||||||
|
- Add cross-field validation with `.superRefine()` for multi-item or relation rules, as in `TagMergeSchema` and `TagMaintenanceRequestSchema` in `src/lib/validations.ts`.
|
||||||
|
|
||||||
|
## Data Access
|
||||||
|
|
||||||
|
**Prisma:**
|
||||||
|
- Use the singleton Prisma client from `src/lib/prisma.ts`.
|
||||||
|
- Define Prisma payload types close to the query layer with `Prisma.*GetPayload`, as in `ProjectWithTagsAndLinks` and `TagWithProjectCount` in `src/hooks/useProjects.ts`.
|
||||||
|
- Prefer explicit `include` and `select` clauses to control payload shape, as seen throughout `src/hooks/useProjects.ts`, `src/hooks/useHome.ts`, `src/app/api/signals/route.ts`, and `src/app/api/tags/reset-projects/route.ts`.
|
||||||
|
- Use `prisma.$transaction(...)` for write paths that change multiple tables, as in `src/app/api/tags/maintenance/route.ts` and `src/app/api/tags/reset-projects/route.ts`.
|
||||||
|
|
||||||
|
**Caching and Fallbacks:**
|
||||||
|
- Wrap cacheable server reads with `unstable_cache`, using stable key arrays and `revalidate` windows, as in `src/app/api/tags/route.ts`, `src/hooks/useProjects.ts`, and `src/hooks/useHome.ts`.
|
||||||
|
- Route cached reads through `runWithCacheFallback` from `src/lib/cache.ts` so execution can fall back when `unstable_cache` is unavailable.
|
||||||
|
- Cache fetcher functions keyed by input when the function signature varies, as in `topTagsCache` and `fixedProjectTypeFilterCache` in `src/hooks/useProjects.ts`.
|
||||||
|
|
||||||
|
**Retry and Degrade Patterns:**
|
||||||
|
- Use retry wrappers for transient DB issues on read paths. `withDbRetry` and `isTransientDbError` in `src/hooks/useProjects.ts` are the current pattern.
|
||||||
|
- Degrade to safe defaults on non-critical homepage and filter data instead of failing the whole page, as in `safeQuery` in `src/hooks/useHome.ts` and `console.error` fallback branches in `src/hooks/useProjects.ts`.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- Wrap route handlers in `try/catch` and return JSON error payloads through `NextResponse.json`, as in `src/app/api/projects/route.ts`, `src/app/api/tags/route.ts`, `src/app/api/search/ai/route.ts`, and `src/app/api/webhook/signals/route.ts`.
|
||||||
|
- Return `400` for schema and cursor validation failures, `401` for invalid API keys, `409` for tag conflicts inside service code, and `500` for unexpected errors.
|
||||||
|
- Use domain-specific error classes when mutation services need to communicate status and details back to routes. `TagMaintenanceApiError` in `src/app/api/tags/maintenance/service.ts` is the established pattern.
|
||||||
|
- Include machine-readable `success`, `error`, `details`, and sometimes `message` fields in JSON responses. The exact shape varies by route and is not fully normalized.
|
||||||
|
|
||||||
|
**Logging:**
|
||||||
|
- Use `console.error` for failures and `console.warn` for degraded or summary logging, for example `src/hooks/useHome.ts`, `src/hooks/useProjects.ts`, `src/app/api/signals/route.ts`, and `src/app/api/webhook/signals/route.ts`.
|
||||||
|
- Error-path tests currently allow log output to stderr, as verified by `pnpm test` on 2026-04-18 from `src/app/api/tags/route.test.ts`.
|
||||||
|
|
||||||
|
## Comments
|
||||||
|
|
||||||
|
**When to Comment:**
|
||||||
|
- Comments are sparse and usually explain non-obvious intent, numbered route steps, or bilingual product context.
|
||||||
|
- English and Chinese comments coexist. Examples include the numbered route comments in `src/app/api/tags/maintenance/route.ts`, Chinese comments in `src/hooks/useProjects.ts`, and mixed bilingual commentary in `src/components/project/ProjectCard.tsx`.
|
||||||
|
- Prefer comments only where intent is not obvious from code.
|
||||||
|
|
||||||
|
**JSDoc/TSDoc:**
|
||||||
|
- JSDoc is uncommon. The clearest example is the security-sensitive note on `isValidApiKey` in `src/lib/auth.ts`.
|
||||||
|
|
||||||
|
## Function Design
|
||||||
|
|
||||||
|
**Size:**
|
||||||
|
- Read/query modules tolerate large files with many helpers. `src/hooks/useProjects.ts` is 602 lines, `src/hooks/useHome.ts` is 250 lines, and `src/app/api/signals/route.ts` is 355 lines.
|
||||||
|
- Keep complex logic split into local helpers inside the same file before extracting a new module. Current examples include `parseSlugList` in `src/app/api/projects/route.ts`, `parseSections` in `src/app/api/signals/route.ts`, and `collectValidationErrorsForProjectItem` in `src/app/api/tags/reset-projects/route.ts`.
|
||||||
|
|
||||||
|
**Parameters:**
|
||||||
|
- Prefer a single typed options object for query functions, as in `getProjects` in `src/hooks/useProjects.ts`.
|
||||||
|
- Use small helper functions for normalization of query and payload input, such as `normalizePositiveInteger` in `src/hooks/useProjects.ts` and `normalizeSlug` in `src/app/api/tags/reset-projects/route.ts`.
|
||||||
|
|
||||||
|
**Return Values:**
|
||||||
|
- Return plain serializable objects from server query helpers when the result is meant for pages or APIs, as in `getProjects`, `getHomePageData`, and `getProjectsByIds`.
|
||||||
|
- Flatten Prisma relation shapes before returning to callers when the UI expects direct lists, as in `getProjects`, `getProjectBySlug`, and `getProjectsByIds` in `src/hooks/useProjects.ts`.
|
||||||
|
|
||||||
|
## Environment and Configuration
|
||||||
|
|
||||||
|
**Environment Files:**
|
||||||
|
- `.env`, `.env.local`, and `.env.example` are present at repository root. Use `.env.example` as the naming reference; do not commit real secrets.
|
||||||
|
|
||||||
|
**Observed Variables:**
|
||||||
|
- `.env.example` defines `DATABASE_URL`, `WEBHOOK_API_KEY`, `N8N_AI_SEARCH_WEBHOOK`, `NEXT_INTL_DEFAULT_LOCALE`, and `NEXT_INTL_SUPPORTED_LOCALES`.
|
||||||
|
- Source code reads `WEBHOOK_API_KEY` in `src/lib/auth.ts`, `N8N_AI_SEARCH_WEBHOOK` in `src/app/api/search/ai/route.ts`, `NEXT_PUBLIC_SITE_URL` in `src/app/robots.ts` and `src/app/sitemap.ts`, `VERCEL_ENV` in `src/app/layout.tsx`, and `NODE_ENV` in `src/lib/prisma.ts`.
|
||||||
|
- `NEXT_INTL_DEFAULT_LOCALE` and `NEXT_INTL_SUPPORTED_LOCALES` appear in `.env.example` but were not detected in runtime code on 2026-04-18. Locale behavior is hard-coded in `src/middleware.ts` and `src/i18n/request.ts`.
|
||||||
|
|
||||||
|
**Conventions:**
|
||||||
|
- Fail fast at module load only for truly required integration config. `src/app/api/search/ai/route.ts` throws immediately if `N8N_AI_SEARCH_WEBHOOK` is unset.
|
||||||
|
- Use safe fallbacks for public metadata values, as in `src/app/robots.ts` and `src/app/sitemap.ts` defaulting `NEXT_PUBLIC_SITE_URL` to `https://agentpark.ai`.
|
||||||
|
- Keep deployment and framework config in root files: `next.config.js`, `tailwind.config.ts`, `postcss.config.mjs`, `tsconfig.json`, `.eslintrc.json`, `.prettierrc.json`, and `vitest.config.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Convention analysis: 2026-04-18*
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
# External Integrations
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-18
|
||||||
|
|
||||||
|
## APIs & External Services
|
||||||
|
|
||||||
|
**Workflow Automation / Search:**
|
||||||
|
- n8n webhook - AI search requests are forwarded from `src/app/api/search/ai/route.ts` to the URL in `process.env.N8N_AI_SEARCH_WEBHOOK`.
|
||||||
|
- SDK/Client: Native `fetch` in `src/app/api/search/ai/route.ts`
|
||||||
|
- Auth: `N8N_AI_SEARCH_WEBHOOK`
|
||||||
|
- Evidence: outbound `GET` request is constructed in `src/app/api/search/ai/route.ts`; `.env.example` provides the webhook variable name.
|
||||||
|
|
||||||
|
**Vercel Runtime Telemetry:**
|
||||||
|
- Vercel Analytics - Client analytics are mounted in `src/app/VercelMetrics.tsx` and only rendered when `process.env.VERCEL_ENV === "production"` in `src/app/layout.tsx`.
|
||||||
|
- SDK/Client: `@vercel/analytics`
|
||||||
|
- Auth: Managed by Vercel runtime; no repo-managed token detected
|
||||||
|
- Vercel Speed Insights - Frontend performance sampling is mounted beside analytics in `src/app/VercelMetrics.tsx`.
|
||||||
|
- SDK/Client: `@vercel/speed-insights`
|
||||||
|
- Auth: Managed by Vercel runtime; no repo-managed token detected
|
||||||
|
|
||||||
|
**Static Asset Providers:**
|
||||||
|
- Google Fonts / Material Icons - CSS imports in `src/app/globals.css` load Inter, Space Mono, and Material Icons from `fonts.googleapis.com`.
|
||||||
|
- SDK/Client: CSS `@import`
|
||||||
|
- Auth: None
|
||||||
|
- Shields.io - GitHub badge images are generated in `src/lib/github/badges.ts` and rendered through `next/image` in `src/components/project/ProjectCard.tsx` and `src/components/project/GitHubTextStatsCard.tsx`.
|
||||||
|
- SDK/Client: URL construction only
|
||||||
|
- Auth: None
|
||||||
|
- Evidence: `img.shields.io` is explicitly whitelisted in `next.config.js`.
|
||||||
|
|
||||||
|
**Content / Link Surfaces:**
|
||||||
|
- GitHub - The app stores GitHub repository links on projects and builds GitHub badge and deep-link URLs in `src/lib/github/badges.ts`.
|
||||||
|
- SDK/Client: None detected
|
||||||
|
- Auth: None detected
|
||||||
|
- Note: GitHub API calls are not detected in current code; integration is via stored URLs and Shields.io images.
|
||||||
|
|
||||||
|
## Data Storage
|
||||||
|
|
||||||
|
**Databases:**
|
||||||
|
- PostgreSQL
|
||||||
|
- Connection: `DATABASE_URL`
|
||||||
|
- Client: Prisma via `@prisma/client` in `src/lib/prisma.ts`
|
||||||
|
- Schema: `prisma/schema.prisma`
|
||||||
|
- Migrations: `prisma/migrations/*/migration.sql`
|
||||||
|
- Usage: Queried from `src/hooks/useProjects.ts`, `src/app/api/projects/route.ts`, `src/app/api/tags/route.ts`, `src/app/api/signals/route.ts`, and webhook-style route handlers.
|
||||||
|
|
||||||
|
**File Storage:**
|
||||||
|
- Local filesystem only
|
||||||
|
- Evidence: No S3, Blob, Cloudinary, or similar storage SDK is declared in `package.json` or imported under `src/**/*`.
|
||||||
|
|
||||||
|
**Caching:**
|
||||||
|
- Next.js data cache via `unstable_cache`
|
||||||
|
- Service: Built-in framework cache, not a separate external service
|
||||||
|
- Implementation: `src/app/api/tags/route.ts` and `src/hooks/useProjects.ts`
|
||||||
|
- External cache service: None detected
|
||||||
|
- Evidence: No Redis, Memcached, or similar client package is declared in `package.json`.
|
||||||
|
|
||||||
|
## Authentication & Identity
|
||||||
|
|
||||||
|
**Auth Provider:**
|
||||||
|
- Custom shared-secret authentication for machine-to-machine routes
|
||||||
|
- Implementation: `src/lib/auth.ts` compares a provided API key against `process.env.WEBHOOK_API_KEY` using `crypto.timingSafeEqual`.
|
||||||
|
- Used by:
|
||||||
|
- `src/app/api/webhook/signals/route.ts`
|
||||||
|
- `src/app/api/tags/maintenance/route.ts`
|
||||||
|
- `src/app/api/tags/reset-projects/route.ts`
|
||||||
|
- End-user authentication: Not detected
|
||||||
|
- Evidence: No NextAuth, Clerk, Auth.js, Supabase Auth, OAuth, or session middleware is present in `package.json` or `src/**/*`.
|
||||||
|
|
||||||
|
## Monitoring & Observability
|
||||||
|
|
||||||
|
**Error Tracking:**
|
||||||
|
- None detected
|
||||||
|
- Evidence: No Sentry, Bugsnag, Datadog, or Rollbar package is declared in `package.json`.
|
||||||
|
|
||||||
|
**Logs:**
|
||||||
|
- Server logging uses `console.error` and `console.warn` in route handlers such as `src/app/api/search/ai/route.ts`, `src/app/api/webhook/signals/route.ts`, and `src/app/api/tags/route.ts`.
|
||||||
|
- Frontend telemetry uses Vercel Analytics and Speed Insights in `src/app/VercelMetrics.tsx`.
|
||||||
|
|
||||||
|
## CI/CD & Deployment
|
||||||
|
|
||||||
|
**Hosting:**
|
||||||
|
- Vercel
|
||||||
|
- Evidence: `vercel.json` sets `"framework": "nextjs"`, `buildCommand`, `installCommand`, and region `hkg1`.
|
||||||
|
|
||||||
|
**CI Pipeline:**
|
||||||
|
- None detected in repo
|
||||||
|
- Evidence: No `.github/workflows/*`, GitLab CI file, CircleCI config, or other CI config file is present at repo root.
|
||||||
|
|
||||||
|
## Environment Configuration
|
||||||
|
|
||||||
|
**Required env vars:**
|
||||||
|
- `DATABASE_URL` - Required by Prisma datasource in `prisma/schema.prisma`.
|
||||||
|
- `WEBHOOK_API_KEY` - Required for authenticated webhook-style POST endpoints in `src/lib/auth.ts`.
|
||||||
|
- `N8N_AI_SEARCH_WEBHOOK` - Required by the outbound AI search proxy in `src/app/api/search/ai/route.ts`.
|
||||||
|
- `NEXT_PUBLIC_SITE_URL` - Optional but used to generate canonical URLs in `src/app/robots.ts` and `src/app/sitemap.ts`; code falls back to `https://agentpark.ai`.
|
||||||
|
- `VERCEL_ENV` - Read in `src/app/layout.tsx` to gate Vercel telemetry; expected when deployed on Vercel.
|
||||||
|
- `.env.example` also includes `NEXT_INTL_DEFAULT_LOCALE` and `NEXT_INTL_SUPPORTED_LOCALES`, but current locale middleware and request config rely on hard-coded values in `src/middleware.ts` and `src/i18n/request.ts`.
|
||||||
|
|
||||||
|
**Secrets location:**
|
||||||
|
- Local development secrets: `.env.local` and `.env` files are present in repo root; contents were not read.
|
||||||
|
- Production secrets: Vercel environment variables are implied by `vercel.json` and `process.env.*` usage, but no separate secret manager config is committed.
|
||||||
|
|
||||||
|
## Webhooks & Callbacks
|
||||||
|
|
||||||
|
**Incoming:**
|
||||||
|
- `POST /api/webhook/signals` in `src/app/api/webhook/signals/route.ts`
|
||||||
|
- Purpose: Ingests batched signal payloads into PostgreSQL via Prisma.
|
||||||
|
- Auth: Shared secret in request body validated against `WEBHOOK_API_KEY`.
|
||||||
|
- `POST /api/tags/maintenance` in `src/app/api/tags/maintenance/route.ts`
|
||||||
|
- Purpose: Applies tag updates/merges and revalidates project pages.
|
||||||
|
- Auth: Shared secret in request body validated against `WEBHOOK_API_KEY`.
|
||||||
|
- `POST /api/tags/reset-projects` in `src/app/api/tags/reset-projects/route.ts`
|
||||||
|
- Purpose: Resets project tag assignments by category and optionally revalidates pages.
|
||||||
|
- Auth: Shared secret in request body validated against `WEBHOOK_API_KEY`.
|
||||||
|
|
||||||
|
**Outgoing:**
|
||||||
|
- n8n AI search webhook
|
||||||
|
- Source: `src/app/api/search/ai/route.ts`
|
||||||
|
- Method: `GET`
|
||||||
|
- Target: URL from `N8N_AI_SEARCH_WEBHOOK`
|
||||||
|
- Search engine / badge asset requests from the browser are not hard-coded beyond standard page navigation, Google Fonts CSS, Material Icons CSS, and Shields.io image URLs.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Integration audit: 2026-04-18*
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
# Technology Stack
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-18
|
||||||
|
|
||||||
|
## Languages
|
||||||
|
|
||||||
|
**Primary:**
|
||||||
|
- TypeScript 5.x - Application code, API routes, hooks, Prisma access, and most 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 - Next.js and PostCSS config live in `next.config.js` and `postcss.config.mjs`.
|
||||||
|
- CSS - Global styling and external font/icon imports live in `src/app/globals.css`.
|
||||||
|
- JSON - Locale message catalogs live in `src/messages/en.json` and `src/messages/zh.json`; repo config also uses `.eslintrc.json` and `.prettierrc.json`.
|
||||||
|
- Prisma schema DSL - Database schema and datasource definitions live in `prisma/schema.prisma`.
|
||||||
|
|
||||||
|
## Runtime
|
||||||
|
|
||||||
|
**Environment:**
|
||||||
|
- Node.js - Repo runtime is Node-based because scripts use `next`, `vitest`, `prisma`, and `ts-node` from `package.json`.
|
||||||
|
- Version pinning: Not detected in repo. No `.nvmrc`, `.node-version`, or `.tool-versions` file is present.
|
||||||
|
- Local tool version observed in this workspace: Node.js `v22.21.1`.
|
||||||
|
|
||||||
|
**Package Manager:**
|
||||||
|
- `pnpm` - Project commands, lockfile, and Vercel install/build configuration use `pnpm` in `package.json`, `pnpm-lock.yaml`, and `vercel.json`.
|
||||||
|
- Local tool version observed in this workspace: `pnpm 10.27.0`.
|
||||||
|
- Lockfile: present in `pnpm-lock.yaml`.
|
||||||
|
|
||||||
|
## Frameworks
|
||||||
|
|
||||||
|
**Core:**
|
||||||
|
- Next.js `15.1.11` - App Router web framework for pages, layouts, metadata, and route handlers in `src/app/**/*`; version declared in `package.json`.
|
||||||
|
- React `19.0.0` and `react-dom` `19.0.0` - UI runtime for components in `src/components/**/*` and route segments in `src/app/**/*`; versions declared in `package.json`.
|
||||||
|
- `next-intl` `4.0.2` - Locale routing and message loading via `next.config.js`, `src/middleware.ts`, `src/i18n/request.ts`, and `src/app/[locale]/layout.tsx`.
|
||||||
|
- Prisma `6.1.0` / `@prisma/client` `6.1.0` - ORM and generated client used in `src/lib/prisma.ts`, `src/hooks/useProjects.ts`, `src/app/api/**/*`, `prisma/schema.prisma`, and `prisma/seed.ts`.
|
||||||
|
- Zod `3.24.1` - Request and payload validation in `src/lib/validations.ts` and multiple route handlers under `src/app/api/**/*`.
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
- Vitest `2.1.8` - Unit-style test runner configured in `vitest.config.ts` and used by files such as `src/lib/auth.test.ts` and `src/app/api/tags/route.test.ts`.
|
||||||
|
|
||||||
|
**Build/Dev:**
|
||||||
|
- Tailwind CSS `3.4.17` - Utility-first styling configured in `tailwind.config.ts` and consumed by `src/app/globals.css` and component classes across `src/components/**/*`.
|
||||||
|
- PostCSS `8.x` with `autoprefixer` `10.4.20` - CSS processing configured in `postcss.config.mjs`.
|
||||||
|
- ESLint `9.x` with `eslint-config-next` `15.1.11` and `eslint-config-prettier` `9.1.0` - Linting configured in `.eslintrc.json`.
|
||||||
|
- Prettier `3.4.2` - Formatting configured in `.prettierrc.json`.
|
||||||
|
- `ts-node` `10.9.2` - TypeScript execution for seeding via the `prisma.seed` command in `package.json`.
|
||||||
|
- `tailwindcss-animate` `1.0.7` - Tailwind plugin loaded in `tailwind.config.ts`.
|
||||||
|
|
||||||
|
## Key Dependencies
|
||||||
|
|
||||||
|
**Critical:**
|
||||||
|
- `next` `15.1.11` - Main application framework; routes, layouts, metadata, and route handlers all depend on files under `src/app/**/*`.
|
||||||
|
- `react` `19.0.0` / `react-dom` `19.0.0` - Required by all React components in `src/components/**/*` and page/layout files in `src/app/**/*`.
|
||||||
|
- `@prisma/client` `6.1.0` - Database access layer instantiated in `src/lib/prisma.ts` and used heavily in `src/hooks/useProjects.ts`, `src/app/api/projects/route.ts`, `src/app/api/signals/route.ts`, and related files.
|
||||||
|
- `prisma` `6.1.0` - Schema and migration tool backing `prisma/schema.prisma` and `prisma/migrations/*/migration.sql`.
|
||||||
|
- `next-intl` `4.0.2` - Locale middleware and message loading depend on `src/middleware.ts`, `src/i18n/request.ts`, and `src/messages/*.json`.
|
||||||
|
- `zod` `3.24.1` - Input validation for search, tag maintenance, and webhook payloads in `src/app/api/search/ai/route.ts`, `src/app/api/tags/maintenance/route.ts`, and `src/app/api/webhook/signals/route.ts`.
|
||||||
|
|
||||||
|
**Infrastructure:**
|
||||||
|
- `@vercel/analytics` `1.6.1` and `@vercel/speed-insights` `1.3.1` - Vercel client telemetry mounted in `src/app/VercelMetrics.tsx` and conditionally included in `src/app/layout.tsx`.
|
||||||
|
- `react-markdown` `10.1.0`, `remark-gfm` `4.0.1`, and `rehype-sanitize` `6.0.0` - Markdown rendering stack used by `src/components/project/MarkdownContent.tsx`.
|
||||||
|
- `lucide-react` `0.468.0` - Icon set optimized through `experimental.optimizePackageImports` in `next.config.js`.
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
**Environment:**
|
||||||
|
- Template env vars are documented in `.env.example`.
|
||||||
|
- Real runtime env files exist as `.env` and `.env.local`; contents were not read.
|
||||||
|
- Code-level env usage is limited to:
|
||||||
|
- `DATABASE_URL` in `prisma/schema.prisma`
|
||||||
|
- `WEBHOOK_API_KEY` in `src/lib/auth.ts`
|
||||||
|
- `N8N_AI_SEARCH_WEBHOOK` in `src/app/api/search/ai/route.ts`
|
||||||
|
- `NEXT_PUBLIC_SITE_URL` in `src/app/robots.ts` and `src/app/sitemap.ts`
|
||||||
|
- `VERCEL_ENV` in `src/app/layout.tsx`
|
||||||
|
- `.env.example` also declares `NEXT_INTL_DEFAULT_LOCALE` and `NEXT_INTL_SUPPORTED_LOCALES`, but locale handling in current code is hard-coded in `src/i18n/request.ts` and `src/middleware.ts`.
|
||||||
|
|
||||||
|
**Build:**
|
||||||
|
- `next.config.js` wires `next-intl`, remote image hosts, and `optimizePackageImports`.
|
||||||
|
- `tsconfig.json` enables strict TypeScript, `noUncheckedIndexedAccess`, `noImplicitReturns`, and the `@/*` path alias.
|
||||||
|
- `tailwind.config.ts` and `postcss.config.mjs` define the styling pipeline.
|
||||||
|
- `vercel.json` defines deployment-time build/install commands, Next.js framework selection, region `hkg1`, and disables Git-triggered deployments.
|
||||||
|
- `package.json` scripts expose `dev`, `build`, `start`, `lint`, `test`, and Prisma seeding.
|
||||||
|
|
||||||
|
## Platform Requirements
|
||||||
|
|
||||||
|
**Development:**
|
||||||
|
- Node.js and `pnpm` are required to run `package.json` scripts.
|
||||||
|
- PostgreSQL is required because `prisma/schema.prisma` uses the `postgresql` provider and `DATABASE_URL`.
|
||||||
|
- Prisma client generation is required before production builds; `vercel.json` explicitly runs `pnpm prisma generate && pnpm build`.
|
||||||
|
|
||||||
|
**Production:**
|
||||||
|
- Vercel is the explicit deployment target indicated by `vercel.json` and the Vercel-specific telemetry components in `src/app/VercelMetrics.tsx`.
|
||||||
|
- The app expects Vercel environment semantics for telemetry gating via `process.env.VERCEL_ENV` in `src/app/layout.tsx`.
|
||||||
|
- Server runtime storage is PostgreSQL via Prisma; no alternate production datastore is configured in repo.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Stack analysis: 2026-04-18*
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
# Codebase Structure
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-18
|
||||||
|
|
||||||
|
## Directory Layout
|
||||||
|
|
||||||
|
```text
|
||||||
|
agent_park/
|
||||||
|
├── prisma/ # Prisma schema, migrations, and seed script
|
||||||
|
├── src/app/ # Next.js App Router entry points, layouts, API routes, global assets
|
||||||
|
├── 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 utilities, validation, Prisma client, taxonomy logic
|
||||||
|
├── src/messages/ # Locale message JSON files
|
||||||
|
├── .eslintrc.json # ESLint rules
|
||||||
|
├── .prettierrc.json # Prettier rules
|
||||||
|
├── next.config.js # Next.js config with next-intl plugin
|
||||||
|
├── tailwind.config.ts # Tailwind theme and content scan config
|
||||||
|
├── tsconfig.json # TypeScript config and `@/*` path alias
|
||||||
|
├── vercel.json # Vercel build/deploy config
|
||||||
|
└── vitest.config.ts # Vitest config for `src/**/*.test.ts`
|
||||||
|
```
|
||||||
|
|
||||||
|
## Directory Purposes
|
||||||
|
|
||||||
|
**`prisma/`:**
|
||||||
|
- Purpose: Own the database contract and seed path.
|
||||||
|
- Contains: `prisma/schema.prisma`, migration directories under `prisma/migrations/*`, and `prisma/seed.ts`.
|
||||||
|
- Key files: `prisma/schema.prisma`, `prisma/seed.ts`
|
||||||
|
|
||||||
|
**`src/app/`:**
|
||||||
|
- Purpose: Hold all App Router entry points, route-local components, API handlers, and app-wide assets.
|
||||||
|
- Contains: `layout.tsx`, `page.tsx`, `route.ts`, route-local client components, `globals.css`, `robots.ts`, `sitemap.ts`, and `icon.svg`.
|
||||||
|
- Key files: `src/app/layout.tsx`, `src/app/[locale]/layout.tsx`, `src/app/[locale]/page.tsx`, `src/app/[locale]/projects/page.tsx`, `src/app/api/projects/route.ts`, `src/app/api/signals/route.ts`
|
||||||
|
|
||||||
|
**`src/app/[locale]/`:**
|
||||||
|
- Purpose: Group locale-prefixed user-facing routes.
|
||||||
|
- Contains: localized pages such as `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`, and fallbacks like `src/app/[locale]/not-found.tsx`.
|
||||||
|
- Key files: `src/app/[locale]/layout.tsx`, `src/app/[locale]/projects/ProjectsPageClient.tsx`, `src/app/[locale]/projects/ProjectsResultsClient.tsx`
|
||||||
|
|
||||||
|
**`src/app/api/`:**
|
||||||
|
- Purpose: Group JSON endpoints and webhook handlers by resource.
|
||||||
|
- Contains: route handlers and a route-local service module at `src/app/api/tags/maintenance/service.ts`.
|
||||||
|
- 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/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 domain area, not by primitive type.
|
||||||
|
- Contains: feature folders `home`, `layout`, `locale`, `project`, `search`, and `signals`.
|
||||||
|
- Key files: `src/components/project/ProjectList.tsx`, `src/components/project/ProjectDetail.tsx`, `src/components/search/AISearchBar.tsx`, `src/components/signals/SignalFeedClient.tsx`
|
||||||
|
|
||||||
|
**`src/hooks/`:**
|
||||||
|
- Purpose: Hold server-side data-fetching and read-model builders.
|
||||||
|
- Contains: `src/hooks/useProjects.ts` and `src/hooks/useHome.ts`.
|
||||||
|
- Key files: `src/hooks/useProjects.ts`, `src/hooks/useHome.ts`
|
||||||
|
|
||||||
|
**`src/i18n/`:**
|
||||||
|
- Purpose: Configure `next-intl` request handling.
|
||||||
|
- Contains: `src/i18n/request.ts`.
|
||||||
|
- Key files: `src/i18n/request.ts`
|
||||||
|
|
||||||
|
**`src/lib/`:**
|
||||||
|
- Purpose: Hold cross-cutting domain and infrastructure utilities.
|
||||||
|
- Contains: auth, cache helpers, Prisma client, GitHub link helpers, tag taxonomy, slug generation, hotness scoring, and validation schemas.
|
||||||
|
- Key files: `src/lib/auth.ts`, `src/lib/cache.ts`, `src/lib/prisma.ts`, `src/lib/tag-taxonomy.ts`, `src/lib/signal-hotness.ts`, `src/lib/validations.ts`, `src/lib/github/badges.ts`
|
||||||
|
|
||||||
|
**`src/messages/`:**
|
||||||
|
- Purpose: Store locale dictionaries loaded by `next-intl`.
|
||||||
|
- Contains: `src/messages/en.json` and `src/messages/zh.json`.
|
||||||
|
- Key files: `src/messages/en.json`, `src/messages/zh.json`
|
||||||
|
|
||||||
|
## Key File Locations
|
||||||
|
|
||||||
|
**Entry Points:**
|
||||||
|
- `src/app/layout.tsx`: Root HTML/body shell and conditional analytics.
|
||||||
|
- `src/app/[locale]/layout.tsx`: Locale-aware site shell, navigation, footer, and translation provider.
|
||||||
|
- `src/app/[locale]/page.tsx`: Localized home page.
|
||||||
|
- `src/app/[locale]/projects/page.tsx`: Projects search/browse page.
|
||||||
|
- `src/app/[locale]/projects/[id]/page.tsx`: Project detail page.
|
||||||
|
- `src/app/[locale]/signals/page.tsx`: Signals feed page shell.
|
||||||
|
- `src/app/[locale]/about/page.tsx`: About page.
|
||||||
|
- `src/middleware.ts`: Locale routing middleware.
|
||||||
|
|
||||||
|
**API Endpoints:**
|
||||||
|
- `src/app/api/projects/route.ts`: Paginated projects list API.
|
||||||
|
- `src/app/api/projects/[slug]/route.ts`: Single-project JSON endpoint.
|
||||||
|
- `src/app/api/search/ai/route.ts`: AI search proxy and hydrator.
|
||||||
|
- `src/app/api/signals/route.ts`: Cursor-paginated signals feed API.
|
||||||
|
- `src/app/api/tags/route.ts`: Tags list API.
|
||||||
|
- `src/app/api/tags/maintenance/route.ts`: Authenticated tag update/merge API.
|
||||||
|
- `src/app/api/tags/reset-projects/route.ts`: Authenticated project-tag replacement API.
|
||||||
|
- `src/app/api/webhook/signals/route.ts`: Authenticated signal ingestion webhook.
|
||||||
|
|
||||||
|
**Configuration:**
|
||||||
|
- `next.config.js`: Wraps Next config with the `next-intl` plugin and remote image rules.
|
||||||
|
- `tailwind.config.ts`: Tailwind content paths and theme extension.
|
||||||
|
- `tsconfig.json`: Strict TypeScript settings and the `@/*` alias.
|
||||||
|
- `vitest.config.ts`: Node test environment and `src/**/*.test.ts` inclusion.
|
||||||
|
- `vercel.json`: Build/install commands and region targeting.
|
||||||
|
- `.eslintrc.json`: Lint rules.
|
||||||
|
- `.prettierrc.json`: Formatting rules.
|
||||||
|
- `.env.example`: Template environment file. `.env` and `.env.local` are present in the repository root but were not inspected.
|
||||||
|
|
||||||
|
**Core Logic:**
|
||||||
|
- `src/hooks/useProjects.ts`: Project list/detail queries, filter normalization, tag grouping, caching, and retry logic.
|
||||||
|
- `src/hooks/useHome.ts`: Home-page aggregate data builder.
|
||||||
|
- `src/lib/tag-taxonomy.ts`: Tag category metadata and inference rules.
|
||||||
|
- `src/lib/validations.ts`: Zod schemas for API inputs and domain payloads.
|
||||||
|
- `src/app/api/tags/maintenance/service.ts`: Transactional tag maintenance logic.
|
||||||
|
- `src/lib/signal-hotness.ts`: Signal hot-score computation and schema capability detection.
|
||||||
|
|
||||||
|
**Testing:**
|
||||||
|
- `src/lib/auth.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 repository tree.
|
||||||
|
|
||||||
|
## Naming Conventions
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Use Next.js route conventions inside `src/app`, for example `page.tsx`, `layout.tsx`, `route.ts`, `not-found.tsx`, `robots.ts`, and `sitemap.ts`.
|
||||||
|
- Use `PascalCase.tsx` for reusable components, for example `src/components/project/ProjectCard.tsx`, `src/components/project/ProjectSidebar.tsx`, and `src/components/home/HomeOverviewStats.tsx`.
|
||||||
|
- Keep route-specific helper components adjacent to the route they support, for example `src/app/[locale]/projects/ProjectsPageClient.tsx` and `src/app/[locale]/projects/ProjectsResultsClient.tsx`.
|
||||||
|
- Use lower-case or kebab-case utility filenames in `src/lib`, for example `src/lib/prisma.ts`, `src/lib/cache.ts`, `src/lib/tag-taxonomy.ts`, and `src/lib/signal-hotness.ts`.
|
||||||
|
- Keep test files adjacent to the code they cover using `*.test.ts`, for example `src/app/api/tags/maintenance/service.test.ts`.
|
||||||
|
|
||||||
|
**Directories:**
|
||||||
|
- Group reusable UI by product area under `src/components`, for example `src/components/project` and `src/components/search`.
|
||||||
|
- Group route files by URL shape under `src/app`, for example `src/app/[locale]/projects/[id]` and `src/app/api/projects/[slug]`.
|
||||||
|
- Keep infrastructure and domain utilities flat under `src/lib` instead of nesting many sublayers. The only nested utility folder detected is `src/lib/github/`.
|
||||||
|
|
||||||
|
## Where to Add New Code
|
||||||
|
|
||||||
|
**New User-Facing Page or Route Segment:**
|
||||||
|
- Primary code: add a new route under `src/app/[locale]/...` using Next conventions, for example `src/app/[locale]/new-section/page.tsx`.
|
||||||
|
- Shared shell changes: modify `src/app/[locale]/layout.tsx` only if the new route needs site-wide navigation or footer changes.
|
||||||
|
- Metadata/SEO for the route: colocate `generateMetadata` in the route file, following `src/app/[locale]/about/page.tsx` and `src/app/[locale]/signals/page.tsx`.
|
||||||
|
|
||||||
|
**New API Endpoint:**
|
||||||
|
- Primary code: add `route.ts` under `src/app/api/<resource>/`.
|
||||||
|
- Route-local helpers or service logic: colocate them next to the route, following `src/app/api/tags/maintenance/service.ts`.
|
||||||
|
- Validation: add or extend schemas in `src/lib/validations.ts` unless the validation is tightly route-local and one-off.
|
||||||
|
|
||||||
|
**New Reusable UI Component:**
|
||||||
|
- Implementation: place it in the matching feature folder under `src/components/`.
|
||||||
|
- Examples:
|
||||||
|
- Project-related UI goes in `src/components/project/`.
|
||||||
|
- Search UI goes in `src/components/search/`.
|
||||||
|
- Home-page modules go in `src/components/home/`.
|
||||||
|
- If a component is only used by one route and owns that route’s browser state, colocate it under the route folder in `src/app/[locale]/...`, following `src/app/[locale]/projects/ProjectsPageClient.tsx`.
|
||||||
|
|
||||||
|
**New Data Query or Read Model:**
|
||||||
|
- Shared project/home reads: extend `src/hooks/useProjects.ts` or `src/hooks/useHome.ts`.
|
||||||
|
- New domain-specific reads: add a new module under `src/hooks/` if the logic becomes large enough to stand alone.
|
||||||
|
- Use `src/lib/prisma.ts` for Prisma access instead of creating new Prisma clients.
|
||||||
|
|
||||||
|
**New Shared Utility or Domain Rule:**
|
||||||
|
- Shared helpers: add to `src/lib/`.
|
||||||
|
- Taxonomy/tag rules: extend `src/lib/tag-taxonomy.ts`.
|
||||||
|
- Request validation: extend `src/lib/validations.ts`.
|
||||||
|
- Authentication helpers for internal APIs: extend `src/lib/auth.ts`.
|
||||||
|
|
||||||
|
**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, following `src/app/[locale]/layout.tsx` and `src/components/project/ProjectList.tsx`.
|
||||||
|
|
||||||
|
**New Database Model or Field:**
|
||||||
|
- Schema: update `prisma/schema.prisma`.
|
||||||
|
- Migration: create a new directory under `prisma/migrations/`.
|
||||||
|
- Seed updates: modify `prisma/seed.ts` only if the new data is required for local bootstrapping.
|
||||||
|
|
||||||
|
**New Tests:**
|
||||||
|
- API and utility tests: colocate with the source file as `*.test.ts`.
|
||||||
|
- There is no current `e2e/` directory in the inspected tree, so introducing end-to-end tests will require creating that top-level directory explicitly.
|
||||||
|
|
||||||
|
## Special Directories
|
||||||
|
|
||||||
|
**`src/app/[locale]/projects/`:**
|
||||||
|
- Purpose: Contains one route page plus its route-local client islands.
|
||||||
|
- Generated: No
|
||||||
|
- Committed: Yes
|
||||||
|
|
||||||
|
**`src/app/api/tags/maintenance/`:**
|
||||||
|
- Purpose: Contains a route handler, a service module, and colocated tests for the tag-maintenance feature.
|
||||||
|
- Generated: No
|
||||||
|
- Committed: Yes
|
||||||
|
|
||||||
|
**`prisma/migrations/`:**
|
||||||
|
- Purpose: Stores schema migration history.
|
||||||
|
- Generated: Yes
|
||||||
|
- Committed: Yes
|
||||||
|
|
||||||
|
**`.next/`:**
|
||||||
|
- Purpose: Next.js build output and development cache.
|
||||||
|
- Generated: Yes
|
||||||
|
- Committed: No
|
||||||
|
|
||||||
|
**`.planning/codebase/`:**
|
||||||
|
- Purpose: Stores generated repository mapping documents such as this file.
|
||||||
|
- Generated: Yes
|
||||||
|
- Committed: Uncertain from inspected source files alone.
|
||||||
|
|
||||||
|
## Placement Rules
|
||||||
|
|
||||||
|
**Use `src/app` for route ownership:**
|
||||||
|
- Put code in `src/app` only when it directly maps to a URL, metadata document, or route-local UI state.
|
||||||
|
|
||||||
|
**Use `src/components` for reuse across routes:**
|
||||||
|
- Promote a route-local component into `src/components` only when another route needs it or it becomes generic enough to stand alone.
|
||||||
|
|
||||||
|
**Treat `src/hooks` as a server query layer, not browser hooks:**
|
||||||
|
- The existing `useProjects.ts` and `useHome.ts` modules are imported by server components and API handlers. Follow that pattern when adding read models there.
|
||||||
|
|
||||||
|
**Keep Prisma out of most UI files:**
|
||||||
|
- UI code typically consumes data returned by `src/hooks/*` or JSON from `src/app/api/*`.
|
||||||
|
- Direct Prisma calls are concentrated in `src/hooks/*`, `src/app/api/*`, and `src/lib/prisma.ts`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Structure analysis: 2026-04-18*
|
||||||
@@ -0,0 +1,267 @@
|
|||||||
|
# Testing Patterns
|
||||||
|
|
||||||
|
**Analysis Date:** 2026-04-18
|
||||||
|
|
||||||
|
## Test Framework
|
||||||
|
|
||||||
|
**Runner:**
|
||||||
|
- `vitest` via `vitest.config.ts`.
|
||||||
|
- `package.json` declares `vitest` in `devDependencies` and exposes `pnpm test`.
|
||||||
|
- Verified on 2026-04-18: `pnpm test` executed with Vitest `v2.1.9` and passed all current test files.
|
||||||
|
|
||||||
|
**Assertion Library:**
|
||||||
|
- Vitest built-ins: `describe`, `it`, `expect`, `beforeEach`, and `vi`, as seen in `src/lib/auth.test.ts`, `src/lib/validations.test.ts`, and `src/app/api/tags/maintenance/route.test.ts`.
|
||||||
|
|
||||||
|
**Config:**
|
||||||
|
- `vitest.config.ts` sets `environment: "node"`, aliases `@` to `./src`, disables watch mode, and includes only `src/**/*.test.ts`.
|
||||||
|
- No separate setup file, coverage config, browser environment, or integration test project was detected in `vitest.config.ts`.
|
||||||
|
|
||||||
|
**Run Commands:**
|
||||||
|
```bash
|
||||||
|
pnpm test # Run all configured Vitest suites
|
||||||
|
pnpm lint # Run ESLint quality checks
|
||||||
|
pnpm build # Run production build, lint, and type validation
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test File Organization
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- Tests are co-located beside the source they exercise.
|
||||||
|
- Library tests live next to utilities, for example `src/lib/auth.test.ts`, `src/lib/validations.test.ts`, and `src/lib/validations.tag-maintenance.test.ts`.
|
||||||
|
- API tests live next to route or service modules, for example `src/app/api/tags/route.test.ts`, `src/app/api/tags/reset-projects/route.test.ts`, `src/app/api/tags/maintenance/route.test.ts`, and `src/app/api/tags/maintenance/service.test.ts`.
|
||||||
|
|
||||||
|
**Naming:**
|
||||||
|
- Use `*.test.ts`. No `*.spec.ts` files were detected under `src` on 2026-04-18.
|
||||||
|
- The include pattern in `vitest.config.ts` means `*.test.tsx` and files outside `src` are not picked up by default.
|
||||||
|
|
||||||
|
**Structure:**
|
||||||
|
```text
|
||||||
|
src/
|
||||||
|
lib/
|
||||||
|
auth.ts
|
||||||
|
auth.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(() => {
|
||||||
|
// create hoisted mocks here
|
||||||
|
});
|
||||||
|
|
||||||
|
vi.mock("@/lib/prisma", () => ({ prisma: { $transaction: transactionMock } }));
|
||||||
|
vi.mock("next/cache", () => ({ revalidatePath: revalidatePathMock }));
|
||||||
|
|
||||||
|
function buildRequest(body: unknown): NextRequest {
|
||||||
|
return new NextRequest("http://localhost:3000/api/tags/maintenance", {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("POST /api/tags/maintenance", () => {
|
||||||
|
beforeEach(() => {
|
||||||
|
process.env.WEBHOOK_API_KEY = "k".repeat(32);
|
||||||
|
vi.clearAllMocks();
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns 401 for wrong API key", async () => {
|
||||||
|
const response = await POST(buildRequest({ apiKey: "a".repeat(32), updates: [], merges: [] }));
|
||||||
|
expect(response.status).toBe(401);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
- Use a top-level `describe(...)` per module or endpoint, with test names phrased as behavior statements.
|
||||||
|
- Reset spies and mock state in `beforeEach`, as in `src/app/api/tags/route.test.ts`, `src/app/api/tags/reset-projects/route.test.ts`, and `src/app/api/tags/maintenance/route.test.ts`.
|
||||||
|
- For API routes, call the exported `GET` or `POST` function directly and assert on both `response.status` and `await response.json()`.
|
||||||
|
- For pure helpers and schemas, call the function directly and assert return values or thrown errors, as in `src/lib/auth.test.ts` and `src/lib/validations.test.ts`.
|
||||||
|
|
||||||
|
## Mocking
|
||||||
|
|
||||||
|
**Framework:**
|
||||||
|
- Vitest mocking via `vi.mock`, `vi.fn`, `vi.hoisted`, `mockResolvedValue`, and `mockRejectedValue`.
|
||||||
|
|
||||||
|
**Patterns:**
|
||||||
|
```typescript
|
||||||
|
const { findManyMock } = vi.hoisted(() => ({
|
||||||
|
findManyMock: vi.fn(),
|
||||||
|
}));
|
||||||
|
|
||||||
|
vi.mock("@/lib/prisma", () => ({
|
||||||
|
prisma: {
|
||||||
|
tag: {
|
||||||
|
findMany: findManyMock,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
findManyMock.mockResolvedValue([
|
||||||
|
{
|
||||||
|
id: "tag-1",
|
||||||
|
name: "机器学习",
|
||||||
|
slug: "machine-learning",
|
||||||
|
_count: { projects: 4 },
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
vi.mock("next/cache", () => ({
|
||||||
|
revalidatePath: revalidatePathMock,
|
||||||
|
}));
|
||||||
|
```
|
||||||
|
|
||||||
|
**What to Mock:**
|
||||||
|
- Mock Prisma client calls for route and service tests instead of hitting a real database. This is the established pattern in `src/app/api/tags/route.test.ts`, `src/app/api/tags/reset-projects/route.test.ts`, and `src/app/api/tags/maintenance/route.test.ts`.
|
||||||
|
- Mock Next.js side effects such as `revalidatePath` when testing mutation endpoints, as in `src/app/api/tags/reset-projects/route.test.ts` and `src/app/api/tags/maintenance/route.test.ts`.
|
||||||
|
- Set environment variables inline per suite when auth behavior depends on them, as in `process.env.WEBHOOK_API_KEY` usage in `src/app/api/tags/reset-projects/route.test.ts` and `src/app/api/tags/maintenance/route.test.ts`.
|
||||||
|
|
||||||
|
**What NOT to Mock:**
|
||||||
|
- Do not mock pure validation or auth utilities when they can be tested directly. `src/lib/auth.test.ts` and `src/lib/validations.tag-maintenance.test.ts` exercise real implementation logic without mocks.
|
||||||
|
- There is no current pattern for browser, React component, or DOM mocking because no component tests are checked in.
|
||||||
|
|
||||||
|
## Fixtures and Helpers
|
||||||
|
|
||||||
|
**Test Data:**
|
||||||
|
```typescript
|
||||||
|
function buildValidPayload(apiKey: string) {
|
||||||
|
return {
|
||||||
|
apiKey,
|
||||||
|
projects: [
|
||||||
|
{
|
||||||
|
projectSlug: "project-one",
|
||||||
|
selectedTagSlugsByCategory: {
|
||||||
|
FIXED_PROJECT_TYPE: ["agent-tooling"],
|
||||||
|
TECH_STACK: ["typescript"],
|
||||||
|
AI_PARADIGM: ["ai-agents"],
|
||||||
|
PRODUCT_FORM: ["web-application"],
|
||||||
|
DOMAIN_SCENARIO: ["code-dev"],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```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" }],
|
||||||
|
};
|
||||||
|
```
|
||||||
|
|
||||||
|
**Location:**
|
||||||
|
- Builders and fixtures are defined inline in each test file. No shared `test-utils`, factory module, or fixture directory was detected under `src` or repository root.
|
||||||
|
- Reuse small local helpers such as `buildRequest`, `buildValidPayload`, `createTxMock`, and `baseProjectInput` instead of creating cross-suite abstractions.
|
||||||
|
|
||||||
|
## Coverage
|
||||||
|
|
||||||
|
**Requirements:**
|
||||||
|
- No coverage thresholds or coverage command are configured in `package.json` or `vitest.config.ts`.
|
||||||
|
- No CI coverage upload or report configuration was detected.
|
||||||
|
|
||||||
|
**View Coverage:**
|
||||||
|
```bash
|
||||||
|
Not configured
|
||||||
|
```
|
||||||
|
|
||||||
|
## Test Types
|
||||||
|
|
||||||
|
**Unit Tests:**
|
||||||
|
- Pure utility tests cover security and schema logic in `src/lib/auth.test.ts`, `src/lib/validations.test.ts`, and `src/lib/validations.tag-maintenance.test.ts`.
|
||||||
|
- Service-level unit tests cover tag maintenance merge behavior in `src/app/api/tags/maintenance/service.test.ts`.
|
||||||
|
|
||||||
|
**Route-Level Tests:**
|
||||||
|
- Route handler tests call Next.js App Router handlers directly with mocked dependencies in `src/app/api/tags/route.test.ts`, `src/app/api/tags/reset-projects/route.test.ts`, and `src/app/api/tags/maintenance/route.test.ts`.
|
||||||
|
- These are closer to isolated handler tests than full integration tests because Prisma and cache modules are mocked.
|
||||||
|
|
||||||
|
**Integration Tests:**
|
||||||
|
- Not detected. No test currently exercises a real Prisma client, live database, or full Next.js server.
|
||||||
|
|
||||||
|
**E2E Tests:**
|
||||||
|
- Not detected in the repository. No `e2e/` directory and no `playwright.config.*` file were found on 2026-04-18.
|
||||||
|
- Repository guidance mentions `pnpm test:e2e`, but `package.json` does not define that script. Treat E2E support as undocumented or not yet checked in.
|
||||||
|
|
||||||
|
## Common Patterns
|
||||||
|
|
||||||
|
**Async 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");
|
||||||
|
```
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
await expect(
|
||||||
|
executeTagMaintenance(tx as Parameters<typeof executeTagMaintenance>[0], {
|
||||||
|
updates: [{ tagId: "missing-tag", nameEn: "Missing" }],
|
||||||
|
merges: [],
|
||||||
|
})
|
||||||
|
).rejects.toMatchObject({
|
||||||
|
status: 400,
|
||||||
|
message: "Validation error",
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
## Current Gaps
|
||||||
|
|
||||||
|
**Untested Server Modules:**
|
||||||
|
- No tests were detected for `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`, or `src/app/api/webhook/signals/route.ts`.
|
||||||
|
- 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`, or `src/lib/github/badges.ts`.
|
||||||
|
|
||||||
|
**Untested UI:**
|
||||||
|
- No component tests were detected for large client components such as `src/app/[locale]/projects/ProjectsResultsClient.tsx` (784 lines), `src/components/project/TagFilterPanel.tsx` (698 lines), or `src/components/signals/SignalFeedClient.tsx` (603 lines).
|
||||||
|
- No tests were detected for route pages under `src/app/[locale]`.
|
||||||
|
|
||||||
|
**Test Infrastructure Gaps:**
|
||||||
|
- No shared test helpers, factories, or fixture libraries are present.
|
||||||
|
- No browser or DOM test environment is configured.
|
||||||
|
- No E2E harness or Playwright configuration is present.
|
||||||
|
- No CI workflow files were detected under `.github/`.
|
||||||
|
|
||||||
|
## Verification Signals
|
||||||
|
|
||||||
|
**Current Quality Checks:**
|
||||||
|
- `pnpm test` passed on 2026-04-18 with 7 test files and 24 tests passing.
|
||||||
|
- `pnpm lint` passed on 2026-04-18 with no warnings or errors.
|
||||||
|
- `pnpm build` passed on 2026-04-18. The build completed static generation and route analysis successfully.
|
||||||
|
|
||||||
|
**Observed Test Output Notes:**
|
||||||
|
- `pnpm test` prints expected stderr from the error-path test in `src/app/api/tags/route.test.ts` because `src/app/api/tags/route.ts` logs with `console.error`.
|
||||||
|
- Vitest emitted a Vite deprecation notice about the CJS Node API during the run. This is a tooling signal, not a failing test.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Testing analysis: 2026-04-18*
|
||||||
@@ -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.
|
||||||
+38
@@ -0,0 +1,38 @@
|
|||||||
|
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 ./
|
||||||
|
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"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -4,6 +4,7 @@ const withNextIntl = createNextIntlPlugin('./src/i18n/request.ts')
|
|||||||
|
|
||||||
/** @type {import('next').NextConfig} */
|
/** @type {import('next').NextConfig} */
|
||||||
const nextConfig = {
|
const nextConfig = {
|
||||||
|
output: 'standalone',
|
||||||
images: {
|
images: {
|
||||||
remotePatterns: [
|
remotePatterns: [
|
||||||
{ hostname: 'localhost' },
|
{ 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"
|
||||||
+1
-16
@@ -7,41 +7,26 @@
|
|||||||
"build": "next build",
|
"build": "next build",
|
||||||
"start": "next start",
|
"start": "next start",
|
||||||
"lint": "next lint",
|
"lint": "next lint",
|
||||||
"test": "vitest",
|
"test": "vitest"
|
||||||
"test:e2e": "playwright test"
|
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@prisma/client": "^6.1.0",
|
"@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/analytics": "^1.6.1",
|
||||||
"@vercel/speed-insights": "^1.3.1",
|
"@vercel/speed-insights": "^1.3.1",
|
||||||
"class-variance-authority": "^0.7.1",
|
|
||||||
"clsx": "^2.1.1",
|
|
||||||
"lucide-react": "^0.468.0",
|
"lucide-react": "^0.468.0",
|
||||||
"next": "15.1.11",
|
"next": "15.1.11",
|
||||||
"next-intl": "^4.0.2",
|
"next-intl": "^4.0.2",
|
||||||
"react": "^19.0.0",
|
"react": "^19.0.0",
|
||||||
"react-dom": "^19.0.0",
|
"react-dom": "^19.0.0",
|
||||||
"react-markdown": "^10.1.0",
|
"react-markdown": "^10.1.0",
|
||||||
"rehype-raw": "^7.0.0",
|
|
||||||
"rehype-sanitize": "^6.0.0",
|
"rehype-sanitize": "^6.0.0",
|
||||||
"rehype-shiki": "^0.0.9",
|
|
||||||
"remark-gfm": "^4.0.1",
|
"remark-gfm": "^4.0.1",
|
||||||
"shiki": "^3.20.0",
|
|
||||||
"tailwind-merge": "^2.6.0",
|
|
||||||
"zod": "^3.24.1"
|
"zod": "^3.24.1"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@playwright/test": "^1.49.1",
|
|
||||||
"@testing-library/jest-dom": "^6.6.3",
|
|
||||||
"@testing-library/react": "^16.1.0",
|
|
||||||
"@types/node": "^22",
|
"@types/node": "^22",
|
||||||
"@types/react": "^19",
|
"@types/react": "^19",
|
||||||
"@types/react-dom": "^19",
|
"@types/react-dom": "^19",
|
||||||
"@vitejs/plugin-react": "^4.3.4",
|
|
||||||
"autoprefixer": "^10.4.20",
|
"autoprefixer": "^10.4.20",
|
||||||
"eslint": "^9",
|
"eslint": "^9",
|
||||||
"eslint-config-next": "15.1.11",
|
"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
+17
-1644
File diff suppressed because it is too large
Load Diff
@@ -1,5 +1,6 @@
|
|||||||
generator client {
|
generator client {
|
||||||
provider = "prisma-client-js"
|
provider = "prisma-client-js"
|
||||||
|
binaryTargets = ["native", "debian-openssl-3.0.x", "debian-openssl-1.1.x"]
|
||||||
previewFeatures = ["postgresqlExtensions"]
|
previewFeatures = ["postgresqlExtensions"]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -13,11 +13,10 @@ export async function generateStaticParams() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export async function generateMetadata({
|
export async function generateMetadata({
|
||||||
params
|
params: _params
|
||||||
}: {
|
}: {
|
||||||
params: Promise<{ locale: string }>
|
params: Promise<{ locale: string }>
|
||||||
}): Promise<Metadata> {
|
}): Promise<Metadata> {
|
||||||
const { locale } = await params
|
|
||||||
const t = await getTranslations('home')
|
const t = await getTranslations('home')
|
||||||
|
|
||||||
return {
|
return {
|
||||||
@@ -43,7 +42,6 @@ export default async function LocaleLayout({
|
|||||||
// Get translations
|
// Get translations
|
||||||
const t = await getTranslations('layout')
|
const t = await getTranslations('layout')
|
||||||
const tNav = await getTranslations('navigation')
|
const tNav = await getTranslations('navigation')
|
||||||
const tHome = await getTranslations('home')
|
|
||||||
const messages = await getMessages()
|
const messages = await getMessages()
|
||||||
return (
|
return (
|
||||||
<NextIntlClientProvider messages={messages}>
|
<NextIntlClientProvider messages={messages}>
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ import { prisma } from "@/lib/prisma";
|
|||||||
*
|
*
|
||||||
* 根据项目的 slug 获取项目详情
|
* 根据项目的 slug 获取项目详情
|
||||||
*/
|
*/
|
||||||
export async function GET(request: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
export async function GET(_request: NextRequest, { params }: { params: Promise<{ slug: string }> }) {
|
||||||
try {
|
try {
|
||||||
const { slug } = await params;
|
const { slug } = await params;
|
||||||
|
|
||||||
|
|||||||
@@ -2,8 +2,22 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { POST } from "./route";
|
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 { transactionMock, revalidatePathMock, txMock } = vi.hoisted(() => {
|
||||||
const tx = {
|
const tx: MaintenanceRouteTxMock = {
|
||||||
tag: {
|
tag: {
|
||||||
findMany: vi.fn(),
|
findMany: vi.fn(),
|
||||||
findUnique: vi.fn(),
|
findUnique: vi.fn(),
|
||||||
@@ -18,7 +32,7 @@ const { transactionMock, revalidatePathMock, txMock } = vi.hoisted(() => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
return {
|
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(),
|
revalidatePathMock: vi.fn(),
|
||||||
txMock: tx,
|
txMock: tx,
|
||||||
};
|
};
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
import { describe, expect, it, vi } from "vitest";
|
import { describe, expect, it, vi } from "vitest";
|
||||||
import { executeTagMaintenance, TagMaintenanceApiError } from "./service";
|
import { executeTagMaintenance } from "./service";
|
||||||
|
|
||||||
function createTxMock() {
|
function createTxMock() {
|
||||||
return {
|
return {
|
||||||
@@ -34,7 +34,7 @@ describe("executeTagMaintenance", () => {
|
|||||||
tx.projectTag.createMany.mockResolvedValue({ count: 2 });
|
tx.projectTag.createMany.mockResolvedValue({ count: 2 });
|
||||||
tx.tag.deleteMany.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: [],
|
updates: [],
|
||||||
merges: [
|
merges: [
|
||||||
{
|
{
|
||||||
@@ -70,11 +70,11 @@ describe("executeTagMaintenance", () => {
|
|||||||
tx.tag.findMany.mockResolvedValue([]);
|
tx.tag.findMany.mockResolvedValue([]);
|
||||||
|
|
||||||
await expect(
|
await expect(
|
||||||
executeTagMaintenance(tx as Parameters<typeof executeTagMaintenance>[0], {
|
executeTagMaintenance(tx as unknown as Parameters<typeof executeTagMaintenance>[0], {
|
||||||
updates: [{ tagId: "missing-tag", nameEn: "Missing" }],
|
updates: [{ tagId: "missing-tag", nameEn: "Missing" }],
|
||||||
merges: [],
|
merges: [],
|
||||||
})
|
})
|
||||||
).rejects.toMatchObject<TagMaintenanceApiError>({
|
).rejects.toMatchObject({
|
||||||
status: 400,
|
status: 400,
|
||||||
message: "Validation error",
|
message: "Validation error",
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -2,9 +2,16 @@ import { beforeEach, describe, expect, it, vi } from "vitest";
|
|||||||
import { NextRequest } from "next/server";
|
import { NextRequest } from "next/server";
|
||||||
import { POST } from "./route";
|
import { POST } from "./route";
|
||||||
|
|
||||||
|
type ResetProjectsRouteTxMock = {
|
||||||
|
projectTag: {
|
||||||
|
deleteMany: ReturnType<typeof vi.fn>;
|
||||||
|
createMany: ReturnType<typeof vi.fn>;
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
const { revalidatePathMock, transactionMock, txMock, projectFindManyMock, tagFindManyMock } =
|
const { revalidatePathMock, transactionMock, txMock, projectFindManyMock, tagFindManyMock } =
|
||||||
vi.hoisted(() => {
|
vi.hoisted(() => {
|
||||||
const tx = {
|
const tx: ResetProjectsRouteTxMock = {
|
||||||
projectTag: {
|
projectTag: {
|
||||||
deleteMany: vi.fn(),
|
deleteMany: vi.fn(),
|
||||||
createMany: vi.fn(),
|
createMany: vi.fn(),
|
||||||
@@ -13,7 +20,7 @@ const { revalidatePathMock, transactionMock, txMock, projectFindManyMock, tagFin
|
|||||||
|
|
||||||
return {
|
return {
|
||||||
revalidatePathMock: vi.fn(),
|
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,
|
txMock: tx,
|
||||||
projectFindManyMock: vi.fn(),
|
projectFindManyMock: vi.fn(),
|
||||||
tagFindManyMock: 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 () => {
|
it("returns 400 when FIXED_PROJECT_TYPE is missing", async () => {
|
||||||
const payload = buildValidPayload(validApiKey);
|
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 response = await POST(buildRequest(payload));
|
||||||
const json = await response.json();
|
const json = await response.json();
|
||||||
|
|||||||
@@ -1,105 +1,93 @@
|
|||||||
import { NextRequest, NextResponse } from 'next/server'
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
import crypto from 'crypto'
|
import { revalidatePath } from "next/cache";
|
||||||
import { revalidatePath } from 'next/cache'
|
import type { TagCategory } from "@prisma/client";
|
||||||
import type { TagCategory } from '@prisma/client'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { prisma } from '@/lib/prisma'
|
import { isValidApiKey } from "@/lib/auth";
|
||||||
import {
|
import {
|
||||||
ProjectTagResetRequestSchema,
|
ProjectTagResetRequestSchema,
|
||||||
type ProjectTagResetItem,
|
type ProjectTagResetItem,
|
||||||
type ResettableTagCategory,
|
type ResettableTagCategory,
|
||||||
} from '@/lib/validations'
|
} from "@/lib/validations";
|
||||||
|
|
||||||
type ResetResultItem = {
|
type ResetResultItem = {
|
||||||
projectSlug: string
|
projectSlug: string;
|
||||||
status: 'updated' | 'dry-run' | 'failed'
|
status: "updated" | "dry-run" | "failed";
|
||||||
selectedTagCount: number
|
selectedTagCount: number;
|
||||||
addedCount: number
|
addedCount: number;
|
||||||
removedCount: number
|
removedCount: number;
|
||||||
details: string[]
|
details: string[];
|
||||||
}
|
};
|
||||||
|
|
||||||
const DEFAULT_RESET_CATEGORIES: ResettableTagCategory[] = [
|
const DEFAULT_RESET_CATEGORIES: ResettableTagCategory[] = [
|
||||||
'FIXED_PROJECT_TYPE',
|
"FIXED_PROJECT_TYPE",
|
||||||
'TECH_STACK',
|
"TECH_STACK",
|
||||||
'AI_PARADIGM',
|
"AI_PARADIGM",
|
||||||
'PRODUCT_FORM',
|
"PRODUCT_FORM",
|
||||||
'DOMAIN_SCENARIO',
|
"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)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
function normalizeSlug(slug: string): string {
|
function normalizeSlug(slug: string): string {
|
||||||
return slug.trim().toLowerCase()
|
return slug.trim().toLowerCase();
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectSelectedTagSlugs(
|
function collectSelectedTagSlugs(
|
||||||
item: ProjectTagResetItem,
|
item: ProjectTagResetItem,
|
||||||
categories: ResettableTagCategory[]
|
categories: ResettableTagCategory[]
|
||||||
): string[] {
|
): string[] {
|
||||||
const selectedTagSlugSet = new Set<string>()
|
const selectedTagSlugSet = new Set<string>();
|
||||||
|
|
||||||
for (const category of categories) {
|
for (const category of categories) {
|
||||||
const categorySlugs = item.selectedTagSlugsByCategory[category] || []
|
const categorySlugs = item.selectedTagSlugsByCategory[category] || [];
|
||||||
for (const slug of categorySlugs) {
|
for (const slug of categorySlugs) {
|
||||||
selectedTagSlugSet.add(normalizeSlug(slug))
|
selectedTagSlugSet.add(normalizeSlug(slug));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return [...selectedTagSlugSet]
|
return [...selectedTagSlugSet];
|
||||||
}
|
}
|
||||||
|
|
||||||
function collectValidationErrorsForProjectItem(params: {
|
function collectValidationErrorsForProjectItem(params: {
|
||||||
item: ProjectTagResetItem
|
item: ProjectTagResetItem;
|
||||||
categories: ResettableTagCategory[]
|
categories: ResettableTagCategory[];
|
||||||
existingTagBySlug: Map<string, { id: string; slug: string; category: TagCategory }>
|
existingTagBySlug: Map<string, { id: string; slug: string; category: TagCategory }>;
|
||||||
}): string[] {
|
}): string[] {
|
||||||
const { item, categories, existingTagBySlug } = params
|
const { item, categories, existingTagBySlug } = params;
|
||||||
const errors: string[] = []
|
const errors: string[] = [];
|
||||||
|
|
||||||
for (const category of categories) {
|
for (const category of categories) {
|
||||||
for (const rawSlug of item.selectedTagSlugsByCategory[category] || []) {
|
for (const rawSlug of item.selectedTagSlugsByCategory[category] || []) {
|
||||||
const normalizedSlug = normalizeSlug(rawSlug)
|
const normalizedSlug = normalizeSlug(rawSlug);
|
||||||
const existingTag = existingTagBySlug.get(normalizedSlug)
|
const existingTag = existingTagBySlug.get(normalizedSlug);
|
||||||
|
|
||||||
if (!existingTag) {
|
if (!existingTag) {
|
||||||
errors.push(`Unknown tag slug "${rawSlug}" in category ${category}`)
|
errors.push(`Unknown tag slug "${rawSlug}" in category ${category}`);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
if (existingTag.category !== category) {
|
if (existingTag.category !== category) {
|
||||||
errors.push(
|
errors.push(
|
||||||
`Tag slug "${rawSlug}" belongs to ${existingTag.category}, expected ${category}`
|
`Tag slug "${rawSlug}" belongs to ${existingTag.category}, expected ${category}`
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return errors
|
return errors;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function POST(request: NextRequest) {
|
export async function POST(request: NextRequest) {
|
||||||
try {
|
try {
|
||||||
const body = await request.json()
|
const body = await request.json();
|
||||||
const validation = ProjectTagResetRequestSchema.safeParse(body)
|
const validation = ProjectTagResetRequestSchema.safeParse(body);
|
||||||
|
|
||||||
if (!validation.success) {
|
if (!validation.success) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Validation error',
|
error: "Validation error",
|
||||||
details: validation.error.errors.map((issue) => issue.message),
|
details: validation.error.errors.map((issue) => issue.message),
|
||||||
},
|
},
|
||||||
{ status: 400 }
|
{ status: 400 }
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const {
|
const {
|
||||||
@@ -108,27 +96,26 @@ export async function POST(request: NextRequest) {
|
|||||||
replaceAllCategories,
|
replaceAllCategories,
|
||||||
projects,
|
projects,
|
||||||
categories: requestedCategories,
|
categories: requestedCategories,
|
||||||
} = validation.data
|
} = validation.data;
|
||||||
|
|
||||||
if (!isApiKeyValid(apiKey, process.env.WEBHOOK_API_KEY)) {
|
if (!isValidApiKey(apiKey)) {
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Unauthorized',
|
error: "Unauthorized",
|
||||||
details: ['Invalid or missing API Key'],
|
details: ["Invalid or missing API Key"],
|
||||||
},
|
},
|
||||||
{ status: 401 }
|
{ status: 401 }
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const categories = requestedCategories.length > 0
|
const categories =
|
||||||
? requestedCategories
|
requestedCategories.length > 0 ? requestedCategories : DEFAULT_RESET_CATEGORIES;
|
||||||
: DEFAULT_RESET_CATEGORIES
|
|
||||||
|
|
||||||
const normalizedProjectSlugs = projects.map((item) => normalizeSlug(item.projectSlug))
|
const normalizedProjectSlugs = projects.map((item) => normalizeSlug(item.projectSlug));
|
||||||
const normalizedSelectedTagSlugs = [
|
const normalizedSelectedTagSlugs = [
|
||||||
...new Set(projects.flatMap((item) => collectSelectedTagSlugs(item, categories))),
|
...new Set(projects.flatMap((item) => collectSelectedTagSlugs(item, categories))),
|
||||||
]
|
];
|
||||||
|
|
||||||
const [existingProjects, existingTags] = await Promise.all([
|
const [existingProjects, existingTags] = await Promise.all([
|
||||||
prisma.project.findMany({
|
prisma.project.findMany({
|
||||||
@@ -157,63 +144,67 @@ export async function POST(request: NextRequest) {
|
|||||||
category: true,
|
category: true,
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
])
|
]);
|
||||||
|
|
||||||
const projectBySlug = new Map(existingProjects.map((project) => [project.slug, project]))
|
const projectBySlug = new Map(existingProjects.map((project) => [project.slug, project]));
|
||||||
const existingTagBySlug = new Map(existingTags.map((tag) => [tag.slug, tag]))
|
const existingTagBySlug = new Map(existingTags.map((tag) => [tag.slug, tag]));
|
||||||
|
|
||||||
const results: ResetResultItem[] = []
|
const results: ResetResultItem[] = [];
|
||||||
const updatedProjectSlugs: string[] = []
|
const updatedProjectSlugs: string[] = [];
|
||||||
|
|
||||||
for (const item of projects) {
|
for (const item of projects) {
|
||||||
const projectSlug = normalizeSlug(item.projectSlug)
|
const projectSlug = normalizeSlug(item.projectSlug);
|
||||||
const project = projectBySlug.get(projectSlug)
|
const project = projectBySlug.get(projectSlug);
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
results.push({
|
results.push({
|
||||||
projectSlug,
|
projectSlug,
|
||||||
status: 'failed',
|
status: "failed",
|
||||||
selectedTagCount: 0,
|
selectedTagCount: 0,
|
||||||
addedCount: 0,
|
addedCount: 0,
|
||||||
removedCount: 0,
|
removedCount: 0,
|
||||||
details: [`Project with slug "${projectSlug}" not found`],
|
details: [`Project with slug "${projectSlug}" not found`],
|
||||||
})
|
});
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const validationErrors = collectValidationErrorsForProjectItem({
|
const validationErrors = collectValidationErrorsForProjectItem({
|
||||||
item,
|
item,
|
||||||
categories,
|
categories,
|
||||||
existingTagBySlug,
|
existingTagBySlug,
|
||||||
})
|
});
|
||||||
|
|
||||||
if (validationErrors.length > 0) {
|
if (validationErrors.length > 0) {
|
||||||
results.push({
|
results.push({
|
||||||
projectSlug,
|
projectSlug,
|
||||||
status: 'failed',
|
status: "failed",
|
||||||
selectedTagCount: 0,
|
selectedTagCount: 0,
|
||||||
addedCount: 0,
|
addedCount: 0,
|
||||||
removedCount: 0,
|
removedCount: 0,
|
||||||
details: validationErrors,
|
details: validationErrors,
|
||||||
})
|
});
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
|
|
||||||
const selectedTagIds = collectSelectedTagSlugs(item, categories)
|
const selectedTagIds = collectSelectedTagSlugs(item, categories)
|
||||||
.map((slug) => existingTagBySlug.get(slug)?.id)
|
.map((slug) => existingTagBySlug.get(slug)?.id)
|
||||||
.filter((id): id is string => Boolean(id))
|
.filter((id): id is string => Boolean(id));
|
||||||
|
|
||||||
const previousCategoryTagIds = new Set(
|
const previousCategoryTagIds = new Set(
|
||||||
project.tags
|
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)
|
.map((projectTag) => projectTag.tag.id)
|
||||||
)
|
);
|
||||||
|
|
||||||
const nextTagIdSet = new Set(selectedTagIds)
|
const nextTagIdSet = new Set(selectedTagIds);
|
||||||
const removedCount = replaceAllCategories
|
const removedCount = replaceAllCategories
|
||||||
? [...previousCategoryTagIds].filter((tagId) => !nextTagIdSet.has(tagId)).length
|
? [...previousCategoryTagIds].filter((tagId) => !nextTagIdSet.has(tagId)).length
|
||||||
: 0
|
: 0;
|
||||||
const addedCount = [...nextTagIdSet].filter((tagId) => !previousCategoryTagIds.has(tagId)).length
|
const addedCount = [...nextTagIdSet].filter(
|
||||||
|
(tagId) => !previousCategoryTagIds.has(tagId)
|
||||||
|
).length;
|
||||||
|
|
||||||
if (!dryRun) {
|
if (!dryRun) {
|
||||||
await prisma.$transaction(async (tx) => {
|
await prisma.$transaction(async (tx) => {
|
||||||
@@ -227,7 +218,7 @@ export async function POST(request: NextRequest) {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (selectedTagIds.length > 0) {
|
if (selectedTagIds.length > 0) {
|
||||||
@@ -237,32 +228,32 @@ export async function POST(request: NextRequest) {
|
|||||||
tagId,
|
tagId,
|
||||||
})),
|
})),
|
||||||
skipDuplicates: true,
|
skipDuplicates: true,
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
})
|
});
|
||||||
updatedProjectSlugs.push(projectSlug)
|
updatedProjectSlugs.push(projectSlug);
|
||||||
}
|
}
|
||||||
|
|
||||||
results.push({
|
results.push({
|
||||||
projectSlug,
|
projectSlug,
|
||||||
status: dryRun ? 'dry-run' : 'updated',
|
status: dryRun ? "dry-run" : "updated",
|
||||||
selectedTagCount: selectedTagIds.length,
|
selectedTagCount: selectedTagIds.length,
|
||||||
addedCount,
|
addedCount,
|
||||||
removedCount,
|
removedCount,
|
||||||
details: [],
|
details: [],
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const updatedCount = results.filter((item) => item.status === 'updated').length
|
const updatedCount = results.filter((item) => item.status === "updated").length;
|
||||||
const dryRunCount = results.filter((item) => item.status === 'dry-run').length
|
const dryRunCount = results.filter((item) => item.status === "dry-run").length;
|
||||||
const failedCount = results.filter((item) => item.status === 'failed').length
|
const failedCount = results.filter((item) => item.status === "failed").length;
|
||||||
|
|
||||||
if (!dryRun && updatedProjectSlugs.length > 0) {
|
if (!dryRun && updatedProjectSlugs.length > 0) {
|
||||||
revalidatePath('/zh/projects', 'page')
|
revalidatePath("/zh/projects", "page");
|
||||||
revalidatePath('/en/projects', 'page')
|
revalidatePath("/en/projects", "page");
|
||||||
for (const projectSlug of updatedProjectSlugs) {
|
for (const projectSlug of updatedProjectSlugs) {
|
||||||
revalidatePath(`/zh/projects/${projectSlug}`, 'page')
|
revalidatePath(`/zh/projects/${projectSlug}`, "page");
|
||||||
revalidatePath(`/en/projects/${projectSlug}`, 'page')
|
revalidatePath(`/en/projects/${projectSlug}`, "page");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -278,16 +269,16 @@ export async function POST(request: NextRequest) {
|
|||||||
failedCount,
|
failedCount,
|
||||||
results,
|
results,
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error('[POST /api/tags/reset-projects] Error:', error)
|
console.error("[POST /api/tags/reset-projects] Error:", error);
|
||||||
return NextResponse.json(
|
return NextResponse.json(
|
||||||
{
|
{
|
||||||
success: false,
|
success: false,
|
||||||
error: 'Internal server error',
|
error: "Internal server error",
|
||||||
details: [error instanceof Error ? error.message : 'Unknown error'],
|
details: [error instanceof Error ? error.message : "Unknown error"],
|
||||||
},
|
},
|
||||||
{ status: 500 }
|
{ status: 500 }
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+18
-18
@@ -1,31 +1,31 @@
|
|||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
import { prisma } from "@/lib/prisma";
|
import { prisma } from "@/lib/prisma";
|
||||||
import { unstable_cache } from "next/cache";
|
import { unstable_cache } from "next/cache";
|
||||||
|
import { runWithCacheFallback } from "@/lib/cache";
|
||||||
|
|
||||||
const TAGS_CACHE_REVALIDATE_SECONDS = 300;
|
const TAGS_CACHE_REVALIDATE_SECONDS = 300;
|
||||||
|
|
||||||
const getCachedTags = unstable_cache(
|
async function getTagsFromDb() {
|
||||||
async () =>
|
return prisma.tag.findMany({
|
||||||
prisma.tag.findMany({
|
include: {
|
||||||
include: {
|
_count: {
|
||||||
_count: {
|
select: { projects: true },
|
||||||
select: { projects: true },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
orderBy: {
|
},
|
||||||
name: "asc",
|
orderBy: {
|
||||||
},
|
name: "asc",
|
||||||
}),
|
},
|
||||||
["api-tags:v1"],
|
});
|
||||||
{
|
}
|
||||||
revalidate: TAGS_CACHE_REVALIDATE_SECONDS,
|
|
||||||
tags: ["api-tags"],
|
const getCachedTags = unstable_cache(getTagsFromDb, ["api-tags:v1"], {
|
||||||
}
|
revalidate: TAGS_CACHE_REVALIDATE_SECONDS,
|
||||||
);
|
tags: ["api-tags"],
|
||||||
|
});
|
||||||
|
|
||||||
export async function GET() {
|
export async function GET() {
|
||||||
try {
|
try {
|
||||||
const tags = await getCachedTags();
|
const tags = await runWithCacheFallback(getCachedTags, getTagsFromDb);
|
||||||
|
|
||||||
return NextResponse.json({
|
return NextResponse.json({
|
||||||
success: true,
|
success: true,
|
||||||
|
|||||||
@@ -2,7 +2,6 @@
|
|||||||
|
|
||||||
import Link from 'next/link'
|
import Link from 'next/link'
|
||||||
|
|
||||||
const locales = ['zh', 'en'] as const
|
|
||||||
const localeNames: Record<string, string> = {
|
const localeNames: Record<string, string> = {
|
||||||
zh: '中文',
|
zh: '中文',
|
||||||
en: 'EN'
|
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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -116,9 +116,6 @@ export async function ProjectDetail({ project, locale }: ProjectDetailProps) {
|
|||||||
{/* Full content with Markdown rendering */}
|
{/* Full content with Markdown rendering */}
|
||||||
<MarkdownContent content={displayContent ?? ''} noContentText={t('noContentAvailable')} />
|
<MarkdownContent content={displayContent ?? ''} noContentText={t('noContentAvailable')} />
|
||||||
</article>
|
</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) {
|
export async function ProjectSidebar({ project, locale }: ProjectSidebarProps) {
|
||||||
const t = await getTranslations('project')
|
const t = await getTranslations('project')
|
||||||
const displayName = locale === 'en' && project.nameEn ? project.nameEn : project.name
|
|
||||||
|
|
||||||
// 获取 GitHub 统计数据
|
// 获取 GitHub 统计数据
|
||||||
const githubInfo = getGitHubInfoFromLinks(project.links)
|
const githubInfo = getGitHubInfoFromLinks(project.links)
|
||||||
|
|||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -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>
|
|
||||||
)
|
|
||||||
}
|
|
||||||
+98
-104
@@ -1,77 +1,82 @@
|
|||||||
import { prisma } from '@/lib/prisma'
|
import { prisma } from "@/lib/prisma";
|
||||||
import { getTopTags } from '@/hooks/useProjects'
|
import { getTopTags } from "@/hooks/useProjects";
|
||||||
import { Prisma } from '@prisma/client'
|
import { Prisma } from "@prisma/client";
|
||||||
import { unstable_cache } from 'next/cache'
|
import { unstable_cache } from "next/cache";
|
||||||
|
import { runWithCacheFallback } from "@/lib/cache";
|
||||||
|
|
||||||
const ONE_DAY_MS = 24 * 60 * 60 * 1000
|
const ONE_DAY_MS = 24 * 60 * 60 * 1000;
|
||||||
const DEFAULT_RANKING_LIMIT = 6
|
const DEFAULT_RANKING_LIMIT = 6;
|
||||||
const DEFAULT_TIMELINE_LIMIT = 8
|
const DEFAULT_TIMELINE_LIMIT = 8;
|
||||||
const DEFAULT_TOP_TAG_LIMIT = 12
|
const DEFAULT_TOP_TAG_LIMIT = 12;
|
||||||
const HOME_PAGE_REVALIDATE_SECONDS = 300
|
const HOME_PAGE_REVALIDATE_SECONDS = 300;
|
||||||
|
|
||||||
export type HomeProjectSummary = {
|
export type HomeProjectSummary = {
|
||||||
id: string
|
id: string;
|
||||||
slug: string
|
slug: string;
|
||||||
name: string
|
name: string;
|
||||||
nameEn: string | null
|
nameEn: string | null;
|
||||||
description: string
|
description: string;
|
||||||
descriptionEn: string | null
|
descriptionEn: string | null;
|
||||||
githubStars: number
|
githubStars: number;
|
||||||
createdAt: string
|
createdAt: string;
|
||||||
tags: Array<{
|
tags: Array<{
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
nameEn: string | null
|
nameEn: string | null;
|
||||||
slug: string
|
slug: string;
|
||||||
}>
|
}>;
|
||||||
}
|
};
|
||||||
|
|
||||||
export type HomePageData = {
|
export type HomePageData = {
|
||||||
overview: {
|
overview: {
|
||||||
totalProjects: number
|
totalProjects: number;
|
||||||
newProjects30d: number
|
newProjects30d: number;
|
||||||
newProjects7d: number
|
newProjects7d: number;
|
||||||
newProjects24h: number
|
newProjects24h: number;
|
||||||
}
|
};
|
||||||
rankings: {
|
rankings: {
|
||||||
latestByWindow: {
|
latestByWindow: {
|
||||||
'24h': HomeProjectSummary[]
|
"24h": HomeProjectSummary[];
|
||||||
'7d': HomeProjectSummary[]
|
"7d": HomeProjectSummary[];
|
||||||
'30d': HomeProjectSummary[]
|
"30d": HomeProjectSummary[];
|
||||||
}
|
};
|
||||||
topStars: HomeProjectSummary[]
|
topStars: HomeProjectSummary[];
|
||||||
}
|
};
|
||||||
tagInsights: {
|
tagInsights: {
|
||||||
topTags: Array<{
|
topTags: Array<{
|
||||||
id: string
|
id: string;
|
||||||
name: string
|
name: string;
|
||||||
nameEn: string | null
|
nameEn: string | null;
|
||||||
slug: string
|
slug: string;
|
||||||
projectCount: number
|
projectCount: number;
|
||||||
}>
|
}>;
|
||||||
}
|
};
|
||||||
timeline: HomeProjectSummary[]
|
timeline: HomeProjectSummary[];
|
||||||
}
|
};
|
||||||
|
|
||||||
type ProjectWithRelations = Prisma.ProjectGetPayload<{
|
type ProjectWithRelations = Prisma.ProjectGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
tags: {
|
tags: {
|
||||||
include: {
|
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 {
|
try {
|
||||||
return await task()
|
return await task();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
`[db] ${operationName} degraded to fallback:`,
|
`[db] ${operationName} degraded to fallback:`,
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
)
|
);
|
||||||
return fallback
|
return fallback;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -91,14 +96,17 @@ function mapProjectSummary(project: ProjectWithRelations): HomeProjectSummary {
|
|||||||
nameEn: projectTag.tag.nameEn,
|
nameEn: projectTag.tag.nameEn,
|
||||||
slug: projectTag.tag.slug,
|
slug: projectTag.tag.slug,
|
||||||
})),
|
})),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getLatestProjects(limit: number, createdAfter?: Date): Promise<HomeProjectSummary[]> {
|
async function getLatestProjects(
|
||||||
const projects = await safeQuery('getLatestProjects', [] as ProjectWithRelations[], () =>
|
limit: number,
|
||||||
|
createdAfter?: Date
|
||||||
|
): Promise<HomeProjectSummary[]> {
|
||||||
|
const projects = await safeQuery("getLatestProjects", [] as ProjectWithRelations[], () =>
|
||||||
prisma.project.findMany({
|
prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: 'ACTIVE',
|
status: "ACTIVE",
|
||||||
...(createdAfter ? { createdAt: { gte: createdAfter } } : {}),
|
...(createdAfter ? { createdAt: { gte: createdAfter } } : {}),
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -109,20 +117,20 @@ async function getLatestProjects(limit: number, createdAfter?: Date): Promise<Ho
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
createdAt: 'desc',
|
createdAt: "desc",
|
||||||
},
|
},
|
||||||
take: limit,
|
take: limit,
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
|
|
||||||
return projects.map(mapProjectSummary)
|
return projects.map(mapProjectSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTopStarsProjects(limit: number): Promise<HomeProjectSummary[]> {
|
async function getTopStarsProjects(limit: number): Promise<HomeProjectSummary[]> {
|
||||||
const projects = await safeQuery('getTopStarsProjects', [] as ProjectWithRelations[], () =>
|
const projects = await safeQuery("getTopStarsProjects", [] as ProjectWithRelations[], () =>
|
||||||
prisma.project.findMany({
|
prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
status: 'ACTIVE',
|
status: "ACTIVE",
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
tags: {
|
tags: {
|
||||||
@@ -131,12 +139,12 @@ async function getTopStarsProjects(limit: number): Promise<HomeProjectSummary[]>
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: [{ githubStars: 'desc' }, { createdAt: 'desc' }],
|
orderBy: [{ githubStars: "desc" }, { createdAt: "desc" }],
|
||||||
take: limit,
|
take: limit,
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
|
|
||||||
return projects.map(mapProjectSummary)
|
return projects.map(mapProjectSummary);
|
||||||
}
|
}
|
||||||
|
|
||||||
function getLatestProjectsByWindow(
|
function getLatestProjectsByWindow(
|
||||||
@@ -144,17 +152,15 @@ function getLatestProjectsByWindow(
|
|||||||
createdAfter: Date,
|
createdAfter: Date,
|
||||||
limit: number
|
limit: number
|
||||||
): HomeProjectSummary[] {
|
): HomeProjectSummary[] {
|
||||||
return projects
|
return projects.filter((project) => new Date(project.createdAt) >= createdAfter).slice(0, limit);
|
||||||
.filter((project) => new Date(project.createdAt) >= createdAfter)
|
|
||||||
.slice(0, limit)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async function buildHomePageData(): Promise<HomePageData> {
|
async function buildHomePageData(): Promise<HomePageData> {
|
||||||
const now = Date.now()
|
const now = Date.now();
|
||||||
const last24Hours = new Date(now - ONE_DAY_MS)
|
const last24Hours = new Date(now - ONE_DAY_MS);
|
||||||
const last7Days = new Date(now - ONE_DAY_MS * 7)
|
const last7Days = new Date(now - ONE_DAY_MS * 7);
|
||||||
const last30Days = new Date(now - ONE_DAY_MS * 30)
|
const last30Days = new Date(now - ONE_DAY_MS * 30);
|
||||||
const latestProjectsLimit = Math.max(DEFAULT_RANKING_LIMIT, DEFAULT_TIMELINE_LIMIT)
|
const latestProjectsLimit = Math.max(DEFAULT_RANKING_LIMIT, DEFAULT_TIMELINE_LIMIT);
|
||||||
|
|
||||||
const [
|
const [
|
||||||
totalProjects,
|
totalProjects,
|
||||||
@@ -165,31 +171,31 @@ async function buildHomePageData(): Promise<HomePageData> {
|
|||||||
topStars,
|
topStars,
|
||||||
topTags,
|
topTags,
|
||||||
] = await Promise.all([
|
] = await Promise.all([
|
||||||
safeQuery('countTotalProjects', 0, () => prisma.project.count()),
|
safeQuery("countTotalProjects", 0, () => prisma.project.count()),
|
||||||
safeQuery('countNewProjects30d', 0, () =>
|
safeQuery("countNewProjects30d", 0, () =>
|
||||||
prisma.project.count({
|
prisma.project.count({
|
||||||
where: {
|
where: {
|
||||||
status: 'ACTIVE',
|
status: "ACTIVE",
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gte: last30Days,
|
gte: last30Days,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
safeQuery('countNewProjects7d', 0, () =>
|
safeQuery("countNewProjects7d", 0, () =>
|
||||||
prisma.project.count({
|
prisma.project.count({
|
||||||
where: {
|
where: {
|
||||||
status: 'ACTIVE',
|
status: "ACTIVE",
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gte: last7Days,
|
gte: last7Days,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
),
|
),
|
||||||
safeQuery('countNewProjects24h', 0, () =>
|
safeQuery("countNewProjects24h", 0, () =>
|
||||||
prisma.project.count({
|
prisma.project.count({
|
||||||
where: {
|
where: {
|
||||||
status: 'ACTIVE',
|
status: "ACTIVE",
|
||||||
createdAt: {
|
createdAt: {
|
||||||
gte: last24Hours,
|
gte: last24Hours,
|
||||||
},
|
},
|
||||||
@@ -199,24 +205,12 @@ async function buildHomePageData(): Promise<HomePageData> {
|
|||||||
getLatestProjects(latestProjectsLimit),
|
getLatestProjects(latestProjectsLimit),
|
||||||
getTopStarsProjects(DEFAULT_RANKING_LIMIT),
|
getTopStarsProjects(DEFAULT_RANKING_LIMIT),
|
||||||
getTopTags(DEFAULT_TOP_TAG_LIMIT),
|
getTopTags(DEFAULT_TOP_TAG_LIMIT),
|
||||||
])
|
]);
|
||||||
|
|
||||||
const latest24h = getLatestProjectsByWindow(
|
const latest24h = getLatestProjectsByWindow(latestProjects, last24Hours, DEFAULT_RANKING_LIMIT);
|
||||||
latestProjects,
|
const latest7d = getLatestProjectsByWindow(latestProjects, last7Days, DEFAULT_RANKING_LIMIT);
|
||||||
last24Hours,
|
const latest30d = getLatestProjectsByWindow(latestProjects, last30Days, DEFAULT_RANKING_LIMIT);
|
||||||
DEFAULT_RANKING_LIMIT
|
const timeline = latestProjects.slice(0, DEFAULT_TIMELINE_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 {
|
return {
|
||||||
overview: {
|
overview: {
|
||||||
@@ -227,9 +221,9 @@ async function buildHomePageData(): Promise<HomePageData> {
|
|||||||
},
|
},
|
||||||
rankings: {
|
rankings: {
|
||||||
latestByWindow: {
|
latestByWindow: {
|
||||||
'24h': latest24h,
|
"24h": latest24h,
|
||||||
'7d': latest7d,
|
"7d": latest7d,
|
||||||
'30d': latest30d,
|
"30d": latest30d,
|
||||||
},
|
},
|
||||||
topStars,
|
topStars,
|
||||||
},
|
},
|
||||||
@@ -243,14 +237,14 @@ async function buildHomePageData(): Promise<HomePageData> {
|
|||||||
})),
|
})),
|
||||||
},
|
},
|
||||||
timeline,
|
timeline,
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
const getCachedHomePageData = unstable_cache(buildHomePageData, ['home-page-data:v1'], {
|
const getCachedHomePageData = unstable_cache(buildHomePageData, ["home-page-data:v1"], {
|
||||||
revalidate: HOME_PAGE_REVALIDATE_SECONDS,
|
revalidate: HOME_PAGE_REVALIDATE_SECONDS,
|
||||||
tags: ['home-page-data'],
|
tags: ["home-page-data"],
|
||||||
})
|
});
|
||||||
|
|
||||||
export async function getHomePageData(): Promise<HomePageData> {
|
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 } from "@/lib/prisma";
|
||||||
import { Prisma, type TagCategory } from '@prisma/client'
|
import { Prisma, type TagCategory } from "@prisma/client";
|
||||||
import { unstable_cache } from 'next/cache'
|
import { unstable_cache } from "next/cache";
|
||||||
|
import { runWithCacheFallback } from "@/lib/cache";
|
||||||
import {
|
import {
|
||||||
FIXED_PROJECT_TYPE_TAGS,
|
FIXED_PROJECT_TYPE_TAGS,
|
||||||
TAG_CATEGORY_META,
|
TAG_CATEGORY_META,
|
||||||
getTagCategoryOrder,
|
getTagCategoryOrder,
|
||||||
isFixedProjectTypeSlug,
|
isFixedProjectTypeSlug,
|
||||||
type FixedProjectTypeSlug,
|
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> {
|
function sleep(ms: number): Promise<void> {
|
||||||
return new Promise((resolve) => {
|
return new Promise((resolve) => {
|
||||||
setTimeout(resolve, ms)
|
setTimeout(resolve, ms);
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function isTransientDbError(error: unknown): boolean {
|
function isTransientDbError(error: unknown): boolean {
|
||||||
if (error instanceof Prisma.PrismaClientInitializationError) {
|
if (error instanceof Prisma.PrismaClientInitializationError) {
|
||||||
return true
|
return true;
|
||||||
}
|
}
|
||||||
if (error instanceof Prisma.PrismaClientRustPanicError) {
|
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 (
|
return (
|
||||||
message.includes("can't reach database server") ||
|
message.includes("can't reach database server") ||
|
||||||
message.includes('p1001') ||
|
message.includes("p1001") ||
|
||||||
message.includes('connection terminated') ||
|
message.includes("connection terminated") ||
|
||||||
message.includes('timeout') ||
|
message.includes("timeout") ||
|
||||||
message.includes('econnreset')
|
message.includes("econnreset")
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function withDbRetry<T>(operationName: string, task: () => Promise<T>): Promise<T> {
|
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) {
|
for (let attempt = 0; attempt <= DB_RETRY_DELAYS_MS.length; attempt += 1) {
|
||||||
try {
|
try {
|
||||||
return await task()
|
return await task();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
lastError = error
|
lastError = error;
|
||||||
if (!isTransientDbError(error) || attempt === DB_RETRY_DELAYS_MS.length) {
|
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: ${
|
`[db] ${operationName} failed: ${
|
||||||
lastError instanceof Error ? lastError.message : String(lastError)
|
lastError instanceof Error ? lastError.message : String(lastError)
|
||||||
}`
|
}`
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义带有标签和链接的项目类型
|
// 定义带有标签和链接的项目类型
|
||||||
export type ProjectWithTagsAndLinks = Prisma.ProjectGetPayload<{
|
export type ProjectWithTagsAndLinks = Prisma.ProjectGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
tags: { include: { tag: true } }
|
tags: { include: { tag: true } };
|
||||||
links: true
|
links: true;
|
||||||
}
|
};
|
||||||
}>
|
}>;
|
||||||
|
|
||||||
// 定义扁平化标签的项目类型
|
// 定义扁平化标签的项目类型
|
||||||
export type ProjectWithFlatTags = Omit<ProjectWithTagsAndLinks, 'tags'> & {
|
export type ProjectWithFlatTags = Omit<ProjectWithTagsAndLinks, "tags"> & {
|
||||||
tags: Prisma.TagGetPayload<{}>[]
|
tags: Prisma.TagGetPayload<{}>[];
|
||||||
}
|
};
|
||||||
|
|
||||||
// 定义标签计数类型
|
// 定义标签计数类型
|
||||||
export type TagWithProjectCount = Prisma.TagGetPayload<{
|
export type TagWithProjectCount = Prisma.TagGetPayload<{
|
||||||
include: {
|
include: {
|
||||||
_count: { select: { projects: true } }
|
_count: { select: { projects: true } };
|
||||||
}
|
};
|
||||||
}>
|
}>;
|
||||||
|
|
||||||
export type FilterTagCategoryGroup = {
|
export type FilterTagCategoryGroup = {
|
||||||
category: Exclude<TagCategory, 'FIXED_PROJECT_TYPE'>
|
category: Exclude<TagCategory, "FIXED_PROJECT_TYPE">;
|
||||||
name: string
|
name: string;
|
||||||
nameEn: string
|
nameEn: string;
|
||||||
tags: TagWithProjectCount[]
|
tags: TagWithProjectCount[];
|
||||||
}
|
};
|
||||||
|
|
||||||
export type FixedProjectTypeFilter = {
|
export type FixedProjectTypeFilter = {
|
||||||
slug: FixedProjectTypeSlug
|
slug: FixedProjectTypeSlug;
|
||||||
name: string
|
name: string;
|
||||||
nameEn: string
|
nameEn: string;
|
||||||
projectCount: number
|
projectCount: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
export const PROJECT_SORT_OPTIONS = ['latest', 'stars_desc', 'stars_asc'] as const
|
export const PROJECT_SORT_OPTIONS = ["latest", "stars_desc", "stars_asc"] as const;
|
||||||
export type ProjectSortOption = (typeof PROJECT_SORT_OPTIONS)[number]
|
export type ProjectSortOption = (typeof PROJECT_SORT_OPTIONS)[number];
|
||||||
const DEFAULT_PAGE = 1
|
const DEFAULT_PAGE = 1;
|
||||||
const DEFAULT_LIMIT = 10
|
const DEFAULT_LIMIT = 10;
|
||||||
const MAX_LIMIT = 100
|
const MAX_LIMIT = 100;
|
||||||
const DEFAULT_CACHE_REVALIDATE_SECONDS = 300
|
const DEFAULT_CACHE_REVALIDATE_SECONDS = 300;
|
||||||
|
|
||||||
export function normalizeProjectSort(value?: string): ProjectSortOption {
|
export function normalizeProjectSort(value?: string): ProjectSortOption {
|
||||||
const candidate = String(value || '').trim()
|
const candidate = String(value || "").trim();
|
||||||
if (PROJECT_SORT_OPTIONS.includes(candidate as ProjectSortOption)) {
|
if (PROJECT_SORT_OPTIONS.includes(candidate as ProjectSortOption)) {
|
||||||
return candidate as ProjectSortOption
|
return candidate as ProjectSortOption;
|
||||||
}
|
}
|
||||||
return 'latest'
|
return "latest";
|
||||||
}
|
}
|
||||||
|
|
||||||
function normalizePositiveInteger(
|
function normalizePositiveInteger(
|
||||||
@@ -111,30 +113,30 @@ function normalizePositiveInteger(
|
|||||||
fallback: number,
|
fallback: number,
|
||||||
max?: number
|
max?: number
|
||||||
): number {
|
): number {
|
||||||
const normalized = Number.isFinite(value) ? Math.trunc(value as number) : fallback
|
const normalized = Number.isFinite(value) ? Math.trunc(value as number) : fallback;
|
||||||
const bounded = normalized > 0 ? normalized : fallback
|
const bounded = normalized > 0 ? normalized : fallback;
|
||||||
return typeof max === 'number' ? Math.min(bounded, max) : bounded
|
return typeof max === "number" ? Math.min(bounded, max) : bounded;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProjects(options?: {
|
export async function getProjects(options?: {
|
||||||
search?: string
|
search?: string;
|
||||||
tag?: string
|
tag?: string;
|
||||||
tags?: string[]
|
tags?: string[];
|
||||||
domains?: string[]
|
domains?: string[];
|
||||||
productForms?: string[]
|
productForms?: string[];
|
||||||
projectType?: string
|
projectType?: string;
|
||||||
sort?: ProjectSortOption
|
sort?: ProjectSortOption;
|
||||||
status?: 'ACTIVE' | 'ARCHIVED'
|
status?: "ACTIVE" | "ARCHIVED";
|
||||||
page?: number
|
page?: number;
|
||||||
limit?: number
|
limit?: number;
|
||||||
}): Promise<{
|
}): Promise<{
|
||||||
projects: ProjectWithFlatTags[]
|
projects: ProjectWithFlatTags[];
|
||||||
pagination: {
|
pagination: {
|
||||||
page: number
|
page: number;
|
||||||
limit: number
|
limit: number;
|
||||||
total: number
|
total: number;
|
||||||
totalPages: number
|
totalPages: number;
|
||||||
}
|
};
|
||||||
}> {
|
}> {
|
||||||
const {
|
const {
|
||||||
search,
|
search,
|
||||||
@@ -143,65 +145,57 @@ export async function getProjects(options?: {
|
|||||||
domains = [],
|
domains = [],
|
||||||
productForms = [],
|
productForms = [],
|
||||||
projectType,
|
projectType,
|
||||||
sort = 'latest',
|
sort = "latest",
|
||||||
status = 'ACTIVE',
|
status = "ACTIVE",
|
||||||
page = DEFAULT_PAGE,
|
page = DEFAULT_PAGE,
|
||||||
limit = DEFAULT_LIMIT,
|
limit = DEFAULT_LIMIT,
|
||||||
} = options || {}
|
} = options || {};
|
||||||
|
|
||||||
const safePage = normalizePositiveInteger(page, DEFAULT_PAGE)
|
const safePage = normalizePositiveInteger(page, DEFAULT_PAGE);
|
||||||
const safeLimit = normalizePositiveInteger(limit, DEFAULT_LIMIT, MAX_LIMIT)
|
const safeLimit = normalizePositiveInteger(limit, DEFAULT_LIMIT, MAX_LIMIT);
|
||||||
|
|
||||||
const where: Prisma.ProjectWhereInput = {
|
const where: Prisma.ProjectWhereInput = {
|
||||||
status,
|
status,
|
||||||
}
|
};
|
||||||
|
|
||||||
// 添加搜索字符串长度验证
|
// 添加搜索字符串长度验证
|
||||||
if (search && search.length >= 2 && search.length <= 100) {
|
if (search && search.length >= 2 && search.length <= 100) {
|
||||||
where.OR = [
|
where.OR = [
|
||||||
{ name: { contains: search, mode: 'insensitive' } },
|
{ name: { contains: search, mode: "insensitive" } },
|
||||||
{ nameEn: { contains: search, mode: 'insensitive' } },
|
{ nameEn: { contains: search, mode: "insensitive" } },
|
||||||
{ description: { contains: search, mode: 'insensitive' } },
|
{ description: { contains: search, mode: "insensitive" } },
|
||||||
{ descriptionEn: { contains: search, mode: 'insensitive' } },
|
{ descriptionEn: { contains: search, mode: "insensitive" } },
|
||||||
]
|
];
|
||||||
}
|
}
|
||||||
|
|
||||||
const andFilters: Prisma.ProjectWhereInput[] = []
|
const andFilters: Prisma.ProjectWhereInput[] = [];
|
||||||
const normalizedDomainSlugs = Array.from(
|
const normalizedDomainSlugs = Array.from(
|
||||||
new Set(
|
new Set(domains.map((value) => (value || "").trim()).filter((value) => value.length > 0))
|
||||||
domains
|
);
|
||||||
.map((value) => (value || '').trim())
|
|
||||||
.filter((value) => value.length > 0)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
const normalizedProductFormSlugs = Array.from(
|
const normalizedProductFormSlugs = Array.from(
|
||||||
new Set(
|
new Set(productForms.map((value) => (value || "").trim()).filter((value) => value.length > 0))
|
||||||
productForms
|
);
|
||||||
.map((value) => (value || '').trim())
|
|
||||||
.filter((value) => value.length > 0)
|
|
||||||
)
|
|
||||||
)
|
|
||||||
const normalizedTagSlugs = Array.from(
|
const normalizedTagSlugs = Array.from(
|
||||||
new Set(
|
new Set(
|
||||||
[tag, ...tags]
|
[tag, ...tags]
|
||||||
.map((value) => (value || '').trim())
|
.map((value) => (value || "").trim())
|
||||||
.filter((value) => value.length > 0)
|
.filter((value) => value.length > 0)
|
||||||
.filter((value) => !normalizedDomainSlugs.includes(value))
|
.filter((value) => !normalizedDomainSlugs.includes(value))
|
||||||
.filter((value) => !normalizedProductFormSlugs.includes(value))
|
.filter((value) => !normalizedProductFormSlugs.includes(value))
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
|
|
||||||
for (const domainSlug of normalizedDomainSlugs) {
|
for (const domainSlug of normalizedDomainSlugs) {
|
||||||
andFilters.push({
|
andFilters.push({
|
||||||
tags: {
|
tags: {
|
||||||
some: {
|
some: {
|
||||||
tag: {
|
tag: {
|
||||||
category: 'DOMAIN_SCENARIO',
|
category: "DOMAIN_SCENARIO",
|
||||||
slug: domainSlug,
|
slug: domainSlug,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const tagSlug of normalizedTagSlugs) {
|
for (const tagSlug of normalizedTagSlugs) {
|
||||||
@@ -213,7 +207,7 @@ export async function getProjects(options?: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const productFormSlug of normalizedProductFormSlugs) {
|
for (const productFormSlug of normalizedProductFormSlugs) {
|
||||||
@@ -221,12 +215,12 @@ export async function getProjects(options?: {
|
|||||||
tags: {
|
tags: {
|
||||||
some: {
|
some: {
|
||||||
tag: {
|
tag: {
|
||||||
category: 'PRODUCT_FORM',
|
category: "PRODUCT_FORM",
|
||||||
slug: productFormSlug,
|
slug: productFormSlug,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (projectType && isFixedProjectTypeSlug(projectType)) {
|
if (projectType && isFixedProjectTypeSlug(projectType)) {
|
||||||
@@ -238,25 +232,25 @@ export async function getProjects(options?: {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
})
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
if (andFilters.length > 0) {
|
if (andFilters.length > 0) {
|
||||||
where.AND = andFilters
|
where.AND = andFilters;
|
||||||
}
|
}
|
||||||
|
|
||||||
const orderBy: Prisma.ProjectOrderByWithRelationInput[] =
|
const orderBy: Prisma.ProjectOrderByWithRelationInput[] =
|
||||||
sort === 'stars_desc'
|
sort === "stars_desc"
|
||||||
? [{ githubStars: 'desc' }, { createdAt: 'desc' }]
|
? [{ githubStars: "desc" }, { createdAt: "desc" }]
|
||||||
: sort === 'stars_asc'
|
: sort === "stars_asc"
|
||||||
? [{ githubStars: 'asc' }, { createdAt: 'desc' }]
|
? [{ githubStars: "asc" }, { createdAt: "desc" }]
|
||||||
: [{ createdAt: 'desc' }]
|
: [{ createdAt: "desc" }];
|
||||||
|
|
||||||
let projects: ProjectWithTagsAndLinks[] = []
|
let projects: ProjectWithTagsAndLinks[] = [];
|
||||||
let total = 0
|
let total = 0;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
;[projects, total] = await withDbRetry('getProjects', () =>
|
[projects, total] = await withDbRetry("getProjects", () =>
|
||||||
Promise.all([
|
Promise.all([
|
||||||
prisma.project.findMany({
|
prisma.project.findMany({
|
||||||
where,
|
where,
|
||||||
@@ -274,19 +268,19 @@ export async function getProjects(options?: {
|
|||||||
}),
|
}),
|
||||||
prisma.project.count({ where }),
|
prisma.project.count({ where }),
|
||||||
])
|
])
|
||||||
)
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
'[db] getProjects degraded to empty result:',
|
"[db] getProjects degraded to empty result:",
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform tags to flatten the structure
|
// Transform tags to flatten the structure
|
||||||
const transformedProjects = projects.map((project) => ({
|
const transformedProjects = projects.map((project) => ({
|
||||||
...project,
|
...project,
|
||||||
tags: project.tags.map((pt) => pt.tag),
|
tags: project.tags.map((pt) => pt.tag),
|
||||||
}))
|
}));
|
||||||
|
|
||||||
return {
|
return {
|
||||||
projects: transformedProjects,
|
projects: transformedProjects,
|
||||||
@@ -296,11 +290,11 @@ export async function getProjects(options?: {
|
|||||||
total,
|
total,
|
||||||
totalPages: Math.ceil(total / safeLimit),
|
totalPages: Math.ceil(total / safeLimit),
|
||||||
},
|
},
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getProjectBySlug(slug: string): Promise<ProjectWithFlatTags | null> {
|
export async function getProjectBySlug(slug: string): Promise<ProjectWithFlatTags | null> {
|
||||||
const project = await withDbRetry('getProjectBySlug', () =>
|
const project = await withDbRetry("getProjectBySlug", () =>
|
||||||
prisma.project.findUnique({
|
prisma.project.findUnique({
|
||||||
where: { slug },
|
where: { slug },
|
||||||
include: {
|
include: {
|
||||||
@@ -312,25 +306,25 @@ export async function getProjectBySlug(slug: string): Promise<ProjectWithFlatTag
|
|||||||
links: true,
|
links: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
|
|
||||||
if (!project) {
|
if (!project) {
|
||||||
return null
|
return null;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Transform tags to flatten the structure
|
// Transform tags to flatten the structure
|
||||||
return {
|
return {
|
||||||
...project,
|
...project,
|
||||||
tags: project.tags.map((pt) => pt.tag),
|
tags: project.tags.map((pt) => pt.tag),
|
||||||
}
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getAllTags(): Promise<TagWithProjectCount[]> {
|
export async function getAllTags(): Promise<TagWithProjectCount[]> {
|
||||||
return withDbRetry('getAllTags', () =>
|
return withDbRetry("getAllTags", () =>
|
||||||
prisma.tag.findMany({
|
prisma.tag.findMany({
|
||||||
where: {
|
where: {
|
||||||
category: {
|
category: {
|
||||||
not: 'FIXED_PROJECT_TYPE',
|
not: "FIXED_PROJECT_TYPE",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -339,18 +333,18 @@ export async function getAllTags(): Promise<TagWithProjectCount[]> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
name: 'asc',
|
name: "asc",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTagsWithProjectCounts(): Promise<TagWithProjectCount[]> {
|
export async function getTagsWithProjectCounts(): Promise<TagWithProjectCount[]> {
|
||||||
const tags = await withDbRetry('getTagsWithProjectCounts', () =>
|
const tags = await withDbRetry("getTagsWithProjectCounts", () =>
|
||||||
prisma.tag.findMany({
|
prisma.tag.findMany({
|
||||||
where: {
|
where: {
|
||||||
category: {
|
category: {
|
||||||
not: 'FIXED_PROJECT_TYPE',
|
not: "FIXED_PROJECT_TYPE",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
@@ -359,16 +353,16 @@ export async function getTagsWithProjectCounts(): Promise<TagWithProjectCount[]>
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
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[]> {
|
async function getTopTagsFromDb(limit: number): Promise<TagWithProjectCount[]> {
|
||||||
return withDbRetry('getTopTags', () =>
|
return withDbRetry("getTopTags", () =>
|
||||||
prisma.tag.findMany({
|
prisma.tag.findMany({
|
||||||
include: {
|
include: {
|
||||||
_count: {
|
_count: {
|
||||||
@@ -377,49 +371,45 @@ async function getTopTagsFromDb(limit: number): Promise<TagWithProjectCount[]> {
|
|||||||
},
|
},
|
||||||
where: {
|
where: {
|
||||||
category: {
|
category: {
|
||||||
not: 'FIXED_PROJECT_TYPE',
|
not: "FIXED_PROJECT_TYPE",
|
||||||
},
|
},
|
||||||
projects: {
|
projects: {
|
||||||
some: {},
|
some: {},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: [{ projects: { _count: 'desc' } }, { name: 'asc' }],
|
orderBy: [{ projects: { _count: "desc" } }, { name: "asc" }],
|
||||||
take: limit,
|
take: limit,
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const topTagsCache = new Map<number, () => Promise<TagWithProjectCount[]>>()
|
const topTagsCache = new Map<number, () => Promise<TagWithProjectCount[]>>();
|
||||||
|
|
||||||
function getTopTagsCachedFetcher(limit: number): () => Promise<TagWithProjectCount[]> {
|
function getTopTagsCachedFetcher(limit: number): () => Promise<TagWithProjectCount[]> {
|
||||||
const existing = topTagsCache.get(limit)
|
const existing = topTagsCache.get(limit);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetcher = unstable_cache(
|
const fetcher = unstable_cache(async () => getTopTagsFromDb(limit), [`top-tags:${limit}`], {
|
||||||
async () => getTopTagsFromDb(limit),
|
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
||||||
[`top-tags:${limit}`],
|
tags: ["top-tags"],
|
||||||
{
|
});
|
||||||
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
topTagsCache.set(limit, fetcher);
|
||||||
tags: ['top-tags'],
|
return fetcher;
|
||||||
}
|
|
||||||
)
|
|
||||||
topTagsCache.set(limit, fetcher)
|
|
||||||
return fetcher
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getTopTags(limit: number = 10): Promise<TagWithProjectCount[]> {
|
export async function getTopTags(limit: number = 10): Promise<TagWithProjectCount[]> {
|
||||||
return getTopTagsCachedFetcher(limit)()
|
return runWithCacheFallback(getTopTagsCachedFetcher(limit), () => getTopTagsFromDb(limit));
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getFixedProjectTypeFiltersFromDb(
|
async function getFixedProjectTypeFiltersFromDb(
|
||||||
status: 'ACTIVE' | 'ARCHIVED'
|
status: "ACTIVE" | "ARCHIVED"
|
||||||
): Promise<FixedProjectTypeFilter[]> {
|
): Promise<FixedProjectTypeFilter[]> {
|
||||||
let counts: number[] = []
|
let counts: number[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
counts = await withDbRetry('getFixedProjectTypeFilters', () =>
|
counts = await withDbRetry("getFixedProjectTypeFilters", () =>
|
||||||
Promise.all(
|
Promise.all(
|
||||||
FIXED_PROJECT_TYPE_TAGS.map((type) =>
|
FIXED_PROJECT_TYPE_TAGS.map((type) =>
|
||||||
prisma.project.count({
|
prisma.project.count({
|
||||||
@@ -436,12 +426,12 @@ async function getFixedProjectTypeFiltersFromDb(
|
|||||||
})
|
})
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
)
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
'[db] getFixedProjectTypeFilters degraded to zero counts:',
|
"[db] getFixedProjectTypeFilters degraded to zero counts:",
|
||||||
error instanceof Error ? error.message : String(error)
|
error instanceof Error ? error.message : String(error)
|
||||||
)
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
return FIXED_PROJECT_TYPE_TAGS.map((type, index) => ({
|
return FIXED_PROJECT_TYPE_TAGS.map((type, index) => ({
|
||||||
@@ -449,20 +439,20 @@ async function getFixedProjectTypeFiltersFromDb(
|
|||||||
name: type.name,
|
name: type.name,
|
||||||
nameEn: type.nameEn,
|
nameEn: type.nameEn,
|
||||||
projectCount: counts[index] ?? 0,
|
projectCount: counts[index] ?? 0,
|
||||||
}))
|
}));
|
||||||
}
|
}
|
||||||
|
|
||||||
const fixedProjectTypeFilterCache = new Map<
|
const fixedProjectTypeFilterCache = new Map<
|
||||||
'ACTIVE' | 'ARCHIVED',
|
"ACTIVE" | "ARCHIVED",
|
||||||
() => Promise<FixedProjectTypeFilter[]>
|
() => Promise<FixedProjectTypeFilter[]>
|
||||||
>()
|
>();
|
||||||
|
|
||||||
function getFixedProjectTypeFilterCachedFetcher(
|
function getFixedProjectTypeFilterCachedFetcher(
|
||||||
status: 'ACTIVE' | 'ARCHIVED'
|
status: "ACTIVE" | "ARCHIVED"
|
||||||
): () => Promise<FixedProjectTypeFilter[]> {
|
): () => Promise<FixedProjectTypeFilter[]> {
|
||||||
const existing = fixedProjectTypeFilterCache.get(status)
|
const existing = fixedProjectTypeFilterCache.get(status);
|
||||||
if (existing) {
|
if (existing) {
|
||||||
return existing
|
return existing;
|
||||||
}
|
}
|
||||||
|
|
||||||
const fetcher = unstable_cache(
|
const fetcher = unstable_cache(
|
||||||
@@ -470,28 +460,30 @@ function getFixedProjectTypeFilterCachedFetcher(
|
|||||||
[`fixed-project-type-filters:${status}`],
|
[`fixed-project-type-filters:${status}`],
|
||||||
{
|
{
|
||||||
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
||||||
tags: ['fixed-project-type-filters'],
|
tags: ["fixed-project-type-filters"],
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
fixedProjectTypeFilterCache.set(status, fetcher)
|
fixedProjectTypeFilterCache.set(status, fetcher);
|
||||||
return fetcher
|
return fetcher;
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function getFixedProjectTypeFilters(
|
export async function getFixedProjectTypeFilters(
|
||||||
status: 'ACTIVE' | 'ARCHIVED' = 'ACTIVE'
|
status: "ACTIVE" | "ARCHIVED" = "ACTIVE"
|
||||||
): Promise<FixedProjectTypeFilter[]> {
|
): Promise<FixedProjectTypeFilter[]> {
|
||||||
return getFixedProjectTypeFilterCachedFetcher(status)()
|
return runWithCacheFallback(getFixedProjectTypeFilterCachedFetcher(status), () =>
|
||||||
|
getFixedProjectTypeFiltersFromDb(status)
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getTagCategoryGroupsFromDb(): Promise<FilterTagCategoryGroup[]> {
|
async function getTagCategoryGroupsFromDb(): Promise<FilterTagCategoryGroup[]> {
|
||||||
let tags: TagWithProjectCount[] = []
|
let tags: TagWithProjectCount[] = [];
|
||||||
|
|
||||||
try {
|
try {
|
||||||
tags = await withDbRetry('getTagCategoryGroups', () =>
|
tags = await withDbRetry("getTagCategoryGroups", () =>
|
||||||
prisma.tag.findMany({
|
prisma.tag.findMany({
|
||||||
where: {
|
where: {
|
||||||
category: {
|
category: {
|
||||||
not: 'FIXED_PROJECT_TYPE',
|
not: "FIXED_PROJECT_TYPE",
|
||||||
},
|
},
|
||||||
projects: {
|
projects: {
|
||||||
some: {},
|
some: {},
|
||||||
@@ -503,35 +495,35 @@ async function getTagCategoryGroupsFromDb(): Promise<FilterTagCategoryGroup[]> {
|
|||||||
},
|
},
|
||||||
},
|
},
|
||||||
orderBy: {
|
orderBy: {
|
||||||
name: 'asc',
|
name: "asc",
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.error(
|
console.error(
|
||||||
'[db] getTagCategoryGroups degraded to empty groups:',
|
"[db] getTagCategoryGroups degraded to empty groups:",
|
||||||
error instanceof Error ? error.message : String(error)
|
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()) {
|
for (const category of getTagCategoryOrder()) {
|
||||||
if (category === 'FIXED_PROJECT_TYPE') {
|
if (category === "FIXED_PROJECT_TYPE") {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
groups.set(category, [])
|
groups.set(category, []);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const tag of tags) {
|
for (const tag of tags) {
|
||||||
if (tag.category === 'FIXED_PROJECT_TYPE') {
|
if (tag.category === "FIXED_PROJECT_TYPE") {
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
const current = groups.get(tag.category)
|
const current = groups.get(tag.category);
|
||||||
if (!current) {
|
if (!current) {
|
||||||
groups.set(tag.category, [tag])
|
groups.set(tag.category, [tag]);
|
||||||
continue
|
continue;
|
||||||
}
|
}
|
||||||
current.push(tag)
|
current.push(tag);
|
||||||
}
|
}
|
||||||
|
|
||||||
return Array.from(groups.entries())
|
return Array.from(groups.entries())
|
||||||
@@ -540,39 +532,39 @@ async function getTagCategoryGroupsFromDb(): Promise<FilterTagCategoryGroup[]> {
|
|||||||
name: TAG_CATEGORY_META[category].name,
|
name: TAG_CATEGORY_META[category].name,
|
||||||
nameEn: TAG_CATEGORY_META[category].nameEn,
|
nameEn: TAG_CATEGORY_META[category].nameEn,
|
||||||
tags: categoryTags.sort((a, b) => {
|
tags: categoryTags.sort((a, b) => {
|
||||||
const countDiff = b._count.projects - a._count.projects
|
const countDiff = b._count.projects - a._count.projects;
|
||||||
if (countDiff !== 0) {
|
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(
|
const getCachedTagCategoryGroups = unstable_cache(
|
||||||
getTagCategoryGroupsFromDb,
|
getTagCategoryGroupsFromDb,
|
||||||
['tag-category-groups:v1'],
|
["tag-category-groups:v1"],
|
||||||
{
|
{
|
||||||
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
revalidate: DEFAULT_CACHE_REVALIDATE_SECONDS,
|
||||||
tags: ['tag-category-groups'],
|
tags: ["tag-category-groups"],
|
||||||
}
|
}
|
||||||
)
|
);
|
||||||
|
|
||||||
export async function getTagCategoryGroups(): Promise<FilterTagCategoryGroup[]> {
|
export async function getTagCategoryGroups(): Promise<FilterTagCategoryGroup[]> {
|
||||||
return getCachedTagCategoryGroups()
|
return runWithCacheFallback(getCachedTagCategoryGroups, getTagCategoryGroupsFromDb);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 定义 AI 搜索结果类型
|
// 定义 AI 搜索结果类型
|
||||||
export type AISearchResultItem = ProjectWithFlatTags & {
|
export type AISearchResultItem = ProjectWithFlatTags & {
|
||||||
similarity: number
|
similarity: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
// n8n 返回的简化搜索结果类型
|
// n8n 返回的简化搜索结果类型
|
||||||
export type N8NSearchResult = {
|
export type N8NSearchResult = {
|
||||||
id: string
|
id: string;
|
||||||
similarity: number
|
similarity: number;
|
||||||
}
|
};
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* 根据 ID 列表批量获取项目(用于 AI 搜索结果组装)
|
* 根据 ID 列表批量获取项目(用于 AI 搜索结果组装)
|
||||||
@@ -581,10 +573,10 @@ export type N8NSearchResult = {
|
|||||||
*/
|
*/
|
||||||
export async function getProjectsByIds(ids: string[]): Promise<ProjectWithFlatTags[]> {
|
export async function getProjectsByIds(ids: string[]): Promise<ProjectWithFlatTags[]> {
|
||||||
if (ids.length === 0) {
|
if (ids.length === 0) {
|
||||||
return []
|
return [];
|
||||||
}
|
}
|
||||||
|
|
||||||
const projects = await withDbRetry('getProjectsByIds', () =>
|
const projects = await withDbRetry("getProjectsByIds", () =>
|
||||||
prisma.project.findMany({
|
prisma.project.findMany({
|
||||||
where: {
|
where: {
|
||||||
id: {
|
id: {
|
||||||
@@ -600,11 +592,11 @@ export async function getProjectsByIds(ids: string[]): Promise<ProjectWithFlatTa
|
|||||||
links: true,
|
links: true,
|
||||||
},
|
},
|
||||||
})
|
})
|
||||||
)
|
);
|
||||||
|
|
||||||
// Transform tags to flatten the structure
|
// Transform tags to flatten the structure
|
||||||
return projects.map((project) => ({
|
return projects.map((project) => ({
|
||||||
...project,
|
...project,
|
||||||
tags: project.tags.map((pt) => pt.tag),
|
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,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 {
|
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;
|
||||||
|
|||||||
@@ -1,6 +0,0 @@
|
|||||||
import { type ClassValue, clsx } from "clsx";
|
|
||||||
import { twMerge } from "tailwind-merge";
|
|
||||||
|
|
||||||
export function cn(...inputs: ClassValue[]) {
|
|
||||||
return twMerge(clsx(inputs));
|
|
||||||
}
|
|
||||||
@@ -3,7 +3,6 @@ import type { Config } from "tailwindcss"
|
|||||||
const config: Config = {
|
const config: Config = {
|
||||||
darkMode: ["class"],
|
darkMode: ["class"],
|
||||||
content: [
|
content: [
|
||||||
"./src/pages/**/*.{js,ts,jsx,tsx,mdx}",
|
|
||||||
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
"./src/components/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
"./src/app/**/*.{js,ts,jsx,tsx,mdx}",
|
||||||
],
|
],
|
||||||
|
|||||||
+1
-1
@@ -4,6 +4,6 @@
|
|||||||
"framework": "nextjs",
|
"framework": "nextjs",
|
||||||
"regions": ["hkg1"],
|
"regions": ["hkg1"],
|
||||||
"git": {
|
"git": {
|
||||||
"deploymentEnabled": false
|
"deploymentEnabled": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user