Files
67/docs/compose/plans/2026-06-22-backend-stabilization.md
2026-06-22 22:39:08 +03:00

17 KiB

Backend Stabilization Implementation Plan

For agentic workers: REQUIRED SUB-SKILL: Use compose:subagent (recommended) or compose:execute to implement this plan task-by-task. Steps use checkbox (- [ ]) syntax for tracking.

Goal: Turn the current scaffold into a repeatable backend-first almost-production demo: real PostgreSQL persistence, real auth, real gateway-backed API responses, and passing root verification.

Architecture: Keep the current three-layer shape (apps/web -> apps/gateway -> services/cmd/*) and replace the shared Go demoStore with a PostgreSQL-backed store built on GORM. Preserve existing HTTP contracts where possible, add only the minimal new plumbing needed for config, auth, health checks, and smoke verification.

Tech Stack: npm workspaces, React 19, Vite, Axios, Zustand, Node.js + Elysia gateway, Go HTTP services, GORM, PostgreSQL 17, SQL migrations, Docker Compose.


File Map

  • package.json: root verification commands and workspace entrypoints.
  • README.md: authoritative local run and acceptance instructions.
  • .env.example: example backend DSN, token secret, token TTL and API base URL.
  • docker-compose.yml: consistent local runtime for postgres, gateway, web and services.
  • scripts/dev-backend.sh: local backend boot with PostgreSQL-aware env.
  • scripts/smoke-backend.sh: curl-based critical-path smoke verification.
  • apps/gateway/src/index.ts: gateway proxying, auth fallback removal, unified error/health behavior.
  • apps/web/src/shared/api/client.js: correct default API base URL and error normalization.
  • apps/web/src/shared/api/endpoints.js: API contract used by the UI.
  • apps/web/src/app/store/session.js: login payload compatibility, me, logout, session reset.
  • apps/web/src/pages/*.jsx: key pages that currently assume mock-only data shapes.
  • apps/web/src/**/*.test.*: frontend regression tests for login and key data pages.
  • services/go.mod: PostgreSQL driver and password-hash dependency declarations.
  • services/internal/service/types.go: backend DTOs and enum/value mapping.
  • services/internal/service/service.go: service bootstrap, DB-aware health/readiness.
  • services/internal/service/api.go: HTTP handlers for auth, content, media, search, comments, subscriptions, notifications, admin and analytics.
  • services/internal/service/store.go: store interface and bootstrap hook.
  • services/internal/service/db.go: PostgreSQL connection, GORM bootstrap and migration helpers.
  • services/internal/service/store_gorm.go: GORM-backed implementation of the store.
  • services/internal/service/auth.go: password hashing and signed token helpers.
  • services/internal/service/seed.go: idempotent bootstrap data for roles, demo users and demo content.
  • services/internal/service/*_test.go: Go unit/integration coverage for critical backend flows.
  • database/migrations/001_init.sql: schema source of truth; must match current Go enum values and required tables.

Task 1: Reproduce and lock the runtime baseline

Covers: [S1, S2, S7]

Files:

  • Modify: README.md

  • Modify: .env.example

  • Modify: package.json

  • Modify: scripts/dev-backend.sh

  • Create: scripts/smoke-backend.sh

  • Step 1: Capture the failing baseline in notes and a smoke script stub

npm install
npm run check

Expected: failure is reproduced before code changes. Right now the first expected failure is the missing frontend toolchain (vite: command not found) or another dependency/runtime error from a clean install.

  • Step 2: Write the failing smoke script skeleton
#!/usr/bin/env bash
set -euo pipefail

API_BASE_URL="${API_BASE_URL:-http://127.0.0.1:3000/api}"

echo "[1/4] login"
echo "[2/4] fetch current user"
echo "[3/4] create draft content"
echo "[4/4] read admin dashboard"

exit 1

Save as scripts/smoke-backend.sh and mark it executable after the rest of the plan fills the real requests.

  • Step 3: Make the local runtime explicit and consistent

Add backend env examples to .env.example:

DATABASE_URL=postgres://fable:fable_dev_password@127.0.0.1:5432/fable?sslmode=disable
TOKEN_SECRET=local-dev-secret-change-me
TOKEN_TTL=24h
VITE_API_URL=http://127.0.0.1:3000/api

Update scripts/dev-backend.sh so every Go service receives DATABASE_URL, TOKEN_SECRET and TOKEN_TTL instead of relying on demo-only state.

  • Step 4: Update root docs and verification contract

Document the intended acceptance flow in README.md:

1. `npm install`
2. `docker compose up -d postgres`
3. `npm run dev:backend`
4. `npm run dev:web`
5. `npm run check`
6. `bash scripts/smoke-backend.sh`
  • Step 5: Re-run the root verification command
npm run check

Expected: it may still fail, but only on real application gaps rather than missing setup instructions.

Task 2: Replace the shared demo store with PostgreSQL-backed GORM bootstrap

Covers: [S2, S4, S5, S6]

Files:

  • Modify: services/go.mod

  • Modify: services/internal/service/store.go

  • Modify: services/internal/service/service.go

  • Create: services/internal/service/db.go

  • Create: services/internal/service/store_gorm.go

  • Create: services/internal/service/seed.go

  • Create: services/internal/service/store_gorm_test.go

  • Modify: database/migrations/001_init.sql

  • Step 1: Write the failing DB bootstrap test

func TestBootstrapStoreLoadsSeededUsersAndContent(t *testing.T) {
	store := newTestGORMStore(t)

	user, ok := store.UserByLogin(context.Background(), "demo_admin")
	if !ok {
		t.Fatal("expected seeded admin user")
	}

	items, err := store.ListContent(context.Background(), ContentFilter{})
	if err != nil {
		t.Fatalf("list content: %v", err)
	}
	if len(items) == 0 {
		t.Fatal("expected seeded content")
	}
}
  • Step 2: Run the failing Go test
cd services && go test ./internal/service -run TestBootstrapStoreLoadsSeededUsersAndContent -v

Expected: FAIL because there is no GORM bootstrap/store yet.

  • Step 3: Introduce DB config, connection and store interface

In services/internal/service/store.go, replace the global concrete store with an interface:

type Store interface {
	UserByLogin(ctx context.Context, login string) (UserProfile, bool, error)
	ListContent(ctx context.Context, filter ContentFilter) ([]ContentItem, error)
	// add the remaining methods currently hanging off demoStore
}

In services/internal/service/db.go, add a PostgreSQL opener using DATABASE_URL and return a shared *gorm.DB.

  • Step 4: Implement GORM store and idempotent seed bootstrap

Key shape for services/internal/service/store_gorm.go:

type gormStore struct {
	db *gorm.DB
}

func (s *gormStore) UserByLogin(ctx context.Context, login string) (UserProfile, bool, error) {
	var user userRecord
	err := s.db.WithContext(ctx).Preload("Roles").Where("login = ?", login).First(&user).Error
	// map record -> API DTO
}

In seed.go, upsert roles, demo users, speakers, categories, tags, content, notifications and comments.

  • Step 5: Align the migration with Go values before wiring queries

Fix the enum mismatch in database/migrations/001_init.sql so the stored content type matches Go (event, not event_announcement) or update Go and frontend in one consistent direction. Pick one representation and use it everywhere.

  • Step 6: Run the new store test and the existing Go service test
cd services && go test ./internal/service -run 'TestBootstrapStoreLoadsSeededUsersAndContent|TestHealthEndpoint' -v

Expected: PASS.

Task 3: Implement real auth, session lookup and readiness behavior

Covers: [S2, S3, S5, S6, S7]

Files:

  • Modify: services/internal/service/api.go

  • Modify: services/internal/service/types.go

  • Create: services/internal/service/auth.go

  • Create: services/internal/service/auth_test.go

  • Modify: services/internal/service/service.go

  • Step 1: Write failing auth tests for register, login and me

func TestAuthFlowRegisterLoginAndMe(t *testing.T) {
	h := newTestHandler(t, Config{Name: "auth", Domain: "auth"})

	registerBody := `{"login":"new_user","password":"verysecret","name":"Новый пользователь"}`
	registerRec := performJSONRequest(t, h, http.MethodPost, "/api/auth/register", registerBody, "")
	if registerRec.Code != http.StatusCreated {
		t.Fatalf("register status = %d", registerRec.Code)
	}

	token := readTokenFromResponse(t, registerRec.Body.Bytes())
	meRec := performJSONRequest(t, h, http.MethodGet, "/api/auth/me", "", token)
	if meRec.Code != http.StatusOK {
		t.Fatalf("me status = %d", meRec.Code)
	}
}
  • Step 2: Run the failing auth test
cd services && go test ./internal/service -run TestAuthFlowRegisterLoginAndMe -v

Expected: FAIL because login currently accepts any password and returns a demo token.

  • Step 3: Add password hashing and signed tokens

In services/internal/service/auth.go add minimal helpers:

func HashPassword(password string) (string, error) { /* bcrypt */ }
func ComparePassword(hash, password string) error { /* bcrypt */ }
func SignToken(secret string, claims TokenClaims, ttl time.Duration) (string, error) { /* HMAC-signed token */ }
func ParseToken(secret, token string) (TokenClaims, error) { /* validate signature + expiry */ }

