From 59dd303e9779183ec035c16291de91b138703f24 Mon Sep 17 00:00:00 2001 From: Martin Mederly <8963255+the-medo@users.noreply.github.com> Date: Thu, 22 Oct 2026 19:58:59 +0200 Subject: [PATCH 1/2] Add isolated worktree development environments --- .github/workflows/worktree-dev-test.yml | 33 ++ .gitignore | 8 + AGENTS.md | 48 +++ Makefile | 51 ++- README.md | 55 ++- backend/mail/sender.go | 24 +- backend/mail/smtp_test.go | 70 ++++ backend/main.go | 2 +- frontend/components/Layout/Layout.tsx | 8 +- scripts/worktree-dev/README.md | 195 +++++++++++ scripts/worktree-dev/backend-entrypoint.sh | 21 ++ scripts/worktree-dev/bootstrap-worktree.sh | 4 + scripts/worktree-dev/compose.yaml | 122 +++++++ scripts/worktree-dev/talebound-worktree-dev | 4 + .../worktree-dev/test-concurrent-worktrees.sh | 4 + .../worktree-dev/test_concurrent_worktrees.py | 107 ++++++ scripts/worktree-dev/worktree_dev.py | 319 ++++++++++++++++++ 17 files changed, 1025 insertions(+), 50 deletions(-) create mode 100644 .github/workflows/worktree-dev-test.yml create mode 100644 backend/mail/smtp_test.go create mode 100644 scripts/worktree-dev/README.md create mode 100755 scripts/worktree-dev/backend-entrypoint.sh create mode 100755 scripts/worktree-dev/bootstrap-worktree.sh create mode 100644 scripts/worktree-dev/compose.yaml create mode 100755 scripts/worktree-dev/talebound-worktree-dev create mode 100755 scripts/worktree-dev/test-concurrent-worktrees.sh create mode 100644 scripts/worktree-dev/test_concurrent_worktrees.py create mode 100644 scripts/worktree-dev/worktree_dev.py diff --git a/.github/workflows/worktree-dev-test.yml b/.github/workflows/worktree-dev-test.yml new file mode 100644 index 00000000..a8ee13ee --- /dev/null +++ b/.github/workflows/worktree-dev-test.yml @@ -0,0 +1,33 @@ +name: Worktree development tooling + +on: + push: + branches: [main] + paths: + - 'scripts/worktree-dev/**' + - '.gitignore' + - 'Makefile' + - '.github/workflows/worktree-dev-test.yml' + pull_request: + paths: + - 'scripts/worktree-dev/**' + - '.gitignore' + - 'Makefile' + - '.github/workflows/worktree-dev-test.yml' + workflow_dispatch: + +permissions: + contents: read + +jobs: + lifecycle: + runs-on: ubuntu-latest + timeout-minutes: 15 + steps: + - uses: actions/checkout@v4 + - name: Check syntax + run: | + bash -n scripts/worktree-dev/*.sh scripts/worktree-dev/talebound-worktree-dev + python3 -m py_compile scripts/worktree-dev/*.py + - name: Verify concurrent worktree isolation and cleanup + run: scripts/worktree-dev/test-concurrent-worktrees.sh diff --git a/.gitignore b/.gitignore index b24428df..35a60376 100644 --- a/.gitignore +++ b/.gitignore @@ -1,2 +1,10 @@ .DS_Store .worktrees/ + +# Generated local worktree configuration and runtime state +.talebound/ +.local-dev/ +.env.worktree +backend/app.env.worktree +frontend/.env.worktree +__pycache__/ diff --git a/AGENTS.md b/AGENTS.md index a83c2da5..c00a5163 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -20,3 +20,51 @@ - Prefer an existing suitable worktree. Otherwise branch from fetched `origin/main` with an explicit task branch and destination path, using `--no-track`. Use `git worktree add` if a managed tool cannot honor the required location. + +## Local worktree development + +- Read `scripts/worktree-dev/README.md` before changing local lifecycle tooling. +- On Linux/WSL, bootstrap a new worktree with + `scripts/worktree-dev/bootstrap-worktree.sh`. This installs locked frontend + dependencies, starts this worktree's data services, and applies migrations. +- Use `scripts/worktree-dev/talebound-worktree-dev up`, `status`, `logs`, and + `down` for the app lifecycle. Root Makefile aliases are also supported. + Do not create ad-hoc shared databases or use the legacy backend Compose file. +- `setup` and `up` are idempotent; neither replaces existing database data. + `down` retains data. Before `down --purge-data`, check `status` and the exact + current worktree path. Purge deletes this worktree's labelled data and cache + volumes. Never remove unrelated or unlabelled Docker resources. +- Every worktree has a separate PostgreSQL container/volume, Redis queue, + Mailpit inbox, auth key, and loopback address/ports. Open the frontend URL + reported by `status`; substituting `localhost` breaks origin/session isolation. +- `.talebound/`, `.env.worktree`, `backend/app.env.worktree`, and + `frontend/.env.worktree` are generated local state. Never commit them, print + their secret values, or copy them between worktrees. Registry/lock files live + in the primary clone's Git metadata and must not be edited while tooling runs. +- The launcher generates development configuration and does not copy another + checkout's environment. Email stays in Mailpit, Sentry is disabled, and + Cloudflare is unconfigured by default. Optional development image credentials + belong in `.talebound/integrations.env`; never add production fallbacks. +- Frontend changes hot reload. For backend changes, use + `scripts/worktree-dev/talebound-worktree-dev restart backend`: the container + snapshots the source so migrations do not write generated SQL into the checkout. +- Purge the current stack before removing a disposable worktree. Use + `git worktree remove` for Git-created worktrees. Preserve development data when + it has not been intentionally discarded. + +## Validation + +- For lifecycle, ownership, ports, or cleanup changes: run Bash syntax checks, + Python compilation, `status`, and + `scripts/worktree-dev/test-concurrent-worktrees.sh`. This creates disposable + worktrees under this workspace and verifies isolation and scoped cleanup. +- For backend changes, use `scripts/worktree-dev/talebound-worktree-dev migrate` + when needed, then `test-backend`. Short tests skip the legacy real-email test. + Tests write fixtures to the current worktree database; never point them at a + shared or production database. +- For frontend changes, run relevant lint/build checks and verify the affected + browser flow. The README records inherited frontend lint/type-check failures; + report those honestly and do not suppress checks to make a build appear green. +- For worktree authentication/email changes, verify signup, local email delivery, + verification, login, and isolation from another running worktree. Report the + actual commands/results and any untested behavior before handoff. diff --git a/Makefile b/Makefile index e7c2005f..d00f370b 100644 --- a/Makefile +++ b/Makefile @@ -2,28 +2,47 @@ NODE ?= node FRONTEND_YARN = cd frontend && $(NODE) .yarn/releases/yarn-3.6.1.cjs -COMPOSE = docker compose -f backend/docker-compose.yaml +WORKTREE_DEV = scripts/worktree-dev/talebound-worktree-dev -.PHONY: help frontend-install frontend-dev frontend-build frontend-lint backend-dev backend-build backend-test backend-migrate api-generate services-up services-down +.PHONY: help dev-setup dev-up dev-down dev-status dev-logs frontend-install frontend-dev frontend-build frontend-lint backend-dev backend-build backend-test backend-migrate api-generate services-up services-down help: + @echo "dev-setup Install dependencies and migrate this worktree's local database" + @echo "dev-up Set up and start this worktree's complete local app" + @echo "dev-down Stop this worktree, retaining data" + @echo "dev-status Show this worktree's URLs and services" + @echo "dev-logs Show recent logs for this worktree" @echo "frontend-install Install frontend dependencies from the lockfile" - @echo "frontend-dev Start Next.js on port 4000" + @echo "frontend-dev Alias for dev-up (starts both applications)" @echo "frontend-build Build the frontend" @echo "frontend-lint Lint the frontend" - @echo "backend-dev Start the Go API" + @echo "backend-dev Alias for dev-up (starts both applications)" @echo "backend-build Compile all Go packages" - @echo "backend-test Run Go tests (requires a migrated test database)" - @echo "backend-migrate Apply database migrations to the configured database" + @echo "backend-test Run Go short tests in this worktree's container and database" + @echo "backend-migrate Apply migrations to this worktree's database" @echo "api-generate Generate frontend API types from the local Swagger schema" - @echo "services-up Start local PostgreSQL and Redis" - @echo "services-down Stop local Compose services, retaining volumes" + @echo "services-up Start this worktree's PostgreSQL, Redis, and local email inbox" + @echo "services-down Alias for dev-down" + +dev-setup: + $(WORKTREE_DEV) setup + +dev-up: + $(WORKTREE_DEV) up + +dev-down: + $(WORKTREE_DEV) down + +dev-status: + $(WORKTREE_DEV) status + +dev-logs: + $(WORKTREE_DEV) logs frontend-install: $(FRONTEND_YARN) install --immutable -frontend-dev: - $(FRONTEND_YARN) dev +frontend-dev: dev-up frontend-build: $(FRONTEND_YARN) build @@ -31,23 +50,21 @@ frontend-build: frontend-lint: $(FRONTEND_YARN) lint -backend-dev: - $(MAKE) -C backend server +backend-dev: dev-up backend-build: cd backend && go build ./... backend-test: - $(MAKE) -C backend test + $(WORKTREE_DEV) test-backend backend-migrate: - $(MAKE) -C backend migrateup + $(WORKTREE_DEV) migrate api-generate: $(FRONTEND_YARN) generate services-up: - $(COMPOSE) up -d postgres redis + $(WORKTREE_DEV) setup --services-only -services-down: - $(COMPOSE) down +services-down: dev-down diff --git a/README.md b/README.md index b53077c4..c64e97ca 100644 --- a/README.md +++ b/README.md @@ -10,37 +10,34 @@ The frontend and backend for Talebound, developed together in one repository. ## Development -Use Node.js 22, Go 1.23 (as declared in `backend/go.mod`), GNU Make, and Docker -with Compose. Yarn is checked into the frontend, so a global Yarn installation -is not required. Run `make` to see the available commands. +On Linux/WSL, use Bash, Python 3.9+, Git, and Docker with Compose v2. The supported +worktree launcher runs Node.js 22, Go 1.23, PostgreSQL 15, Redis 7, and Mailpit in +containers. No host Node, Go, Yarn, or Make installation is needed to run the app. ```sh -make frontend-install -make services-up -make backend-migrate +scripts/worktree-dev/bootstrap-worktree.sh +scripts/worktree-dev/talebound-worktree-dev up +scripts/worktree-dev/talebound-worktree-dev status ``` -The backend loads `backend/app.env`. Put local overrides in the ignored -`backend/app.env.local`, which is loaded by the backend Makefile. Review the -database configuration before running migrations. The supplied development -database uses port 5432 and Redis uses port 6379. - -Create `frontend/.env.local` using the settings described in -[the frontend README](frontend/README.md), including the API URL and the service -keys needed for features you use. The existing development CORS setup uses -`dev.talebound.net`; map it to `127.0.0.1` in your hosts file. - -Start each application in its own terminal: - -```sh -make backend-dev -make frontend-dev -``` - -The frontend runs at `http://dev.talebound.net:4000` and the HTTP API at -`http://dev.talebound.net:8080`. Email, image delivery, and other integrations -still need their own configuration. See [the backend README](backend/README.md) -for its existing development tools and workflow. +Each worktree gets its own database, Redis queue, email inbox, loopback address, +ports, and authentication key. `status` prints the exact app and inbox URLs. +Use those URLs: separate loopback addresses also keep browser cookies isolated. +No hosts-file edits or AWS credentials are required. Register an account in the +app and open the verification email in the local inbox. + +Bootstrap is idempotent and stops after installing dependencies and applying +migrations; `up` starts both applications. `down` stops this worktree and retains +data. Generated `.env.worktree`, `backend/app.env.worktree`, +`frontend/.env.worktree`, and `.talebound/` are ignored local state. Do not copy +them between worktrees. Optional development Cloudflare settings go in +`.talebound/integrations.env`; uploads require those credentials. + +See [the worktree development guide](scripts/worktree-dev/README.md) for lifecycle +commands, environment precedence, database access, cleanup, and validation. +GNU Make aliases (`make dev-up`, `make dev-status`, `make dev-down`) are optional. +The older `backend/Makefile` and `backend/docker-compose.yaml` contain historical +single-repository commands; use the root launcher for local worktree development. ## Validation and API generation @@ -52,7 +49,9 @@ make backend-test make api-generate ``` -Backend tests require the migrated PostgreSQL database. API generation reads +Backend tests use the current worktree's migrated PostgreSQL database. The +frontend checks/API generator and `backend-build` use host Node.js 22/Go 1.23; +their container equivalents are documented in the worktree guide. API generation reads `backend/doc/swagger/talebound.swagger.json` directly; it no longer needs a running backend or the old development server. After changing protobuf API definitions, regenerate the backend schema with `make -C backend proto` using diff --git a/backend/mail/sender.go b/backend/mail/sender.go index 56fe8d71..0dac7fdf 100644 --- a/backend/mail/sender.go +++ b/backend/mail/sender.go @@ -3,7 +3,9 @@ package mail import ( "fmt" "github.com/jordan-wright/email" + "net" "net/smtp" + "strings" ) const ( @@ -27,14 +29,25 @@ type AwsSesSender struct { fromEmailAddress string smtpUsername string smtpPassword string + smtpEndpoint string } func NewAwsSesSender(name string, fromEmailAddress string, smtpUsername string, smtpPassword string) EmailSender { + return NewSMTPSender(name, fromEmailAddress, smtpServerAddress, smtpUsername, smtpPassword) +} + +// NewSMTPSender accepts host:port, or a hostname using the submission port 587. +// An unauthenticated local SMTP sink needs neither username nor password. +func NewSMTPSender(name, fromEmailAddress, endpoint, smtpUsername, smtpPassword string) EmailSender { + if !strings.Contains(endpoint, ":") { + endpoint = net.JoinHostPort(endpoint, "587") + } return &AwsSesSender{ name: name, fromEmailAddress: fromEmailAddress, smtpUsername: smtpUsername, smtpPassword: smtpPassword, + smtpEndpoint: endpoint, } } @@ -61,6 +74,13 @@ func (sender *AwsSesSender) SendEmail( } } - smtpAuth := smtp.PlainAuth("", sender.smtpUsername, sender.smtpPassword, smtpAuthAddress) - return e.Send(smtpServerAddress, smtpAuth) + host, _, err := net.SplitHostPort(sender.smtpEndpoint) + if err != nil { + return fmt.Errorf("invalid SMTP endpoint: %w", err) + } + var smtpAuth smtp.Auth + if sender.smtpUsername != "" || sender.smtpPassword != "" { + smtpAuth = smtp.PlainAuth("", sender.smtpUsername, sender.smtpPassword, host) + } + return e.Send(sender.smtpEndpoint, smtpAuth) } diff --git a/backend/mail/smtp_test.go b/backend/mail/smtp_test.go new file mode 100644 index 00000000..cb0289cb --- /dev/null +++ b/backend/mail/smtp_test.go @@ -0,0 +1,70 @@ +package mail + +import ( + "bufio" + "fmt" + "net" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/require" +) + +func TestSendEmailToLocalSMTPWithoutAuthentication(t *testing.T) { + listener, err := net.Listen("tcp", "127.0.0.1:0") + require.NoError(t, err) + t.Cleanup(func() { listener.Close() }) + message := make(chan string, 1) + go func() { + conn, err := listener.Accept() + if err != nil { + return + } + defer conn.Close() + conn.SetDeadline(time.Now().Add(5 * time.Second)) + fmt.Fprint(conn, "220 localhost SMTP test\r\n") + scanner := bufio.NewScanner(conn) + var body strings.Builder + inData := false + for scanner.Scan() { + line := scanner.Text() + if inData { + if line == "." { + message <- body.String() + fmt.Fprint(conn, "250 accepted\r\n") + inData = false + } else { + body.WriteString(line + "\n") + } + continue + } + switch { + case strings.HasPrefix(line, "EHLO"), strings.HasPrefix(line, "HELO"): + fmt.Fprint(conn, "250 localhost\r\n") + case strings.HasPrefix(line, "MAIL FROM:"), strings.HasPrefix(line, "RCPT TO:"): + fmt.Fprint(conn, "250 OK\r\n") + case line == "DATA": + inData = true + fmt.Fprint(conn, "354 send message\r\n") + case line == "QUIT": + fmt.Fprint(conn, "221 bye\r\n") + return + default: + fmt.Fprint(conn, "500 unsupported command\r\n") + } + } + }() + + sender := NewSMTPSender("Talebound", "noreply@talebound.test", listener.Addr().String(), "", "") + require.NoError(t, sender.SendEmail("Local verification", "

Verify your email

", + []string{"developer@talebound.test"}, nil, nil, nil)) + select { + case body := <-message: + require.Contains(t, body, "Subject: Local verification") + require.Contains(t, body, "developer@talebound.test") + require.Contains(t, body, "Verify your email") + case <-time.After(5 * time.Second): + t.Fatal("SMTP server did not receive the message") + } +} diff --git a/backend/main.go b/backend/main.go index f613be22..6643b015 100644 --- a/backend/main.go +++ b/backend/main.go @@ -110,7 +110,7 @@ func runDBMigration(migrationURL string, dbSource string, migrationObjectsURL st } func runTaskProcessor(config util.Config, redisOpt asynq.RedisClientOpt, store db.Store) { - mailer := mail.NewAwsSesSender(config.EmailSenderName, config.EmailSenderAddress, config.SmtpUsername, config.SmtpPassword) + mailer := mail.NewSMTPSender(config.EmailSenderName, config.EmailSenderAddress, config.SmtpEndpoint, config.SmtpUsername, config.SmtpPassword) taskProcessor := worker.NewRedisTaskProcessor(redisOpt, store, mailer, config) log.Info().Msg("starting task processor") err := taskProcessor.Start() diff --git a/frontend/components/Layout/Layout.tsx b/frontend/components/Layout/Layout.tsx index 9c85f62a..c3723a7e 100644 --- a/frontend/components/Layout/Layout.tsx +++ b/frontend/components/Layout/Layout.tsx @@ -1,4 +1,4 @@ -import React, { PropsWithChildren, useMemo } from 'react'; +import React, { PropsWithChildren, Suspense, useMemo } from 'react'; import { useAuth } from '../../hooks/useAuth'; import Header from '../../component-sections/header/Header'; import Footer from '../../component-sections/footer/Footer'; @@ -68,7 +68,11 @@ const Layout: React.FC = ({ return ( - {isLoggedIn &&
} + {isLoggedIn && ( + +
+ + )} {!isLoggedIn && } diff --git a/scripts/worktree-dev/README.md b/scripts/worktree-dev/README.md new file mode 100644 index 00000000..96643a69 --- /dev/null +++ b/scripts/worktree-dev/README.md @@ -0,0 +1,195 @@ +# Talebound worktree development + +This Linux/WSL workflow follows the lifecycle conventions in the +[SWUBASE agent guide](https://github.com/the-medo/swu-collection/blob/main/AGENTS.md), +adapted for Talebound's Go/Next.js applications. It requires Bash, Python 3.9+, +Git, and a local Docker Engine with Compose v2. Remote Docker contexts and native +macOS/Windows Docker Desktop are not supported by the loopback-address scheme. + +## Start a worktree + +Keep the primary checkout at `/home/medo/dev/talebound/monorepo` and worktrees +under `/home/medo/dev/talebound/worktrees/`. From a task worktree: + +```sh +scripts/worktree-dev/bootstrap-worktree.sh +scripts/worktree-dev/talebound-worktree-dev up +scripts/worktree-dev/talebound-worktree-dev status +``` + +The bootstrap delegates to `setup`. It creates fresh local configuration, +starts PostgreSQL/Redis/Mailpit, installs Yarn dependencies with `--immutable`, +and applies migrations. It does not start the frontend or backend. `up` also +runs setup, then starts both applications and waits for their health checks. +The first run downloads container images/dependencies and compiles Go. + +`setup`/`up` preserve existing data, credentials, and allocated addresses. There +is no production database import or automatic dump download. A new database +contains the schema and reference data supplied by the migrations. Register +your own local account and open its verification email in Mailpit. Password-reset +emails go to that same inbox. No AWS or email credentials are needed. + +## Isolation and addresses + +Each checkout gets a path-derived Compose identity and its own: + +- PostgreSQL 15 container and persistent database volume; +- Redis 7 instance and persistent email queue; +- Mailpit inbox with persistent captured messages; +- Go module/build cache volumes and frontend dependency/build directories; +- random database password and 32-character token key; +- loopback IP in `127.64.0.0/16`, plus four reserved ports in `20000–44999`. + +The app, API, database, and inbox bind only to the allocated loopback IP. Redis, +SMTP, and gRPC have no published host ports. Host-only auth cookies stay on that +worktree's IP, and backend CORS permits its exact frontend origin. Cookies do +not distinguish ports, which is why the address also differs between worktrees. +No DNS service or hosts-file change is needed on Linux. + +Always use the URLs printed by `status`; do not replace the address with +`localhost`. For example, a worktree might use `http://127.64.20.30:20000`. +These addresses refer to the development machine. Access from a different +machine requires explicit forwarding; this tool does not set up Tailscale or +HTTPS. If a local proxy intercepts loopback traffic, bypass it for `127.0.0.0/8`. + +Address reservations are serialized in `/talebound-dev/`, and +lifecycle operations on the same worktree are locked. Stopped worktrees keep +their reservations. If an unrelated process later occupies an allocated port, +startup fails without killing it or silently changing the worktree URL. + +## Commands + +```sh +scripts/worktree-dev/talebound-worktree-dev setup +scripts/worktree-dev/talebound-worktree-dev up +scripts/worktree-dev/talebound-worktree-dev status +scripts/worktree-dev/talebound-worktree-dev status --json +scripts/worktree-dev/talebound-worktree-dev logs backend +scripts/worktree-dev/talebound-worktree-dev restart backend +scripts/worktree-dev/talebound-worktree-dev migrate +scripts/worktree-dev/talebound-worktree-dev test-backend +scripts/worktree-dev/talebound-worktree-dev down +``` + +`setup --services-only` starts just PostgreSQL, Redis, and Mailpit; it skips +dependency installation and migrations. It is useful for infrastructure tests. +`logs` shows the last 100 log lines; it accepts any service name or no argument +for all services. `restart frontend` applies changed frontend configuration. + +The frontend uses a live source mount and hot reload. The backend copies source +into its container at startup because the existing migrator generates SQL files. +Use `restart backend` after backend source changes. Its entrypoint compiles the +binary and uses `exec` so Docker can terminate the actual application process. + +## Local environment files + +| Path | Purpose | +| --- | --- | +| `.talebound/state.json` | Worktree identity, addresses, credentials | +| `.env.worktree` | Generated Compose settings | +| `backend/app.env.worktree` | Generated backend runtime overrides | +| `frontend/.env.worktree` | Generated frontend runtime settings | +| `.talebound/database.env` | Host connection settings for psql/IDE tools | +| `.talebound/integrations.env` | Optional developer-supplied image settings | + +All these files are ignored and created with mode `0600`; `.talebound/` is +`0700`. Generated files are refreshed by `setup`/`up` without rotating credentials. +Do not copy state or generated env files between worktrees, manually edit generated +settings, or move an initialized worktree. Purge its stack before moving/removing +it, then bootstrap it at the new path. + +The backend snapshot replaces `app.env.local` with the generated worktree +overrides and also receives them as process environment. The frontend receives +its generated settings as process environment, taking precedence over Next.js +dotenv files. Existing `backend/app.env.local` and `frontend/.env.local` are +left alone and are not copied into a newly created worktree. The launcher +explicitly sets every currently used frontend integration variable. + +Optional image integrations can be configured in `.talebound/integrations.env`: + +```dotenv +CLOUDFLARE_API_TOKEN=development-token +CLOUDFLARE_ACCOUNT_ID=development-account-id +NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_HASH=development-delivery-hash +NEXT_PUBLIC_CLOUDFLARE_HOSTNAME=imagedelivery.net +``` + +Only these keys plus `NEXT_PUBLIC_RECAPTCHA_SITE_KEY` are accepted. Values are +literal strings, not shell expressions. Use development-only credentials; +uploads with configured credentials contact Cloudflare. The default reCAPTCHA +key is Google's public development test key. Sentry is disabled. SMTP always +targets this stack's Mailpit; the backend now honors `SMTP_ENDPOINT` (hostname +or `host:port`), while the existing deployed configuration still specifies SES. + +## Database and development checks + +To run SQL without exposing a password in a command: + +```sh +printf 'SELECT version, dirty FROM schema_migrations;\n' | \ + scripts/worktree-dev/talebound-worktree-dev db +``` + +For an IDE connection, use the host/port and credentials in +`.talebound/database.env` with database `talebound`, user `root`, and SSL disabled. +The existing function migrations require the `root` ownership role. +Backend short tests write fixtures to this worktree's database and skip the +legacy test that sends a real email. + +For containerized frontend lint/build/API generation after setup: + +```sh +# Obtain the project name without printing credentials. +project=$(python3 -c 'import json; print(json.load(open(".talebound/state.json"))["project"])') +docker compose -p "$project" --env-file .env.worktree \ + -f scripts/worktree-dev/compose.yaml run --rm --no-deps frontend \ + node .yarn/releases/yarn-3.6.1.cjs lint +``` + +Replace `lint` with `build` or `generate`. Stop the frontend before `build` +because Next.js uses the same `.next` directory for development and production +builds. The root README records the imported frontend's existing build failures. +For backend compilation using the same `project` variable: + +```sh +docker compose -p "$project" --env-file .env.worktree \ + -f scripts/worktree-dev/compose.yaml run --rm --no-deps backend go build ./... +``` + +## Cleanup + +`down` stops only this worktree's containers and retains data/caches/settings. +To intentionally discard the worktree database, email queue, inbox, and caches: + +```sh +scripts/worktree-dev/talebound-worktree-dev status +scripts/worktree-dev/talebound-worktree-dev down --purge-data +``` + +Purge checks ownership, removes this Compose project's containers/network/volumes, +and releases its generated configuration and reservations. Optional integration +settings and frontend dependencies remain in the worktree. Every managed Docker +resource carries `com.talebound.managed`, `com.talebound.worktree`, and +`com.talebound.identity` labels. Operations refuse resources with mismatched or +missing ownership labels, including resources with only a matching Compose +project label. No global Docker prune command is used. + +Purge before `git worktree remove` when the data is disposable. Keep the worktree +and its state if its database is still needed. There is no automatic stale +worktree cleanup, database reset, or restore operation. + +## Lifecycle validation + +```sh +bash -n scripts/worktree-dev/*.sh scripts/worktree-dev/talebound-worktree-dev +python3 -m py_compile scripts/worktree-dev/*.py +scripts/worktree-dev/test-concurrent-worktrees.sh +``` + +The integration test creates two disposable Git worktrees under this workspace, +copies the current tooling into them, and starts real data services concurrently. +It checks isolated databases/Redis/addresses/keys, private ignored configuration, +idempotent setup, persistence after stopping, rejection of a foreign-labelled +resource, and that purging one stack leaves the other usable. It removes only +its test resources and worktrees. It needs Docker and may pull the three data +service images; it does not install frontend dependencies or contact production. diff --git a/scripts/worktree-dev/backend-entrypoint.sh b/scripts/worktree-dev/backend-entrypoint.sh new file mode 100755 index 00000000..5eb38fb4 --- /dev/null +++ b/scripts/worktree-dev/backend-entrypoint.sh @@ -0,0 +1,21 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +# The migrator writes generated SQL files. Keep these out of the source tree. +mkdir -p /tmp/backend +cp -a /source/. /tmp/backend/ +cp /runtime/backend.env /tmp/backend/app.env.local +cd /tmp/backend +case "${1:-serve}" in + migrate) + exec go run github.com/the-medo/golang-migrate-objects \ + -mpath=file://db/migration -obj_path=file://db/migration/objects \ + -db_source="$DB_SOURCE" -co_filename=migrations_create_objects.sql \ + -do_filename=migrations_drop_objects.sql -up + ;; + test) exec go test -short ./... ;; + serve) + go build -o /tmp/talebound . + exec /tmp/talebound + ;; + *) exec "$@" ;; +esac diff --git a/scripts/worktree-dev/bootstrap-worktree.sh b/scripts/worktree-dev/bootstrap-worktree.sh new file mode 100755 index 00000000..41c6e3bf --- /dev/null +++ b/scripts/worktree-dev/bootstrap-worktree.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +exec "$SCRIPT_DIR/talebound-worktree-dev" setup "$@" diff --git a/scripts/worktree-dev/compose.yaml b/scripts/worktree-dev/compose.yaml new file mode 100644 index 00000000..9b7a87d5 --- /dev/null +++ b/scripts/worktree-dev/compose.yaml @@ -0,0 +1,122 @@ +x-labels: &labels + com.talebound.managed: worktree-dev + com.talebound.worktree: ${TB_ROOT:?Run setup first} + com.talebound.identity: ${TB_PROJECT:?Run setup first} + +services: + postgres: + image: postgres:15-alpine + labels: *labels + environment: + POSTGRES_USER: root + POSTGRES_PASSWORD: ${TB_DB_PASSWORD:?Run setup first} + POSTGRES_DB: talebound + ports: + - '${TB_HOST}:${TB_DB_PORT}:5432' + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ['CMD-SHELL', 'pg_isready -U root -d talebound'] + interval: 2s + timeout: 3s + retries: 30 + + redis: + image: redis:7-alpine + labels: *labels + command: ['redis-server', '--appendonly', 'yes'] + volumes: + - redis-data:/data + healthcheck: + test: ['CMD', 'redis-cli', 'ping'] + interval: 2s + timeout: 3s + retries: 30 + + mailpit: + image: axllent/mailpit:v1.27.4 + labels: *labels + environment: + MP_DATABASE: /data/mailpit.db + ports: + - '${TB_HOST}:${TB_MAIL_PORT}:8025' + volumes: + - mail-data:/data + healthcheck: + test: ['CMD', '/mailpit', 'readyz'] + interval: 2s + timeout: 3s + retries: 30 + + backend: + image: golang:1.23-bookworm + labels: *labels + init: true + entrypoint: ['bash', '/tooling/backend-entrypoint.sh'] + command: ['serve'] + env_file: + - ${TB_ROOT}/backend/app.env.worktree + ports: + - '${TB_HOST}:${TB_API_PORT}:8080' + volumes: + - ${TB_ROOT}/backend:/source:ro + - ${TB_ROOT}/backend/app.env.worktree:/runtime/backend.env:ro + - ${TB_ROOT}/scripts/worktree-dev:/tooling:ro + - go-modules:/go + - go-build:/root/.cache/go-build + depends_on: + postgres: + condition: service_healthy + redis: + condition: service_healthy + mailpit: + condition: service_healthy + healthcheck: + test: ['CMD-SHELL', 'curl -fsS http://127.0.0.1:8080/swagger/talebound.swagger.json >/dev/null'] + interval: 5s + timeout: 3s + start_period: 120s + retries: 60 + + frontend: + image: node:22-bookworm-slim + labels: *labels + init: true + user: '${TB_UID}:${TB_GID}' + working_dir: /app + command: ['node', '.yarn/releases/yarn-3.6.1.cjs', 'dev', '--hostname', '0.0.0.0'] + environment: + NEXT_TELEMETRY_DISABLED: '1' + YARN_CACHE_FOLDER: /app/.yarn/cache + YARN_GLOBAL_FOLDER: /tmp/yarn-global + env_file: + - ${TB_ROOT}/frontend/.env.worktree + ports: + - '${TB_HOST}:${TB_WEB_PORT}:4000' + volumes: + - ${TB_ROOT}/frontend:/app + depends_on: + backend: + condition: service_healthy + healthcheck: + test: ['CMD', 'node', '-e', 'fetch("http://127.0.0.1:4000").then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))'] + interval: 10s + timeout: 10s + start_period: 90s + retries: 30 + +networks: + default: + labels: *labels + +volumes: + postgres-data: + labels: *labels + redis-data: + labels: *labels + mail-data: + labels: *labels + go-modules: + labels: *labels + go-build: + labels: *labels diff --git a/scripts/worktree-dev/talebound-worktree-dev b/scripts/worktree-dev/talebound-worktree-dev new file mode 100755 index 00000000..b136e8cd --- /dev/null +++ b/scripts/worktree-dev/talebound-worktree-dev @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +exec python3 "$SCRIPT_DIR/worktree_dev.py" "$@" diff --git a/scripts/worktree-dev/test-concurrent-worktrees.sh b/scripts/worktree-dev/test-concurrent-worktrees.sh new file mode 100755 index 00000000..56240685 --- /dev/null +++ b/scripts/worktree-dev/test-concurrent-worktrees.sh @@ -0,0 +1,4 @@ +#!/usr/bin/env bash +set -Eeuo pipefail +SCRIPT_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd -P)" +exec python3 "$SCRIPT_DIR/test_concurrent_worktrees.py" diff --git a/scripts/worktree-dev/test_concurrent_worktrees.py b/scripts/worktree-dev/test_concurrent_worktrees.py new file mode 100644 index 00000000..56f813d4 --- /dev/null +++ b/scripts/worktree-dev/test_concurrent_worktrees.py @@ -0,0 +1,107 @@ +#!/usr/bin/env python3 +"""Real Docker integration test: isolation, repeatability, ownership and cleanup.""" +import concurrent.futures +import hashlib +import json +from pathlib import Path +import shutil +import subprocess +import tempfile + +ROOT = Path(__file__).resolve().parents[2] + + +def run(*args, cwd=ROOT, **kwargs): + return subprocess.run(args, cwd=cwd, text=True, check=True, + stdout=subprocess.PIPE, stderr=subprocess.STDOUT, **kwargs).stdout + + +def cli(root, *args, **kwargs): + return run(str(root / 'scripts/worktree-dev/talebound-worktree-dev'), *args, cwd=root, **kwargs) + + +def sql(root, statement): + return cli(root, 'db', input=statement + '\n') + + +def main(): + common = Path(run('git', 'rev-parse', '--path-format=absolute', '--git-common-dir').strip()) + parent = common.parent.parent / 'worktrees' + parent.mkdir(exist_ok=True) + temporary = Path(tempfile.mkdtemp(prefix='verify-worktree-dev-', dir=parent)) + roots = [temporary / 'a', temporary / 'b'] + foreign = None + try: + for root in roots: + run('git', 'worktree', 'add', '--detach', str(root), 'HEAD') + shutil.copytree(ROOT / 'scripts/worktree-dev', root / 'scripts/worktree-dev', dirs_exist_ok=True) + shutil.copyfile(ROOT / '.gitignore', root / '.gitignore') + with concurrent.futures.ThreadPoolExecutor(max_workers=2) as pool: + list(pool.map(lambda root: cli(root, 'setup', '--services-only'), roots)) + states = [json.loads((root / '.talebound/state.json').read_text()) for root in roots] + a, b = states + assert a['project'] != b['project'] and a['host'] != b['host'] + assert not set(a['ports'].values()) & set(b['ports'].values()) + assert a['db_password'] != b['db_password'] and a['token_key'] != b['token_key'] + for root, state in zip(roots, states): + cli(root, 'status') + public = cli(root, 'status', '--json') + assert state['db_password'] not in public and state['token_key'] not in public + for relative in ['.env.worktree', 'backend/app.env.worktree', 'frontend/.env.worktree', '.talebound/state.json']: + assert (root / relative).stat().st_mode & 0o777 == 0o600 + run('git', 'check-ignore', '--quiet', relative, cwd=root) + containers = run('docker', 'ps', '-q', '--filter', f"label=com.talebound.identity={state['project']}").split() + assert len(containers) == 3 + for container in containers: + obj = json.loads(run('docker', 'inspect', container))[0] + assert obj['Config']['Labels']['com.talebound.worktree'] == str(root) + for mappings in obj['NetworkSettings']['Ports'].values(): + for binding in mappings or []: + assert binding['HostIp'] == state['host'] + sql(roots[0], 'CREATE TABLE isolation_marker(value text); INSERT INTO isolation_marker VALUES (\'only-a\');') + assert 'only-a' in sql(roots[0], 'TABLE isolation_marker;') + assert 'missing' in sql(roots[1], "SELECT coalesce(to_regclass('isolation_marker')::text, 'missing');") + run('docker', 'exec', f"{a['project']}-redis-1", 'redis-cli', 'set', 'isolation', 'only-a') + assert run('docker', 'exec', f"{b['project']}-redis-1", 'redis-cli', 'get', 'isolation').strip() == '' + env_before = {rel: hashlib.sha256((roots[0] / rel).read_bytes()).hexdigest() + for rel in ['.env.worktree', 'backend/app.env.worktree', 'frontend/.env.worktree']} + cli(roots[0], 'setup', '--services-only') + assert json.loads((roots[0] / '.talebound/state.json').read_text()) == a + assert all(hashlib.sha256((roots[0] / rel).read_bytes()).hexdigest() == value for rel, value in env_before.items()) + assert 'only-a' in sql(roots[0], 'TABLE isolation_marker;') + cli(roots[0], 'down') + assert 'ready' in sql(roots[1], "SELECT 'ready';") + cli(roots[0], 'setup', '--services-only') + assert 'only-a' in sql(roots[0], 'TABLE isolation_marker;') + # A foreign volume sharing the Compose project must block destructive cleanup. + foreign = f"{a['project']}_foreign-test" + run('docker', 'volume', 'create', '--label', f"com.docker.compose.project={a['project']}", foreign) + refused = subprocess.run([str(roots[0] / 'scripts/worktree-dev/talebound-worktree-dev'), 'down', '--purge-data'], + text=True, stdout=subprocess.PIPE, stderr=subprocess.STDOUT) + assert refused.returncode != 0 and 'ownership labels do not match' in refused.stdout + assert 'only-a' in run('docker', 'exec', f"{a['project']}-postgres-1", 'psql', + '-U', 'root', '-d', 'talebound', '-c', 'TABLE isolation_marker;') + run('docker', 'volume', 'rm', foreign) + foreign = None + cli(roots[0], 'down', '--purge-data') + assert not run('docker', 'volume', 'ls', '-q', '--filter', f"label=com.talebound.identity={a['project']}").strip() + assert 'ready' in sql(roots[1], "SELECT 'ready';") + print('PASS: concurrent setup, distinct DB/Redis/hosts/ports/keys, private ignored env files, repeatability, stop/restart persistence, ownership refusal, and scoped purge.') + finally: + if foreign: + run('docker', 'volume', 'rm', foreign) + for root in roots: + if (root / '.talebound/state.json').exists(): + cli(root, 'down', '--purge-data') + if root.exists(): + # Only these newly-created disposable checkouts are removed. + run('git', 'worktree', 'remove', '--force', str(root)) + temporary.rmdir() + + +if __name__ == '__main__': + try: + main() + except subprocess.CalledProcessError as error: + print(error.stdout) + raise diff --git a/scripts/worktree-dev/worktree_dev.py b/scripts/worktree-dev/worktree_dev.py new file mode 100644 index 00000000..2d86a0d4 --- /dev/null +++ b/scripts/worktree-dev/worktree_dev.py @@ -0,0 +1,319 @@ +#!/usr/bin/env python3 +"""Linux/WSL worktree lifecycle; requires only Python's standard library and Docker.""" + +import argparse +import contextlib +import fcntl +import hashlib +import json +import os +from pathlib import Path +import secrets +import socket +import subprocess +import sys + +ROOT = Path(__file__).resolve().parents[2] +LOCAL = ROOT / '.talebound' +STATE = LOCAL / 'state.json' +SERVICES = ('postgres', 'redis', 'mailpit', 'backend', 'frontend') +VOLUMES = ('postgres-data', 'redis-data', 'mail-data', 'go-modules', 'go-build') +EXTERNAL_KEYS = { + 'CLOUDFLARE_API_TOKEN', 'CLOUDFLARE_ACCOUNT_ID', + 'NEXT_PUBLIC_CLOUDFLARE_HOSTNAME', 'NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_HASH', + 'NEXT_PUBLIC_RECAPTCHA_SITE_KEY', +} + + +def run(*args, capture=False, **kwargs): + return subprocess.run(args, check=True, text=True, cwd=ROOT, + stdout=subprocess.PIPE if capture else None, **kwargs) + + +def git(*args): + return run('git', *args, capture=True).stdout.strip() + + +def atomic_write(path, content): + path.parent.mkdir(parents=True, exist_ok=True) + temp = path.with_name(path.name + '.tmp') + with temp.open('w') as handle: + os.chmod(temp, 0o600) + handle.write(content) + temp.replace(path) + + +@contextlib.contextmanager +def locked(path): + path.parent.mkdir(parents=True, exist_ok=True) + with path.open('a') as handle: + os.chmod(path, 0o600) + fcntl.flock(handle, fcntl.LOCK_EX) + yield + + +def identity(): + return 'talebound-' + hashlib.sha256(str(ROOT).encode()).hexdigest()[:12] + + +def load_state(): + state = json.loads(STATE.read_text()) + if state.get('version') != 1 or state.get('root') != str(ROOT) or state.get('project') != identity(): + raise RuntimeError('Local state belongs to another path/version. Never copy .talebound between worktrees.') + if not state['host'].startswith('127.64.'): + raise RuntimeError('Worktree services must bind to their allocated loopback address.') + return state + + +def port_available(host, port): + with socket.socket() as sock: + try: + sock.bind((host, port)) + return True + except OSError: + return False + + +def create_state(): + # Reserve addresses and ports even while stopped; serialize simultaneous setups. + registry_dir = Path(git('rev-parse', '--path-format=absolute', '--git-common-dir')) / 'talebound-dev' + with locked(registry_dir / 'registry.lock'): + registry_path = registry_dir / 'registry.json' + registry = json.loads(registry_path.read_text()) if registry_path.exists() else {} + if STATE.exists(): + return load_state() + if str(ROOT) in registry: + raise RuntimeError('State is missing for a registered worktree. Restore .talebound/state.json before proceeding.') + used_hosts = {item['host'] for item in registry.values()} + used_ports = {port for item in registry.values() for port in item['ports'].values()} + seed = int(hashlib.sha256(str(ROOT).encode()).hexdigest()[:8], 16) + for offset in range(65534): + address = (seed + offset) % 65534 + 1 + host = f'127.64.{address // 256}.{address % 256}' + if host not in used_hosts: + break + else: + raise RuntimeError('No free worktree loopback addresses.') + ports = {} + for name in ('web', 'api', 'db', 'mail'): + for port in range(20000, 45000): + if port not in used_ports and port_available('127.0.0.1', port) and port_available(host, port): + ports[name] = port + used_ports.add(port) + break + else: + raise RuntimeError('No free worktree ports.') + state = dict(version=1, root=str(ROOT), project=identity(), host=host, ports=ports, + db_password=secrets.token_hex(20), token_key=secrets.token_hex(16)) + atomic_write(STATE, json.dumps(state, indent=2) + '\n') + registry[str(ROOT)] = dict(host=host, ports=ports) + atomic_write(registry_path, json.dumps(registry, indent=2) + '\n') + return state + + +def forget_state(): + registry_dir = Path(git('rev-parse', '--path-format=absolute', '--git-common-dir')) / 'talebound-dev' + with locked(registry_dir / 'registry.lock'): + registry_path = registry_dir / 'registry.json' + registry = json.loads(registry_path.read_text()) if registry_path.exists() else {} + registry.pop(str(ROOT), None) + atomic_write(registry_path, json.dumps(registry, indent=2) + '\n') + for path in (STATE, ROOT / '.env.worktree', ROOT / 'backend/app.env.worktree', + ROOT / 'frontend/.env.worktree', LOCAL / 'database.env'): + path.unlink(missing_ok=True) + + +def write_env(path, values): + # Single-quoted dotenv values disable Compose interpolation (including $). + lines = ['# Generated by talebound-worktree-dev. Do not commit or copy.'] + for key, value in values.items(): + value = str(value) + if any(char in value for char in "\n\r'\\"): + raise RuntimeError(f'Unsupported character in {key}; values must be single-line literals.') + lines.append(f"{key}='{value}'") + atomic_write(path, '\n'.join(lines) + '\n') + + +def integrations(): + path = LOCAL / 'integrations.env' + if not path.exists(): + atomic_write(path, '# Optional development-only Cloudflare settings. No shell expansion.\n' + '# CLOUDFLARE_API_TOKEN=\n# CLOUDFLARE_ACCOUNT_ID=\n' + '# NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_HASH=\n') + result = {} + for line in path.read_text().splitlines(): + line = line.strip() + if not line or line.startswith('#'): + continue + key, separator, value = line.partition('=') + if not separator or key not in EXTERNAL_KEYS: + raise RuntimeError(f'Unsupported setting in {path}: {key}') + if len(value) >= 2 and value[0] == value[-1] and value[0] in "\"'": + value = value[1:-1] + result[key] = value + return result + + +def generate_env(state): + host, ports = state['host'], state['ports'] + external = integrations() + root_env = dict(TB_ROOT=ROOT, TB_PROJECT=state['project'], TB_HOST=host, + TB_UID=os.getuid(), TB_GID=os.getgid(), TB_DB_PASSWORD=state['db_password']) + root_env.update({f'TB_{name.upper()}_PORT': value for name, value in ports.items()}) + write_env(ROOT / '.env.worktree', root_env) + backend = dict( + ENVIRONMENT='development', DB_DRIVER='postgres', + DB_SOURCE=f"postgresql://root:{state['db_password']}@postgres:5432/talebound?sslmode=disable", + HTTP_SERVER_ADDRESS='0.0.0.0:8080', GRPC_SERVER_ADDRESS='0.0.0.0:9090', + REDIS_ADDRESS='redis:6379', TOKEN_SYMMETRIC_KEY=state['token_key'], + ACCESS_TOKEN_DURATION='15m', REFRESH_TOKEN_DURATION='24h', + COOKIE_DOMAIN='', FULL_DOMAIN=f"http://{host}:{ports['web']}", + EMAIL_SENDER_NAME='Talebound-local', EMAIL_SENDER_ADDRESS='noreply@talebound.test', + SMTP_ENDPOINT='mailpit:1025', SMTP_USERNAME='', SMTP_PASSWORD='', + SENTRY_DSN='', SENTRY_TRACES_SAMPLE_RATE='0', + CLOUDFLARE_API_TOKEN=external.get('CLOUDFLARE_API_TOKEN', ''), + CLOUDFLARE_ACCOUNT_ID=external.get('CLOUDFLARE_ACCOUNT_ID', ''), + ) + write_env(ROOT / 'backend/app.env.worktree', backend) + frontend = dict( + NEXT_PUBLIC_API_URL=f"http://{host}:{ports['api']}", + NEXT_PUBLIC_RECAPTCHA_SITE_KEY='6LeIxAcTAAAAAJcZVRqyHh71UMIEGNQ_MXjiZKhI', + NEXT_PUBLIC_CLOUDFLARE_HOSTNAME='imagedelivery.net', NEXT_PUBLIC_CLOUDFLARE_ACCOUNT_HASH='', + ) + frontend.update({key: value for key, value in external.items() if key.startswith('NEXT_PUBLIC_')}) + write_env(ROOT / 'frontend/.env.worktree', frontend) + write_env(LOCAL / 'database.env', dict( + PGHOST=host, PGPORT=ports['db'], PGDATABASE='talebound', PGUSER='root', + PGPASSWORD=state['db_password'], PGSSLMODE='disable')) + + +def compose(state, *args, **kwargs): + # Ambient COMPOSE_* and TB_* variables must not redirect the selected stack. + env = {key: value for key, value in os.environ.items() + if not key.startswith(('COMPOSE_', 'TB_'))} + return run('docker', 'compose', '--project-name', state['project'], + '--env-file', str(ROOT / '.env.worktree'), + '-f', str(ROOT / 'scripts/worktree-dev/compose.yaml'), *args, env=env, **kwargs) + + +def expected_labels(state): + return {'com.talebound.managed': 'worktree-dev', 'com.talebound.worktree': str(ROOT), + 'com.talebound.identity': state['project']} + + +def assert_owned(state): + """Inspect before mutations, including resources bearing only Compose labels.""" + project = state['project'] + containers = run('docker', 'ps', '-aq', '--filter', f'label=com.docker.compose.project={project}', capture=True).stdout.split() + resources = [('container', name) for name in containers] + names = set(run('docker', 'ps', '-a', '--format', '{{.Names}}', capture=True).stdout.split()) + resources.extend(('container', f'{project}-{service}-1') for service in SERVICES + if f'{project}-{service}-1' in names) + for kind, candidates in [('volume', [f'{project}_{v}' for v in VOLUMES]), + ('network', [f'{project}_default'])]: + existing = set(run('docker', kind, 'ls', '--format', '{{.Name}}', capture=True).stdout.split()) + resources.extend((kind, name) for name in candidates if name in existing) + labelled = run('docker', kind, 'ls', '--filter', f'label=com.docker.compose.project={project}', + '--format', '{{.Name}}', capture=True).stdout.split() + resources.extend((kind, name) for name in labelled) + for kind, name in sorted(set(resources)): + data = json.loads(run('docker', kind, 'inspect', name, capture=True).stdout)[0] + labels = (data['Config'].get('Labels') if kind == 'container' else data.get('Labels')) or {} + if any(labels.get(key) != value for key, value in expected_labels(state).items()): + raise RuntimeError(f'Refusing to touch {kind} {name}: worktree ownership labels do not match.') + + +def setup(state, services_only=False): + generate_env(state) + assert_owned(state) + compose(state, 'up', '-d', '--wait', '--wait-timeout', '120', 'postgres', 'redis', 'mailpit') + if not services_only: + compose(state, 'run', '--rm', '--no-deps', 'frontend', + 'node', '.yarn/releases/yarn-3.6.1.cjs', 'install', '--immutable') + compose(state, 'run', '--rm', '--no-deps', 'backend', 'migrate') + + +def status(state, as_json=False): + public = dict(root=str(ROOT), project=state['project'], host=state['host'], ports=state['ports'], + frontend=f"http://{state['host']}:{state['ports']['web']}", + api=f"http://{state['host']}:{state['ports']['api']}", + inbox=f"http://{state['host']}:{state['ports']['mail']}") + if as_json: + print(json.dumps(public, indent=2)) + return + print(f"Worktree: {ROOT}\nProject: {state['project']}\nFrontend: {public['frontend']}\n" + f"API: {public['api']}\nSwagger: {public['api']}/swagger/\nInbox: {public['inbox']}\n" + f"Database: {state['host']}:{state['ports']['db']}/talebound\n" + f"DB login: {LOCAL / 'database.env'}", flush=True) + compose(state, 'ps', '--all') + + +def main(): + parser = argparse.ArgumentParser(description=__doc__) + commands = parser.add_subparsers(dest='command', required=True) + for name in ('setup', 'up'): + sub = commands.add_parser(name, help='Provision this worktree' if name == 'setup' else 'Provision and start the app') + if name == 'setup': + sub.add_argument('--services-only', action='store_true', help='Start data services only; skip dependencies and migrations') + sub = commands.add_parser('status', help='Show addresses and container status, without secrets') + sub.add_argument('--json', action='store_true') + sub = commands.add_parser('down', help='Stop this worktree, retaining data by default') + sub.add_argument('--purge-data', action='store_true', help='Delete only this worktree\'s labelled containers, network and volumes') + sub = commands.add_parser('logs', help='Show recent logs') + sub.add_argument('service', choices=SERVICES, nargs='?') + sub = commands.add_parser('restart', help='Reload the backend source snapshot or frontend configuration') + sub.add_argument('service', choices=('backend', 'frontend'), nargs='?', default='backend') + commands.add_parser('migrate', help='Apply migrations to this worktree database') + commands.add_parser('test-backend', help='Run Go short tests against this worktree database') + commands.add_parser('db', help='Run psql; accepts SQL on stdin') + args = parser.parse_args() + if Path(git('rev-parse', '--show-toplevel')).resolve() != ROOT: + raise RuntimeError('Run this from a Talebound Git checkout.') + LOCAL.mkdir(mode=0o700, exist_ok=True) + os.chmod(LOCAL, 0o700) + with locked(LOCAL / 'lifecycle.lock'): + if args.command in ('setup', 'up'): + state = create_state() + setup(state, getattr(args, 'services_only', False)) + if args.command == 'up': + compose(state, 'up', '-d', '--force-recreate', '--wait', '--wait-timeout', '600', 'backend', 'frontend') + status(state) + return + if not STATE.exists(): + if args.command == 'status': + print('This worktree is not set up. Run scripts/worktree-dev/bootstrap-worktree.sh') + return + raise RuntimeError('Run setup first.') + state = load_state() + if args.command == 'status': + status(state, args.json) + return + assert_owned(state) + if args.command == 'down': + if args.purge_data: + print(f"Purging only {state['project']} at {ROOT}: {', '.join(VOLUMES)}", flush=True) + compose(state, 'down', '--volumes') + forget_state() + else: + compose(state, 'stop') + elif args.command == 'logs': + compose(state, 'logs', '--tail', '100', *([args.service] if args.service else [])) + elif args.command == 'restart': + generate_env(state) + compose(state, 'up', '-d', '--no-deps', '--force-recreate', '--wait', '--wait-timeout', '600', args.service) + elif args.command in ('migrate', 'test-backend'): + generate_env(state) + compose(state, 'run', '--rm', '--no-deps', 'backend', + 'migrate' if args.command == 'migrate' else 'test') + elif args.command == 'db': + compose(state, 'exec', '-T', 'postgres', 'psql', '-X', '-v', 'ON_ERROR_STOP=1', '-U', 'root', '-d', 'talebound') + + +if __name__ == '__main__': + try: + main() + except (RuntimeError, OSError, ValueError, subprocess.CalledProcessError) as error: + print(f'Error: {error}', file=sys.stderr) + sys.exit(1) + except KeyboardInterrupt: + sys.exit(130) From ee0c7429b0e68da12583993793fe36ba9aa8e9fa Mon Sep 17 00:00:00 2001 From: Martin Mederly <8963255+the-medo@users.noreply.github.com> Date: Thu, 22 Oct 2026 20:21:37 +0200 Subject: [PATCH 2/2] Fix frontend lint and production build checks --- AGENTS.md | 5 +- README.md | 9 +- frontend/adapters/LocationAdapter.ts | 5 +- .../entities/useCreateEntityGroupContent.ts | 12 +- frontend/api/images/useGetImages.ts | 3 +- frontend/api/images/useUploadImage.ts | 68 +------- frontend/api/locations/useCreateLocation.ts | 18 +-- .../api/locations/useDeleteBulkLocation.ts | 18 +-- frontend/api/locations/useDeleteLocation.ts | 8 + frontend/api/locations/useUpdateLocation.ts | 28 +--- frontend/api/middlewareEnabled.ts | 2 +- .../Location/LocationForm.tsx | 28 ++-- .../Location/LocationFormModal.tsx | 6 +- .../Location/LocationList.tsx | 21 ++- .../Location/LocationListSelection.tsx | 25 ++- .../Location/LocationTable.tsx | 49 +++--- .../Location/LocationTablePostCell.tsx | 6 +- .../MapLayerSectionDraggable.tsx | 2 - .../MapPinAdministrationContent.tsx | 2 +- .../MapPinTypeAdministrationContent.tsx | 4 +- .../PinTypeActions/PinTypeActions.tsx | 16 +- .../Map/MapLayout/MapLayout.tsx | 4 +- frontend/component-sections/Map/MapList.tsx | 4 +- .../Map/MapSidebar/MapSidebarPins.tsx | 2 +- .../Menu/MenuCategoryPage.tsx | 4 +- .../component-sections/Post/ModulePosts.tsx | 1 - .../component-sections/Post/PostFormOld.tsx | 149 ------------------ frontend/component-sections/Post/PostList.tsx | 4 +- .../UserProfile/UserProfile.tsx | 4 +- .../UserSettings/AvatarChange.tsx | 11 +- frontend/component-sections/header/Header.tsx | 8 +- frontend/component-sections/header/Menu.tsx | 3 +- .../component-sections/homepage/Login.tsx | 4 +- frontend/components/AvatarById/AvatarById.tsx | 10 +- .../ImageModal/EditorImageAttributes.tsx | 8 +- .../ImageModal/EditorImageModalContent.tsx | 8 +- .../ImageModal/EditorImageModalTabUrl.tsx | 6 +- .../InlineImageComponent.tsx | 6 +- .../nodes/InlineImageComponent/LazyImage.tsx | 2 +- .../ToolbarPlugin/ToolbarButtonBlockType.tsx | 5 +- .../ToolbarDropdownBlockType.tsx | 2 +- frontend/components/ImageModal/ImageModal.tsx | 4 +- .../ImageModal/ImageModalContent.tsx | 8 +- frontend/components/LeftNavbar/Navbar.tsx | 4 +- .../components/LeftNavbar/NavbarHeader.tsx | 4 +- .../components/UserDropdown/UserDropdown.tsx | 4 +- frontend/hooks/useAspectRatioResizer.ts | 2 +- frontend/hooks/useAuth.ts | 6 +- frontend/hooks/useCharacter.ts | 4 +- frontend/hooks/useEntity.ts | 4 +- frontend/hooks/useEntityExtended.ts | 4 +- frontend/hooks/useImage.ts | 4 +- frontend/hooks/useLocation.ts | 4 +- frontend/hooks/useMap.ts | 4 +- frontend/hooks/useModule.ts | 4 +- frontend/hooks/useModuleAdmins.ts | 6 +- frontend/hooks/useModuleExtended.ts | 4 +- frontend/hooks/usePost.ts | 4 +- frontend/hooks/useQuest.ts | 4 +- frontend/hooks/useSystem.ts | 4 +- frontend/hooks/useUserRole.ts | 8 +- frontend/hooks/useWorld.ts | 4 +- .../MenuAdministration/MenuHeaderImage.tsx | 3 +- .../MenuCategory/CreateEntityGroupModal.tsx | 18 +-- .../MenuCategory/EditEntityGroupModal.tsx | 6 +- .../EntityComponent/EntityMap.tsx | 4 +- .../EntityComponent/EntityPost.tsx | 4 +- .../EntityComponent/EntityTableDragHandle.tsx | 1 - .../GenericEntityRowAvatarView.tsx | 2 - .../RemoveEntityContentButton.tsx | 10 +- .../EntityComponent/useLinkEntity.tsx | 4 +- .../menus/MenuCategory/MenuCategory.tsx | 6 +- .../MenuCategory/MenuCategoryContent.tsx | 6 +- .../MenuItemContentElementEntity.tsx | 4 +- .../MenuItemContentElementEntityGroup.tsx | 8 +- .../screens/modules/Edit/ModuleImages.tsx | 3 +- .../CollaboratorRowApproved.tsx | 4 +- frontend/screens/modules/ModuleOpened.tsx | 4 +- .../quests/CreateQuest/CreateQuest.tsx | 14 -- frontend/store/hooks.ts | 5 + frontend/utils/types/redux-overrides.d.ts | 12 -- frontend/yarn.lock | 13 +- scripts/worktree-dev/README.md | 2 +- 83 files changed, 282 insertions(+), 525 deletions(-) delete mode 100644 frontend/component-sections/Post/PostFormOld.tsx create mode 100644 frontend/store/hooks.ts delete mode 100644 frontend/utils/types/redux-overrides.d.ts diff --git a/AGENTS.md b/AGENTS.md index c00a5163..ff3d2ea3 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -63,8 +63,9 @@ Tests write fixtures to the current worktree database; never point them at a shared or production database. - For frontend changes, run relevant lint/build checks and verify the affected - browser flow. The README records inherited frontend lint/type-check failures; - report those honestly and do not suppress checks to make a build appear green. + browser flow. Report failures honestly and do not suppress checks to make a + build appear green. Use `useAppSelector`/`useAppDispatch` from + `frontend/store/hooks.ts` for typed Redux access. - For worktree authentication/email changes, verify signup, local email delivery, verification, login, and isolation from another running worktree. Report the actual commands/results and any untested behavior before handoff. diff --git a/README.md b/README.md index c64e97ca..1c387e58 100644 --- a/README.md +++ b/README.md @@ -69,12 +69,9 @@ existing imports and protobuf package identities. Go builds use the local client without changes. The Yarn lockfile installed with `--immutable`. - GitHub Actions workflows passed `actionlint`; Compose configuration and root Makefile command paths were checked. -- The imported frontend has existing build blockers: lint reports 28 errors - and one warning, and a separate build with lint disabled fails type checking - in `frontend/api/entities/useCreateEntityGroupContent.ts` because - `entityGroupId` can be undefined. The source and generated client are unchanged - by this migration. Frontend CI will report these failures until they are fixed; - lint and type checking have not been disabled in the build configuration. +- Frontend lint, TypeScript checking, and the production build pass with Node 22 + and the checked-in Yarn lockfile. The inherited API/type mismatches and unused + code have been corrected; lint and type checking remain enabled. ## Builds and deployment diff --git a/frontend/adapters/LocationAdapter.ts b/frontend/adapters/LocationAdapter.ts index 8dcb597a..9dd197cd 100644 --- a/frontend/adapters/LocationAdapter.ts +++ b/frontend/adapters/LocationAdapter.ts @@ -29,7 +29,10 @@ export const locationSelectors = LocationAdapter.getSelectors( export const selectLocationsByIds = createSelector( [locationSelectors.selectEntities, (_, ids: number[]) => ids], - (entities, ids) => ids.map((id) => entities[id]).filter(Boolean), + (entities, ids) => + ids + .map((id) => entities[id]) + .filter((location): location is PbLocation => location !== undefined), ); // And then use the selectors to retrieve values diff --git a/frontend/api/entities/useCreateEntityGroupContent.ts b/frontend/api/entities/useCreateEntityGroupContent.ts index 71de2b58..db540941 100644 --- a/frontend/api/entities/useCreateEntityGroupContent.ts +++ b/frontend/api/entities/useCreateEntityGroupContent.ts @@ -10,8 +10,13 @@ export type CreateEntityGroupContentParams = { }; export const useCreateEntityGroupContent = createMutation({ - mutationFn: async (variables: CreateEntityGroupContentParams) => - EntitiesCollection.entitiesCreateEntityGroupContent(variables.entityGroupId, variables.body), + mutationFn: async (variables: CreateEntityGroupContentParams) => { + if (!variables.entityGroupId) throw new Error('An entity group is required'); + return EntitiesCollection.entitiesCreateEntityGroupContent( + variables.entityGroupId, + variables.body, + ); + }, onSuccess: (data, variables) => { const entityGroupContent = data.data; if (entityGroupContent) { @@ -29,7 +34,8 @@ export const useCreateEntityGroupContent = createMutation({ ...oldData, contents: [ ...cachedData.map((c) => { - if (!c.position) return c; + if (c.position === undefined || entityGroupContent.position === undefined) + return c; let position = c.position; if ( diff --git a/frontend/api/images/useGetImages.ts b/frontend/api/images/useGetImages.ts index aac3e620..ac394581 100644 --- a/frontend/api/images/useGetImages.ts +++ b/frontend/api/images/useGetImages.ts @@ -14,8 +14,7 @@ export const useGetImages = createInfiniteQuery< primaryKey: 'useGetImages', queryFn: async ({ queryKey: [_primaryKey, variables], pageParam: offset }) => { const { data } = await ImagesCollection.imagesGetImages({ - userId: variables?.userId, - imageTypeId: variables?.imageTypeId, + ...variables, limit: PAGE_SIZE, offset, }); diff --git a/frontend/api/images/useUploadImage.ts b/frontend/api/images/useUploadImage.ts index 174259d5..d6a65146 100644 --- a/frontend/api/images/useUploadImage.ts +++ b/frontend/api/images/useUploadImage.ts @@ -1,4 +1,4 @@ -import { createMutation, inferData } from 'react-query-kit'; +import { createMutation } from 'react-query-kit'; import { ImagesCollection } from '../collections'; import { queryClient } from '../../pages/_app'; import { useGetImages } from './useGetImages'; @@ -14,69 +14,9 @@ export const useUploadImage = createMutation({ mutationFn: async (variables: ExpandedUploadImageRequest) => ImagesCollection.imagesUploadDefaultImage(variables), - onSuccess: (data, variables) => { - const queryKeys = []; + onSuccess: (data) => { store.dispatch(imageAdapterSlice.actions.upsertImage(data.data)); - - if (variables.userId && variables.imageTypeId) { - queryKeys.push( - useGetImages.getKey({ - userId: variables.userId, - imageTypeId: variables.imageTypeId, - }), - ); - } - - if (variables.userId) { - queryKeys.push( - useGetImages.getKey({ - userId: variables.userId, - }), - ); - } - - if (variables.imageTypeId) { - queryKeys.push( - useGetImages.getKey({ - imageTypeId: variables.imageTypeId, - }), - ); - } - - queryKeys.push(useGetImages.getKey({})); - - queryKeys.forEach((queryKey) => { - queryClient.setQueryData>(queryKey, (oldData) => { - const newData = oldData - ? { - pages: oldData.pages.map((page, pageIndex) => { - const images = page.images; - - if (images && pageIndex === 0) { - images.unshift(data.data); - } - - return { - ...page, - images, - totalCount: (page.totalCount ?? 0) + 1, - newOffset: page.newOffset ? page.newOffset + 1 : undefined, - }; - }), - pageParams: oldData.pageParams, - } - : { - pages: [ - { - images: [data.data], - totalCount: 1, - newOffset: undefined, - }, - ], - pageParams: [undefined], - }; - return newData; - }); - }); + // Refetch filtered pages so their membership, counts, and offsets stay consistent. + return queryClient.invalidateQueries({ queryKey: useGetImages.getKey() }); }, }); diff --git a/frontend/api/locations/useCreateLocation.ts b/frontend/api/locations/useCreateLocation.ts index f1390c0d..18b5f481 100644 --- a/frontend/api/locations/useCreateLocation.ts +++ b/frontend/api/locations/useCreateLocation.ts @@ -1,3 +1,7 @@ +import { queryClient } from '../../pages/_app'; +import { useGetLocations } from './useGetLocations'; +import { store } from '../../store'; +import { locationAdapterSlice } from '../../adapters/LocationAdapter'; import { createMutation } from 'react-query-kit'; import { LocationsCollection } from '../collections'; import { PbCreateLocationRequest } from '../../generated/api-types/data-contracts'; @@ -5,16 +9,8 @@ import { PbCreateLocationRequest } from '../../generated/api-types/data-contract export const useCreateLocation = createMutation({ mutationFn: async (variables: PbCreateLocationRequest) => LocationsCollection.locationsCreateLocation(variables), - onSuccess: (data, variables) => { - const newLocation = data.data; - if (newLocation && variables.moduleId) { - // const getLocationsQueryKey = useGetLocations.getKey(variables.module); - // queryClient.setQueryData>( - // getLocationsQueryKey, - // (locations) => { - // return [...(locations ?? []), newLocation]; - // }, - // ); - } + onSuccess: (data) => { + store.dispatch(locationAdapterSlice.actions.upsertLocation(data.data)); + return queryClient.invalidateQueries({ queryKey: useGetLocations.getKey() }); }, }); diff --git a/frontend/api/locations/useDeleteBulkLocation.ts b/frontend/api/locations/useDeleteBulkLocation.ts index 0f9ddc86..c47d5b4c 100644 --- a/frontend/api/locations/useDeleteBulkLocation.ts +++ b/frontend/api/locations/useDeleteBulkLocation.ts @@ -1,27 +1,19 @@ -import { createMutation, inferData } from 'react-query-kit'; +import { createMutation } from 'react-query-kit'; import { LocationsCollection } from '../collections'; import { queryClient } from '../../pages/_app'; -import { PbModule } from '../../generated/api-types/data-contracts'; import { useGetLocations } from './useGetLocations'; +import { store } from '../../store'; +import { locationAdapterSlice } from '../../adapters/LocationAdapter'; interface DeleteBulkLocationParams { locationIds: number[]; - module?: PbModule; } export const useDeleteBulkLocation = createMutation({ mutationFn: async (variables: DeleteBulkLocationParams) => LocationsCollection.locationsDeleteBulkLocation({ locationIds: variables.locationIds }), onSuccess: (_, variables) => { - const deletedLocationIds = variables.locationIds; - const keyParams = variables.module; - - if (deletedLocationIds && keyParams) { - const getLocationsQueryKey = useGetLocations.getKey(keyParams); - queryClient.setQueryData>( - getLocationsQueryKey, - (oldData) => oldData?.filter(({ id }) => (id ? !deletedLocationIds.includes(id) : true)), - ); - } + store.dispatch(locationAdapterSlice.actions.removeLocations(variables.locationIds)); + return queryClient.invalidateQueries({ queryKey: useGetLocations.getKey() }); }, }); diff --git a/frontend/api/locations/useDeleteLocation.ts b/frontend/api/locations/useDeleteLocation.ts index 0f5f7994..dca0dc5e 100644 --- a/frontend/api/locations/useDeleteLocation.ts +++ b/frontend/api/locations/useDeleteLocation.ts @@ -1,3 +1,7 @@ +import { queryClient } from '../../pages/_app'; +import { useGetLocations } from './useGetLocations'; +import { store } from '../../store'; +import { locationAdapterSlice } from '../../adapters/LocationAdapter'; import { createMutation } from 'react-query-kit'; import { LocationsCollection } from '../collections'; @@ -8,4 +12,8 @@ interface DeleteLocationParams { export const useDeleteLocation = createMutation({ mutationFn: async (variables: DeleteLocationParams) => LocationsCollection.locationsDeleteLocation(variables.locationId), + onSuccess: (_, variables) => { + store.dispatch(locationAdapterSlice.actions.removeLocation(variables.locationId)); + return queryClient.invalidateQueries({ queryKey: useGetLocations.getKey() }); + }, }); diff --git a/frontend/api/locations/useUpdateLocation.ts b/frontend/api/locations/useUpdateLocation.ts index d4578d1a..8e074b12 100644 --- a/frontend/api/locations/useUpdateLocation.ts +++ b/frontend/api/locations/useUpdateLocation.ts @@ -1,40 +1,20 @@ import { LocationsCollection } from '../collections'; -import { createMutation, inferData } from 'react-query-kit'; +import { createMutation } from 'react-query-kit'; import { queryClient } from '../../pages/_app'; import { useGetLocations } from './useGetLocations'; -import { PbViewModule } from '../../generated/api-types/data-contracts'; import { store } from '../../store'; import { locationAdapterSlice } from '../../adapters/LocationAdapter'; type UpdateLocationParams = { locationId: number; - module?: PbViewModule; body: Parameters[1]; }; export const useUpdateLocation = createMutation({ mutationFn: async (variables: UpdateLocationParams) => LocationsCollection.locationsUpdateLocation(variables.locationId, variables.body), - onSuccess: (data, variables) => { - const locationId = variables.locationId; - const keyParams = variables.module; - - if (locationId && keyParams) { - const getLocationsQueryKey = useGetLocations.getKey(keyParams); - queryClient.setQueryData>( - getLocationsQueryKey, - (locations) => { - return ( - locations?.map((l) => { - if (l.id === locationId) { - return data.data; - } - return l; - }) ?? [] - ); - }, - ); - store.dispatch(locationAdapterSlice.actions.upsertLocation(data.data)); - } + onSuccess: (data) => { + store.dispatch(locationAdapterSlice.actions.upsertLocation(data.data)); + return queryClient.invalidateQueries({ queryKey: useGetLocations.getKey() }); }, }); diff --git a/frontend/api/middlewareEnabled.ts b/frontend/api/middlewareEnabled.ts index c00eb3a5..9868e001 100644 --- a/frontend/api/middlewareEnabled.ts +++ b/frontend/api/middlewareEnabled.ts @@ -1,4 +1,4 @@ -import { Middleware, QueryHook, SuspenseQueryHook } from 'react-query-kit'; +import { Middleware, QueryHook } from 'react-query-kit'; // import { Middleware, QueryHook, SuspenseQueryHook } from 'react-query-kit'; // diff --git a/frontend/component-sections/Location/LocationForm.tsx b/frontend/component-sections/Location/LocationForm.tsx index 91eb11fa..b98de1eb 100644 --- a/frontend/component-sections/Location/LocationForm.tsx +++ b/frontend/component-sections/Location/LocationForm.tsx @@ -3,10 +3,11 @@ import Textarea from '../../components/Textarea/Textarea'; import { useInput } from '../../hooks/useInput'; import Input from '../../components/Input/Input'; import { Col, Row } from '../../components/Flex/Flex'; -import Avatar from '../../components/Avatar/Avatar'; +import AvatarById from '../../components/AvatarById/AvatarById'; +import { useGetModuleId } from '../../api/modules/useGetModuleId'; import { Label } from '../../components/Typography/Label'; import ImageModal from '../../components/ImageModal/ImageModal'; -import { PbImage, PbModule, PbViewLocation } from '../../generated/api-types/data-contracts'; +import { PbImage, PbViewModule, PbLocation } from '../../generated/api-types/data-contracts'; import ErrorText from '../../components/ErrorText/ErrorText'; import { Button } from '../../components/Button/Button'; import { useCreateLocation } from '../../api/locations/useCreateLocation'; @@ -16,12 +17,12 @@ const textareaPlaceholder = 'Short description of the post. What information does this post contain?'; interface LocationFormProps { - module: PbModule; + module: PbViewModule; canChangeName?: boolean; canChangeDescription?: boolean; canChangeThumbnail?: boolean; onFinishCallback?: () => void; - location?: PbViewLocation; + location?: PbLocation; } const LocationForm: React.FC = ({ @@ -32,6 +33,7 @@ const LocationForm: React.FC = ({ onFinishCallback, location, }) => { + const { data: moduleId } = useGetModuleId({ variables: module }); const { mutate: createLocation, isPending: isPendingCreate, @@ -48,9 +50,6 @@ const LocationForm: React.FC = ({ const [thumbnailImageId, setThumbnailImageId] = useState( location ? location.thumbnailImageId : undefined, ); - const [thumbnailImageUrl, setThumbnailImageUrl] = useState( - location ? location.thumbnailImageUrl : undefined, - ); const { value: name, onChange: onChangeName } = useInput( location ? location.name ?? '' : '', ); @@ -63,17 +62,16 @@ const LocationForm: React.FC = ({ }, []); const changeThumbnailImage = useCallback( - (image: PbImage) => { - if (canChangeThumbnail) { + (image: PbImage | undefined) => { + if (canChangeThumbnail && image) { setThumbnailImageId(image.id); - setThumbnailImageUrl(image.baseUrl); } }, [canChangeThumbnail], ); const createLocationHandler = useCallback(() => { - if (module !== undefined) { + if (moduleId) { const options = { onSuccess: () => { if (onFinishCallback) onFinishCallback(); @@ -92,7 +90,6 @@ const LocationForm: React.FC = ({ updateLocation( { locationId: location.id, - module, body, }, options, @@ -103,13 +100,14 @@ const LocationForm: React.FC = ({ createLocation( { ...body, + moduleId, }, options, ); } } }, [ - module, + moduleId, canChangeName, name, canChangeDescription, @@ -151,11 +149,11 @@ const LocationForm: React.FC = ({ {canChangeThumbnail && ( - )} diff --git a/frontend/component-sections/Location/LocationFormModal.tsx b/frontend/component-sections/Location/LocationFormModal.tsx index ea2c66bc..ccda79b6 100644 --- a/frontend/component-sections/Location/LocationFormModal.tsx +++ b/frontend/component-sections/Location/LocationFormModal.tsx @@ -1,11 +1,11 @@ import React, { useCallback, useMemo } from 'react'; import Modal from '../../components/Modal/Modal'; import LocationForm from './LocationForm'; -import { PbModule, PbViewLocation } from '../../generated/api-types/data-contracts'; +import { PbViewModule, PbLocation } from '../../generated/api-types/data-contracts'; interface LocationFormModalProps { - module: PbModule; - location?: PbViewLocation; + module: PbViewModule; + location?: PbLocation; trigger: React.ReactNode; open?: boolean; setOpen: (v: boolean) => void; diff --git a/frontend/component-sections/Location/LocationList.tsx b/frontend/component-sections/Location/LocationList.tsx index 3c03a902..1f107b36 100644 --- a/frontend/component-sections/Location/LocationList.tsx +++ b/frontend/component-sections/Location/LocationList.tsx @@ -1,4 +1,5 @@ import React from 'react'; +import { Button } from '../../components/Button/Button'; import { useModuleRoute } from '../../hooks/useModuleRoute'; import { useGetLocations } from '../../api/locations/useGetLocations'; import LocationTable from './LocationTable'; @@ -9,13 +10,27 @@ interface LocationListProps { } const LocationList: React.FC = ({ canEdit }) => { - const [module] = useModuleRoute(PbEntityType.ENTITY_TYPE_LOCATION); + const [module, moduleId] = useModuleRoute(PbEntityType.ENTITY_TYPE_LOCATION); - const { data: locationsData = [] } = useGetLocations({ variables: module }); + const { + data: locationsData, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useGetLocations({ variables: { moduleId }, enabled: !!moduleId }); return (
- + page.locationIds) ?? []} + canEdit={canEdit} + module={module} + /> + {hasNextPage && ( + + )}
); }; diff --git a/frontend/component-sections/Location/LocationListSelection.tsx b/frontend/component-sections/Location/LocationListSelection.tsx index bad6ab43..004975a2 100644 --- a/frontend/component-sections/Location/LocationListSelection.tsx +++ b/frontend/component-sections/Location/LocationListSelection.tsx @@ -1,4 +1,6 @@ import React from 'react'; +import { Button } from '../../components/Button/Button'; +import { useGetModuleId } from '../../api/modules/useGetModuleId'; import { useGetLocations } from '../../api/locations/useGetLocations'; import LocationTable from './LocationTable'; import { PbViewModule } from '../../generated/api-types/data-contracts'; @@ -9,9 +11,28 @@ interface LocationListSelectionProps { } const LocationListSelection: React.FC = ({ module }) => { - const { data: locationsData = [] } = useGetLocations({ variables: module }); + const { data: moduleId } = useGetModuleId({ variables: module }); + const { + data: locationsData, + hasNextPage, + fetchNextPage, + isFetchingNextPage, + } = useGetLocations({ variables: { moduleId }, enabled: !!moduleId }); - return ; + return ( +
+ page.locationIds) ?? []} + module={module} + isSelectionTable={true} + /> + {hasNextPage && ( + + )} +
+ ); }; export default LocationListSelection; diff --git a/frontend/component-sections/Location/LocationTable.tsx b/frontend/component-sections/Location/LocationTable.tsx index 5722e8c2..035cfcce 100644 --- a/frontend/component-sections/Location/LocationTable.tsx +++ b/frontend/component-sections/Location/LocationTable.tsx @@ -1,12 +1,14 @@ import React, { useCallback, useMemo, useState } from 'react'; import { ColumnType } from 'antd/es/table'; -import Avatar from '../../components/Avatar/Avatar'; +import AvatarById from '../../components/AvatarById/AvatarById'; +import { useAppSelector } from '../../store/hooks'; +import { selectLocationsByIds } from '../../adapters/LocationAdapter'; import { Table } from 'antd'; import { TableProps } from 'antd/lib'; import { Button } from '../../components/Button/Button'; import { MdDelete, MdEdit } from 'react-icons/md'; import LocationFormModal from './LocationFormModal'; -import { PbViewLocation, PbViewModule } from '../../generated/api-types/data-contracts'; +import { PbLocation, PbViewModule } from '../../generated/api-types/data-contracts'; import { useDeleteBulkLocation } from '../../api/locations/useDeleteBulkLocation'; import ErrorText from '../../components/ErrorText/ErrorText'; import { Row } from '../../components/Flex/Flex'; @@ -15,7 +17,7 @@ import AlertDialog from '../../components/AlertDialog/AlertDialog'; import LocationTablePostCell from './LocationTablePostCell'; interface LocationTableProps { - data: PbViewLocation[]; + locationIds: number[]; canEdit?: boolean; module: PbViewModule; isSelectionTable?: boolean; @@ -23,14 +25,15 @@ interface LocationTableProps { } const LocationTable: React.FC = ({ - data, + locationIds, canEdit, module, isSelectionTable = false, isSelectionMultiple = false, }) => { + const data = useAppSelector((state) => selectLocationsByIds(state, locationIds)); const [selectedRowKeys, setSelectedRowKeys] = useState([]); - const [updateLocation, setUpdateLocation] = useState(); + const [updateLocation, setUpdateLocation] = useState(); canEdit = canEdit && !isSelectionTable; @@ -55,7 +58,7 @@ const LocationTable: React.FC = ({ setSelectedRowKeys(newSelectedRowKeys); }, []); - const rowSelection: TableProps['rowSelection'] = useMemo( + const rowSelection: TableProps['rowSelection'] = useMemo( () => canEdit || isSelectionTable ? { @@ -67,10 +70,10 @@ const LocationTable: React.FC = ({ [canEdit, selectedRowKeys, onSelectChange, isSelectionTable, isSelectionMultiple], ); - const openEditModal = useCallback((record: PbViewLocation) => setUpdateLocation(record), []); + const openEditModal = useCallback((record: PbLocation) => setUpdateLocation(record), []); const deleteLocationRecord = useCallback( - (record: PbViewLocation) => { + (record: PbLocation) => { const { id: locationId } = record; if (locationId) { deleteLocation({ locationId }); @@ -80,7 +83,7 @@ const LocationTable: React.FC = ({ ); const actionButtons = useCallback( - (record: PbViewLocation) => { + (record: PbLocation) => { const updateHandler = () => openEditModal(record); const deleteHandler = () => deleteLocationRecord(record); @@ -106,13 +109,13 @@ const LocationTable: React.FC = ({ [openEditModal, deleteLocationRecord], ); - const columns: ColumnType[] = useMemo(() => { - const cols: ColumnType[] = [ + const columns: ColumnType[] = useMemo(() => { + const cols: ColumnType[] = [ { title: '', - key: 'thumbnailImageUrl', - dataIndex: 'thumbnailImageUrl', - render: (value: string) => , + key: 'thumbnailImageId', + dataIndex: 'thumbnailImageId', + render: (value: number | undefined) => , }, { title: 'Name', @@ -130,18 +133,16 @@ const LocationTable: React.FC = ({ }, { title: 'Post', - key: 'postTitle', - dataIndex: 'postTitle', + key: 'postId', + dataIndex: 'postId', render: (_, record) => record.id && ( ), - sorter: (a, b) => (a.postTitle ?? '').localeCompare(b.postTitle ?? ''), width: '150px', }, ]; @@ -157,14 +158,14 @@ const LocationTable: React.FC = ({ return cols; }, [canEdit, actionButtons]); - const summary: TableProps['summary'] = useCallback(() => { + const summary: TableProps['summary'] = useCallback(() => { if (!canEdit) return undefined; if (selectedRowKeys.length === 0) return undefined; const deleteBulkHandler = () => { const locationIds = selectedRowKeys.map((key) => parseInt(key.toString())); deleteBulkLocations( - { locationIds, module }, + { locationIds }, { onSuccess: () => { setSelectedRowKeys([]); @@ -197,10 +198,10 @@ const LocationTable: React.FC = ({ ); - }, [canEdit, deleteBulkLocations, errorDeleteBulk, isPendingDeleteBulk, module, selectedRowKeys]); + }, [canEdit, deleteBulkLocations, errorDeleteBulk, isPendingDeleteBulk, selectedRowKeys]); - const onRow: TableProps['onRow'] = useCallback( - (record: PbViewLocation) => { + const onRow: TableProps['onRow'] = useCallback( + (record: PbLocation) => { if (!isSelectionTable) return {}; return { @@ -214,7 +215,7 @@ const LocationTable: React.FC = ({ return ( <> - + loading={isPendingDelete} rowSelection={rowSelection} showSorterTooltip={false} diff --git a/frontend/component-sections/Location/LocationTablePostCell.tsx b/frontend/component-sections/Location/LocationTablePostCell.tsx index 313a3f79..97ffebac 100644 --- a/frontend/component-sections/Location/LocationTablePostCell.tsx +++ b/frontend/component-sections/Location/LocationTablePostCell.tsx @@ -1,3 +1,4 @@ +import { usePost } from '../../hooks/usePost'; import React, { useCallback, useMemo, useState } from 'react'; import { MdAdd } from 'react-icons/md'; import { useCreateLocationPost } from '../../api/locations/useCreateLocationPost'; @@ -8,16 +9,15 @@ import ErrorText from '../../components/ErrorText/ErrorText'; interface LocationTablePostCellProps { locationId: number; postId?: number; - postTitle?: string; canEdit?: boolean; } const LocationTablePostCell: React.FC = ({ locationId, postId, - postTitle, canEdit, }) => { + const { post } = usePost(postId); const [assignPostModal, setAssignPostModal] = useState(false); const { @@ -62,7 +62,7 @@ const LocationTablePostCell: React.FC = ({ ); } - return
{postTitle}
; + return
{post?.title}
; }; export default LocationTablePostCell; diff --git a/frontend/component-sections/Map/MapAdministrationModals/MapLayerAdministrationModal/MapLayerSectionDraggable.tsx b/frontend/component-sections/Map/MapAdministrationModals/MapLayerAdministrationModal/MapLayerSectionDraggable.tsx index d8bf0bf8..4267de52 100644 --- a/frontend/component-sections/Map/MapAdministrationModals/MapLayerAdministrationModal/MapLayerSectionDraggable.tsx +++ b/frontend/component-sections/Map/MapAdministrationModals/MapLayerAdministrationModal/MapLayerSectionDraggable.tsx @@ -1,7 +1,6 @@ import React, { useCallback, useEffect, useState } from 'react'; import { PbViewMapLayer } from '../../../../generated/api-types/data-contracts'; import { Row } from '../../../../components/Flex/Flex'; -import ContentSection from '../../../../components/ContentSection/ContentSection'; import { Reorder, useDragControls } from 'framer-motion'; import { useUpdateMapLayer } from '../../../../api/maps/useUpdateMapLayer'; import { DragHandle } from '../../../../screens/menus/MenuAdministration/menuAdministrationComponents'; @@ -32,7 +31,6 @@ const MapLayerSectionDraggable: React.FC = ({ const { mutate: updateMapLayer, isPending: isPendingUpdate, - isError: isErrorUpdate, error: errorUpdate, } = useUpdateMapLayer({ onSettled, diff --git a/frontend/component-sections/Map/MapAdministrationModals/MapPinAdministrationModal/MapPinAdministrationContent.tsx b/frontend/component-sections/Map/MapAdministrationModals/MapPinAdministrationModal/MapPinAdministrationContent.tsx index dfbe1009..807b3ecf 100644 --- a/frontend/component-sections/Map/MapAdministrationModals/MapPinAdministrationModal/MapPinAdministrationContent.tsx +++ b/frontend/component-sections/Map/MapAdministrationModals/MapPinAdministrationModal/MapPinAdministrationContent.tsx @@ -12,7 +12,7 @@ interface MapPinAdministrationContentProps { mapId: number; } -const MapPinAdministrationContent: React.FC = ({ mapId }) => { +const MapPinAdministrationContent: React.FC = () => { return ( diff --git a/frontend/component-sections/Map/MapAdministrationModals/MapPinTypeAdministrationModal/MapPinTypeAdministrationContent.tsx b/frontend/component-sections/Map/MapAdministrationModals/MapPinTypeAdministrationModal/MapPinTypeAdministrationContent.tsx index 8d40075c..0d52d55c 100644 --- a/frontend/component-sections/Map/MapAdministrationModals/MapPinTypeAdministrationModal/MapPinTypeAdministrationContent.tsx +++ b/frontend/component-sections/Map/MapAdministrationModals/MapPinTypeAdministrationModal/MapPinTypeAdministrationContent.tsx @@ -23,9 +23,7 @@ interface MapPinTypeAdministrationContentProps { mapId: number; } -const MapPinTypeAdministrationContent: React.FC = ({ - mapId, -}) => { +const MapPinTypeAdministrationContent: React.FC = () => { const [state, dispatch] = useReducer( mapPinTypeAdministrationReducer, mapPinTypeAdministrationInitialState, diff --git a/frontend/component-sections/Map/MapAdministrationModals/MapPinTypeAdministrationModal/PinTypeActions/PinTypeActions.tsx b/frontend/component-sections/Map/MapAdministrationModals/MapPinTypeAdministrationModal/PinTypeActions/PinTypeActions.tsx index f7a46e11..289c841d 100644 --- a/frontend/component-sections/Map/MapAdministrationModals/MapPinTypeAdministrationModal/PinTypeActions/PinTypeActions.tsx +++ b/frontend/component-sections/Map/MapAdministrationModals/MapPinTypeAdministrationModal/PinTypeActions/PinTypeActions.tsx @@ -1,8 +1,6 @@ -import React, { useMemo } from 'react'; +import React from 'react'; import { Col, Row } from '../../../../../components/Flex/Flex'; import { TitleH2 } from '../../../../../components/Typography/Title'; -import { useUrlModuleId } from '../../../../../hooks/useUrlModuleId'; -import { useGetMapPinTypesAndGroups } from '../../../../../api/maps/useGetMapPinTypesAndGroups'; import { Label } from '../../../../../components/Typography/Label'; import { Button } from '../../../../../components/Button/Button'; import { Text } from '../../../../../components/Typography/Text'; @@ -13,17 +11,7 @@ interface PinTypeActionsProps { pinTypeId: number; } -const PinTypeActions: React.FC = ({ pinTypeId }) => { - const moduleId = useUrlModuleId(); - const { data: mapPinTypesAndGroups, isFetching: isPending } = useGetMapPinTypesAndGroups({ - variables: moduleId, - }); - - const pinData = useMemo( - () => (mapPinTypesAndGroups?.pinTypes ?? []).find((x) => x.id === pinTypeId), - [mapPinTypesAndGroups, pinTypeId], - ); - +const PinTypeActions: React.FC = () => { return ( Actions diff --git a/frontend/component-sections/Map/MapLayout/MapLayout.tsx b/frontend/component-sections/Map/MapLayout/MapLayout.tsx index 612194f8..d44b3563 100644 --- a/frontend/component-sections/Map/MapLayout/MapLayout.tsx +++ b/frontend/component-sections/Map/MapLayout/MapLayout.tsx @@ -27,8 +27,8 @@ interface MapLayoutProps { } const MapLayout: React.FC = ({ mapId, canEdit, allLayersByDefault = false }) => { - const { map: mapData, isFetching: isPendingMap } = useMap(mapId); - const { data: mapLayers, isFetching: isPendingMapLayers } = useGetMapLayers({ variables: mapId }); + const { map: mapData } = useMap(mapId); + const { data: mapLayers } = useGetMapLayers({ variables: mapId }); const wrapperRef = useRef(null); const [displayedLayers, setDisplayedLayers] = useState({}); diff --git a/frontend/component-sections/Map/MapList.tsx b/frontend/component-sections/Map/MapList.tsx index c9d55abd..0fce4dad 100644 --- a/frontend/component-sections/Map/MapList.tsx +++ b/frontend/component-sections/Map/MapList.tsx @@ -1,6 +1,6 @@ import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { ReduxState } from '../../store'; -import { useSelector } from 'react-redux'; +import { useAppSelector } from '../../store/hooks'; import { EntityTableType, Tables } from '../../utils/types/tables'; import { PAGE_SIZE_MAPS, useGetMaps } from '../../api/maps/useGetMaps'; import { selectMapsByIds } from '../../adapters/MapAdapter'; @@ -35,7 +35,7 @@ const MapList: React.FC = ({ [mapsDataPages], ); - const mapsData = useSelector((state: ReduxState) => selectMapsByIds(state, mapIds), [mapIds]); + const mapsData = useAppSelector((state: ReduxState) => selectMapsByIds(state, mapIds)); useEffect(() => { if ( diff --git a/frontend/component-sections/Map/MapSidebar/MapSidebarPins.tsx b/frontend/component-sections/Map/MapSidebar/MapSidebarPins.tsx index d2b4e546..714ba7eb 100644 --- a/frontend/component-sections/Map/MapSidebar/MapSidebarPins.tsx +++ b/frontend/component-sections/Map/MapSidebar/MapSidebarPins.tsx @@ -16,7 +16,7 @@ interface MapSidebarPinsProps { displayedLayers: DisplayedLayers; } -const MapSidebarPins: React.FC = ({ mapId, displayedLayers, canEdit }) => { +const MapSidebarPins: React.FC = ({ mapId, canEdit }) => { const { data: mapPins, isFetching: isPendingMapPins } = useGetMapPins({ variables: mapId }); const [modalOpenPins, setModalOpenPins] = useState(false); diff --git a/frontend/component-sections/Menu/MenuCategoryPage.tsx b/frontend/component-sections/Menu/MenuCategoryPage.tsx index 36484322..0a5a7da6 100644 --- a/frontend/component-sections/Menu/MenuCategoryPage.tsx +++ b/frontend/component-sections/Menu/MenuCategoryPage.tsx @@ -1,7 +1,7 @@ import React, { useEffect, useMemo } from 'react'; import { useRouter } from 'next/router'; import MenuCategory from '../../screens/menus/MenuCategory/MenuCategory'; -import { useDispatch } from 'react-redux'; +import { useAppDispatch } from '../../store/hooks'; import { setOpenedUrlPrefix } from '../../screens/menus/MenuCategory/menuCategorySlice'; interface MenuCategoryProps { @@ -17,7 +17,7 @@ const MenuCategoryPage: React.FC = ({ menuId, canEdit, }) => { - const dispatch = useDispatch(); + const dispatch = useAppDispatch(); const router = useRouter(); const m = router.query['menuCategory']; diff --git a/frontend/component-sections/Post/ModulePosts.tsx b/frontend/component-sections/Post/ModulePosts.tsx index db88be8d..cc51da6a 100644 --- a/frontend/component-sections/Post/ModulePosts.tsx +++ b/frontend/component-sections/Post/ModulePosts.tsx @@ -1,7 +1,6 @@ import React from 'react'; import { isModuleCollaborator, useMyModuleRole } from '../../hooks/useModuleAdmins'; import ModuleLayout from '../../components/Layout/ModuleLayout'; -import MapsScreen from '../../screens/menus/Maps/MapsScreen'; import PostsScreen from '../../screens/menus/Posts/PostsScreen'; interface ModulePostsProps { diff --git a/frontend/component-sections/Post/PostFormOld.tsx b/frontend/component-sections/Post/PostFormOld.tsx deleted file mode 100644 index e52d420f..00000000 --- a/frontend/component-sections/Post/PostFormOld.tsx +++ /dev/null @@ -1,149 +0,0 @@ -import React, { useCallback, useState } from 'react'; -import ContentSection from '../../components/ContentSection/ContentSection'; -import Textarea from '../../components/Textarea/Textarea'; -import { useInput } from '../../hooks/useInput'; -import Input from '../../components/Input/Input'; -import { Col, Row } from '../../components/Flex/Flex'; -import Avatar from '../../components/Avatar/Avatar'; -import { Label } from '../../components/Typography/Label'; -import ImageModal from '../../components/ImageModal/ImageModal'; -import { PbImage } from '../../generated/api-types/data-contracts'; -import ErrorText from '../../components/ErrorText/ErrorText'; -import { Button } from '../../components/Button/Button'; -import { useCreateMenuItemPost } from '../../api/menus/useCreateMenuItemPost'; - -const textareaPlaceholder = - 'Short description of the post. What information does this post contain?'; - -interface PostFormOldProps { - menuId?: number; - menuItemId?: number; - position?: number; - canChangeTitle?: boolean; - canChangeDescription?: boolean; - canChangeThumbnail?: boolean; - onFinishCallback?: () => void; -} - -const PostFormOld: React.FC = ({ - menuId, - menuItemId, - position, - canChangeTitle = true, - canChangeDescription = true, - canChangeThumbnail = true, - onFinishCallback, -}) => { - const { mutate: createMenuItemPost, isPending, error } = useCreateMenuItemPost(); - - const [showImageModal, setShowImageModal] = useState(false); - - const [thumbnailImageId, setThumbnailImageId] = useState(); - const [thumbnailImageUrl, setThumbnailImageUrl] = useState(); - const { value: title, onChange: onChangeTitle } = useInput(''); - const { value: description, onChange } = useInput(''); - - const toggleImageModal = useCallback(() => { - setShowImageModal((p) => !p); - }, []); - - const changeThumbnailImage = useCallback( - (image: PbImage) => { - if (canChangeThumbnail) { - setThumbnailImageId(image.id); - setThumbnailImageUrl(image.baseUrl); - } - }, - [canChangeThumbnail], - ); - - const createPostHandler = useCallback(() => { - if (menuId && menuItemId !== undefined) { - createMenuItemPost( - { - menuId, - menuItemId, - body: { - title: canChangeTitle ? title : undefined, - position, - shortDescription: canChangeDescription ? description : undefined, - imageThumbnailId: thumbnailImageId, - }, - }, - { - onSuccess: () => { - if (onFinishCallback) onFinishCallback(); - }, - }, - ); - } - }, [ - createMenuItemPost, - menuId, - menuItemId, - position, - canChangeTitle, - title, - canChangeDescription, - description, - thumbnailImageId, - ]); - - const pending = isPending; - - if (!canChangeTitle && !canChangeDescription && !canChangeThumbnail) return null; - - return ( - <> - - - {canChangeTitle && ( - - )} - {canChangeDescription && ( -