fix: Code for category

This commit is contained in:
Bakhtiyar Issakhmetov 2026-06-27 19:35:53 +05:00
parent 46add88fd5
commit 148d1fb841
14 changed files with 127 additions and 64 deletions

View File

@ -160,10 +160,18 @@ paths:
- name: category_id
in: query
required: false
description: Filter to a category.
description: Filter to a category by id.
schema:
type: string
format: uuid
- name: category_code
in: query
required: false
description: >-
Filter to a category by its code (slug). An unknown code yields an
empty page.
schema:
type: string
responses:
"200":
description: A page of dataset summaries
@ -187,7 +195,7 @@ paths:
multipart/form-data:
schema:
type: object
required: [file, file_type, category_id, code]
required: [file, file_type, category_id]
properties:
file:
type: string
@ -198,9 +206,6 @@ paths:
category_id:
type: string
format: uuid
code:
type: string
description: Business code.
name:
type: string
description: Display name; defaults to the filename if omitted.
@ -441,7 +446,7 @@ components:
Category:
type: object
required: [id, name, description, created_at, updated_at]
required: [id, code, name, description, created_at, updated_at]
properties:
id:
type: string
@ -449,6 +454,9 @@ components:
parent_id:
type: [string, "null"]
format: uuid
code:
type: string
description: Slug (lowercase latin letters, digits, and dashes).
name:
type: string
description:
@ -462,11 +470,16 @@ components:
CategoryInput:
type: object
required: [name]
required: [code, name]
properties:
parent_id:
type: [string, "null"]
format: uuid
code:
type: string
maxLength: 255
pattern: "^[a-z0-9]+(?:-[a-z0-9]+)*$"
description: Slug (lowercase latin letters, digits, and dashes).
name:
type: string
maxLength: 255
@ -505,7 +518,7 @@ components:
DatasetSummary:
type: object
required: [id, category_id, code, name, file_type, size_bytes, status, created_at, updated_at]
required: [id, category_id, name, file_type, size_bytes, status, created_at, updated_at]
properties:
id:
type: string
@ -513,8 +526,6 @@ components:
category_id:
type: string
format: uuid
code:
type: string
name:
type: string
description:
@ -538,7 +549,7 @@ components:
Dataset:
type: object
required:
[id, category_id, code, name, filename, storage_key, file_type,
[id, category_id, name, filename, storage_key, file_type,
size_bytes, content_type, automated, status, created_at, updated_at]
properties:
id:
@ -547,8 +558,6 @@ components:
category_id:
type: string
format: uuid
code:
type: string
name:
type: string
description:

View File

@ -11,6 +11,7 @@ import (
"gis/api"
"gis/internal/config"
"gis/internal/domain"
"gis/internal/messaging/rabbitmq"
"gis/internal/parser"
"gis/internal/platform/logger"
@ -87,6 +88,9 @@ func New(ctx context.Context) (*App, error) {
// Handler builds the HTTP handler with all routes and readiness checks wired.
func (a *App) Handler() stdhttp.Handler {
validate := validator.New(validator.WithRequiredStructEnabled())
validate.RegisterValidation("slug", func(fl validator.FieldLevel) bool {
return domain.ValidSlug(fl.Field().String())
})
health := transporthttp.NewHealthHandler(map[string]transporthttp.ReadinessCheck{
"postgres": func(ctx context.Context) error { return a.pool.Ping(ctx) },

View File

@ -1,6 +1,7 @@
package domain
import (
"regexp"
"time"
"github.com/google/uuid"
@ -11,8 +12,19 @@ import (
type Category struct {
ID uuid.UUID `json:"id"`
ParentID *uuid.UUID `json:"parent_id"`
Code string `json:"code"`
Name string `json:"name"`
Description string `json:"description"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
}
// slugPattern matches a slug: lowercase latin letters and digits in
// dash-separated groups, e.g. "population", "land-use-2024".
var slugPattern = regexp.MustCompile(`^[a-z0-9]+(?:-[a-z0-9]+)*$`)
// ValidSlug reports whether s is a valid slug (lowercase latin letters, digits,
// and single dashes between groups; no leading, trailing, or repeated dashes).
func ValidSlug(s string) bool {
return slugPattern.MatchString(s)
}

View File

@ -145,7 +145,6 @@ type YearColumn struct {
type DatasetSummary struct {
ID uuid.UUID `json:"id"`
CategoryID uuid.UUID `json:"category_id"`
Code string `json:"code"`
Name string `json:"name"`
Description *string `json:"description"`
Unit *string `json:"unit"`
@ -160,7 +159,6 @@ type DatasetSummary struct {
type Dataset struct {
ID uuid.UUID `json:"id"`
CategoryID uuid.UUID `json:"category_id"`
Code string `json:"code"`
Name string `json:"name"`
Description *string `json:"description"`
Unit *string `json:"unit"`

View File

@ -20,21 +20,21 @@ func NewCategoryRepository(pool *pgxpool.Pool) *CategoryRepository {
return &CategoryRepository{pool: pool}
}
const categoryColumns = `id, parent_id, name, description, created_at, updated_at`
const categoryColumns = `id, parent_id, code, name, description, created_at, updated_at`
func scanCategory(row pgx.Row) (domain.Category, error) {
var c domain.Category
err := row.Scan(&c.ID, &c.ParentID, &c.Name, &c.Description, &c.CreatedAt, &c.UpdatedAt)
err := row.Scan(&c.ID, &c.ParentID, &c.Code, &c.Name, &c.Description, &c.CreatedAt, &c.UpdatedAt)
return c, err
}
// Create inserts a new category and returns the stored row.
func (r *CategoryRepository) Create(ctx context.Context, c domain.Category) (domain.Category, error) {
row := r.pool.QueryRow(ctx,
`INSERT INTO categories (parent_id, name, description)
VALUES ($1, $2, $3)
`INSERT INTO categories (parent_id, code, name, description)
VALUES ($1, $2, $3, $4)
RETURNING `+categoryColumns,
c.ParentID, c.Name, c.Description,
c.ParentID, c.Code, c.Name, c.Description,
)
out, err := scanCategory(row)
return out, mapError(err)
@ -48,6 +48,14 @@ func (r *CategoryRepository) GetByID(ctx context.Context, id uuid.UUID) (domain.
return out, mapError(err)
}
// GetByCode returns the category with the given code, or domain.ErrNotFound.
func (r *CategoryRepository) GetByCode(ctx context.Context, code string) (domain.Category, error) {
row := r.pool.QueryRow(ctx,
`SELECT `+categoryColumns+` FROM categories WHERE code = $1`, code)
out, err := scanCategory(row)
return out, mapError(err)
}
// List returns categories ordered by name. When parentID is non-nil it filters
// to that parent's direct children; otherwise it returns all categories.
func (r *CategoryRepository) List(ctx context.Context, parentID *uuid.UUID) ([]domain.Category, error) {
@ -82,10 +90,10 @@ func (r *CategoryRepository) List(ctx context.Context, parentID *uuid.UUID) ([]d
func (r *CategoryRepository) Update(ctx context.Context, c domain.Category) (domain.Category, error) {
row := r.pool.QueryRow(ctx,
`UPDATE categories
SET parent_id = $2, name = $3, description = $4, updated_at = now()
SET parent_id = $2, code = $3, name = $4, description = $5, updated_at = now()
WHERE id = $1
RETURNING `+categoryColumns,
c.ID, c.ParentID, c.Name, c.Description,
c.ID, c.ParentID, c.Code, c.Name, c.Description,
)
out, err := scanCategory(row)
return out, mapError(err)

View File

@ -26,7 +26,7 @@ func NewDatasetRepository(pool *pgxpool.Pool) *DatasetRepository {
// datasetColumns lists the dataset columns for SELECT and RETURNING. The
// geometry is exposed as GeoJSON (jsonb) rather than its raw EWKB form, and a
// bounding box array is derived for raster datasets only.
const datasetColumns = `id, category_id, code, name, description, unit, filename, storage_key, cog_storage_key, file_type, size_bytes, content_type, properties, meta, automated, status, attribute_columns, kato_column, year_columns, parse_error, ST_AsGeoJSON(geometry)::jsonb AS geometry,
const datasetColumns = `id, category_id, name, description, unit, filename, storage_key, cog_storage_key, file_type, size_bytes, content_type, properties, meta, automated, status, attribute_columns, kato_column, year_columns, parse_error, ST_AsGeoJSON(geometry)::jsonb AS geometry,
CASE WHEN file_type = 'raster' AND geometry IS NOT NULL
THEN ARRAY[ST_XMin(geometry), ST_YMin(geometry), ST_XMax(geometry), ST_YMax(geometry)]
ELSE NULL END AS bbox,
@ -35,7 +35,7 @@ const datasetColumns = `id, category_id, code, name, description, unit, filename
func scanDataset(row pgx.Row) (domain.Dataset, error) {
var d domain.Dataset
err := row.Scan(
&d.ID, &d.CategoryID, &d.Code, &d.Name, &d.Description, &d.Unit,
&d.ID, &d.CategoryID, &d.Name, &d.Description, &d.Unit,
&d.Filename, &d.StorageKey, &d.CogStorageKey, &d.FileType, &d.SizeBytes, &d.ContentType,
&d.Properties, &d.Meta, &d.Automated, &d.Status,
&d.AttributeColumns, &d.KatoColumn, &d.YearColumns, &d.ParseError,
@ -56,10 +56,10 @@ func nullableJSON(raw json.RawMessage) any {
// Create inserts a new dataset and returns the stored row.
func (r *DatasetRepository) Create(ctx context.Context, d domain.Dataset) (domain.Dataset, error) {
row := r.pool.QueryRow(ctx,
`INSERT INTO datasets (category_id, code, name, description, unit, filename, storage_key, file_type, size_bytes, content_type, properties, meta, automated, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14)
`INSERT INTO datasets (category_id, name, description, unit, filename, storage_key, file_type, size_bytes, content_type, properties, meta, automated, status)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
RETURNING `+datasetColumns,
d.CategoryID, d.Code, d.Name, d.Description, d.Unit, d.Filename, d.StorageKey, d.FileType, d.SizeBytes, d.ContentType,
d.CategoryID, d.Name, d.Description, d.Unit, d.Filename, d.StorageKey, d.FileType, d.SizeBytes, d.ContentType,
nullableJSON(d.Properties), nullableJSON(d.Meta), d.Automated, d.Status,
)
out, err := scanDataset(row)
@ -269,12 +269,12 @@ func (r *DatasetRepository) GetByID(ctx context.Context, id uuid.UUID) (domain.D
return out, mapError(err)
}
const datasetSummaryColumns = `id, category_id, code, name, description, unit, file_type, size_bytes, status, created_at, updated_at`
const datasetSummaryColumns = `id, category_id, name, description, unit, file_type, size_bytes, status, created_at, updated_at`
func scanDatasetSummary(row pgx.Row) (domain.DatasetSummary, error) {
var d domain.DatasetSummary
err := row.Scan(
&d.ID, &d.CategoryID, &d.Code, &d.Name, &d.Description, &d.Unit,
&d.ID, &d.CategoryID, &d.Name, &d.Description, &d.Unit,
&d.FileType, &d.SizeBytes, &d.Status, &d.CreatedAt, &d.UpdatedAt,
)
return d, err

View File

@ -25,6 +25,7 @@ type CategoryRepository interface {
// CategoryInput carries the mutable fields of a category.
type CategoryInput struct {
ParentID *uuid.UUID
Code string
Name string
Description string
}
@ -39,13 +40,17 @@ func NewCategoryService(repo CategoryRepository) *CategoryService {
return &CategoryService{repo: repo}
}
// Create validates the parent (if any) and stores a new category.
// Create validates the code and parent (if any) and stores a new category.
func (s *CategoryService) Create(ctx context.Context, in CategoryInput) (domain.Category, error) {
if !domain.ValidSlug(in.Code) {
return domain.Category{}, fmt.Errorf("%w: code must be a slug (lowercase latin letters, digits, and dashes)", domain.ErrValidation)
}
if err := s.ensureParentExists(ctx, in.ParentID); err != nil {
return domain.Category{}, err
}
return s.repo.Create(ctx, domain.Category{
ParentID: in.ParentID,
Code: in.Code,
Name: in.Name,
Description: in.Description,
})
@ -61,8 +66,11 @@ func (s *CategoryService) List(ctx context.Context, parentID *uuid.UUID) ([]doma
return s.repo.List(ctx, parentID)
}
// Update validates the parent change (existence + no cycles) and stores it.
// Update validates the code and parent change (existence + no cycles) and stores it.
func (s *CategoryService) Update(ctx context.Context, id uuid.UUID, in CategoryInput) (domain.Category, error) {
if !domain.ValidSlug(in.Code) {
return domain.Category{}, fmt.Errorf("%w: code must be a slug (lowercase latin letters, digits, and dashes)", domain.ErrValidation)
}
if _, err := s.repo.GetByID(ctx, id); err != nil {
return domain.Category{}, err
}
@ -75,6 +83,7 @@ func (s *CategoryService) Update(ctx context.Context, id uuid.UUID, in CategoryI
return s.repo.Update(ctx, domain.Category{
ID: id,
ParentID: in.ParentID,
Code: in.Code,
Name: in.Name,
Description: in.Description,
})

View File

@ -54,7 +54,7 @@ func TestCategoryService_Create(t *testing.T) {
t.Run("root category succeeds", func(t *testing.T) {
svc := NewCategoryService(newStubCategoryRepo())
got, err := svc.Create(ctx, CategoryInput{Name: "root"})
got, err := svc.Create(ctx, CategoryInput{Code: "root", Name: "root"})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -63,10 +63,19 @@ func TestCategoryService_Create(t *testing.T) {
}
})
t.Run("invalid code is a validation error", func(t *testing.T) {
svc := NewCategoryService(newStubCategoryRepo())
for _, code := range []string{"", "Root", "with space", "-leading", "double--dash"} {
if _, err := svc.Create(ctx, CategoryInput{Code: code, Name: "x"}); !errors.Is(err, domain.ErrValidation) {
t.Fatalf("code %q: want ErrValidation, got %v", code, err)
}
}
})
t.Run("missing parent is a validation error", func(t *testing.T) {
svc := NewCategoryService(newStubCategoryRepo())
missing := uuid.New()
_, err := svc.Create(ctx, CategoryInput{Name: "child", ParentID: &missing})
_, err := svc.Create(ctx, CategoryInput{Code: "child", Name: "child", ParentID: &missing})
if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("want ErrValidation, got %v", err)
}
@ -75,9 +84,9 @@ func TestCategoryService_Create(t *testing.T) {
t.Run("existing parent succeeds", func(t *testing.T) {
repo := newStubCategoryRepo()
svc := NewCategoryService(repo)
root, _ := svc.Create(ctx, CategoryInput{Name: "root"})
root, _ := svc.Create(ctx, CategoryInput{Code: "root", Name: "root"})
child, err := svc.Create(ctx, CategoryInput{Name: "child", ParentID: &root.ID})
child, err := svc.Create(ctx, CategoryInput{Code: "child", Name: "child", ParentID: &root.ID})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -92,26 +101,26 @@ func TestCategoryService_Update_PreventsCycles(t *testing.T) {
repo := newStubCategoryRepo()
svc := NewCategoryService(repo)
root, _ := svc.Create(ctx, CategoryInput{Name: "root"})
child, _ := svc.Create(ctx, CategoryInput{Name: "child", ParentID: &root.ID})
root, _ := svc.Create(ctx, CategoryInput{Code: "root", Name: "root"})
child, _ := svc.Create(ctx, CategoryInput{Code: "child", Name: "child", ParentID: &root.ID})
t.Run("category cannot be its own parent", func(t *testing.T) {
_, err := svc.Update(ctx, root.ID, CategoryInput{Name: "root", ParentID: &root.ID})
_, err := svc.Update(ctx, root.ID, CategoryInput{Code: "root", Name: "root", ParentID: &root.ID})
if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("want ErrValidation, got %v", err)
}
})
t.Run("category cannot descend from its own child", func(t *testing.T) {
_, err := svc.Update(ctx, root.ID, CategoryInput{Name: "root", ParentID: &child.ID})
_, err := svc.Update(ctx, root.ID, CategoryInput{Code: "root", Name: "root", ParentID: &child.ID})
if !errors.Is(err, domain.ErrValidation) {
t.Fatalf("want ErrValidation, got %v", err)
}
})
t.Run("valid reparent succeeds", func(t *testing.T) {
other, _ := svc.Create(ctx, CategoryInput{Name: "other"})
updated, err := svc.Update(ctx, child.ID, CategoryInput{Name: "child", ParentID: &other.ID})
other, _ := svc.Create(ctx, CategoryInput{Code: "other", Name: "other"})
updated, err := svc.Update(ctx, child.ID, CategoryInput{Code: "child", Name: "child", ParentID: &other.ID})
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -123,7 +132,7 @@ func TestCategoryService_Update_PreventsCycles(t *testing.T) {
func TestCategoryService_Update_MissingCategory(t *testing.T) {
svc := NewCategoryService(newStubCategoryRepo())
_, err := svc.Update(context.Background(), uuid.New(), CategoryInput{Name: "x"})
_, err := svc.Update(context.Background(), uuid.New(), CategoryInput{Code: "x", Name: "x"})
if !errors.Is(err, domain.ErrNotFound) {
t.Fatalf("want ErrNotFound, got %v", err)
}

View File

@ -61,9 +61,11 @@ type ObjectStore interface {
Remove(ctx context.Context, key string) error
}
// categoryReader lets the dataset service verify a category exists before upload.
// categoryReader lets the dataset service verify a category exists before upload
// and resolve a category code to its id for list filtering.
type categoryReader interface {
GetByID(ctx context.Context, id uuid.UUID) (domain.Category, error)
GetByCode(ctx context.Context, code string) (domain.Category, error)
}
// JobEnqueuer schedules asynchronous dataset jobs.
@ -90,7 +92,6 @@ type RasterConverter interface {
// UploadInput carries everything needed to store a new dataset.
type UploadInput struct {
CategoryID uuid.UUID
Code string
Name string
Description *string
Unit *string
@ -140,9 +141,6 @@ func NewDatasetService(
// Upload validates input, stores the object, and persists the dataset. If the
// database write fails after upload, the stored object is removed.
func (s *DatasetService) Upload(ctx context.Context, in UploadInput) (domain.Dataset, error) {
if in.Code == "" {
return domain.Dataset{}, fmt.Errorf("%w: code is required", domain.ErrValidation)
}
if !in.FileType.Valid() {
return domain.Dataset{}, fmt.Errorf("%w: unknown file_type %q", domain.ErrValidation, in.FileType)
}
@ -193,7 +191,6 @@ func (s *DatasetService) Upload(ctx context.Context, in UploadInput) (domain.Dat
dataset, err := s.repo.Create(ctx, domain.Dataset{
CategoryID: in.CategoryID,
Code: in.Code,
Name: name,
Description: in.Description,
Unit: in.Unit,
@ -588,8 +585,10 @@ func (s *DatasetService) WaitForStatus(ctx context.Context, id uuid.UUID, curren
}
// ListSummaries returns a page of dataset summaries, optionally filtered to a
// category. page is 1-based; page and pageSize are clamped to sane bounds.
func (s *DatasetService) ListSummaries(ctx context.Context, categoryID *uuid.UUID, page, pageSize int) (DatasetPage, error) {
// category by id and/or by code. page is 1-based; page and pageSize are clamped
// to sane bounds. When categoryCode is set it is resolved to its category id; an
// unknown code yields an empty page.
func (s *DatasetService) ListSummaries(ctx context.Context, categoryID *uuid.UUID, categoryCode *string, page, pageSize int) (DatasetPage, error) {
if page < 1 {
page = 1
}
@ -600,6 +599,17 @@ func (s *DatasetService) ListSummaries(ctx context.Context, categoryID *uuid.UUI
pageSize = MaxPageSize
}
if categoryCode != nil {
category, err := s.categories.GetByCode(ctx, *categoryCode)
if err != nil {
if errors.Is(err, domain.ErrNotFound) {
return DatasetPage{Items: []domain.DatasetSummary{}, Page: page, PageSize: pageSize}, nil
}
return DatasetPage{}, err
}
categoryID = &category.ID
}
items, err := s.repo.ListSummaries(ctx, categoryID, pageSize, (page-1)*pageSize)
if err != nil {
return DatasetPage{}, err

View File

@ -254,11 +254,17 @@ func (s stubCategoryReader) GetByID(_ context.Context, id uuid.UUID) (domain.Cat
return domain.Category{ID: id}, nil
}
func (s stubCategoryReader) GetByCode(_ context.Context, code string) (domain.Category, error) {
if !s.exists {
return domain.Category{}, domain.ErrNotFound
}
return domain.Category{ID: uuid.New(), Code: code}, nil
}
func validUpload() UploadInput {
body := `{"type":"FeatureCollection","features":[]}`
return UploadInput{
CategoryID: uuid.New(),
Code: "POP",
Name: "Population",
Filename: "data.geojson",
FileType: domain.FileTypeVector,
@ -278,7 +284,6 @@ func TestDatasetService_Upload_Validation(t *testing.T) {
name string
mutate func(*UploadInput)
}{
{"missing code", func(in *UploadInput) { in.Code = "" }},
{"invalid file type", func(in *UploadInput) { in.FileType = "bogus" }},
{"unknown extension", func(in *UploadInput) { in.Filename = "data.txt" }},
{"extension/type mismatch", func(in *UploadInput) { in.Filename = "data.tif" }}, // .tif is raster
@ -732,7 +737,7 @@ func TestDatasetService_ListSummaries_ClampsPaging(t *testing.T) {
svc := newDatasetService(repo, &stubStore{}, true)
// page < 1 -> 1, pageSize > max -> MaxPageSize, offset = 0.
res, err := svc.ListSummaries(context.Background(), nil, 0, 10_000)
res, err := svc.ListSummaries(context.Background(), nil, nil, 0, 10_000)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@ -747,7 +752,7 @@ func TestDatasetService_ListSummaries_ClampsPaging(t *testing.T) {
}
// page 3, pageSize 20 -> offset 40.
if _, err := svc.ListSummaries(context.Background(), nil, 3, 20); err != nil {
if _, err := svc.ListSummaries(context.Background(), nil, nil, 3, 20); err != nil {
t.Fatal(err)
}
if repo.lastOffset != 40 || repo.lastLimit != 20 {

View File

@ -34,12 +34,13 @@ func (h *CategoryHandler) Register(r chi.Router) {
type categoryRequest struct {
ParentID *string `json:"parent_id" validate:"omitempty,uuid"`
Code string `json:"code" validate:"required,max=255,slug"`
Name string `json:"name" validate:"required,max=255"`
Description string `json:"description" validate:"max=2000"`
}
func (r categoryRequest) toInput() (service.CategoryInput, error) {
in := service.CategoryInput{Name: r.Name, Description: r.Description}
in := service.CategoryInput{Code: r.Code, Name: r.Name, Description: r.Description}
if r.ParentID != nil {
id, err := uuid.Parse(*r.ParentID)
if err != nil {

View File

@ -131,12 +131,6 @@ func (h *DatasetHandler) upload(w http.ResponseWriter, r *http.Request) {
return
}
code := r.FormValue("code")
if code == "" {
httputil.WriteError(w, http.StatusUnprocessableEntity, "code is required")
return
}
meta, ok := optionalJSONFormValue(w, r, "meta")
if !ok {
return
@ -162,7 +156,6 @@ func (h *DatasetHandler) upload(w http.ResponseWriter, r *http.Request) {
dataset, err := h.svc.Upload(r.Context(), service.UploadInput{
CategoryID: categoryID,
Code: code,
Name: r.FormValue("name"),
Description: optionalFormValue(r, "description"),
Unit: optionalFormValue(r, "unit"),
@ -220,6 +213,10 @@ func (h *DatasetHandler) list(w http.ResponseWriter, r *http.Request) {
if !ok {
return
}
var categoryCode *string
if v := strings.TrimSpace(r.URL.Query().Get("category_code")); v != "" {
categoryCode = &v
}
page, ok := parsePositiveIntQuery(w, r, "page", 1)
if !ok {
return
@ -229,7 +226,7 @@ func (h *DatasetHandler) list(w http.ResponseWriter, r *http.Request) {
return
}
res, err := h.svc.ListSummaries(r.Context(), categoryID, page, pageSize)
res, err := h.svc.ListSummaries(r.Context(), categoryID, categoryCode, page, pageSize)
if err != nil {
respondDomainError(w, err)
return

View File

@ -2,6 +2,8 @@
CREATE TABLE categories (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
parent_id UUID REFERENCES categories (id) ON DELETE RESTRICT,
-- Slug: lowercase latin letters, digits, and dashes. Unique.
code VARCHAR(255) NOT NULL UNIQUE,
name VARCHAR(255) NOT NULL,
description TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),

View File

@ -4,7 +4,6 @@ CREATE TYPE file_type AS ENUM ('vector_with_kato', 'vector', 'raster');
CREATE TABLE datasets (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
category_id UUID NOT NULL REFERENCES categories (id) ON DELETE RESTRICT,
code VARCHAR(255) NOT NULL,
name VARCHAR(255) NOT NULL,
description TEXT,
unit VARCHAR(255),