Keep the token format simple and self-contained; do not preserve demo-token-* compatibility.

  • Step 4: Update auth handlers to use the SQL store

Replace the login fallback in api.go with explicit credential checks:

user, ok, err := backendStore.UserByLogin(ctx, payload.Login)
if err != nil { /* 500 */ }
if !ok || ComparePassword(user.PasswordHash, payload.Password) != nil {
	writeAPIError(w, http.StatusUnauthorized, "INVALID_CREDENTIALS", "Неверный логин или пароль")
	return true
}

Also make /auth/change-password update the stored hash and make /ready fail when DB connectivity is broken.

  • Step 5: Run focused auth/readiness verification
cd services && go test ./internal/service -run 'TestAuthFlowRegisterLoginAndMe|TestHealthEndpoint' -v

Expected: PASS.

Task 4: Persist content, taxonomy, subscriptions, comments, admin and analytics endpoints

Covers: [S3, S4, S5, S6, S7]

Files:

  • Modify: services/internal/service/api.go

  • Modify: services/internal/service/types.go

  • Modify: services/internal/service/store_gorm.go

  • Create: services/internal/service/content_api_test.go

  • Create: services/internal/service/admin_api_test.go

  • Step 1: Write failing tests for the critical domain path

func TestContentCommentSubscriptionAndAdminFlow(t *testing.T) {
	h := newTestHandlerAsAdmin(t)

	created := performJSONRequest(t, h, http.MethodPost, "/api/content", `{"title":"Новый материал","type":"article","category":"Статьи"}`, adminToken(t, h))
	if created.Code != http.StatusCreated {
		t.Fatalf("create content status = %d", created.Code)
	}

	contentID := readItemID(t, created.Body.Bytes())
	comment := performJSONRequest(t, h, http.MethodPost, "/api/comments/"+contentID, `{"text":"Полезный материал"}`, userToken(t, h))
	if comment.Code != http.StatusCreated {
		t.Fatalf("create comment status = %d", comment.Code)
	}
}
  • Step 2: Run the failing domain test
