[Phase 1] Discovery Skeleton — Foundation + Read-Only
Roadmap: docs/roadmap.md#phase-1-discovery-skeleton | PRD: prd.md v1.1 Phased (Phase 1) | Branch: feat/phase-1-discovery | Milestone: MVP — Phase 1/5
1. Overview
Goal: Buktikan core value — browsing projects works tanpa auth/search/submit. Vertical slice pertama: DB → oRPC → TanStack Start UI.
Kenapa dulu: Unblock semua fase berikutnya. Tanpa project.status + published filter + seed, fase 2–5 tidak bisa test.
Demo: Guest buka / → lihat 12 card dari DB → klik card → /projects/:slug detail header + links + stats. Semua dari status='published' saja.
Out of Scope (jangan dikerjakan di issue ini): Search/FTS, categories, submit, auth, admin, GitHub fetch, audit log, R2/Images, bookmark/compare/collections, Quality Score.
2. Tasks — Checklist Teknis
2.1 DB — packages/db
A. Fix relations bug
// BEFORE (bug)
project: {
submitter : r . one . project ( {
from : r . project . id , // ❌ harus submitterId
to : r . user . id ,
} ) ,
}
// AFTER
project: {
submitter : r . one . user ( {
from : r . project . submitterId ,
to : r . user . id ,
} ) ,
}
user: {
projects : r . many . project ( {
from : r . user . id ,
to : r . project . submitterId , // ❌ sebelumnya to: r.project.id
} ) ,
}
Pastikan defineRelations import tetap packages/db/src/schemas.ts aggregate.
Tambah relasi 1-1 di packages/db/src/relations.ts:
project : {
submitter : r . one . user ( { from : r . project . submitterId , to : r . user . id } ) ,
githubRepository : r . one . githubRepository ( { from : r . project . id , to : r . githubRepository . projectId } ) ,
} ,
githubRepository : {
project : r . one . project ( { from : r . githubRepository . projectId , to : r . project . id } ) ,
} ,
B. Migrate project table — packages/db/src/schemas/project.ts (tanpa kolom GitHub — stats pindah ke githubRepository)
Tambah kolom (sesuai docs/prd.md:686-712 + docs/roadmap.md#phase-1 — revisi split 1-1):
import {
boolean ,
index ,
pgEnum ,
pgTable ,
text ,
timestamp ,
uuid ,
varchar ,
} from 'drizzle-orm/pg-core'
import { sql } from 'drizzle-orm'
import { user } from './auth'
export const projectStatusEnum = pgEnum ( 'project_status' , [
'draft' ,
'published' ,
'rejected' ,
'removed' ,
] )
export const project = pgTable (
'projects' ,
{
id : uuid ( 'id' )
. default ( sql `gen_random_uuid()` )
. primaryKey ( ) ,
submitterId : uuid ( 'submitter_id' ) . references ( ( ) => user . id , {
onDelete : 'cascade' ,
} ) ,
name : text ( 'name' ) . notNull ( ) ,
slug : text ( 'slug' ) . notNull ( ) . unique ( ) ,
repositoryUrl : text ( 'repository_url' ) . notNull ( ) . unique ( ) ,
websiteUrl : text ( 'website_url' ) ,
tagline : varchar ( 'tagline' , { length : 80 } ) . notNull ( ) ,
shortDescription : varchar ( 'short_description' , { length : 280 } ) . notNull ( ) ,
logoUrl : text ( 'logo_url' ) . notNull ( ) ,
content : text ( 'content' ) ,
// NEW — Phase 1 (tanpa githubOwner/Repo/stars/forks — pindah ke githubRepository)
status : projectStatusEnum ( 'status' ) . notNull ( ) . default ( 'draft' ) ,
featured : boolean ( 'featured' ) . notNull ( ) . default ( false ) ,
rejectionReason : text ( 'rejection_reason' ) ,
moderatedAt : timestamp ( 'moderated_at' ) ,
moderatedBy : uuid ( 'moderated_by' ) . references ( ( ) => user . id ) ,
createdAt : timestamp ( 'created_at' ) . notNull ( ) . defaultNow ( ) ,
updatedAt : timestamp ( 'updated_at' )
. notNull ( )
. defaultNow ( )
. $onUpdate ( ( ) => new Date ( ) ) ,
} ,
( t ) => [
index ( 'project_slug_idx' ) . on ( t . slug ) ,
index ( 'project_status_idx' ) . on ( t . status ) ,
index ( 'project_featured_idx' ) . on ( t . featured ) ,
]
)
B2. Create githubRepository table — packages/db/src/schemas/github.ts (new, 1-1 strict projectId unique)
import { sql } from 'drizzle-orm'
import {
index ,
integer ,
pgTable ,
text ,
timestamp ,
uuid ,
} from 'drizzle-orm/pg-core'
import { project } from './project'
export const githubRepository = pgTable (
'github_repositories' ,
{
id : uuid ( 'id' )
. default ( sql `gen_random_uuid()` )
. primaryKey ( ) ,
projectId : uuid ( 'project_id' )
. notNull ( )
. unique ( )
. references ( ( ) => project . id , { onDelete : 'cascade' } ) ,
owner : text ( 'owner' ) . notNull ( ) ,
repo : text ( 'repo' ) . notNull ( ) ,
stars : integer ( 'stars' ) . notNull ( ) . default ( 0 ) ,
forks : integer ( 'forks' ) . notNull ( ) . default ( 0 ) ,
createdAt : timestamp ( 'created_at' ) . notNull ( ) . defaultNow ( ) ,
updatedAt : timestamp ( 'updated_at' )
. notNull ( )
. defaultNow ( )
. $onUpdate ( ( ) => new Date ( ) ) ,
} ,
( t ) => [
index ( 'github_repo_owner_repo_idx' ) . on ( t . owner , t . repo ) ,
index ( 'github_repo_project_idx' ) . on ( t . projectId ) ,
]
)
// v1.1 will add: license, topics text[], watchers, openIssues, lastCommitAt, etc.
Update packages/db/src/schemas.ts:
export * from '@altstack/db/schemas/auth'
export * from '@altstack/db/schemas/project'
export * from '@altstack/db/schemas/github'
Notes:
pgEnum harus di satu file, drizzle-kit akan generate CREATE TYPE project_status.
Jika gen_random_uuid() butuh pgcrypto, pastikan migration include CREATE EXTENSION IF NOT EXISTS "pgcrypto".
projectId unique = 1-1 strict (1 project → 1 githubRepository). Cascade delete.
Jangan ubah user schema di issue ini (ROLES tetap ['admin','user'] — cek packages/shared/src/schemas/role.ts:3).
C. Seed — packages/db/src/seed.ts (new) + package.json script
Buat packages/db/src/seed.ts yang insert 15 projects status='published' covering: AI (2), Developer Tools (3), Productivity (1), Design (1), Database (2), DevOps (1), Monitoring (1), Security (1), CMS (1), Self-Hosted (1), Frontend (1) — variasi stars 50–15000, createdAt spread 2023–2026 untuk feed test fase 5.
Contoh 1 record:
// insert project
{
name : 'Better Auth' ,
slug : 'better-auth' ,
repositoryUrl : 'https://github.com/better-auth/better-auth' ,
websiteUrl : 'https://better-auth.com' ,
tagline : 'The most comprehensive authentication library' ,
shortDescription : 'Comprehensive auth for TypeScript with email/password, OAuth, 2FA, organization and more.' ,
logoUrl : 'https://avatars.githubusercontent.com/u/better-auth' ,
content : '# Better Auth
... markdown ...' ,
status : 'published' ,
featured : true , // untuk Editor's Picks test
}
// then insert githubRepository 1-1
{
projectId : '<uuid dari project di atas>' ,
owner : 'better-auth' ,
repo : 'better-auth' ,
stars : 8200 ,
forks : 410 ,
}
"scripts" : { "db:seed" : " bun run src/seed.ts" }
D. Drizzle config
vp run --filter @altstack/db db:generate # drizzle-kit generate
vp run --filter @altstack/db db:push # atau db:migrate
2.2 API — packages/api
A. Contract — packages/api/src/contracts/project.ts (new file)
import { z } from 'zod'
import { baseContract } from '@altstack/api/contracts/base'
export const listProjectsContract = baseContract
. route ( {
path : '/projects' ,
method : 'GET' ,
summary : 'List published projects' ,
tags : [ 'Projects' ] ,
operationId : 'listProjects' ,
} )
. input ( z . object ( {
page : z . coerce . number ( ) . int ( ) . min ( 1 ) . default ( 1 ) ,
limit : z . coerce . number ( ) . int ( ) . min ( 1 ) . max ( 50 ) . default ( 12 ) ,
status : z . enum ( [ 'draft' , 'published' , 'rejected' , 'removed' ] ) . optional ( ) , // default published untuk guest
} ) )
. output ( z . object ( {
items : z . array ( z . object ( {
id : z . string ( ) . uuid ( ) ,
name : z . string ( ) ,
slug : z . string ( ) ,
repositoryUrl : z . string ( ) . url ( ) ,
websiteUrl : z . string ( ) . url ( ) . nullable ( ) ,
tagline : z . string ( ) ,
shortDescription : z . string ( ) ,
logoUrl : z . string ( ) . url ( ) ,
stars : z . number ( ) . int ( ) ,
forks : z . number ( ) . int ( ) ,
featured : z . boolean ( ) ,
createdAt : z . string ( ) ,
updatedAt : z . string ( ) ,
} ) ) ,
total : z . number ( ) . int ( ) ,
page : z . number ( ) . int ( ) ,
limit : z . number ( ) . int ( ) ,
} ) )
export const getProjectBySlugContract = baseContract
. route ( {
path : '/projects/:slug' ,
method : 'GET' ,
summary : 'Get project by slug' ,
tags : [ 'Projects' ] ,
operationId : 'getProjectBySlug' ,
} )
. input ( z . object ( { slug : z . string ( ) . min ( 1 ) } ) )
. output ( z . object ( { /* same as single item above + content nullable */ } ) )
export const projectContract = {
list : listProjectsContract ,
getBySlug : getProjectBySlugContract ,
}
Daftarkan di packages/api/src/contracts/index.ts:
import { projectContract } from '@altstack/api/contracts/project'
export const contracts = { altstack : altstackContract , health : healthContract , project : projectContract }
B. Router — packages/api/src/routers/project.ts (new file)
import { eq , desc , count } from 'drizzle-orm'
import { publicProcedure } from '@altstack/api/procedures'
import { project } from '@altstack/db/schemas/project'
const listHandler = publicProcedure . project . list . handler (
async ( { input, context, errors } ) => {
const { db } = context
const page = input . page ?? 1
const limit = input . limit ?? 12
const offset = ( page - 1 ) * limit
// guest-only published; jika butuh admin preview draft, cek context.auth?.user.role === 'admin'
const where = input . status
? eq ( project . status , input . status )
: eq ( project . status , 'published' )
// join githubRepository 1-1 untuk stars/forks
const [ items , [ totalRow ] ] = await Promise . all ( [
db . query . project . findMany ( {
where,
with : { githubRepository : true } ,
orderBy : ( project , { desc } ) => [ desc ( project . createdAt ) ] ,
limit,
offset,
} ) ,
db . select ( { value : count ( ) } ) . from ( project ) . where ( where ) ,
] )
// flatten untuk UI: { ...project, stars: project.githubRepository?.stars ?? 0, forks: ... }
const flattened = items . map ( ( p ) => ( {
...p ,
stars : p . githubRepository ?. stars ?? 0 ,
forks : p . githubRepository ?. forks ?? 0 ,
} ) )
return { items : flattened , total : totalRow . value , page, limit }
}
)
const getBySlugHandler = publicProcedure . project . getBySlug . handler (
async ( { input, context, errors } ) => {
const row = await context . db . query . project . findFirst ( {
where : eq ( project . slug , input . slug ) ,
with : { githubRepository : true } ,
} )
if ( ! row )
throw errors . NOT_FOUND ( { message : `Project ${ input . slug } not found` } )
if ( row . status !== 'published' ) {
// P1: guest selalu 404 untuk draft; P5 baru admin bisa lihat
throw errors . NOT_FOUND ( )
}
return { ...row , stars : row . githubRepository ?. stars ?? 0 , forks : row . githubRepository ?. forks ?? 0 }
}
)
export const projectRouter = { list : listHandler , getBySlug : getBySlugHandler }
Daftarkan di packages/api/src/routers/index.ts:
import { projectRouter } from '@altstack/api/routers/project'
export const routers = o . router ( { altstack : altstackRouter , health : healthRouter , project : projectRouter } )
Export di packages/api/package.json exports: tambah ./contracts/project + ./routers/project jika pakai subpath export.
C. Shared export — update packages/api/src/contracts/base.ts tidak perlu, sudah ada NOT_FOUND dll.
2.3 UI — apps/web
A. apps/web/src/utils/orpc.ts
Sudah punya orpc = createTanstackQueryUtils(client) — tidak perlu ubah, otomatis typed dari routers baru.
B. Components — apps/web/src/components/project-card.tsx (new)
import { Link } from '@tanstack/react-router'
import { Card } from '@altstack/ui/components/card'
import { Badge } from '@altstack/ui/components/badge'
type Props = { project : { slug : string ; name : string ; logoUrl : string ; tagline : string ; stars : number ; forks : number } }
export function ProjectCard ( { project } : Props ) {
return (
< Link to = "/projects/$slug" params = { { slug : project . slug } } viewTransition className = "block" >
< Card className = "p-4 hover:bg-muted/50 transition" >
< div className = "flex gap-3" >
< img src = { project . logoUrl } alt = { project . name } className = "size-10 rounded-md object-cover" loading = "lazy" />
< div className = "min-w-0 flex-1" >
< h3 className = "truncate font-medium" > { project . name } </ h3 >
< p className = "line-clamp-2 text-sm text-muted-foreground" > { project . tagline } </ p >
< div className = "mt-2 flex gap-2 text-xs text-muted-foreground" >
< Badge variant = "secondary" > ★ { project . stars } </ Badge >
< span > ⑂ { project . forks } </ span >
</ div >
</ div >
</ div >
</ Card >
</ Link >
)
}
Tambah apps/web/src/components/project-grid.tsx (grid + pagination):
import { ProjectCard } from '#/components/project-card'
import { Button } from '@altstack/ui/components/button'
export function ProjectGrid ( { items, page, total, limit, onPage } : { items : any [ ] ; page : number ; total : number ; limit : number ; onPage : ( n :number ) => void } ) {
const totalPages = Math . ceil ( total / limit )
return (
< >
< div className = "grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3" >
{ items . map ( p => < ProjectCard key = { p . id } project = { p } /> ) }
</ div >
{ totalPages > 1 && (
< div className = "mt-6 flex justify-center gap-2" >
< Button variant = "outline" disabled = { page <= 1 } onClick = { ( ) => onPage ( page - 1 ) } > Prev</ Button >
< span className = "py-2 text-sm" > { page } /{ totalPages } · { total } projects</ span >
< Button variant = "outline" disabled = { page >= totalPages } onClick = { ( ) => onPage ( page + 1 ) } > Next</ Button >
</ div >
) }
</ >
)
}
C. Routes — update apps/web/src/routes/index.tsx
Ganti dari static Hero+Filter saja → loader + grid:
import { createFileRoute } from '@tanstack/react-router'
import { HeroSection } from '#/components/hero-section'
import { FilterSection } from '#/components/filter-section'
import { ProjectGrid } from '#/components/project-grid'
import { orpc } from '#/utils/orpc'
export const Route = createFileRoute ( '/' ) ( {
loader : async ( { context } ) => {
const data = await context . orpc . project . list . query ( { page : 1 , limit : 12 } )
return data
} ,
component : Home ,
} )
function Home ( ) {
const data = Route . useLoaderData ( )
// atau useSuspenseQuery: const { data } = useSuspenseQuery(orpc.project.list.queryOptions({ input: { page: 1, limit: 12 } }))
return (
< >
< HeroSection />
< div id = "projects" className = "scroll-mt-12" >
< FilterSection disabledNote = "Search & filters coming in Phase 2" />
</ div >
< section className = "container mx-auto max-w-6xl px-4 lg:px-16 pb-10" >
< ProjectGrid items = { data . items } total = { data . total } page = { data . page } limit = { data . limit } onPage = { ...} />
</ section >
</ >
)
}
FilterSection — tambah prop disabledNote?: string → tampilkan tooltip Coming in Phase 2 dan InputGroupInput disabled untuk sekarang (jangan implement search di P1).
D. Detail route — apps/web/src/routes/projects.$slug.tsx (new)
import { createFileRoute , notFound } from '@tanstack/react-router'
import { orpc } from '#/utils/orpc'
export const Route = createFileRoute ( '/projects/$slug' ) ( {
loader : async ( { params, context } ) => {
try {
return await context . queryClient . ensureQueryData (
orpc . project . getBySlug . queryOptions ( { input : { slug : params . slug } } )
)
} catch ( e ) {
throw notFound ( )
}
} ,
notFoundComponent : ( ) => < div className = "p-10 text-center" > Project not found or not published</ div > ,
component : ProjectDetail ,
} )
function ProjectDetail ( ) {
const project = Route . useLoaderData ( )
return (
< article className = "container mx-auto max-w-3xl px-4 py-10" >
< header className = "flex gap-4" >
< img src = { project . logoUrl } alt = { project . name } className = "size-16 rounded-xl" />
< div >
< h1 className = "text-2xl font-semibold" > { project . name } </ h1 >
< p className = "text-muted-foreground" > { project . tagline } </ p >
< div className = "mt-2 flex gap-2 text-sm" >
< a href = { project . repositoryUrl } target = "_blank" className = "underline" > GitHub</ a >
{ project . websiteUrl && < a href = { project . websiteUrl } target = "_blank" className = "underline" > Website</ a > }
< span > ★ { project . stars } · ⑂ { project . forks } </ span >
</ div >
</ div >
</ header >
< section className = "prose max-w-none mt-8" > { project . content ?? < p > { project . shortDescription } </ p > } </ section >
</ article >
)
}
Pastikan apps/web/src/routeTree.gen.ts regenerate: vp run web#generate-routes atau bun run generate-routes (cek apps/web/package.json:10).
E. Keep HeroSection/FilterSection shell
apps/web/src/components/filter-section.tsx:18-28 biarkan, tapi InputGroupInput disabled + placeholder "Search — coming in Phase 2" di P1.
3. Acceptance Criteria — DoD untuk merge
Check dan harus hijau sebelum merge ke main:
GET /projects (tanpa auth) hanya return status='published' — GET /projects?status=draft tanpa admin → 404 atau 403 (P1 behavior: tetap 404/hidden, belum ada admin guard; cukup filter published default)
GET /projects/:slug untuk slug draft → 404 NOT_FOUND (guest)
Homepage / render 12 cards dari seed, pagination Prev/Next works, tidak N+1 (cek network: 1 query list saja)
Detail /projects/better-auth render header fields: logo, name, tagline, shortDescription, GitHub + Website links, stars/forks — sesuai prd.md:316-338 minimal.
Direct hit ke /projects/non-existent → NotFound component, bukan 500
vp check (fmt+lint) pass, vp run -r build pass, vp run -r test pass (jika ada test)
packages/db db:generate menghasilkan migrasi, db:push idempotent di docker-compose.yml postgres lokal
relations bug fixed — db.query.project.findFirst({ with: { submitter: true } }) return user, bukan null/error
Seed idempotent — rerun db:seed tidak duplikat (pakai onConflictDoNothing atau delete+insert)
No Phase 2 code masuk (search, category, FTS) — PR diff tidak menyentuh file category.ts atau to_tsvector.
4. Cara Test Lokal (Step-by-Step)
# 0. switch branch
git checkout -b feat/phase-1-discovery
vp install
# 1. DB
docker compose up -d postgres
# tunggu healthy, cek .env DATABASE_URL = postgres://postgres:postgres@localhost:5432/altstack
vp run --filter @altstack/db db:generate
vp run --filter @altstack/db db:push
# atau vp run --filter @altstack/db db:migrate
# 2. Seed
vp run --filter @altstack/db db:seed
# verifikasi
psql $DATABASE_URL -c " select slug,status,stars from projects limit 5;"
# 3. API
vp run server#dev # Elysia di :3000
curl http://localhost:3000/api/rpc/project/list --data ' {"page":1,"limit":2}' # via oRPC RPC
# atau via OpenAPI: http://localhost:3000/api/reference
# 4. Web
vp run web#dev # TanStack Start di :3001
# buka http://localhost:3001/ → cek 12 cards
# buka http://localhost:3001/projects/better-auth → cek detail
# buka http://localhost:3001/projects/draft-slug-test → harus 404
# 5. Check
vp check
vp run -r build
Manual QA script: See docs/roadmap.md#phase-1--discovery-skeleton Acceptance Criteria.
5. File Map — What to touch / Not touch
Touch (allow-list):
packages/db/src/schemas/project.ts
packages/db/src/schemas.ts
packages/db/src/relations.ts
packages/db/src/seed.ts (new)
packages/db/package.json (script)
packages/db/drizzle.config.ts (jika perlu)
packages/api/src/contracts/project.ts (new)
packages/api/src/contracts/index.ts
packages/api/src/routers/project.ts (new)
packages/api/src/routers/index.ts
packages/api/package.json (exports, if needed)
apps/web/src/routes/index.tsx
apps/web/src/routes/projects.$slug.tsx (new)
apps/web/src/components/project-card.tsx (new)
apps/web/src/components/project-grid.tsx (new)
apps/web/src/components/filter-section.tsx (minor disable)
apps/web/src/utils/orpc.ts (if needed)
Do NOT touch (out-of-scope): category.ts, search, submit, auth, dashboard, auditLog, bookmark, collection.
6. Estimasi & Risiko
Estimasi: 0.5–1 sprint solo.
Risiko utama: pgEnum migration di PG existing — jika sudah ada data, ALTER TYPE butuh CREATE TYPE dulu. Sediakan sql fallback raw.
Mitigasi: Test db:push di postgres kosong + dengan seed lama (jika ada). Jangan manual edit .sql setelah generate.
7. References
PRD: prd.md:134-160 Phase 1 section, prd.md:316-338 Project Page header, prd.md:686-712 Database
Roadmap: docs/roadmap.md:36-106
Existing: packages/db/src/schemas/project.ts:1-37, packages/db/src/relations.ts:40-54, packages/api/src/contracts/base.ts:1-36, apps/web/src/routes/index.tsx:1-17, apps/web/src/utils/orpc.ts:1-23, apps/web/src/components/hero-section.tsx:50-58
8. Checklist PR
Next after merge: Tag phase-1-done, lanjutkan Phase 2 — Search, Filter & Categories (docs/roadmap.md#phase-2).
[Phase 1] Discovery Skeleton — Foundation + Read-Only
1. Overview
Goal: Buktikan core value — browsing projects works tanpa auth/search/submit. Vertical slice pertama: DB → oRPC → TanStack Start UI.
Kenapa dulu: Unblock semua fase berikutnya. Tanpa
project.status+publishedfilter + seed, fase 2–5 tidak bisa test.Demo: Guest buka
/→ lihat 12 card dari DB → klik card →/projects/:slugdetail header + links + stats. Semua daristatus='published'saja.Out of Scope (jangan dikerjakan di issue ini): Search/FTS, categories, submit, auth, admin, GitHub fetch, audit log, R2/Images, bookmark/compare/collections, Quality Score.
2. Tasks — Checklist Teknis
2.1 DB —
packages/dbA. Fix relations bug
packages/db/src/relations.ts:40-54—project.submittersalah:defineRelationsimport tetappackages/db/src/schemas.tsaggregate.packages/db/src/relations.ts:B. Migrate
projecttable —packages/db/src/schemas/project.ts(tanpa kolom GitHub — stats pindah kegithubRepository)Tambah kolom (sesuai
docs/prd.md:686-712+docs/roadmap.md#phase-1— revisi split 1-1):B2. Create
githubRepositorytable —packages/db/src/schemas/github.ts(new, 1-1 strictprojectId unique)Update
packages/db/src/schemas.ts:Notes:
pgEnumharus di satu file, drizzle-kit akan generateCREATE TYPE project_status.gen_random_uuid()butuhpgcrypto, pastikan migration includeCREATE EXTENSION IF NOT EXISTS "pgcrypto".projectId unique= 1-1 strict (1 project → 1 githubRepository). Cascade delete.userschema di issue ini (ROLES tetap['admin','user']— cekpackages/shared/src/schemas/role.ts:3).C. Seed —
packages/db/src/seed.ts(new) +package.jsonscriptpackages/db/src/seed.tsyang insert 15 projectsstatus='published'covering: AI (2), Developer Tools (3), Productivity (1), Design (1), Database (2), DevOps (1), Monitoring (1), Security (1), CMS (1), Self-Hosted (1), Frontend (1) — variasistars50–15000,createdAtspread 2023–2026 untuk feed test fase 5.packages/db/package.json:packages/db/src/index.tsjika perlu reuse di test.D. Drizzle config
packages/db/drizzle.config.tssudahschema: './src/schemas.ts'— pastikanschemas.tsre-exportprojectStatusEnumjuga.src/migrations/*.sqlcontainsCREATE TYPE project_status,ALTER TABLE projects ADD COLUMN status ..., indexes.2.2 API —
packages/apiA. Contract —
packages/api/src/contracts/project.ts(new file)packages/api/src/contracts/index.ts:B. Router —
packages/api/src/routers/project.ts(new file)packages/api/src/routers/index.ts:packages/api/package.jsonexports: tambah./contracts/project+./routers/projectjika pakai subpath export.C. Shared export — update
packages/api/src/contracts/base.tstidak perlu, sudah adaNOT_FOUNDdll.2.3 UI —
apps/webA.
apps/web/src/utils/orpc.tsorpc = createTanstackQueryUtils(client)— tidak perlu ubah, otomatis typed dariroutersbaru.B. Components —
apps/web/src/components/project-card.tsx(new)apps/web/src/components/project-grid.tsx(grid + pagination):C. Routes — update
apps/web/src/routes/index.tsxGanti dari static Hero+Filter saja → loader + grid:
FilterSection— tambah propdisabledNote?: string→ tampilkan tooltipComing in Phase 2danInputGroupInputdisableduntuk sekarang (jangan implement search di P1).D. Detail route —
apps/web/src/routes/projects.$slug.tsx(new)apps/web/src/routeTree.gen.tsregenerate:vp run web#generate-routesataubun run generate-routes(cekapps/web/package.json:10).E. Keep
HeroSection/FilterSectionshellapps/web/src/components/filter-section.tsx:18-28biarkan, tapiInputGroupInputdisabled+ placeholder"Search — coming in Phase 2"di P1.3. Acceptance Criteria — DoD untuk merge
Check dan harus hijau sebelum merge ke
main:GET /projects(tanpa auth) hanya returnstatus='published'—GET /projects?status=drafttanpa admin → 404 atau 403 (P1 behavior: tetap 404/hidden, belum ada admin guard; cukup filterpublisheddefault)GET /projects/:sluguntuk slug draft →404 NOT_FOUND(guest)/render 12 cards dari seed, pagination Prev/Next works, tidak N+1 (cek network: 1 query list saja)/projects/better-authrender header fields: logo, name, tagline,shortDescription, GitHub + Website links, stars/forks — sesuaiprd.md:316-338minimal./projects/non-existent→ NotFound component, bukan 500vp check(fmt+lint) pass,vp run -r buildpass,vp run -r testpass (jika ada test)packages/dbdb:generatemenghasilkan migrasi,db:pushidempotent didocker-compose.ymlpostgres lokalrelationsbug fixed —db.query.project.findFirst({ with: { submitter: true } })return user, bukan null/errordb:seedtidak duplikat (pakaionConflictDoNothingatau delete+insert)category.tsatauto_tsvector.4. Cara Test Lokal (Step-by-Step)
Manual QA script: See
docs/roadmap.md#phase-1--discovery-skeletonAcceptance Criteria.5. File Map — What to touch / Not touch
Touch (allow-list):
packages/db/src/schemas/project.tspackages/db/src/schemas.tspackages/db/src/relations.tspackages/db/src/seed.ts(new)packages/db/package.json(script)packages/db/drizzle.config.ts(jika perlu)packages/api/src/contracts/project.ts(new)packages/api/src/contracts/index.tspackages/api/src/routers/project.ts(new)packages/api/src/routers/index.tspackages/api/package.json(exports, if needed)apps/web/src/routes/index.tsxapps/web/src/routes/projects.$slug.tsx(new)apps/web/src/components/project-card.tsx(new)apps/web/src/components/project-grid.tsx(new)apps/web/src/components/filter-section.tsx(minor disable)apps/web/src/utils/orpc.ts(if needed)Do NOT touch (out-of-scope):
category.ts,search,submit,auth,dashboard,auditLog,bookmark,collection.6. Estimasi & Risiko
pgEnummigration di PG existing — jika sudah ada data,ALTER TYPEbutuhCREATE TYPEdulu. Sediakansqlfallback raw.db:pushdi postgres kosong + dengan seed lama (jika ada). Jangan manual edit.sqlsetelah generate.7. References
prd.md:134-160Phase 1 section,prd.md:316-338Project Page header,prd.md:686-712Databasedocs/roadmap.md:36-106packages/db/src/schemas/project.ts:1-37,packages/db/src/relations.ts:40-54,packages/api/src/contracts/base.ts:1-36,apps/web/src/routes/index.tsx:1-17,apps/web/src/utils/orpc.ts:1-23,apps/web/src/components/hero-section.tsx:50-588. Checklist PR
feat/phase-1-discoverydarimainfeat(db): add project status/featured/stars/forks + fix relationsfeat(api): add project list/getBySlug contracts & routersfeat(web): add ProjectCard/Grid + projects.$slug detail routerouteTree.gen.tsregenerated & committedmigrations/committedvp check --fixrunNext after merge: Tag
phase-1-done, lanjutkan Phase 2 — Search, Filter & Categories (docs/roadmap.md#phase-2).