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 && (