cd services && go test ./internal/service -run TestContentCommentSubscriptionAndAdminFlow -v

Expected: FAIL because handlers still depend on the in-memory methods and non-persistent side effects.

  • Step 3: Port each handler branch to GORM-backed methods without changing the external contract

Implement GORM methods for:

  • content list/detail/create/update/delete;
  • events/media derived listings;
  • category/tag reads;
  • speaker reads;
  • comment list/create;
  • subscription list/create;
  • notification list/mark-read;
  • admin users/roles/audit/dashboard;
  • analytics summary;
  • search using PostgreSQL text search.

Represent the filter in Go explicitly:

type ContentFilter struct {
	Query    string
	Category string
	Type     string
	Sort     string
	Limit    int
	Exclude  string
}
  • Step 4: Keep API payloads stable for the current frontend

Return the same envelope keys the UI already expects:

{ "user": { ... } }
{ "items": [ ... ] }
{ "item": { ... } }
{ "ok": true }

Do not introduce a second incompatible schema while stabilizing the backend.

  • Step 5: Run the focused Go verification for the critical path
cd services && go test ./internal/service -run 'TestContentCommentSubscriptionAndAdminFlow|TestAuthFlowRegisterLoginAndMe' -v

Expected: PASS.

Task 5: Remove gateway/frontend mock-only assumptions and align contracts

