Compare commits
6 Commits
4f89f59232
...
fix/issue-
| Author | SHA1 | Date | |
|---|---|---|---|
| c2cefe0d32 | |||
| 6d6f5e19d1 | |||
| 5c43740a04 | |||
| 228b30ccf4 | |||
| e63b254395 | |||
| 5c529ef060 |
26
.dockerignore
Normal file
26
.dockerignore
Normal file
@ -0,0 +1,26 @@
|
|||||||
|
# Version control & tooling
|
||||||
|
.git
|
||||||
|
.gitignore
|
||||||
|
.gitattributes
|
||||||
|
|
||||||
|
# Local task runner state & binaries
|
||||||
|
.task
|
||||||
|
bin/
|
||||||
|
tags
|
||||||
|
|
||||||
|
# Local databases & logs
|
||||||
|
*.sqlite
|
||||||
|
*.log
|
||||||
|
|
||||||
|
# Editor / IDE
|
||||||
|
.zed
|
||||||
|
.idea
|
||||||
|
.vscode
|
||||||
|
|
||||||
|
# Docs & examples not needed to build the image
|
||||||
|
*.md
|
||||||
|
htmlexamples/
|
||||||
|
|
||||||
|
# Don't send the docker metadata itself (optional, keeps context lean)
|
||||||
|
Dockerfile
|
||||||
|
.dockerignore
|
||||||
250
AGENTS.md
Normal file
250
AGENTS.md
Normal file
@ -0,0 +1,250 @@
|
|||||||
|
# AGENTS.md
|
||||||
|
|
||||||
|
Guidance for AI agents (and humans) working in this repository.
|
||||||
|
|
||||||
|
## Project Overview
|
||||||
|
|
||||||
|
**kurious** (`git.loyso.art/frx/kurious`) is a course/education platform that
|
||||||
|
aggregates and serves educational course listings. It scrapes/syncs course data
|
||||||
|
from an external source (sravni.ru) via a rate-limited HTTP client, stores it
|
||||||
|
locally, and exposes it through a server-rendered web UI with filtering,
|
||||||
|
pagination, and statistics.
|
||||||
|
|
||||||
|
The project is written in Go and follows a hexagonal (ports & adapters)
|
||||||
|
architecture with a CQRS-flavored application layer.
|
||||||
|
|
||||||
|
## Tech Stack
|
||||||
|
|
||||||
|
- **Language:** Go 1.26 (toolchain `go1.26.4`; see `go.mod`)
|
||||||
|
- **HTTP routing:** `github.com/gorilla/mux`
|
||||||
|
- **HTTP client:** `github.com/go-resty/resty/v2` (sravni.ru scraper)
|
||||||
|
- **Database:** SQLite via `modernc.org/sqlite` (pure-Go, CGO disabled).
|
||||||
|
YDB was historically supported but is **no longer supported**
|
||||||
|
(`service.NewApplication` returns an error for the YDB engine).
|
||||||
|
- **DB access:** `github.com/jmoiron/sqlx` (named queries)
|
||||||
|
- **Templating:** `github.com/a-h/templ` (`.templ` files compile to Go)
|
||||||
|
- **Observability:** OpenTelemetry (`go.opentelemetry.io/otel`) — traces and
|
||||||
|
metrics with stdout / OTLP (HTTP & gRPC) exporters
|
||||||
|
- **Background jobs:** `github.com/robfig/cron/v3`
|
||||||
|
- **Logging:** standard `log/slog` (text or JSON, configurable)
|
||||||
|
- **Rate limiting:** `golang.org/x/time/rate`
|
||||||
|
- **Testing:** `github.com/stretchr/testify` (assert, require, mock)
|
||||||
|
- **Mocking:** `github.com/vektra/mockery/v2` (config in `.mockery.yaml`)
|
||||||
|
- **Linting:** `golangci-lint` v1.55.2
|
||||||
|
- **Build/task runner:** [Taskfile](https://taskfile.dev) (`Taskfile.yml`)
|
||||||
|
- **Build flags:** ldflags inject `version`, `commit`, `buildTime`
|
||||||
|
(see `kurious.go`)
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
The codebase implements a **hexagonal architecture** (ports & adapters) with a
|
||||||
|
CQRS-style separation between commands (writes) and queries (reads).
|
||||||
|
|
||||||
|
```
|
||||||
|
┌─────────────────────────────────────────────┐
|
||||||
|
delivery │ ports/ (HTTP server, cron jobs) │
|
||||||
|
mechanisms └──────────────────────┬──────────────────────┘
|
||||||
|
│ depends on
|
||||||
|
┌──────────────────────▼──────────────────────┐
|
||||||
|
application │ app/ (command/, query/) │
|
||||||
|
layer │ app.Application { Commands, Queries } │
|
||||||
|
│ decorator/ (logging decorators) │
|
||||||
|
└──────────────────────┬──────────────────────┘
|
||||||
|
│ depends on
|
||||||
|
┌──────────────────────▼──────────────────────┐
|
||||||
|
domain │ domain/ (entities, repository ports) │
|
||||||
|
└──────────────────────┬──────────────────────┘
|
||||||
|
│ implemented by
|
||||||
|
┌──────────────────────▼──────────────────────┐
|
||||||
|
adapters │ adapters/ (sqlite_*, memory_mapper, │
|
||||||
|
│ ydb_* legacy stub) │
|
||||||
|
└─────────────────────────────────────────────┘
|
||||||
|
```
|
||||||
|
|
||||||
|
- **`domain/`** — pure business entities (`Course`, `Organization`,
|
||||||
|
`LearningCategory`) and the **repository interfaces** (ports) that the
|
||||||
|
application depends on: `CourseRepository`, `OrganizationRepository`,
|
||||||
|
`LearningCategoryRepository`. No I/O or framework code lives here.
|
||||||
|
- **`app/`** — the application layer. `app.Application` aggregates a
|
||||||
|
`Commands` struct and a `Queries` struct. Handlers in `app/command/` perform
|
||||||
|
writes; handlers in `app/query/` perform reads. Each handler implements a
|
||||||
|
generic `decorator.CommandHandler[T]` or `decorator.QueryHandler[Q, U]`
|
||||||
|
interface and is wrapped with logging decorators at construction time.
|
||||||
|
- **`adapters/`** — concrete implementations of the domain repository ports.
|
||||||
|
`sqlite_course_repository.go`, `sqlite_organization_repository.go`, etc.
|
||||||
|
translate between domain types and SQL rows. `memory_mapper.go` is an
|
||||||
|
in-memory mapper translating external dictionary IDs to human-readable names.
|
||||||
|
- **`ports/`** — delivery mechanisms. `ports/http/` is the HTTP server (gorilla/mux
|
||||||
|
+ templ templates); `ports/background/` runs scheduled cron jobs (e.g.
|
||||||
|
`SyncSravniHandler`).
|
||||||
|
- **`service/`** — the **composition root**. `service.NewApplication` selects
|
||||||
|
the DB engine, constructs the SQLite connection, builds the adapters, and
|
||||||
|
wires everything into an `app.Application`. This is what binaries call.
|
||||||
|
|
||||||
|
Data flows: an HTTP request enters `ports/http`, calls a handler on
|
||||||
|
`service.Application` (which delegates to `app/command` or `app/query`), which
|
||||||
|
calls a `domain` repository interface implemented by an `adapters/*` repository.
|
||||||
|
|
||||||
|
## Directory Structure
|
||||||
|
|
||||||
|
```
|
||||||
|
.
|
||||||
|
├── kurious.go # Root package: version/commit/buildTime getters
|
||||||
|
├── Taskfile.yml # Build, test, lint, generate, run task definitions
|
||||||
|
├── .mockery.yaml # mockery config (with-expecter, keeptree)
|
||||||
|
├── go.mod / go.sum
|
||||||
|
├── cmd/ # Entry points (one package per binary)
|
||||||
|
│ ├── kuriweb/ # Main HTTP web server (config.go, main.go, http.go, trace.go)
|
||||||
|
│ ├── background/ # Background sync process (cron-driven sravni sync)
|
||||||
|
│ └── dev/sravnicli/ # Developer CLI for inspecting the sravni source
|
||||||
|
├── internal/
|
||||||
|
│ ├── kurious/ # Application core (the hexagon)
|
||||||
|
│ │ ├── domain/ # Entities + repository interface ports
|
||||||
|
│ │ ├── app/
|
||||||
|
│ │ │ ├── app.go # Application{Commands, Queries} aggregate
|
||||||
|
│ │ │ ├── command/ # Write-side handlers (Create, Delete, Update...)
|
||||||
|
│ │ │ └── query/ # Read-side handlers (List, Get, Stats...)
|
||||||
|
│ │ ├── adapters/ # Repository implementations + mocks/
|
||||||
|
│ │ ├── ports/
|
||||||
|
│ │ │ ├── http/ # HTTP server, course routes
|
||||||
|
│ │ │ │ └── bootstrap/ # templ templates (.templ) + generated _templ.go
|
||||||
|
│ │ │ ├── background/ # Cron job handlers
|
||||||
|
│ │ │ ├── background.go # BackgroundProcess (cron scheduler)
|
||||||
|
│ │ │ └── services.go # Services{HTTP, Background} aggregate
|
||||||
|
│ │ └── service/ # Composition root: NewApplication wiring
|
||||||
|
│ └── common/ # Shared, reusable utilities
|
||||||
|
│ ├── client/sravni/ # sravni.ru HTTP client + mocks/
|
||||||
|
│ ├── config/ # Config structs (HTTP, Log, Sqlite, Trace, YDB, Duration)
|
||||||
|
│ ├── decorator/ # Command/Query handler interfaces + logging decorators
|
||||||
|
│ ├── errors/ # SimpleError, ValidationError, sentinel errors
|
||||||
|
│ ├── generator/ # ID generators
|
||||||
|
│ ├── nullable/ # Generic nullable Value[T]
|
||||||
|
│ ├── xcontext/ # context helpers (request id, log fields)
|
||||||
|
│ ├── xlog/ # slog + cron logger adapters
|
||||||
|
│ └── xslices/ # slice helpers (Map, Filter, ForEach, LRU)
|
||||||
|
├── pkg/
|
||||||
|
│ └── xdefault/ # WithFallback helper (public-ish pkg)
|
||||||
|
├── migrations/
|
||||||
|
│ └── sqlite/ # SQL migrations (embed.FS) + migrator.go
|
||||||
|
├── assets/kurious/static/ # Static web assets (embedded via go:embed)
|
||||||
|
└── htmlexamples/ # Standalone HTML/templ prototyping examples
|
||||||
|
```
|
||||||
|
|
||||||
|
## Build, Test, and Lint Commands
|
||||||
|
|
||||||
|
All operations are driven by **Taskfile** (`task <name>`). The toolchain is
|
||||||
|
installed into a local `bin/` (`GOBIN={{.USER_WORKING_DIR}}/bin`) and
|
||||||
|
`CGO_ENABLED=0` is enforced.
|
||||||
|
|
||||||
|
| Command | What it does |
|
||||||
|
|--------------------------|--------------------------------------------------------------------|
|
||||||
|
| `task install_tools` | Install `golangci-lint`, `templ`, `mockery` into `bin/` |
|
||||||
|
| `task generate` | Run `templ generate` (compiles `.templ` → `_templ.go`) |
|
||||||
|
| `task mocks` | Run `go generate ./internal/...` (regenerate mockery mocks) |
|
||||||
|
| `task check` | Run `golangci-lint run ./...` (depends on `generate`) |
|
||||||
|
| `task test` | Run `go test ./internal/...` (depends on `generate`) |
|
||||||
|
| `task build_web` | Build `bin/kuriousweb` (depends on `check` + `test`) |
|
||||||
|
| `task build_background` | Build `bin/kuriousbg` (depends on `check` + `test`) |
|
||||||
|
| `task build_dev_cli` | Build `bin/sravnicli` (depends on `check` + `test`) |
|
||||||
|
| `task build` | Build all three binaries |
|
||||||
|
| `task run` | Build then run `bin/kuriousweb` |
|
||||||
|
|
||||||
|
Typical workflow before committing: `task check && task test`.
|
||||||
|
|
||||||
|
### Running the binaries
|
||||||
|
|
||||||
|
Binaries read a JSON config file path as their first argument, defaulting to
|
||||||
|
`config.json` (note: `*.json` is gitignored, so configs are local). Example
|
||||||
|
fields: `log`, `sqlite`, `http.listen_addr`, `db_engine`, `tracing`.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./bin/kuriousweb path/to/config.json
|
||||||
|
```
|
||||||
|
|
||||||
|
## Code Conventions
|
||||||
|
|
||||||
|
### Decorators / handler pattern
|
||||||
|
|
||||||
|
Command and query handlers implement generic interfaces and are wrapped with
|
||||||
|
logging decorators at construction time. When adding a new use case:
|
||||||
|
|
||||||
|
1. Define the command/query struct and a `*Handler` type alias of
|
||||||
|
`decorator.CommandHandler[T]` / `decorator.QueryHandler[Q, U]` in
|
||||||
|
`app/command/` or `app/query/`.
|
||||||
|
2. Implement a private `*Handler` struct holding its repository dependencies.
|
||||||
|
3. Expose a `New*Handler(deps..., log *slog.Logger)` constructor that returns
|
||||||
|
the decorated handler (`decorator.ApplyCommandDecorators` /
|
||||||
|
`decorator.AddQueryDecorators`).
|
||||||
|
4. Register the new handler on `app.Commands` / `app.Queries` in
|
||||||
|
`app/app.go` and wire it in `service/service.go`.
|
||||||
|
|
||||||
|
### Mock generation (mockery)
|
||||||
|
|
||||||
|
- Interfaces marked with `//go:generate mockery ...` directives (see
|
||||||
|
`domain/repository.go`, `common/client/sravni/client.go`) are mocked into
|
||||||
|
sibling `mocks/` packages (`keeptree: True`, `with-expecter: true`).
|
||||||
|
- Regenerate with `task mocks` (runs `go generate ./internal/...`).
|
||||||
|
- Mocks are committed to the repo.
|
||||||
|
|
||||||
|
### Templating (templ)
|
||||||
|
|
||||||
|
- HTML is authored as `.templ` files under
|
||||||
|
`internal/kurious/ports/http/bootstrap/`.
|
||||||
|
- `task generate` compiles them to `*_templ.go` (committed). Run it whenever a
|
||||||
|
`.templ` file changes; `task check` and `task test` both depend on it.
|
||||||
|
|
||||||
|
### Database / migrations
|
||||||
|
|
||||||
|
- SQLite migrations live in `migrations/sqlite/*.sql` and are embedded via
|
||||||
|
`go:embed`. The migrator (`migrations/sqlite/migrator.go`) applies them in
|
||||||
|
order inside a transaction.
|
||||||
|
- Repository adapters use `sqlx` named queries; domain ↔ row translation is
|
||||||
|
centralized via `AsDomain()` methods.
|
||||||
|
|
||||||
|
### Logging & observability
|
||||||
|
|
||||||
|
- Use `log/slog` with structured attributes. Request-scoped fields (e.g.
|
||||||
|
`request_id`) are propagated through `context` via `internal/common/xcontext`.
|
||||||
|
- HTTP handlers wrap requests in trace spans and record metrics
|
||||||
|
(request duration histogram). Database adapters emit spans tagged with
|
||||||
|
`db.*` attributes.
|
||||||
|
|
||||||
|
### Error handling
|
||||||
|
|
||||||
|
- Domain/repository errors use sentinels from `internal/common/errors`
|
||||||
|
(`ErrNotFound`, `ErrNotImplemented`) and `*ValidationError` (mapped to HTTP
|
||||||
|
400/404 in `ports/http/server.go`).
|
||||||
|
- Wrap errors with `fmt.Errorf("doing X: %w", err)` to add context while
|
||||||
|
preserving the underlying error for `errors.Is` / `errors.As`.
|
||||||
|
|
||||||
|
## Testing Patterns
|
||||||
|
|
||||||
|
- **Scope:** unit tests live next to the code they test
|
||||||
|
(`*_test.go`). `task test` runs `go test ./internal/...`.
|
||||||
|
- **Framework:** `github.com/stretchr/testify` — `require` for fatal
|
||||||
|
assertions, `assert` for non-fatal, `mock` for mock interactions.
|
||||||
|
- **Mocks:** generated mockery mocks with the **expecter** API:
|
||||||
|
```go
|
||||||
|
repo.EXPECT().Create(mock.Anything, expectedParams).
|
||||||
|
Return(domain.Course{ID: "c1"}, nil).Once()
|
||||||
|
```
|
||||||
|
Note: when the generated mock does not yet implement every interface method,
|
||||||
|
tests embed the mock and add the missing methods (see the `fullRepo` pattern
|
||||||
|
in `internal/kurious/app/command/command_test.go`).
|
||||||
|
- **Logger:** tests use a `quietLogger()` helper that discards output
|
||||||
|
(`slog.New(slog.NewTextHandler(io.Discard, nil))`).
|
||||||
|
- **Adapter tests:** `internal/kurious/adapters/sqlite_*_test.go` exercise the
|
||||||
|
real SQLite repositories against an in-memory/temp database.
|
||||||
|
- **Conventions:** table-driven where appropriate; use `context.Background()`
|
||||||
|
in tests; assert on wrapped errors with `assert.ErrorIs` and message
|
||||||
|
fragments with `assert.Contains`.
|
||||||
|
|
||||||
|
## Notes for Agents
|
||||||
|
|
||||||
|
- Run `task generate` before linting/testing if you touched any `.templ` file.
|
||||||
|
- Do not re-introduce YDB as a working engine without updating
|
||||||
|
`service.NewApplication` and the migration story (currently SQLite-only).
|
||||||
|
- Generated files (`*_templ.go`, `mocks/*.go`) are committed — regenerate and
|
||||||
|
commit them alongside source changes.
|
||||||
|
- `*.json`, `*.sqlite`, `bin/`, and `*.log` are gitignored; do not commit
|
||||||
|
local configs or databases.
|
||||||
55
Dockerfile
Normal file
55
Dockerfile
Normal file
@ -0,0 +1,55 @@
|
|||||||
|
# syntax=docker/dockerfile:1
|
||||||
|
|
||||||
|
# ---- Build stage ----
|
||||||
|
FROM golang:1.26-alpine AS builder
|
||||||
|
|
||||||
|
ENV CGO_ENABLED=0 GOOS=linux
|
||||||
|
|
||||||
|
ARG VERSION=docker
|
||||||
|
ARG COMMIT=docker
|
||||||
|
ARG BUILD_TIME=unknown
|
||||||
|
ARG PROJECT=git.loyso.art/frx/kurious
|
||||||
|
|
||||||
|
WORKDIR /src
|
||||||
|
|
||||||
|
# Cache module downloads.
|
||||||
|
COPY go.mod go.sum ./
|
||||||
|
RUN go mod download
|
||||||
|
|
||||||
|
# Copy the rest of the source.
|
||||||
|
# Generated files (templ *_templ.go, mockery mocks) are committed, so no
|
||||||
|
# generation step is required here.
|
||||||
|
COPY . .
|
||||||
|
|
||||||
|
# Build the web server and the healthcheck probe.
|
||||||
|
RUN go build -trimpath \
|
||||||
|
-ldflags "-X ${PROJECT}.version=${VERSION} -X ${PROJECT}.commit=${COMMIT} -X ${PROJECT}.buildTime=${BUILD_TIME}" \
|
||||||
|
-o /out/kuriweb ./cmd/kuriweb \
|
||||||
|
&& go build -trimpath -o /out/healthcheck ./cmd/healthcheck
|
||||||
|
|
||||||
|
# Bake a default config into the image (config files are gitignored locally,
|
||||||
|
# so the image must be self-contained).
|
||||||
|
RUN echo '{"log":{"level":"info","format":"json"},"http":{"listen_addr":":8080","mount_live":false},"sqlite":{"dsn":"/tmp/kurious.sqlite","shutdown_timeout":"10s"},"db_engine":"sqlite","tracing":{"type":"stdout","show_metrics":false}}' > /out/config.json
|
||||||
|
|
||||||
|
# ---- Final stage ----
|
||||||
|
FROM gcr.io/distroless/static-debian12
|
||||||
|
|
||||||
|
LABEL org.opencontainers.image.title="kuriousweb" \
|
||||||
|
org.opencontainers.image.source="git.loyso.art/frx/kurious"
|
||||||
|
|
||||||
|
COPY --from=builder /out/kuriweb /kuriweb
|
||||||
|
COPY --from=builder /out/healthcheck /healthcheck
|
||||||
|
COPY --from=builder /out/config.json /etc/kurious/config.json
|
||||||
|
|
||||||
|
# static-debian12 ships a "nonroot" user (uid 65532).
|
||||||
|
USER nonroot:nonroot
|
||||||
|
|
||||||
|
EXPOSE 8080
|
||||||
|
|
||||||
|
ENTRYPOINT ["/kuriweb"]
|
||||||
|
CMD ["/etc/kurious/config.json"]
|
||||||
|
|
||||||
|
# Distroless static has no shell/curl, so probing is done via the tiny
|
||||||
|
# healthcheck binary built above.
|
||||||
|
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
|
||||||
|
CMD ["/healthcheck", "http://127.0.0.1:8080/healthz"]
|
||||||
10
Taskfile.yml
10
Taskfile.yml
@ -67,6 +67,16 @@ tasks:
|
|||||||
- task: build_background
|
- task: build_background
|
||||||
- task: build_web
|
- task: build_web
|
||||||
|
|
||||||
|
build_docker:
|
||||||
|
desc: "Build the kuriousweb Docker image locally (no push)"
|
||||||
|
cmds:
|
||||||
|
- >-
|
||||||
|
docker build
|
||||||
|
--build-arg VERSION={{.GIT_VERSION}}
|
||||||
|
--build-arg COMMIT={{.GIT_COMMIT}}
|
||||||
|
--build-arg BUILD_TIME={{.BUILD_TIME}}
|
||||||
|
-t kuriousweb .
|
||||||
|
|
||||||
run:
|
run:
|
||||||
deps: [build]
|
deps: [build]
|
||||||
cmds:
|
cmds:
|
||||||
|
|||||||
28
cmd/healthcheck/main.go
Normal file
28
cmd/healthcheck/main.go
Normal file
@ -0,0 +1,28 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"net/http"
|
||||||
|
"os"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
func main() {
|
||||||
|
url := "http://127.0.0.1:8080/healthz"
|
||||||
|
if len(os.Args) > 1 {
|
||||||
|
url = os.Args[1]
|
||||||
|
}
|
||||||
|
|
||||||
|
client := &http.Client{Timeout: 3 * time.Second}
|
||||||
|
resp, err := client.Get(url)
|
||||||
|
if err != nil {
|
||||||
|
fmt.Fprintf(os.Stderr, "healthcheck error: %v\n", err)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
defer func() { _ = resp.Body.Close() }()
|
||||||
|
|
||||||
|
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
|
||||||
|
fmt.Fprintf(os.Stderr, "healthcheck failed: status %s\n", resp.Status)
|
||||||
|
os.Exit(1)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,6 +1,7 @@
|
|||||||
package main
|
package main
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"net/http"
|
"net/http"
|
||||||
"strings"
|
"strings"
|
||||||
@ -54,6 +55,7 @@ func setupHTTP(cfg config.HTTP, srv xhttp.Server, log *slog.Logger) *http.Server
|
|||||||
router := mux.NewRouter()
|
router := mux.NewRouter()
|
||||||
|
|
||||||
router.Use(
|
router.Use(
|
||||||
|
middlewareRecovery(log),
|
||||||
middlewareCustomWriterInjector(),
|
middlewareCustomWriterInjector(),
|
||||||
mux.CORSMethodMiddleware(router),
|
mux.CORSMethodMiddleware(router),
|
||||||
middlewareLogger(log),
|
middlewareLogger(log),
|
||||||
@ -61,6 +63,11 @@ func setupHTTP(cfg config.HTTP, srv xhttp.Server, log *slog.Logger) *http.Server
|
|||||||
middlewareMetrics(),
|
middlewareMetrics(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
router.HandleFunc("/healthz", func(w http.ResponseWriter, _ *http.Request) {
|
||||||
|
w.WriteHeader(http.StatusOK)
|
||||||
|
_, _ = w.Write([]byte("ok"))
|
||||||
|
}).Methods(http.MethodGet)
|
||||||
|
|
||||||
setupCoursesHTTP(srv, router, log)
|
setupCoursesHTTP(srv, router, log)
|
||||||
|
|
||||||
if cfg.MountLive {
|
if cfg.MountLive {
|
||||||
@ -101,6 +108,29 @@ func setupHTTP(cfg config.HTTP, srv xhttp.Server, log *slog.Logger) *http.Server
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func middlewareRecovery(log *slog.Logger) mux.MiddlewareFunc {
|
||||||
|
return func(next http.Handler) http.Handler {
|
||||||
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
defer func() {
|
||||||
|
rec := recover()
|
||||||
|
if rec == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if rec == http.ErrAbortHandler {
|
||||||
|
panic(rec)
|
||||||
|
}
|
||||||
|
xcontext.LogWithError(
|
||||||
|
r.Context(), log, fmt.Errorf("%v", rec), "recovered from panic",
|
||||||
|
slog.String("method", r.Method),
|
||||||
|
slog.String("path", r.URL.Path),
|
||||||
|
)
|
||||||
|
http.Error(w, "internal server error", http.StatusInternalServerError)
|
||||||
|
}()
|
||||||
|
next.ServeHTTP(w, r)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func middlewareCustomWriterInjector() mux.MiddlewareFunc {
|
func middlewareCustomWriterInjector() mux.MiddlewareFunc {
|
||||||
return func(next http.Handler) http.Handler {
|
return func(next http.Handler) http.Handler {
|
||||||
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@ -50,7 +50,7 @@ func setupOtelSDK(ctx context.Context, cfg config.Trace) (shutdown shutdownFunc,
|
|||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
resource, err := makeServiceResource(ctx)
|
resource, err := makeServiceResource(ctx, cfg.Environment)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return shutdown, fmt.Errorf("making service resource: %w", err)
|
return shutdown, fmt.Errorf("making service resource: %w", err)
|
||||||
}
|
}
|
||||||
@ -102,7 +102,10 @@ type TraceProviderParams struct {
|
|||||||
Type config.TraceClientType
|
Type config.TraceClientType
|
||||||
}
|
}
|
||||||
|
|
||||||
func makeServiceResource(ctx context.Context) (*resource.Resource, error) {
|
func makeServiceResource(ctx context.Context, environment string) (*resource.Resource, error) {
|
||||||
|
if environment == "" {
|
||||||
|
environment = "development"
|
||||||
|
}
|
||||||
r, err := resource.New(
|
r, err := resource.New(
|
||||||
ctx,
|
ctx,
|
||||||
resource.WithDetectors(
|
resource.WithDetectors(
|
||||||
@ -113,7 +116,7 @@ func makeServiceResource(ctx context.Context) (*resource.Resource, error) {
|
|||||||
resource.WithHost(),
|
resource.WithHost(),
|
||||||
resource.WithAttributes(
|
resource.WithAttributes(
|
||||||
semconv.ServiceName("bigstats:kuriweb"),
|
semconv.ServiceName("bigstats:kuriweb"),
|
||||||
semconv.DeploymentEnvironment("production"),
|
semconv.DeploymentEnvironment(environment),
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@ -85,19 +85,6 @@ type Filters struct {
|
|||||||
IsCourseProfeccion Flagged `json:"isCourseProfession"`
|
IsCourseProfeccion Flagged `json:"isCourseProfession"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type DataSourceType string
|
|
||||||
|
|
||||||
const (
|
|
||||||
DataSourceTypeDictionaryConfig DataSourceType = "dictionaryConfig"
|
|
||||||
DataSourceTypeDictionary DataSourceType = "dictionary"
|
|
||||||
DataSourceTypeOrganization DataSourceType = "organization"
|
|
||||||
)
|
|
||||||
|
|
||||||
type ReduxDataSource struct {
|
|
||||||
Type DataSourceType `json:"type"`
|
|
||||||
Source string `json:"source"`
|
|
||||||
}
|
|
||||||
|
|
||||||
type ReduxConfigDictionaryBaseUnit struct {
|
type ReduxConfigDictionaryBaseUnit struct {
|
||||||
Label string `json:"label"`
|
Label string `json:"label"`
|
||||||
Value string `json:"value"`
|
Value string `json:"value"`
|
||||||
@ -124,23 +111,16 @@ type ReduxConfigFilterUnit struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ReduxConfigSorting struct {
|
type ReduxConfigSorting struct {
|
||||||
|
Name string `json:"name"`
|
||||||
Property string `json:"property"`
|
Property string `json:"property"`
|
||||||
// 3 more fields
|
Direction string `json:"direction"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ReduxConfig struct {
|
type ReduxConfig struct {
|
||||||
Default struct {
|
Default struct {
|
||||||
SortingParameters []ReduxConfigSorting `json:"sortingParameters"`
|
SortingParameters []ReduxConfigSorting `json:"sortingParameters"`
|
||||||
} `json:"default"`
|
} `json:"default"`
|
||||||
DataSources map[string]ReduxDataSource `json:"dataSources"`
|
DataSources map[string]string `json:"dataSources"`
|
||||||
Dictionaries struct {
|
|
||||||
PriceFilter []ReduxConfigDictionaryRangeUnit `json:"dictionaryPriceFilter"`
|
|
||||||
GraphicFilter []ReduxConfigDictionaryBaseUnit `json:"dictionaryGraphicFilterNew"`
|
|
||||||
FormatFilter []ReduxConfigDictionaryBaseUnit `json:"dictionaryFormatFilterNew"`
|
|
||||||
LevelFilter []ReduxConfigDictionaryBaseUnit `json:"dictionaryLevelFilterNew"`
|
|
||||||
TimeFilter []ReduxConfigDictionaryRangeUnit `json:"dictionaryTimeFilter"`
|
|
||||||
InstallmentFilter []ReduxConfigDictionaryRangeUnit `json:"dictionaryInstallmentFilter"`
|
|
||||||
}
|
|
||||||
Filters []ReduxConfigFilterUnit `json:"filters"`
|
Filters []ReduxConfigFilterUnit `json:"filters"`
|
||||||
Sorting []ReduxConfigSorting `json:"sorting"`
|
Sorting []ReduxConfigSorting `json:"sorting"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -33,6 +33,7 @@ type Trace struct {
|
|||||||
APIKey string `json:"api_key"`
|
APIKey string `json:"api_key"`
|
||||||
APIHeader string `json:"api_header"`
|
APIHeader string `json:"api_header"`
|
||||||
Type TraceClientType `json:"type"`
|
Type TraceClientType `json:"type"`
|
||||||
|
Environment string `json:"environment"`
|
||||||
|
|
||||||
ShowMetrics bool `json:"show_metrics"`
|
ShowMetrics bool `json:"show_metrics"`
|
||||||
}
|
}
|
||||||
|
|||||||
@ -5,7 +5,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
||||||
mockrepo "git.loyso.art/frx/kurious/internal/kurious/domain/mocks"
|
mockrepo "git.loyso.art/frx/kurious/internal/kurious/adapters/mocks"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
|
|||||||
42
internal/kurious/adapters/not_implemented.go
Normal file
42
internal/kurious/adapters/not_implemented.go
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
package adapters
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
|
||||||
|
cerrors "git.loyso.art/frx/kurious/internal/common/errors"
|
||||||
|
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
||||||
|
)
|
||||||
|
|
||||||
|
type NotImplementedOrganizationRepository struct{}
|
||||||
|
|
||||||
|
func (NotImplementedOrganizationRepository) ListStats(
|
||||||
|
context.Context,
|
||||||
|
domain.ListOrganizationsParams,
|
||||||
|
) ([]domain.OrganizationStat, error) {
|
||||||
|
return nil, cerrors.ErrNotImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
func (NotImplementedOrganizationRepository) List(context.Context, domain.ListOrganizationsParams) ([]domain.Organization, error) {
|
||||||
|
return nil, cerrors.ErrNotImplemented
|
||||||
|
}
|
||||||
|
func (NotImplementedOrganizationRepository) Get(context.Context, domain.GetOrganizationParams) (domain.Organization, error) {
|
||||||
|
return domain.Organization{}, cerrors.ErrNotImplemented
|
||||||
|
}
|
||||||
|
func (NotImplementedOrganizationRepository) Create(context.Context, domain.CreateOrganizationParams) (domain.Organization, error) {
|
||||||
|
return domain.Organization{}, cerrors.ErrNotImplemented
|
||||||
|
}
|
||||||
|
func (NotImplementedOrganizationRepository) Delete(ctx context.Context, id string) error {
|
||||||
|
return cerrors.ErrNotImplemented
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotImplementedLearningCategory struct{}
|
||||||
|
|
||||||
|
func (NotImplementedLearningCategory) Upsert(context.Context, domain.LearningCategory) error {
|
||||||
|
return cerrors.ErrNotImplemented
|
||||||
|
}
|
||||||
|
func (NotImplementedLearningCategory) List(context.Context) ([]domain.LearningCategory, error) {
|
||||||
|
return nil, cerrors.ErrNotImplemented
|
||||||
|
}
|
||||||
|
func (NotImplementedLearningCategory) Get(context.Context, string) (domain.LearningCategory, error) {
|
||||||
|
return domain.LearningCategory{}, cerrors.ErrNotImplemented
|
||||||
|
}
|
||||||
@ -10,7 +10,6 @@ import (
|
|||||||
"time"
|
"time"
|
||||||
|
|
||||||
"git.loyso.art/frx/kurious/internal/common/nullable"
|
"git.loyso.art/frx/kurious/internal/common/nullable"
|
||||||
"git.loyso.art/frx/kurious/internal/common/xcontext"
|
|
||||||
"git.loyso.art/frx/kurious/internal/common/xslices"
|
"git.loyso.art/frx/kurious/internal/common/xslices"
|
||||||
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
||||||
|
|
||||||
@ -112,7 +111,7 @@ func (r *sqliteCourseRepository) List(
|
|||||||
|
|
||||||
result.Count, err = r.listCount(ctx, params)
|
result.Count, err = r.listCount(ctx, params)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
xcontext.LogWithWarnError(ctx, r.log, err, "unable to list count")
|
return result, fmt.Errorf("listing count: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
span.SetAttributes(
|
span.SetAttributes(
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import (
|
|||||||
"log/slog"
|
"log/slog"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
|
cerrors "git.loyso.art/frx/kurious/internal/common/errors"
|
||||||
"git.loyso.art/frx/kurious/internal/common/xslices"
|
"git.loyso.art/frx/kurious/internal/common/xslices"
|
||||||
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
||||||
|
|
||||||
@ -156,7 +157,7 @@ func (r *sqliteLearingCategoryRepository) Get(ctx context.Context, id string) (c
|
|||||||
err = r.db.GetContext(ctx, &cdb, query, id)
|
err = r.db.GetContext(ctx, &cdb, query, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return domain.LearningCategory{}, domain.ErrNotFound
|
return domain.LearningCategory{}, cerrors.ErrNotFound
|
||||||
}
|
}
|
||||||
return domain.LearningCategory{}, fmt.Errorf("executing query: %w", err)
|
return domain.LearningCategory{}, fmt.Errorf("executing query: %w", err)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,6 +3,7 @@ package adapters
|
|||||||
import (
|
import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
|
"git.loyso.art/frx/kurious/internal/common/errors"
|
||||||
"git.loyso.art/frx/kurious/internal/common/nullable"
|
"git.loyso.art/frx/kurious/internal/common/nullable"
|
||||||
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
||||||
|
|
||||||
@ -75,7 +76,7 @@ func (s *sqliteLearningCategoriesRepositorySuite) TestUpsert() {
|
|||||||
const categoryID = "test-id-1"
|
const categoryID = "test-id-1"
|
||||||
repo := s.connection.LearningCategory()
|
repo := s.connection.LearningCategory()
|
||||||
gotCategory, err := repo.Get(s.ctx, categoryID)
|
gotCategory, err := repo.Get(s.ctx, categoryID)
|
||||||
s.ErrorIs(err, domain.ErrNotFound)
|
s.ErrorIs(err, errors.ErrNotFound)
|
||||||
s.Empty(gotCategory)
|
s.Empty(gotCategory)
|
||||||
|
|
||||||
createdCategory := domain.LearningCategory{
|
createdCategory := domain.LearningCategory{
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
cerrors "git.loyso.art/frx/kurious/internal/common/errors"
|
||||||
"git.loyso.art/frx/kurious/internal/common/xslices"
|
"git.loyso.art/frx/kurious/internal/common/xslices"
|
||||||
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
||||||
"go.opentelemetry.io/otel/attribute"
|
"go.opentelemetry.io/otel/attribute"
|
||||||
@ -228,7 +229,7 @@ func (r *sqliteOrganizationRepository) Get(ctx context.Context, params domain.Ge
|
|||||||
err = r.db.GetContext(ctx, &orgdb, query, args...)
|
err = r.db.GetContext(ctx, &orgdb, query, args...)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return out, domain.ErrNotFound
|
return out, cerrors.ErrNotFound
|
||||||
}
|
}
|
||||||
return out, fmt.Errorf("executing query: %w", err)
|
return out, fmt.Errorf("executing query: %w", err)
|
||||||
}
|
}
|
||||||
@ -306,14 +307,14 @@ func (r *sqliteOrganizationRepository) Delete(ctx context.Context, id string) (e
|
|||||||
result, err := r.db.ExecContext(ctx, query, id)
|
result, err := r.db.ExecContext(ctx, query, id)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
if errors.Is(err, sql.ErrNoRows) {
|
if errors.Is(err, sql.ErrNoRows) {
|
||||||
return domain.ErrNotFound
|
return cerrors.ErrNotFound
|
||||||
}
|
}
|
||||||
return fmt.Errorf("executing query: %w", err)
|
return fmt.Errorf("executing query: %w", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
affected, _ := result.RowsAffected()
|
affected, _ := result.RowsAffected()
|
||||||
if affected == 0 {
|
if affected == 0 {
|
||||||
return domain.ErrNotFound
|
return cerrors.ErrNotFound
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@ -110,11 +110,11 @@ func (conn *YDBConnection) Close() error {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func (conn *YDBConnection) Organization() domain.OrganizationRepository {
|
func (conn *YDBConnection) Organization() domain.OrganizationRepository {
|
||||||
return domain.NotImplementedOrganizationRepository{}
|
return NotImplementedOrganizationRepository{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (conn *YDBConnection) LearningCategory() domain.LearningCategoryRepository {
|
func (conn *YDBConnection) LearningCategory() domain.LearningCategoryRepository {
|
||||||
return domain.NotImplementedLearningCategory{}
|
return NotImplementedLearningCategory{}
|
||||||
}
|
}
|
||||||
|
|
||||||
func (conn *YDBConnection) CourseRepository() *ydbCourseRepository {
|
func (conn *YDBConnection) CourseRepository() *ydbCourseRepository {
|
||||||
|
|||||||
@ -9,7 +9,7 @@ import (
|
|||||||
|
|
||||||
"git.loyso.art/frx/kurious/internal/common/nullable"
|
"git.loyso.art/frx/kurious/internal/common/nullable"
|
||||||
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
||||||
mockrepo "git.loyso.art/frx/kurious/internal/kurious/domain/mocks"
|
mockrepo "git.loyso.art/frx/kurious/internal/kurious/adapters/mocks"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import (
|
|||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
"git.loyso.art/frx/kurious/internal/kurious/domain"
|
||||||
mockrepo "git.loyso.art/frx/kurious/internal/kurious/domain/mocks"
|
mockrepo "git.loyso.art/frx/kurious/internal/kurious/adapters/mocks"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/mock"
|
"github.com/stretchr/testify/mock"
|
||||||
|
|||||||
@ -1,12 +0,0 @@
|
|||||||
package domain
|
|
||||||
|
|
||||||
const (
|
|
||||||
ErrNotFound PlainError = "not found"
|
|
||||||
ErrNotImplemented PlainError = "not implemented"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PlainError string
|
|
||||||
|
|
||||||
func (err PlainError) Error() string {
|
|
||||||
return string(err)
|
|
||||||
}
|
|
||||||
@ -80,7 +80,7 @@ type ListStatisticsResult struct {
|
|||||||
LearningTypeStatistics []StatisticUnit
|
LearningTypeStatistics []StatisticUnit
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:generate mockery --name CourseRepository
|
//go:generate mockery --name CourseRepository --output ../adapters/mocks
|
||||||
type CourseRepository interface {
|
type CourseRepository interface {
|
||||||
// List courses by specifid parameters.
|
// List courses by specifid parameters.
|
||||||
List(context.Context, ListCoursesParams) (ListCoursesResult, error)
|
List(context.Context, ListCoursesParams) (ListCoursesResult, error)
|
||||||
@ -126,7 +126,7 @@ type ListOrganizationsParams struct {
|
|||||||
IDs []string
|
IDs []string
|
||||||
}
|
}
|
||||||
|
|
||||||
//go:generate mockery --name OrganizationRepository
|
//go:generate mockery --name OrganizationRepository --output ../adapters/mocks
|
||||||
type OrganizationRepository interface {
|
type OrganizationRepository interface {
|
||||||
ListStats(context.Context, ListOrganizationsParams) ([]OrganizationStat, error)
|
ListStats(context.Context, ListOrganizationsParams) ([]OrganizationStat, error)
|
||||||
List(context.Context, ListOrganizationsParams) ([]Organization, error)
|
List(context.Context, ListOrganizationsParams) ([]Organization, error)
|
||||||
@ -135,44 +135,10 @@ type OrganizationRepository interface {
|
|||||||
Delete(ctx context.Context, id string) error
|
Delete(ctx context.Context, id string) error
|
||||||
}
|
}
|
||||||
|
|
||||||
type NotImplementedOrganizationRepository struct{}
|
//go:generate mockery --name LearningCategoryRepository --output ../adapters/mocks
|
||||||
|
|
||||||
func (NotImplementedOrganizationRepository) ListStats(
|
|
||||||
context.Context,
|
|
||||||
ListOrganizationsParams,
|
|
||||||
) ([]OrganizationStat, error) {
|
|
||||||
return nil, ErrNotImplemented
|
|
||||||
}
|
|
||||||
|
|
||||||
func (NotImplementedOrganizationRepository) List(context.Context, ListOrganizationsParams) ([]Organization, error) {
|
|
||||||
return nil, ErrNotImplemented
|
|
||||||
}
|
|
||||||
func (NotImplementedOrganizationRepository) Get(context.Context, GetOrganizationParams) (Organization, error) {
|
|
||||||
return Organization{}, ErrNotImplemented
|
|
||||||
}
|
|
||||||
func (NotImplementedOrganizationRepository) Create(context.Context, CreateOrganizationParams) (Organization, error) {
|
|
||||||
return Organization{}, ErrNotImplemented
|
|
||||||
}
|
|
||||||
func (NotImplementedOrganizationRepository) Delete(ctx context.Context, id string) error {
|
|
||||||
return ErrNotImplemented
|
|
||||||
}
|
|
||||||
|
|
||||||
//go:generate mockery --name LearningCategoryRepository
|
|
||||||
type LearningCategoryRepository interface {
|
type LearningCategoryRepository interface {
|
||||||
Upsert(context.Context, LearningCategory) error
|
Upsert(context.Context, LearningCategory) error
|
||||||
|
|
||||||
List(context.Context) ([]LearningCategory, error)
|
List(context.Context) ([]LearningCategory, error)
|
||||||
Get(context.Context, string) (LearningCategory, error)
|
Get(context.Context, string) (LearningCategory, error)
|
||||||
}
|
}
|
||||||
|
|
||||||
type NotImplementedLearningCategory struct{}
|
|
||||||
|
|
||||||
func (NotImplementedLearningCategory) Upsert(context.Context, LearningCategory) error {
|
|
||||||
return ErrNotImplemented
|
|
||||||
}
|
|
||||||
func (NotImplementedLearningCategory) List(context.Context) ([]LearningCategory, error) {
|
|
||||||
return nil, ErrNotImplemented
|
|
||||||
}
|
|
||||||
func (NotImplementedLearningCategory) Get(context.Context, string) (LearningCategory, error) {
|
|
||||||
return LearningCategory{}, ErrNotImplemented
|
|
||||||
}
|
|
||||||
|
|||||||
@ -85,6 +85,7 @@ func (h *syncSravniHandler) Handle(ctx context.Context) (err error) {
|
|||||||
courses := make([]sravni.Course, 0, 1024)
|
courses := make([]sravni.Course, 0, 1024)
|
||||||
buffer := make([]sravni.Course, 0, 512)
|
buffer := make([]sravni.Course, 0, 512)
|
||||||
organizations := make([]sravni.Organization, 0, 256)
|
organizations := make([]sravni.Organization, 0, 256)
|
||||||
|
var insertErr error
|
||||||
for _, learningType := range learningTypes.Fields {
|
for _, learningType := range learningTypes.Fields {
|
||||||
select {
|
select {
|
||||||
case <-ctx.Done():
|
case <-ctx.Done():
|
||||||
@ -174,22 +175,22 @@ func (h *syncSravniHandler) Handle(ctx context.Context) (err error) {
|
|||||||
|
|
||||||
var insertCourseSuccess bool
|
var insertCourseSuccess bool
|
||||||
if len(courses) > 0 {
|
if len(courses) > 0 {
|
||||||
err = h.insertCourses(lctx, courses)
|
if cerr := h.insertCourses(lctx, courses); cerr != nil {
|
||||||
if err != nil {
|
xcontext.LogWithError(lctx, h.log, cerr, "unable to insert courses")
|
||||||
xcontext.LogWithError(lctx, h.log, err, "unable to insert courses")
|
insertErr = errors.Join(insertErr, cerr)
|
||||||
|
} else {
|
||||||
|
insertCourseSuccess = true
|
||||||
}
|
}
|
||||||
|
|
||||||
insertCourseSuccess = err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var insertOrgsSuccess bool
|
var insertOrgsSuccess bool
|
||||||
if len(organizations) > 0 {
|
if len(organizations) > 0 {
|
||||||
err = h.insertOrganizations(lctx, organizations)
|
if oerr := h.insertOrganizations(lctx, organizations); oerr != nil {
|
||||||
if err != nil {
|
xcontext.LogWithError(lctx, h.log, oerr, "unable to insert organizations")
|
||||||
xcontext.LogWithError(lctx, h.log, err, "unable to insert courses")
|
insertErr = errors.Join(insertErr, oerr)
|
||||||
|
} else {
|
||||||
|
insertOrgsSuccess = true
|
||||||
}
|
}
|
||||||
|
|
||||||
insertOrgsSuccess = err == nil
|
|
||||||
}
|
}
|
||||||
|
|
||||||
elapsed = time.Since(start) - elapsed
|
elapsed = time.Since(start) - elapsed
|
||||||
@ -205,7 +206,7 @@ func (h *syncSravniHandler) Handle(ctx context.Context) (err error) {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
return nil
|
return insertErr
|
||||||
}
|
}
|
||||||
|
|
||||||
func (h *syncSravniHandler) loadEducationalProducts(ctx context.Context, learningType, courseThematic string, buf []sravni.Course) ([]sravni.Course, map[string]sravni.Organization, error) {
|
func (h *syncSravniHandler) loadEducationalProducts(ctx context.Context, learningType, courseThematic string, buf []sravni.Course) ([]sravni.Course, map[string]sravni.Organization, error) {
|
||||||
@ -384,8 +385,12 @@ func courseAsCreateCourseParams(course sravni.Course) command.CreateCourse {
|
|||||||
startAt = *course.DateStart
|
startAt = *course.DateStart
|
||||||
}
|
}
|
||||||
if course.TimeStart != nil {
|
if course.TimeStart != nil {
|
||||||
startAtUnix := startAt.Unix() + course.TimeStart.Unix()
|
clock := *course.TimeStart
|
||||||
startAt = time.Unix(startAtUnix, 0)
|
startAt = time.Date(
|
||||||
|
startAt.Year(), startAt.Month(), startAt.Day(),
|
||||||
|
clock.Hour(), clock.Minute(), clock.Second(), clock.Nanosecond(),
|
||||||
|
startAt.Location(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
var courseDuration time.Duration
|
var courseDuration time.Duration
|
||||||
|
|||||||
@ -23,7 +23,7 @@ import (
|
|||||||
var (
|
var (
|
||||||
paramsAttr = attribute.Key("params")
|
paramsAttr = attribute.Key("params")
|
||||||
|
|
||||||
webtracer = otel.Tracer("http")
|
webtracer = otel.Tracer("kuriweb.http")
|
||||||
)
|
)
|
||||||
|
|
||||||
type courseTemplServer struct {
|
type courseTemplServer struct {
|
||||||
@ -213,6 +213,15 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
totalPages := 0
|
||||||
|
if pathParams.PerPage > 0 {
|
||||||
|
totalPages = listCoursesResult.Count / pathParams.PerPage
|
||||||
|
}
|
||||||
|
currentPage := pathParams.Page
|
||||||
|
if currentPage > 0 && totalPages > 0 && currentPage > totalPages {
|
||||||
|
currentPage = totalPages
|
||||||
|
}
|
||||||
|
|
||||||
params = bootstrap.ListCoursesParams{
|
params = bootstrap.ListCoursesParams{
|
||||||
FilterForm: bootstrap.FilterFormParams{
|
FilterForm: bootstrap.FilterFormParams{
|
||||||
Render: true,
|
Render: true,
|
||||||
@ -233,8 +242,8 @@ func (c courseTemplServer) List(w http.ResponseWriter, r *http.Request) {
|
|||||||
Courses: params.Courses,
|
Courses: params.Courses,
|
||||||
Categories: params.Categories,
|
Categories: params.Categories,
|
||||||
Pagination: bootstrap.Pagination{
|
Pagination: bootstrap.Pagination{
|
||||||
Page: pathParams.Page,
|
Page: currentPage,
|
||||||
TotalPages: listCoursesResult.Count / pathParams.PerPage,
|
TotalPages: totalPages,
|
||||||
BaseURL: r.URL.Path,
|
BaseURL: r.URL.Path,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
@ -285,7 +294,10 @@ func (c courseTemplServer) Index(w http.ResponseWriter, r *http.Request) {
|
|||||||
|
|
||||||
stats := bootstrap.MakeNewStats(1, 2, 3)
|
stats := bootstrap.MakeNewStats(1, 2, 3)
|
||||||
|
|
||||||
coursesResult, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{})
|
const indexCoursesLimit = 200
|
||||||
|
coursesResult, err := c.app.Queries.ListCourses.Handle(ctx, query.ListCourse{
|
||||||
|
Limit: indexCoursesLimit,
|
||||||
|
})
|
||||||
if handleError(ctx, err, w, c.log, "unable to list courses") {
|
if handleError(ctx, err, w, c.log, "unable to list courses") {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|||||||
@ -87,14 +87,22 @@ func parsePaginationFromQuery(r *http.Request) (out pagination, err error) {
|
|||||||
} else {
|
} else {
|
||||||
out.PerPage = 20
|
out.PerPage = 20
|
||||||
}
|
}
|
||||||
|
if out.PerPage < 1 {
|
||||||
|
out.PerPage = 1
|
||||||
|
} else if out.PerPage > 100 {
|
||||||
|
out.PerPage = 100
|
||||||
|
}
|
||||||
if query.Has("page") {
|
if query.Has("page") {
|
||||||
out.Page, err = strconv.Atoi(query.Get("page"))
|
out.Page, err = strconv.Atoi(query.Get("page"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return out, errors.NewValidationError("page", "bad per_page value")
|
return out, errors.NewValidationError("page", "bad page value")
|
||||||
}
|
}
|
||||||
} else if !query.Has("next") {
|
} else if !query.Has("next") {
|
||||||
out.Page = 1
|
out.Page = 1
|
||||||
}
|
}
|
||||||
|
if out.Page < 1 && !query.Has("next") {
|
||||||
|
out.Page = 1
|
||||||
|
}
|
||||||
|
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user