ASR and extraction are separate, independently swappable roles resolved per locale from config. All three locales point at the same OpenRouter models today (whisper-1, gemini-3.7-flash); the indirection stays because Persian ASR is the weakest link and repointing only `fa` must not be a code change. The model emits a deliberately flat wire shape rather than the internal discriminated unions — strict json_schema mode has poor union support — and toVoiceIntent narrows it. That normalizer is total: a missing or malformed payload yields a shape the resolvers report as unresolved rather than one that throws. The prompt supplies catalog codes with labels in the actor's locale, so the model matches spoken words rather than translating, and carries per-locale tooth vocabulary. English gets an explicit warning that a bare two-digit number is ambiguous under Universal numbering, and must not be treated as FDI unless the speaker said so. From review of this commit: - only an actually FDI-shaped code takes the explicit branch; fdi:"6" alongside valid arch/side/position used to lose the tooth entirely - an unrecognised due kind passes through to be flagged, instead of collapsing to null and looking like no deadline was ever spoken - vendor error bodies stay out of the thrown message and the default log level; a 4xx can echo the request back, transcript included - the chat call sets provider.require_parameters so OpenRouter only routes to endpoints that honour the JSON schema, rather than ones treating it as a hint - an unknown locale in VOICE_ENABLED_LOCALES now fails at boot like an unknown provider id, instead of silently disabling the microphone everywhere Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Dyolink
Monorepo: NestJS backend (backend/), Next.js frontend (frontend/), Docker stack under infrastructure/..
Local development: see backend/README.md and frontend/README.md.
Cursor AI: project conventions for agents are in AGENTS.md, .cursor/rules/, and .cursor/skills/.
Production deploy (Docker Hub + HTTPS + Let's Encrypt)
Full step-by-step guide: infrastructure/DEPLOY.md
Minimal server setup: install Docker, create .env + secrets/, docker login, run one script.
| On server (once) | In repo / Docker |
|---|---|
| DNS A record → server IP | docker-compose.prod.yml, nginx, certbot |
docker login (private Hub) |
Build & push images from dev machine |
secrets/database.env, secrets/backend.env |
Examples: database.prod.env.example, backend.prod.env.example |
infrastructure/.env (DOMAIN, LETSENCRYPT_EMAIL) |
deploy.prod.env.example |
Dev machine — build frontend with the public domain baked in, push to Docker Hub:
./infrastructure/scripts/build-and-push-prod.sh wixur.ir latest
Server — from infrastructure/:
cp deploy.prod.env.example .env # edit DOMAIN, paths
mkdir -p ../secrets && cp database.prod.env.example ../secrets/database.env
cp backend.prod.env.example ../secrets/backend.env # set passwords + FRONTEND_URL
docker login
chmod +x scripts/*.sh
./scripts/deploy-prod.sh
SSL is issued automatically via Certbot (scripts/init-letsencrypt.sh). Nginx config is generated from DOMAIN in .env. When you move to another domain (e.g. dyolink.com), update .env + backend.env, re-run init-letsencrypt.sh, and rebuild the frontend image with the new URL.
Deploy on your own server (Docker + Gitea)
High level: build container images → push to a registry → server pulls images and runs Compose. Optionally Gitea Actions automates that on every merge to main / master.
1. One-time server preparation
-
Install Docker and Docker Compose on the server.
-
Run Gitea with the container registry enabled (same host/port you use for
docker login, e.g.178.131.50.201:3000). -
Copy the repo (or deploy only
infrastructure/+ secrets). You need at least:infrastructure/docker-compose.registry.ymlinfrastructure/nginx/configs referenced by that compose fileinfrastructure/database/init.sqlif used by your Postgres service
-
Secrets on the server (never commit real values):
- Copy
infrastructure/database.staging.env.example→database.staging.env(Postgres user/password/db). - Copy
infrastructure/backend.staging.env.example→backend.staging.env(e.g.DATABASE_URL, JWT, pointing at the compose Postgres service name). - Put both files in one directory on the server, e.g.
/opt/dyolink/secrets/.
- Copy
-
Registry login from the server (same credentials you use for
docker push):docker login <registry-host>:<port> -u <user>For HTTP registries, Docker may require
insecure-registrieson the daemon.
2. Manual deploy (build images elsewhere, run on server)
On your dev machine (after successful local builds):
$REG = "<registry-host>:<port>"
$OWN = "<registry-owner>"
$TAG = "manual"
docker build -t "${REG}/${OWN}/dyolink-backend:${TAG}" -t "${REG}/${OWN}/dyolink-backend:latest" ./backend
docker build `
--build-arg NEXT_PUBLIC_API_URL="http://<your-public-ip>:<nginx-port>/api" `
--build-arg NEXT_PUBLIC_APP_URL="http://<your-public-ip>:<nginx-port>" `
--build-arg NEXT_PUBLIC_APP_NAME="Dyolink" `
-t "${REG}/${OWN}/dyolink-frontend:${TAG}" `
-t "${REG}/${OWN}/dyolink-frontend:latest" `
./frontend
docker push "${REG}/${OWN}/dyolink-backend:${TAG}"
docker push "${REG}/${OWN}/dyolink-backend:latest"
docker push "${REG}/${OWN}/dyolink-frontend:${TAG}"
docker push "${REG}/${OWN}/dyolink-frontend:latest"
On the server, from infrastructure/:
-
Create
deploy.registry.env(seeinfrastructure/deploy.registry.env.example):REGISTRY_PREFIX=<host>:<port>/<owner>(nohttp://, no trailing slash)IMAGE_TAG=latestor the tag you pushedSTAGING_HTTP_PORT=<host port>(e.g.8088— browser useshttp://<ip>:8088)
-
Set
DEPLOY_SECRETS_DIRto the absolute path of the folder containingdatabase.staging.envandbackend.staging.env(you can export it in the shell or add it todeploy.registry.envif your Compose setup expects it). -
Pull and start:
docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull backend frontend docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d
The backend container runs prisma migrate deploy on startup (via entrypoint) when NODE_ENV=production, so schema updates apply after you deploy a new image that includes new migrations.
3. Automatic deploy (Gitea Actions)
Workflow file: .gitea/workflows/registry-build-deploy.yml.
Requirements:
-
Gitea Actions enabled for the repository.
-
A self-hosted runner (with Docker) registered to Gitea — the workflow uses
runs-on: self-hosted. -
Windows runners: the workflow uses PowerShell (not Bash). Gitea’s runner was failing with
execvpe(/bin/bash) failedwhen Bash was routed through WSL without a real/bin/bash. If your runner is Linux, switch.gitea/workflows/registry-build-deploy.ymltodefaults.run.shell: bashand use Bash syntax instead. -
Repository → Actions → Variables (examples):
REGISTRY_HOST— e.g.178.131.50.201:3000REGISTRY_OWNER— image namespace (same as Docker image path after the host), e.g.adminPUBLIC_BASE_URL— URL users open in the browser, e.g.http://178.131.50.201:8088(no trailing slash)DEPLOY_SECRETS_DIR— absolute path on the runner machine to the folder containingdatabase.staging.envandbackend.staging.env- Optional:
STAGING_HTTP_PORT(defaults to8088)
-
Repository → Actions → Secrets:
REGISTRY_USERNAMEREGISTRY_PASSWORD— access token with package read/write (or equivalent)
Trigger: push to main or master, or run the workflow manually (workflow_dispatch).
The pipeline clones from your Gitea instance, builds and pushes backend/frontend images, then on the runner runs docker compose pull and up -d using infrastructure/docker-compose.registry.yml.
Related paths
| Path | Role |
|---|---|
backend/Dockerfile |
API image |
frontend/Dockerfile |
Web image |
infrastructure/docker-compose.registry.yml |
Pull-only staging stack (registry images + nginx + postgres) |
infrastructure/deploy.registry.env.example |
Template for deploy.registry.env |