Skip to content

[Phase 1] Discovery Skeleton — Foundation + Read-Only #27

Description

@isntboxs

[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

  • packages/db/src/relations.ts:40-54project.submitter salah:
// 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,
}
  • Tambah di packages/db/package.json:
"scripts": { "db:seed": "bun run src/seed.ts" }
  • Export seed dari packages/db/src/index.ts jika perlu reuse di test.

D. Drizzle config

  • packages/db/drizzle.config.ts sudah schema: './src/schemas.ts' — pastikan schemas.ts re-export projectStatusEnum juga.
  • Jalan generate:
vp run --filter @altstack/db db:generate  # drizzle-kit generate
vp run --filter @altstack/db db:push      # atau db:migrate
  • Verifikasi src/migrations/*.sql contains CREATE TYPE project_status, ALTER TABLE projects ADD COLUMN status ..., indexes.

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

  • Branch feat/phase-1-discovery dari main
  • Commit: feat(db): add project status/featured/stars/forks + fix relations
  • Commit: feat(api): add project list/getBySlug contracts & routers
  • Commit: feat(web): add ProjectCard/Grid + projects.$slug detail route
  • routeTree.gen.ts regenerated & committed
  • migrations/ committed
  • Screenshot homepage + detail di PR description
  • vp check --fix run
  • Linked issue: closes # (auto-close on merge)

Next after merge: Tag phase-1-done, lanjutkan Phase 2 — Search, Filter & Categories (docs/roadmap.md#phase-2).

Metadata

Metadata

Assignees

No one assigned

    Labels

    enhancementNew feature or request

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions