Resources
6Install
npx skillscat add lunatearr/cmu-review Install via the SkillsCat registry.
SKILL.md
Technical playbook for Claude Code working in this repository. Describes patterns to follow when generating or modifying code.
1. Clean Architecture — Dependency Rule
domain ← usecase ← adapter ← infrastructureinternal/domain/— zero external imports. Only stdlib.internal/usecase/— imports domain only. Never imports adapter or infrastructure.internal/adapter/— imports domain + usecase/port. Never imports infrastructure directly.cmd/main.go— wires all layers together. DB open, server start, migration run all live here directly.
Violation: domain importing gin, pgx, or any adapter package = architecture breach.
2. Domain Layer Patterns
Entities (current shapes)
// internal/domain/entity/review.go
type Review struct {
ID int
CourseID int // FK → courses.id
UserID *int // nil = anonymous
Rating uint8 // 1–5
Grade string // "" = not specified
AcademicYear int // Buddhist era, e.g. 2567
Semester int // 1, 2, or 3
Content string
Category string // optional, e.g. "หมวดวิชาบังคับ"
Program string // optional, e.g. "ภาคปกติ"
Professor string // optional lecturer name
ReviewerName string // optional display nickname, "" = anonymous
IPHash string // sha256(ip:ua) — never raw PII
IsHidden bool
CreatedAt time.Time
}
// internal/domain/entity/course.go
type Course struct {
ID int
CourseCode string // CMU code e.g. "204111"
NameEN string
NameTH string
Credits uint8
FacultyID int
Description string
Faculty Faculty
AvgRating float64
ReviewCount int
}
// internal/domain/entity/faculty.go
type Faculty struct {
ID int
Code string
NameTH string
NameEN string
}Value Objects
// internal/domain/valueobject/rating.go
type Rating uint8
func NewRating(v uint8) (Rating, error) {
if v < 1 || v > 5 {
return 0, domainerrors.ErrInvalidRating
}
return Rating(v), nil
}// internal/domain/valueobject/review_status.go
type ReviewStatus string
const (
StatusPending ReviewStatus = "pending"
StatusApproved ReviewStatus = "approved"
StatusRejected ReviewStatus = "rejected"
StatusFlagged ReviewStatus = "flagged"
)Domain Errors
Sentinel errors in internal/domain/errors/errors.go. Match with errors.Is.
var (
ErrCourseNotFound = errors.New("course not found")
ErrFacultyNotFound = errors.New("faculty not found")
ErrDuplicateCourse = errors.New("course already exists")
ErrReviewNotFound = errors.New("review not found")
ErrDuplicateReview = errors.New("you have already reviewed this course for this term")
ErrHoneypotTripped = errors.New("invalid submission")
ErrRateLimited = errors.New("too many submissions, please try again later")
ErrContentTooShort = errors.New("review content is too short")
ErrInvalidRating = errors.New("rating must be between 1 and 5")
ErrInvalidSemester = errors.New("semester must be 1, 2, or 3")
)3. Repository Pattern
Interfaces — defined in domain
// internal/domain/repository/review_repository.go
type ReviewRepository interface {
Create(ctx context.Context, r *entity.Review) (*entity.Review, error)
ListByCourse(ctx context.Context, courseID int, opts ListOpts) ([]entity.Review, int, error)
CountRecentByHash(ctx context.Context, ipHash string, since time.Time) (int, error)
}
type ListOpts struct {
Limit int
Offset int
}
// internal/domain/repository/course_repository.go
type CourseListOpts struct {
Search string
Faculty string
Credits int // 0 = all
SortBy string
Limit int
Offset int
}
type CourseRepository interface {
Exists(ctx context.Context, id int) (bool, error)
Create(ctx context.Context, c *entity.Course) (*entity.Course, error)
List(ctx context.Context, opts CourseListOpts) ([]entity.Course, int, error)
GetByID(ctx context.Context, id int) (*entity.Course, error)
}
// internal/domain/repository/faculty_repository.go
type FacultyRepository interface {
ListAll(ctx context.Context) ([]entity.Faculty, error)
}Rules:
- Accept and return domain entities, never raw DB rows or pgx types.
context.Contextas first arg always.- Map pgx errors to domain errors in
mapPgError/mapCourseError— callers never see driver-specific errors. Listreturns([]entity.T, int, error)— theintis total count for pagination.
Implementation — in adapter/repository/postgres
type coursePgRepo struct{ db *sql.DB }
func NewCourseRepo(db *sql.DB) repository.CourseRepository {
return &coursePgRepo{db: db}
}- Use
database/sqlwith_ "github.com/jackc/pgx/v5/stdlib". - SQL strings as
constinside functions. Use string concatenation only for ORDER BY and WHERE clauses shared between count and list queries. mapPgErrortranslates pgx unique-violation (23505) / FK violation (23503) to domain errors.- LIKE/ILIKE searches use
escapeLike()to prevent injection:strings.ReplaceAllon\,%,_. - Course search:
to_tsvector + plainto_tsqueryfor full-text with ILIKE fallback using%term%(contains, not prefix).
4. Port Interfaces (usecase/port)
// internal/usecase/port/review_port.go
type SpamInput struct {
HoneypotValue string
SubmitterHash string
CourseID int
Content string
}
type SpamChecker interface {
Check(ctx context.Context, in SpamInput) error
}
type Actor interface {
SubmitterHash() string
UserID() *int // nil if anonymous
}- Use cases depend on these interfaces, never on concrete adapters.
- Input structs carry all data — no HTTP primitives (
*gin.Context,*http.Request) ever enter a use case.
5. Use Case Structure
One file, one struct, one Execute method.
// internal/usecase/review/create_review.go
type CreateReviewUseCase struct {
reviews repository.ReviewRepository
courses repository.CourseRepository
spam port.SpamChecker
}
type CreateReviewInput struct {
CourseID int
Actor port.Actor
Rating uint8
Grade string
AcademicYear int
Semester int
Content string
Category string
Program string
Professor string
ReviewerName string
HoneypotValue string
}
func (uc *CreateReviewUseCase) Execute(ctx context.Context, in CreateReviewInput) (*entity.Review, error) {
// 1. spam pipeline (honeypot → rate-limit → content)
// 2. course exists check
// 3. validate rating and semester
// 4. build entity and persist
}Business validation lives in the use case, not the handler.
6. Anti-Spam Pipeline
SubmitterHash (actor middleware)
// sha256(ip + ":" + user-agent) — no salt yet
h := sha256.New()
h.Write([]byte(ip + ":" + ua))
hash := hex.EncodeToString(h.Sum(nil))Never log or persist raw IPs. Only the hash is stored in reviews.ip_hash.
Pipeline order (fastest first)
1. Honeypot — "website" JSON field must be empty; bots fill it
2. Rate limit — CountRecentByHash(hash, since) >= threshold → ErrRateLimited
3. Content rules — len(TrimSpace(content)) < MinLen → ErrContentTooShortEach checker implements port.SpamChecker. Composed via spamcheck.Pipeline (slice of SpamChecker, short-circuits on first error).
Rate limit (3 reviews/IP hash/hour) is currently commented out in cmd/main.go — re-enable after seeding test data.
7. HTTP Handler Pattern
Handlers: parse → call use case → map error → write response.
func (h *ReviewHandler) Create(c *gin.Context) {
courseID, err := strconv.Atoi(c.Param("id"))
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid course id"})
return
}
var body dto.CreateReviewRequest
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid request body"})
return
}
out, err := h.create.Execute(c.Request.Context(), reviewuc.CreateReviewInput{...})
if err != nil {
respondError(c, err)
return
}
c.JSON(http.StatusCreated, dto.ToReviewResponse(out))
}respondError maps domain errors to HTTP status codes:
ErrCourseNotFound→ 404ErrDuplicateReview→ 409ErrRateLimited→ 429ErrHoneypotTripped,ErrInvalidRating,ErrInvalidSemester,ErrContentTooShort→ 422- anything else → 500 with generic message
Route registration (current)
// internal/adapter/http/router.go
func Register(r *gin.Engine,
reviewHandler *handler.ReviewHandler,
facultyHandler *handler.FacultyHandler,
courseHandler *handler.CourseHandler,
cors configs.CorsConfig,
) {
r.Use(middleware.CORS(cfg), middleware.Recovery(), middleware.RequestID(), middleware.Actor())
r.GET("/healthz", ...)
v1 := r.Group("/api/v1")
v1.Use(middleware.RateLimit(200, time.Minute))
v1.GET("/courses", courseHandler.List)
v1.POST("/courses", courseHandler.Create)
v1.GET("/courses/:id", courseHandler.Get)
v1.GET("/courses/:id/reviews", reviewHandler.List)
v1.POST("/courses/:id/reviews", reviewHandler.Create)
v1.GET("/faculties", facultyHandler.List)
}Adding a new handler: add it to Register signature, wire in cmd/main.go.
8. DTO vs Entity Rules
| Concern | Use |
|---|---|
| HTTP request/response bodies | DTO (internal/adapter/http/dto/) |
| Business logic / persistence | Entity (internal/domain/entity/) |
| Cross-layer transport inside app | Entity |
DTOs hold json/binding tags; entities never do. Conversion functions live in the DTO package.
// internal/adapter/http/dto/review_dto.go
type CreateReviewRequest struct {
Rating uint8 `json:"rating" binding:"required,min=1,max=5"`
Grade string `json:"grade"`
AcademicYear int `json:"academic_year" binding:"required,min=2560"`
Semester int `json:"semester" binding:"required,min=1,max=3"`
Content string `json:"content" binding:"required,min=10,max=2000"`
Category string `json:"category" binding:"max=255"`
Program string `json:"program" binding:"max=255"`
Professor string `json:"professor" binding:"max=255"`
ReviewerName string `json:"reviewer_name" binding:"max=100"`
Website string `json:"website"` // honeypot — must be empty
}
type ReviewResponse struct {
ID int `json:"id"`
Rating uint8 `json:"rating"`
Grade string `json:"grade"`
AcademicYear int `json:"academic_year"`
Semester int `json:"semester"`
Content string `json:"content"`
Category string `json:"category"`
Program string `json:"program"`
Professor string `json:"professor"`
ReviewerName string `json:"reviewer_name"`
CreatedAt string `json:"created_at"` // RFC3339
}
// internal/adapter/http/dto/course_dto.go
type CourseResponse struct {
ID int `json:"id"`
CourseCode string `json:"course_id"`
NameTH string `json:"name_th"`
NameEN string `json:"name_en"`
Credits uint8 `json:"credits"`
Description string `json:"description"`
Faculty FacultyEmbed `json:"faculty"`
AvgRating float64 `json:"avg_rating"`
ReviewCount int `json:"review_count"`
}Faculty list endpoint returns {"data": []FacultyResponse}. The frontend fetchFaculties unwraps .data before returning to callers.
9. PostgreSQL Query Patterns
Full-text search on courses
-- course_pg_repo.go uses both approaches:
-- 1. tsquery for full-word matching
to_tsvector('simple', name_th || ' ' || name_en || ' ' || course_id)
@@ plainto_tsquery('simple', $search)
-- 2. ILIKE fallback for substring matching (e.g. "111" in "204111")
course_id ILIKE $likeContains ESCAPE '\' -- $likeContains = '%' + escaped + '%'escapeLike(s) escapes \, %, _ before interpolating into ILIKE patterns.'simple' config works for both Thai and ASCII without stemming.
Aggregates inline
AvgRating and ReviewCount are computed inline per query:
COALESCE(AVG(rv.rating) FILTER (WHERE NOT rv.is_hidden), 0) AS avg_rating,
COUNT(rv.id) FILTER (WHERE NOT rv.is_hidden) AS review_countGROUP BY c.id, f.id required when joining reviews.
10. Config Pattern (Viper)
configs/config.go uses Viper with explicit BindEnv for every key that lacks a default, because AutomaticEnv alone does not reliably resolve keys with no registered default.
viper.BindEnv("DATABASE_URL")
viper.BindEnv("DATABASE_PRIVATE_URL")
viper.BindEnv("PGHOST")
// ... etcDB connection resolved by resolveDBURL():
DATABASE_PRIVATE_URL(Railway internal)DATABASE_URL(standard)- Build from
PGHOST,PGPORT,PGUSER,PGPASSWORD,PGDATABASE
11. React + TypeScript Patterns
State + fetch pattern in pages
const loadInitial = useCallback(async (f: typeof filters) => {
setLoading(true)
setError(null)
try {
const res = await fetchCourses({ search: f.search, faculty: f.faculty, page: 1, limit: LIMIT })
setCourses(res.data)
setTotal(res.total)
setOffset(res.data.length)
} catch {
setError('โหลดข้อมูลไม่สำเร็จ กรุณาลองใหม่')
} finally {
setLoading(false)
}
}, [])
useEffect(() => { loadInitial(filters) }, [filters, loadInitial])API client rules
- All HTTP via
src/api/client.ts. Never callfetchdirectly from components. - Base path:
VITE_API_BASE_URLenv var (baked at Vite build time) falling back to/api/v1. - Throws
ApiError(not plainError) on non-2xx. - When backend returns
{data: T[]}envelope, unwrap in the API module (.then(r => r.data)) — callers always receive the concrete type.
SearchableSelect component
src/components/SearchableSelect.tsx — reusable typeahead dropdown. Use for all filter dropdowns.
interface SelectOption {
value: string | number
label: string
searchKeys?: string[] // extra strings to match (e.g. English name, code)
}- Shows search input only when
options.length > 6. searchKeysenables cross-field matching: faculty options pass[name_en, code]so typing "sci" matches "คณะวิทยาศาสตร์" via codeSCIor English name.- Keyboard: Arrow keys navigate, Enter selects, Escape closes.
ReviewModal
src/components/ReviewModal.tsx — full review detail overlay.
createPortaltodocument.body— escapes sidebar stacking context.displayedstate lags thereviewprop by 220 ms so CSS exit animation plays before unmount.- ESC, backdrop click, and close button all dismiss. Body scroll locked while open.
Styling
Inline styles only — no CSS framework. CSS custom properties defined in src/index.css:
--cmu-primary: brand purple--cmu-text,--cmu-text-muted--cmu-bg,--cmu-bg-card--cmu-border,--cmu-border-strong--cmu-error
12. Extensibility Patterns
Adding authentication
Review.UserID *intis already nullable — no schema change needed.- Add JWT parsing in
internal/adapter/http/middleware/actor.go. Populate anauthenticatedActorthat returns non-nilUserID(). ActorFromContext(c)always returns a non-nilport.Actor— use cases don't change.
Adding new domains
Follow the layer sequence: entity → repository interface → usecase → postgres impl → handler + dto → wire in router + main.
Never skip layers. A handler calling a repository directly violates the architecture.
Re-enabling rate limiter
Uncomment this line in cmd/main.go:
spamcheck.NewRateLimitChecker(reviewRepo, 3, time.Hour),