diff --git a/api/openapi.yaml b/api/openapi.yaml index 9f6eb7b..4abe8c2 100644 --- a/api/openapi.yaml +++ b/api/openapi.yaml @@ -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: diff --git a/internal/app/app.go b/internal/app/app.go index 1798f5f..04f3bb0 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -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) }, diff --git a/internal/domain/category.go b/internal/domain/category.go index 3e102a1..1b9af44 100644 --- a/internal/domain/category.go +++ b/internal/domain/category.go @@ -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) +} diff --git a/internal/domain/dataset.go b/internal/domain/dataset.go index f5eb6ff..c291f86 100644 --- a/internal/domain/dataset.go +++ b/internal/domain/dataset.go @@ -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"` diff --git a/internal/repository/postgres/category.go b/internal/repository/postgres/category.go index c6cb045..4b16bc8 100644 --- a/internal/repository/postgres/category.go +++ b/internal/repository/postgres/category.go @@ -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) diff --git a/internal/repository/postgres/dataset.go b/internal/repository/postgres/dataset.go index 6b4abb9..dcaed2f 100644 --- a/internal/repository/postgres/dataset.go +++ b/internal/repository/postgres/dataset.go @@ -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 diff --git a/internal/service/category.go b/internal/service/category.go index 7634778..45fd9a5 100644 --- a/internal/service/category.go +++ b/internal/service/category.go @@ -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, }) diff --git a/internal/service/category_test.go b/internal/service/category_test.go index 3e97c84..03ab453 100644 --- a/internal/service/category_test.go +++ b/internal/service/category_test.go @@ -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) } diff --git a/internal/service/dataset.go b/internal/service/dataset.go index 4341922..a3377cd 100644 --- a/internal/service/dataset.go +++ b/internal/service/dataset.go @@ -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 diff --git a/internal/service/dataset_test.go b/internal/service/dataset_test.go index eeb5a47..452a722 100644 --- a/internal/service/dataset_test.go +++ b/internal/service/dataset_test.go @@ -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 { diff --git a/internal/transport/http/category_handler.go b/internal/transport/http/category_handler.go index 2ae3f77..2059199 100644 --- a/internal/transport/http/category_handler.go +++ b/internal/transport/http/category_handler.go @@ -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 { diff --git a/internal/transport/http/dataset_handler.go b/internal/transport/http/dataset_handler.go index 95a7303..7e9f016 100644 --- a/internal/transport/http/dataset_handler.go +++ b/internal/transport/http/dataset_handler.go @@ -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 diff --git a/migrations/00002_create_categories_table.sql b/migrations/00002_create_categories_table.sql index 727a2ec..164d05d 100644 --- a/migrations/00002_create_categories_table.sql +++ b/migrations/00002_create_categories_table.sql @@ -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(), diff --git a/migrations/00003_create_datasets_table.sql b/migrations/00003_create_datasets_table.sql index b2d289e..c4015a4 100644 --- a/migrations/00003_create_datasets_table.sql +++ b/migrations/00003_create_datasets_table.sql @@ -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),