Covers: [S2, S3, S5, S7, S8]

Files:

  • Modify: apps/gateway/src/index.ts

  • Modify: apps/web/src/shared/api/client.js

  • Modify: apps/web/src/shared/api/endpoints.js

  • Modify: apps/web/src/app/store/session.js

  • Modify: apps/web/src/pages/LoginPage.test.jsx

  • Modify: apps/web/src/pages/MaterialsPage.test.jsx

  • Create: apps/web/src/app/store/session.test.js

  • Step 1: Write the failing frontend session test

it("stores token after login and loads current user from /auth/me", async () => {
  authApi.login = vi.fn().mockResolvedValue({ accessToken: "real-token" });
  authApi.me = vi.fn().mockResolvedValue({ id: "u1", login: "demo_admin", roles: ["администратор"] });

  const user = await useSession.getState().login({ email: "demo_admin", password: "secret123" });

  expect(tokenStorage.get()).toBe("real-token");
  expect(user.login).toBe("demo_admin");
});
  • Step 2: Run the failing frontend tests
npm --workspace @fable/web run test -- --runInBand

Expected: FAIL because the current store sends { email, password } while the backend expects login, and the default API URL points to :8000/api instead of the gateway on :3000/api.

  • Step 3: Fix the frontend contract with minimal surface area

Make these concrete adjustments:

  • client.js: default VITE_API_URL to http://localhost:3000/api;
  • client.js: normalize backend errors from { error: { message } } as well as { message };
  • session.js: submit login instead of email, while still reading the same form field from UI;
  • session.js: persist the me response shape without assuming mock-only fields.

Target login change:

login: async ({ email, password }) => {
  const response = await authApi.login({ login: email, password });
  const token = getToken(response);
  tokenStorage.set(token);
  const user = response.user ?? (await authApi.me());
  set({ user });
  persistUser(user);
  return user;
}
  • Step 4: Remove gateway-local demo fallback behavior

In apps/gateway/src/index.ts, delete or bypass the hardcoded demoUser, tokens and local content arrays once real service URLs are configured. Gateway behavior should become: proxy or fail explicitly, but never silently serve a shadow backend.

  • Step 5: Re-run frontend verification
npm --workspace @fable/web run test
npm --workspace @fable/web run build
npm --workspace @fable/gateway run check

Expected: PASS.

Task 6: Final smoke verification for local and Docker flows

Covers: [S2, S6, S7]

Files:

  • Modify: docker-compose.yml

  • Modify: scripts/smoke-backend.sh

  • Modify: README.md

  • Step 1: Fill the smoke script with real requests

TOKEN="$({
  curl -sS -X POST "$API_BASE_URL/auth/login" \
    -H 'Content-Type: application/json' \
    -d '{"login":"demo_admin","password":"demo_password"}'
} | jq -r '.token // .accessToken')"

curl -sS "$API_BASE_URL/auth/me" -H "Authorization: Bearer $TOKEN"
curl -sS -X POST "$API_BASE_URL/content" -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' -d '{"title":"Smoke draft","type":"article","category":"Статьи"}'
curl -sS "$API_BASE_URL/admin/users" -H "Authorization: Bearer $TOKEN"
  • Step 2: Verify the non-Docker local path

Run in separate shells:

docker compose up -d postgres
npm run dev:backend
npm run dev:web
bash scripts/smoke-backend.sh

Expected: PASS.

  • Step 3: Verify the Docker path
docker compose up --build -d
bash scripts/smoke-backend.sh

Expected: PASS against the same API base URL.

  • Step 4: Run the final root verification command
npm run check

Expected: PASS.

  • Step 5: Update the README acceptance section with the final proven commands
Проверка готовности:
- `npm run check`
- `bash scripts/smoke-backend.sh`
- `docker compose up --build`

Self-Review

  • Every spec section [S1]..[S8] is covered by at least one task.
  • The migration/schema mismatch (event_announcement vs event) is explicitly addressed.
  • No TODO/TBD placeholders remain in tasks.
  • Frontend/gateway/backend contract mismatches are called out explicitly (VITE_API_URL, login payload, demo fallback removal).