Compare commits

..

13 Commits

Author SHA1 Message Date
00207786f9 improvement: workflow bypassed temporarily. 2026-05-05 16:29:44 +03:30
7dfff53c71 improvement: workflow bypassed temporarily. 2026-05-05 16:21:31 +03:30
517ce0d5ba improvement: workflow updated for git actions. 2026-05-05 00:33:30 +03:30
d03ecdfc7c Merge branch 'master' into improvement/infrastructure 2026-05-05 00:04:36 +03:30
15ef17228f improvement: fixed some package.json issues. readme files updated. 2026-05-05 00:01:06 +03:30
f943b2c7be Merge pull request 'improvement: docker infrastructure overhauled.' (#10) from improvement/infrastructure into master
Some checks failed
Registry — build, push, deploy / build-and-push (push) Failing after 29s
Registry — build, push, deploy / deploy (push) Has been skipped
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/10
2026-05-03 20:32:33 +03:30
4c61885cef improvement: a port issue for local dev fixed. 2026-05-03 20:29:58 +03:30
5682da87aa improvement: docker infrastructure overhauled. 2026-05-03 20:15:39 +03:30
a05249e343 Merge pull request 'improvement: dashboard UX improved' (#9) from improvement/dashboard-ux-improvements into master
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/9
2026-04-30 18:53:08 +03:30
b6bd4b47be improvement: dashboard UX improved 2026-04-30 18:49:23 +03:30
c2be551a04 Merge pull request 'improvement: newly created organization's subscriptions flow improved.' (#8) from improvement/new-org-subscription into master
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/8
2026-04-30 18:19:26 +03:30
64215f07e6 improvement: newly created organization's subscriptions flow improved. 2026-04-30 18:15:40 +03:30
9fa406e35e Merge pull request 'feature/staff-management' (#7) from feature/staff-management into master
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/7
2026-04-30 14:09:31 +03:30
53 changed files with 24600 additions and 519 deletions

View File

@@ -0,0 +1,226 @@
# Build backend/frontend images, push to Gitea Container Registry, deploy with pull-only compose.
#
# Repository Variables (Settings → Actions → Variables) — non-secret:
# REGISTRY_HOST e.g. 178.131.50.201:3000 (no http/https)
# REGISTRY_OWNER Gitea user or org that owns the packages (same as image namespace)
# PUBLIC_BASE_URL URL users open in browser, e.g. http://178.131.50.201:8088 (no trailing slash)
#
# Repository Secrets (Settings → Actions → Secrets):
# REGISTRY_USERNAME Gitea username for docker login
# REGISTRY_PASSWORD Gitea access token (packages:read/write) or account password
#
# HTTP registry (typical self-hosted Gitea): Docker defaults to HTTPS. If login/push fails with
# "server gave HTTP response to HTTPS client", add REGISTRY_HOST (e.g. 192.168.1.100:3000) to the
# Docker daemon "insecure-registries" on the RUNNER machine, then restart Docker (Docker Desktop
# → Settings → Docker Engine → JSON → "insecure-registries": ["host:port"]).
#
# Optional:
# STAGING_HTTP_PORT host port for nginx (default 8088)
#
# Required for deploy job (absolute path on the runner host):
# DEPLOY_SECRETS_DIR folder containing database.staging.env + backend.staging.env
#
# Runner: self-hosted with Docker. Default shell is powershell (Windows act_runner often has no WSL bash).
# For a Linux runner, change defaults.run.shell to bash and restore bash syntax if needed.
#
# We do NOT use gitea.com/actions/checkout — many restricted networks cannot reach gitea.com.
# Checkout is a plain git clone from the same Gitea host.
name: Registry — build, push, deploy
on:
push:
branches: [main, master]
workflow_dispatch:
defaults:
run:
shell: powershell
jobs:
temp-success:
runs-on: self-hosted
steps:
- name: Temporary placeholder (always success)
run: |
$ErrorActionPreference = 'Stop'
Write-Host "Temporary workflow is active."
Write-Host "Trigger: ${{ github.event_name }}"
Write-Host "Branch: ${{ github.ref_name }}"
Write-Host "Commit: ${{ github.sha }}"
Write-Host "Production build/push/deploy steps are intentionally commented."
exit 0
# ---------------------------------------------------------------------------
# Production pipeline is temporarily disabled.
# Uncomment these jobs after split-DNS / registry reachability is fixed.
# ---------------------------------------------------------------------------
#
# build-and-push:
# runs-on: self-hosted
# outputs:
# image_tag: ${{ steps.meta.outputs.image_tag }}
# steps:
# - name: Checkout (clone from this Gitea — no gitea.com)
# - name: Image tag and registry prefix
# - name: Log in to container registry
# - name: Build and push backend
# - name: Build and push frontend
#
# deploy:
# needs: build-and-push
# runs-on: self-hosted
# steps:
# - name: Checkout (shallow clone from this Gitea — no gitea.com)
# - name: Write deploy.registry.env and validate secrets path
# - name: Log in to container registry (for pull)
# - name: Pull and start stack
####SAMPLE
# name: Registry — build, push, deploy
# on:
# push:
# branches: [main, master]
# workflow_dispatch:
# defaults:
# run:
# shell: powershell
# jobs:
# build-and-push:
# runs-on: self-hosted
# outputs:
# image_tag: ${{ steps.meta.outputs.image_tag }}
# steps:
# - name: Checkout (clone from this Gitea — no gitea.com)
# run: |
# $ErrorActionPreference = 'Stop'
# $Server = "${{ github.server_url }}".TrimEnd('/')
# $Repo = "${{ github.repository }}"
# $Branch = "${{ github.ref_name }}"
# $Token = "${{ github.token }}"
# $Actor = "${{ github.actor }}"
# $hp = $Server -replace '^https?://', ''
# if ($Server.StartsWith('https')) {
# $cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
# } else {
# $cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
# }
# $env:GIT_TERMINAL_PROMPT = '0'
# git clone --depth 1 --branch $Branch $cloneUrl .
# - name: Image tag and registry prefix
# id: meta
# run: |
# $ErrorActionPreference = 'Stop'
# $short = (git rev-parse --short HEAD).Trim()
# $utf8 = New-Object System.Text.UTF8Encoding $false
# [System.IO.File]::AppendAllText($env:GITHUB_OUTPUT, "image_tag=$short`n", $utf8)
# $prefix = "${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}"
# [System.IO.File]::AppendAllText($env:GITHUB_ENV, "REGISTRY_PREFIX=$prefix`n", $utf8)
# - name: Log in to container registry
# run: |
# $ErrorActionPreference = 'Stop'
# $pass = @'
# ${{ secrets.REGISTRY_PASSWORD }}
# '@
# $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
# - name: Build and push backend
# run: |
# $ErrorActionPreference = 'Stop'
# $tag = "${{ steps.meta.outputs.image_tag }}"
# docker build `
# -t "$env:REGISTRY_PREFIX/dyolink-backend:$tag" `
# -t "$env:REGISTRY_PREFIX/dyolink-backend:latest" `
# ./backend
# docker push "$env:REGISTRY_PREFIX/dyolink-backend:$tag"
# docker push "$env:REGISTRY_PREFIX/dyolink-backend:latest"
# - name: Build and push frontend
# env:
# PUBLIC_BASE_URL: ${{ vars.PUBLIC_BASE_URL }}
# run: |
# $ErrorActionPreference = 'Stop'
# $tag = "${{ steps.meta.outputs.image_tag }}"
# $base = $env:PUBLIC_BASE_URL
# docker build `
# --build-arg "NEXT_PUBLIC_API_URL=$base/api" `
# --build-arg "NEXT_PUBLIC_APP_URL=$base" `
# --build-arg "NEXT_PUBLIC_APP_NAME=Dyolink" `
# -t "$env:REGISTRY_PREFIX/dyolink-frontend:$tag" `
# -t "$env:REGISTRY_PREFIX/dyolink-frontend:latest" `
# ./frontend
# docker push "$env:REGISTRY_PREFIX/dyolink-frontend:$tag"
# docker push "$env:REGISTRY_PREFIX/dyolink-frontend:latest"
# deploy:
# needs: build-and-push
# runs-on: self-hosted
# steps:
# - name: Checkout (shallow clone from this Gitea — no gitea.com)
# run: |
# $ErrorActionPreference = 'Stop'
# $Server = "${{ github.server_url }}".TrimEnd('/')
# $Repo = "${{ github.repository }}"
# $Branch = "${{ github.ref_name }}"
# $Token = "${{ github.token }}"
# $Actor = "${{ github.actor }}"
# $hp = $Server -replace '^https?://', ''
# if ($Server.StartsWith('https')) {
# $cloneUrl = 'https://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
# } else {
# $cloneUrl = 'http://' + $Actor + ':' + $Token + '@' + $hp + '/' + $Repo + '.git'
# }
# $env:GIT_TERMINAL_PROMPT = '0'
# git clone --depth 1 --branch $Branch $cloneUrl .
# - name: Write deploy.registry.env and validate secrets path
# run: |
# $ErrorActionPreference = 'Stop'
# $SD = '${{ vars.DEPLOY_SECRETS_DIR }}'.Trim()
# if ([string]::IsNullOrWhiteSpace($SD)) {
# Write-Host "Set repository variable DEPLOY_SECRETS_DIR to the absolute path on this runner"
# Write-Host "where database.staging.env and backend.staging.env live (not in git)."
# exit 1
# }
# if (-not (Test-Path (Join-Path $SD "database.staging.env"))) {
# Write-Host "Missing $(Join-Path $SD 'database.staging.env')"
# exit 1
# }
# if (-not (Test-Path (Join-Path $SD "backend.staging.env"))) {
# Write-Host "Missing $(Join-Path $SD 'backend.staging.env')"
# exit 1
# }
# $stagingPort = '${{ vars.STAGING_HTTP_PORT }}'.Trim()
# if ([string]::IsNullOrWhiteSpace($stagingPort)) { $stagingPort = '8088' }
# $imageTag = "${{ needs.build-and-push.outputs.image_tag }}"
# $lines = @(
# "REGISTRY_PREFIX=${{ vars.REGISTRY_HOST }}/${{ vars.REGISTRY_OWNER }}",
# "IMAGE_TAG=$imageTag",
# "STAGING_HTTP_PORT=$stagingPort",
# "DEPLOY_SECRETS_DIR=$SD"
# )
# Set-Location infrastructure
# $lines | Set-Content -Path deploy.registry.env -Encoding utf8
# - name: Log in to container registry (for pull)
# run: |
# $ErrorActionPreference = 'Stop'
# $pass = @'
# ${{ secrets.REGISTRY_PASSWORD }}
# '@
# $pass.Trim() | docker login "${{ vars.REGISTRY_HOST }}" -u "${{ secrets.REGISTRY_USERNAME }}" --password-stdin
# - name: Pull and start stack
# run: |
# $ErrorActionPreference = 'Stop'
# Set-Location infrastructure
# 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

7
.gitignore vendored
View File

@@ -5,7 +5,6 @@ node_modules/
npm-debug.log* npm-debug.log*
yarn-debug.log* yarn-debug.log*
yarn-error.log* yarn-error.log*
package-lock.json
yarn.lock yarn.lock
# === Build outputs === # === Build outputs ===
@@ -82,8 +81,10 @@ secrets/
*.db *.db
*.sqlite3 *.sqlite3
# === Gitea specific === # === Gitea specific (keep Actions workflows; ignore other local .gitea noise) ===
.gitea/ .gitea/*
!.gitea/workflows
!.gitea/workflows/**
# Prisma generated files # Prisma generated files
prisma/*.db prisma/*.db

116
README.md Normal file
View File

@@ -0,0 +1,116 @@
# Dyolink
Monorepo: **NestJS** backend (`backend/`), **Next.js** frontend (`frontend/`), **Docker** stack under `infrastructure/`.
Local development: see **`backend/README.md`** and **`frontend/README.md`**.
---
## 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
1. Install **Docker** and **Docker Compose** on the server.
2. Run **Gitea** with the **container registry** enabled (same host/port you use for `docker login`, e.g. `178.131.50.201:3000`).
3. Copy the repo (or deploy only `infrastructure/` + secrets). You need at least:
- `infrastructure/docker-compose.registry.yml`
- `infrastructure/nginx/` configs referenced by that compose file
- `infrastructure/database/init.sql` if used by your Postgres service
4. **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/`.
5. **Registry login from the server** (same credentials you use for `docker push`):
```bash
docker login <registry-host>:<port> -u <user>
```
For HTTP registries, Docker may require **`insecure-registries`** on the daemon.
### 2. Manual deploy (build images elsewhere, run on server)
On your **dev machine** (after successful local builds):
```powershell
$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/`:
1. Create **`deploy.registry.env`** (see `infrastructure/deploy.registry.env.example`):
- `REGISTRY_PREFIX=<host>:<port>/<owner>` (no `http://`, no trailing slash)
- `IMAGE_TAG=latest` or the tag you pushed
- `STAGING_HTTP_PORT=<host port>` (e.g. `8088` — browser uses `http://<ip>:8088`)
2. Set **`DEPLOY_SECRETS_DIR`** to the absolute path of the folder containing `database.staging.env` and `backend.staging.env` (you can export it in the shell or add it to `deploy.registry.env` if your Compose setup expects it).
3. Pull and start:
```bash
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). Giteas runner was failing with `execvpe(/bin/bash) failed` when Bash was routed through WSL without a real `/bin/bash`. If your runner is **Linux**, switch `.gitea/workflows/registry-build-deploy.yml` to `defaults.run.shell: bash` and use Bash syntax instead.
- **Repository → Actions → Variables** (examples):
- `REGISTRY_HOST` — e.g. `178.131.50.201:3000`
- `REGISTRY_OWNER` — image namespace (same as Docker image path after the host), e.g. `admin`
- `PUBLIC_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 containing `database.staging.env` and `backend.staging.env`
- Optional: `STAGING_HTTP_PORT` (defaults to `8088`)
- **Repository → Actions → Secrets:**
- `REGISTRY_USERNAME`
- `REGISTRY_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` |

1
backend/.npmrc Normal file
View File

@@ -0,0 +1 @@
legacy-peer-deps=true

View File

@@ -1,92 +1,55 @@
# ============================================ # ============================================
# STAGE 1: BUILDER STAGE # STAGE 1: BUILDER STAGE
# ============================================ # ============================================
# This stage builds the application and prepares assets FROM node:20-alpine AS builder
FROM node:18-alpine AS builder
# Set working directory
WORKDIR /app WORKDIR /app
# Copy package.json and package-lock.json first (for better caching)
COPY package*.json ./ COPY package*.json ./
COPY .npmrc ./
# Copy Prisma schema (needed for Prisma client generation)
COPY prisma ./prisma/ COPY prisma ./prisma/
# Install ALL dependencies (including dev dependencies for build)
RUN npm ci RUN npm ci
# Copy source code
COPY . . COPY . .
# Generate Prisma client RUN npm run prisma:generate
RUN npx prisma generate
# Build the NestJS application
RUN npm run build RUN npm run build
# Remove development dependencies to reduce size
RUN npm prune --production RUN npm prune --production
# ============================================ # ============================================
# STAGE 2: PRODUCTION STAGE # STAGE 2: PRODUCTION STAGE
# ============================================ # ============================================
# This stage creates the final production image FROM node:20-alpine
FROM node:18-alpine
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init RUN apk add --no-cache dumb-init
# Set working directory
WORKDIR /app WORKDIR /app
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \ RUN addgroup -g 1001 -S nodejs && \
adduser -S dyolink -u 1001 adduser -S dyolink -u 1001
# Copy package.json files # Prisma client comes from the builder (npm prune --production keeps @prisma/client).
COPY package*.json ./ COPY package*.json ./
# Copy Prisma schema
COPY prisma ./prisma/ COPY prisma ./prisma/
# Install ONLY production dependencies
RUN npm ci --only=production && \
npm cache clean --force
# Generate Prisma client in production
RUN npx prisma generate
# Copy built application from builder stage
COPY --from=builder /app/dist ./dist COPY --from=builder /app/dist ./dist
# Copy node_modules (already pruned)
COPY --from=builder /app/node_modules ./node_modules COPY --from=builder /app/node_modules ./node_modules
# Create necessary directories with proper permissions COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
RUN mkdir -p /app/logs && \ RUN mkdir -p /app/logs && \
chown -R dyolink:nodejs /app chown -R dyolink:nodejs /app
# Set ownership of all files to non-root user
RUN chown -R dyolink:nodejs /app
# Switch to non-root user
USER dyolink USER dyolink
# Expose the application port
EXPOSE 3000 EXPOSE 3000
# Health check configuration
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \ HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000/api/health', (r) => {if(r.statusCode!==200)throw new Error()})" || exit 1 CMD node -e "require('http').get('http://127.0.0.1:3000/api/health', (r) => {if(r.statusCode!==200)process.exit(1)})"
# Copy entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Use dumb-init to properly handle signals
ENTRYPOINT ["dumb-init", "--", "docker-entrypoint.sh"] ENTRYPOINT ["dumb-init", "--", "docker-entrypoint.sh"]
# Start the application
CMD ["node", "dist/main"] CMD ["node", "dist/main"]

View File

@@ -1,98 +1,88 @@
<p align="center"> # Dyolink — Backend (NestJS)
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456 ## Prerequisites
[circleci-url]: https://circleci.com/gh/nestjs/nest
<p align="center">A progressive <a href="http://nodejs.org" target="_blank">Node.js</a> framework for building efficient and scalable server-side applications.</p> - **Node.js 20+** and **npm**
<p align="center"> - **PostgreSQL** reachable from your machine (local or remote)
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/v/@nestjs/core.svg" alt="NPM Version" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/l/@nestjs/core.svg" alt="Package License" /></a>
<a href="https://www.npmjs.com/~nestjscore" target="_blank"><img src="https://img.shields.io/npm/dm/@nestjs/common.svg" alt="NPM Downloads" /></a>
<a href="https://circleci.com/gh/nestjs/nest" target="_blank"><img src="https://img.shields.io/circleci/build/github/nestjs/nest/master" alt="CircleCI" /></a>
<a href="https://discord.gg/G7Qnnhy" target="_blank"><img src="https://img.shields.io/badge/discord-online-brightgreen.svg" alt="Discord"/></a>
<a href="https://opencollective.com/nest#backer" target="_blank"><img src="https://opencollective.com/nest/backers/badge.svg" alt="Backers on Open Collective" /></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://opencollective.com/nest/sponsors/badge.svg" alt="Sponsors on Open Collective" /></a>
<a href="https://paypal.me/kamilmysliwiec" target="_blank"><img src="https://img.shields.io/badge/Donate-PayPal-ff3f59.svg" alt="Donate us"/></a>
<a href="https://opencollective.com/nest#sponsor" target="_blank"><img src="https://img.shields.io/badge/Support%20us-Open%20Collective-41B883.svg" alt="Support us"></a>
<a href="https://twitter.com/nestframework" target="_blank"><img src="https://img.shields.io/twitter/follow/nestframework.svg?style=social&label=Follow" alt="Follow us on Twitter"></a>
</p>
<!--[![Backers on Open Collective](https://opencollective.com/nest/backers/badge.svg)](https://opencollective.com/nest#backer)
[![Sponsors on Open Collective](https://opencollective.com/nest/sponsors/badge.svg)](https://opencollective.com/nest#sponsor)-->
## Description ## First-time setup
[Nest](https://github.com/nestjs/nest) framework TypeScript starter repository. 1. **Clone the monorepo** and go to the backend app:
## Project setup ```bash
git clone <repository-url> dyolink
cd dyolink/backend
```
2. **Install dependencies**
```bash
npm install
```
3. **Environment**
Copy `.env.example` to `.env` and set at least:
- `DATABASE_URL` — PostgreSQL connection string for your dev database
- `JWT_SECRET` — strong secret for signing tokens
Do not commit `.env`.
4. **Database URL for local dev**
Point `DATABASE_URL` at a database you created in Postgres (create an empty DB first if needed).
5. **Generate Prisma Client**
```bash
npm run prisma:generate
```
6. **Apply migrations** (creates/updates tables to match `prisma/schema.prisma`)
```bash
npm run prisma:migrate
```
This runs `prisma migrate dev`. Use it during development when the schema changes.
7. **Seed** (optional — sample data / bootstrap)
```bash
npm run prisma:seed
```
## Run (development)
```bash ```bash
$ npm install npm run start:dev
``` ```
## Compile and run the project API listens on **`http://localhost:3000`** by default (`PORT` in `.env`).
If the frontend runs on another origin (e.g. `http://localhost:3001`), set `CORS_ORIGIN` in `.env` to that URL.
## After pulling latest `main`
```bash ```bash
# development git pull
$ npm run start npm install
npm run prisma:generate
# watch mode npm run prisma:migrate
$ npm run start:dev
# production mode
$ npm run start:prod
``` ```
## Run tests If teammates added migrations, step 4 applies them. Resolve migration conflicts locally before pushing.
```bash ## Useful commands
# unit tests
$ npm run test
# e2e tests | Command | Purpose |
$ npm run test:e2e |--------|---------|
| `npm run prisma:generate` | Regenerate client after `schema.prisma` changes |
| `npm run prisma:migrate` | Dev migrations (`migrate dev`) |
| `npm run prisma:deploy` | Production-style apply (`migrate deploy`) — e.g. CI/containers |
| `npm run build` | Compile Nest app |
| `npm run start:prod` | Run compiled app (`node dist/main`) |
# test coverage ## Docker
$ npm run test:cov
```
## Deployment Image build is defined in **`Dockerfile`** at this folder. For full-stack deployment and CI, see the **repository root `README.md`**.
When you're ready to deploy your NestJS application to production, there are some key steps you can take to ensure it runs as efficiently as possible. Check out the [deployment documentation](https://docs.nestjs.com/deployment) for more information.
If you are looking for a cloud-based platform to deploy your NestJS application, check out [Mau](https://mau.nestjs.com), our official platform for deploying NestJS applications on AWS. Mau makes deployment straightforward and fast, requiring just a few simple steps:
```bash
$ npm install -g @nestjs/mau
$ mau deploy
```
With Mau, you can deploy your application in just a few clicks, allowing you to focus on building features rather than managing infrastructure.
## Resources
Check out a few resources that may come in handy when working with NestJS:
- Visit the [NestJS Documentation](https://docs.nestjs.com) to learn more about the framework.
- For questions and support, please visit our [Discord channel](https://discord.gg/G7Qnnhy).
- To dive deeper and get more hands-on experience, check out our official video [courses](https://courses.nestjs.com/).
- Deploy your application to AWS with the help of [NestJS Mau](https://mau.nestjs.com) in just a few clicks.
- Visualize your application graph and interact with the NestJS application in real-time using [NestJS Devtools](https://devtools.nestjs.com).
- Need help with your project (part-time to full-time)? Check out our official [enterprise support](https://enterprise.nestjs.com).
- To stay in the loop and get updates, follow us on [X](https://x.com/nestframework) and [LinkedIn](https://linkedin.com/company/nestjs).
- Looking for a job, or have a job to offer? Check out our official [Jobs board](https://jobs.nestjs.com).
## Support
Nest is an MIT-licensed open source project. It can grow thanks to the sponsors and support by the amazing backers. If you'd like to join them, please [read more here](https://docs.nestjs.com/support).
## Stay in touch
- Author - [Kamil Myśliwiec](https://twitter.com/kammysliwiec)
- Website - [https://nestjs.com](https://nestjs.com/)
- Twitter - [@nestframework](https://twitter.com/nestframework)
## License
Nest is [MIT licensed](https://github.com/nestjs/nest/blob/master/LICENSE).

View File

@@ -1,95 +0,0 @@
# Dyolink Backend - Development Setup Guide
## 📋 Prerequisites
Before starting, ensure you have the following installed:
- **Node.js** (v18 or higher)
- **PostgreSQL** (v15 or higher) - We use v18, but any v15+ works
- **Git** (for cloning)
- **npm** or **yarn** (npm comes with Node.js)
## 🚀 Initial Setup Steps
1. Clone the Repository
```bash
git clone [your-repository-url]
cd dyolink/backend
2. Install Dependencies
bash
npm install
3. Environment Configuration
Create a .env file in the backend folder:
env
# backend/.env
DATABASE_URL=postgresql://postgres:1234@localhost:5432/dyolink_db
JWT_SECRET=your-super-secret-key-here-change-this
JWT_REFRESH_SECRET=your-super-secret-refresh-key-here-different-from-above
JWT_EXPIRES_IN=15m
JWT_REFRESH_EXPIRES_IN=7d
PORT=3000
⚠️ Important: Never commit the .env file to git! We have .gitignore set up to prevent this.
4. Database Setup
Option A: Fresh PostgreSQL Installation
If you don't have PostgreSQL installed:
Windows (using Chocolatey):
bash
choco install postgresql
macOS (using Homebrew):
bash
brew install postgresql@15
brew services start postgresql@15
Common Installation Issues & Fixes:
Issue Solution
"Password not set during installation" Edit pg_hba.conf temporarily (see Troubleshooting section)
"Service not starting" Run PowerShell/Terminal as Administrator
"Port 5432 already in use" Stop local PostgreSQL service or change port
Option B: Using Existing PostgreSQL
If you already have PostgreSQL:
bash
# Connect to PostgreSQL
psql -U postgres
# Create the database (if it doesn't exist)
CREATE DATABASE dyolink_db;
\q
5. Database Migrations
Once PostgreSQL is running and you've created the database:
bash
# Generate Prisma client
npx prisma generate
# Run migrations to create tables
npx prisma migrate dev --name init_schema
⚠️ Known Issue: If you get P1001: Can't reach database server, ensure PostgreSQL is running:
bash
# Check PostgreSQL status
# Windows:
Get-Service postgresql-x64-18
# macOS:
brew services list | grep postgres
6. Seed the Database
bash
# Seed with initial data (organization types, plans, permissions, test user)
npx prisma db seed
⚠️ Prisma 7 Note: If seeding fails with PrismaClientInitializationError, we've fixed this by using the driver adapter pattern. The seed file now uses:
typescript
import { PrismaPg } from '@prisma/adapter-pg';
import { Pool } from 'pg';
const adapter = new PrismaPg(pool);
const prisma = new PrismaClient({ adapter });
7. Verify Setup
bash
# Open Prisma Studio to verify data
npx prisma studio
# This opens http://localhost:5555 - you should see all tables with seeded data
8. Start Development Server
bash
npm run start:dev
You should see:
text
Application is running on: http://localhost:3000
✅ Database connected successfully

View File

@@ -1,76 +1,36 @@
#!/bin/sh #!/bin/sh
set -e set -e
# ============================================ echo "=========================================="
# DOCKER ENTRYPOINT SCRIPT echo " Dyolink Backend - Docker Entrypoint"
# This script runs BEFORE the application starts echo "=========================================="
# ============================================
# Colors for logging (optional, for better readability)
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m' # No Color
echo "${GREEN}========================================${NC}"
echo "${GREEN} Dyolink Backend - Docker Entrypoint ${NC}"
echo "${GREEN}========================================${NC}"
# Check if we're in development or production
if [ "$NODE_ENV" = "production" ]; then if [ "$NODE_ENV" = "production" ]; then
echo "${GREEN}Running in PRODUCTION mode${NC}" echo "Running in PRODUCTION mode"
echo "Running database migrations..."
# Run database migrations ./node_modules/.bin/prisma migrate deploy
echo "${YELLOW}Running database migrations...${NC}"
npx prisma migrate deploy
# Check if migrations were successful
if [ $? -eq 0 ]; then
echo "${GREEN}✓ Database migrations completed successfully${NC}"
else
echo "${RED}✗ Database migrations failed!${NC}"
exit 1
fi
else else
echo "${YELLOW}Running in DEVELOPMENT mode${NC}" echo "Running in DEVELOPMENT mode"
echo "Syncing database schema..."
./node_modules/.bin/prisma db push
fi
# In development, we might want to push schema instead of migrations if [ "$NODE_ENV" != "production" ]; then
echo "${YELLOW}Syncing database schema...${NC}" if [ -f "prisma/seed.ts" ] || [ -f "prisma/seed.js" ]; then
npx prisma db push echo "Running database seed..."
./node_modules/.bin/prisma db seed
if [ $? -eq 0 ]; then
echo "${GREEN}✓ Database schema synced successfully${NC}"
else
echo "${RED}✗ Database schema sync failed!${NC}"
exit 1
fi fi
fi fi
# Optional: Run seed script if it exists and NODE_ENV is not production echo "Verifying database connection..."
if [ "$NODE_ENV" != "production" ] && [ -f "prisma/seed.js" ]; then if ! echo "SELECT 1" | ./node_modules/.bin/prisma db execute --stdin --schema prisma/schema.prisma >/dev/null 2>&1; then
echo "${YELLOW}Running database seed...${NC}" echo "Cannot connect to database or execute query."
npx prisma db seed
echo "${GREEN}✓ Database seeded successfully${NC}"
fi
# Verify database connection
echo "${YELLOW}Verifying database connection...${NC}"
npx prisma db execute --file /dev/null --schema prisma/schema.prisma 2>/dev/null
if [ $? -eq 0 ]; then
echo "${GREEN}✓ Database connection verified${NC}"
else
echo "${RED}✗ Cannot connect to database!${NC}"
exit 1 exit 1
fi fi
echo "Database connection OK"
# Print application information echo "Starting Dyolink Backend..."
echo "${GREEN}========================================${NC}" echo " Environment: ${NODE_ENV:-development}"
echo "${GREEN}Starting Dyolink Backend Application...${NC}" echo " Port: ${PORT:-3000}"
echo "${GREEN} • Environment: ${NODE_ENV:-development}${NC}"
echo "${GREEN} • Port: ${PORT:-3000}${NC}"
echo "${GREEN} • Database: ${DATABASE_URL%%@*}@***${NC}"
echo "${GREEN}========================================${NC}"
# Execute the main command (passed as CMD)
exec "$@" exec "$@"

16353
backend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -41,6 +41,7 @@
"@nestjs/swagger": "^11.2.6", "@nestjs/swagger": "^11.2.6",
"@nestjs/throttler": "^6.5.0", "@nestjs/throttler": "^6.5.0",
"@prisma/client": "^6.19.2", "@prisma/client": "^6.19.2",
"prisma": "^6.19.2",
"adminjs": "^7.8.17", "adminjs": "^7.8.17",
"axios": "^1.13.5", "axios": "^1.13.5",
"bcrypt": "^6.0.0", "bcrypt": "^6.0.0",
@@ -84,7 +85,6 @@
"globals": "^16.0.0", "globals": "^16.0.0",
"jest": "^30.0.0", "jest": "^30.0.0",
"prettier": "^3.4.2", "prettier": "^3.4.2",
"prisma": "^6.19.2",
"source-map-support": "^0.5.21", "source-map-support": "^0.5.21",
"supertest": "^7.0.0", "supertest": "^7.0.0",
"ts-jest": "^29.2.5", "ts-jest": "^29.2.5",
@@ -94,6 +94,10 @@
"typescript": "^5.7.3", "typescript": "^5.7.3",
"typescript-eslint": "^8.20.0" "typescript-eslint": "^8.20.0"
}, },
"overrides": {
"@tiptap/core": "2.27.2",
"@tiptap/pm": "2.27.2"
},
"jest": { "jest": {
"moduleFileExtensions": [ "moduleFileExtensions": [
"js", "js",

View File

@@ -0,0 +1,5 @@
-- DropForeignKey
ALTER TABLE "organizations" DROP CONSTRAINT "organizations_planId_fkey";
-- AddForeignKey
ALTER TABLE "organizations" ADD CONSTRAINT "organizations_planId_fkey" FOREIGN KEY ("planId") REFERENCES "plans"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -0,0 +1,3 @@
-- Allow organizations without an active subscription plan.
ALTER TABLE "organizations"
ALTER COLUMN "planId" DROP NOT NULL;

View File

@@ -0,0 +1,24 @@
-- Ensure Treatment feature and permissions exist for existing databases.
WITH treatment_feature AS (
INSERT INTO "features" ("id", "name")
VALUES (md5(random()::text || clock_timestamp()::text), 'Treatment')
ON CONFLICT ("name") DO UPDATE SET "name" = EXCLUDED."name"
RETURNING "id"
),
selected_feature AS (
SELECT "id" FROM treatment_feature
UNION ALL
SELECT f."id" FROM "features" f WHERE f."name" = 'Treatment' LIMIT 1
)
INSERT INTO "permissions" ("id", "name", "featureId")
SELECT md5(random()::text || clock_timestamp()::text), 'TAB_TREATMENT_READ', sf."id"
FROM selected_feature sf
ON CONFLICT ("name") DO NOTHING;
WITH treatment_feature AS (
SELECT "id" FROM "features" WHERE "name" = 'Treatment' LIMIT 1
)
INSERT INTO "permissions" ("id", "name", "featureId")
SELECT md5(random()::text || clock_timestamp()::text), 'TAB_TREATMENT_EDIT', tf."id"
FROM treatment_feature tf
ON CONFLICT ("name") DO NOTHING;

View File

@@ -52,8 +52,8 @@ model Organization {
owner User @relation("OrganizationOwner", fields: [ownerId], references: [id]) owner User @relation("OrganizationOwner", fields: [ownerId], references: [id])
memberships Membership[] memberships Membership[]
planId String planId String?
plan Plan @relation(fields: [planId], references: [id]) plan Plan? @relation(fields: [planId], references: [id])
sharedWithMe OrganizationLink[] @relation("OrganizationB") sharedWithMe OrganizationLink[] @relation("OrganizationB")
sharedWithOthers OrganizationLink[] @relation("OrganizationA") sharedWithOthers OrganizationLink[] @relation("OrganizationA")

View File

@@ -69,21 +69,25 @@ async function main() {
name: 'Today', name: 'Today',
permissions: ['TAB_TODAY_READ', 'TAB_TODAY_EDIT'], permissions: ['TAB_TODAY_READ', 'TAB_TODAY_EDIT'],
}, },
{
name: 'Staff',
permissions: ['TAB_STAFF_READ', 'TAB_STAFF_EDIT'],
},
{
name: 'Labs / Clinics',
permissions: ['TAB_LAB_READ', 'TAB_LAB_EDIT'],
},
{ {
name: 'Patients', name: 'Patients',
permissions: ['TAB_PATIENTS_READ', 'TAB_PATIENTS_EDIT'], permissions: ['TAB_PATIENTS_READ', 'TAB_PATIENTS_EDIT'],
}, },
{ {
name: 'Appointments', name: 'Appointment',
permissions: ['TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_EDIT'], permissions: ['TAB_APPOINTMENTS_READ', 'TAB_APPOINTMENTS_EDIT'],
}, },
{ {
name: 'Staff Management', name: 'Treatment',
permissions: ['TAB_STAFF_READ', 'TAB_STAFF_EDIT'], permissions: ['TAB_TREATMENT_READ', 'TAB_TREATMENT_EDIT'],
},
{
name: 'Lab Management',
permissions: ['TAB_LAB_READ', 'TAB_LAB_EDIT'],
}, },
{ {
name: 'Billing', name: 'Billing',

View File

@@ -22,4 +22,13 @@ export class AppController {
getHello(): string { getHello(): string {
return this.appService.getHello(); return this.appService.getHello();
} }
/** Used by Docker / load balancer health checks (GET /api/health) */
@Get('health')
health() {
return {
status: 'ok',
timestamp: new Date().toISOString(),
};
}
} }

View File

@@ -2,14 +2,16 @@
export const ALL_TAB_PERMISSIONS = [ export const ALL_TAB_PERMISSIONS = [
'TAB_TODAY_READ', 'TAB_TODAY_READ',
'TAB_TODAY_EDIT', 'TAB_TODAY_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
'TAB_APPOINTMENTS_EDIT',
'TAB_STAFF_READ', 'TAB_STAFF_READ',
'TAB_STAFF_EDIT', 'TAB_STAFF_EDIT',
'TAB_LAB_READ', 'TAB_LAB_READ',
'TAB_LAB_EDIT', 'TAB_LAB_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
'TAB_APPOINTMENTS_EDIT',
'TAB_TREATMENT_READ',
'TAB_TREATMENT_EDIT',
'TAB_BILLING_READ', 'TAB_BILLING_READ',
'TAB_BILLING_EDIT', 'TAB_BILLING_EDIT',
'TAB_REPORTS_READ', 'TAB_REPORTS_READ',
@@ -37,6 +39,7 @@ const EDIT_TO_READ: Record<string, string> = {
TAB_APPOINTMENTS_EDIT: 'TAB_APPOINTMENTS_READ', TAB_APPOINTMENTS_EDIT: 'TAB_APPOINTMENTS_READ',
TAB_STAFF_EDIT: 'TAB_STAFF_READ', TAB_STAFF_EDIT: 'TAB_STAFF_READ',
TAB_LAB_EDIT: 'TAB_LAB_READ', TAB_LAB_EDIT: 'TAB_LAB_READ',
TAB_TREATMENT_EDIT: 'TAB_TREATMENT_READ',
TAB_BILLING_EDIT: 'TAB_BILLING_READ', TAB_BILLING_EDIT: 'TAB_BILLING_READ',
TAB_REPORTS_EDIT: 'TAB_REPORTS_READ', TAB_REPORTS_EDIT: 'TAB_REPORTS_READ',
}; };

View File

@@ -18,20 +18,33 @@ import { JwtPayload } from './interfaces/jwt-payload.interface';
const ALL_PERMISSIONS = [ const ALL_PERMISSIONS = [
'TAB_TODAY_READ', 'TAB_TODAY_READ',
'TAB_TODAY_EDIT', 'TAB_TODAY_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
'TAB_APPOINTMENTS_EDIT',
'TAB_STAFF_READ', 'TAB_STAFF_READ',
'TAB_STAFF_EDIT', 'TAB_STAFF_EDIT',
'TAB_LAB_READ', 'TAB_LAB_READ',
'TAB_LAB_EDIT', 'TAB_LAB_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
'TAB_APPOINTMENTS_EDIT',
'TAB_TREATMENT_READ',
'TAB_TREATMENT_EDIT',
'TAB_BILLING_READ', 'TAB_BILLING_READ',
'TAB_BILLING_EDIT', 'TAB_BILLING_EDIT',
'TAB_REPORTS_READ', 'TAB_REPORTS_READ',
'TAB_REPORTS_EDIT', 'TAB_REPORTS_EDIT',
]; ];
const READ_ONLY_PERMISSIONS = [
'TAB_TODAY_READ',
'TAB_STAFF_READ',
'TAB_LAB_READ',
'TAB_PATIENTS_READ',
'TAB_APPOINTMENTS_READ',
'TAB_TREATMENT_READ',
'TAB_BILLING_READ',
'TAB_REPORTS_READ',
];
@Injectable() @Injectable()
export class AuthService { export class AuthService {
constructor( constructor(
@@ -141,9 +154,7 @@ export class AuthService {
name: membership.organization.name, name: membership.organization.name,
type: membership.organization.type.name, // 'CLINIC' or 'LAB' type: membership.organization.type.name, // 'CLINIC' or 'LAB'
isOwner: membership.isOwner, isOwner: membership.isOwner,
permissions: membership.isOwner permissions: this.getMembershipPermissions(membership),
? ALL_PERMISSIONS
: membership.permissions?.map(p => p.permission.name) || [],
plan: membership.organization.plan plan: membership.organization.plan
? { ? {
name: membership.organization.plan.name, name: membership.organization.plan.name,
@@ -253,16 +264,13 @@ export class AuthService {
async createOrganization(userId: string, dto: CreateOrganizationDto) { async createOrganization(userId: string, dto: CreateOrganizationDto) {
const owner = await this.prisma.user.findUnique({ const owner = await this.prisma.user.findUnique({
where: { id: userId }, where: { id: userId },
select: { id: true, trialUsedAt: true }, select: { id: true },
}); });
if (!owner) { if (!owner) {
throw new UnauthorizedException('User not found'); throw new UnauthorizedException('User not found');
} }
const planName = dto.planName?.trim() || 'Small';
const effectivePlanName = owner.trialUsedAt ? planName : 'trial';
const organization = await this.prisma.$transaction(async (tx) => { const organization = await this.prisma.$transaction(async (tx) => {
const createdOrganization = await tx.organization.create({ const createdOrganization = await tx.organization.create({
data: { data: {
@@ -271,9 +279,6 @@ export class AuthService {
owner: { owner: {
connect: { id: userId }, connect: { id: userId },
}, },
plan: {
connect: { name: effectivePlanName },
},
type: { type: {
connect: { name: dto.organizationType }, connect: { name: dto.organizationType },
}, },
@@ -287,14 +292,6 @@ export class AuthService {
isOwner: true, isOwner: true,
}, },
}); });
if (!owner.trialUsedAt) {
await tx.user.update({
where: { id: userId },
data: { trialUsedAt: new Date() },
});
}
return createdOrganization; return createdOrganization;
}); });
@@ -350,9 +347,7 @@ export class AuthService {
name: membership.organization.name, name: membership.organization.name,
type: membership.organization.type.name, type: membership.organization.type.name,
isOwner: membership.isOwner, isOwner: membership.isOwner,
permissions: membership.isOwner permissions: this.getMembershipPermissions(membership),
? ALL_PERMISSIONS
: membership.permissions?.map(p => p.permission.name) || [],
plan: membership.organization.plan plan: membership.organization.plan
? { ? {
name: membership.organization.plan.name, name: membership.organization.plan.name,
@@ -471,9 +466,7 @@ export class AuthService {
name: membership.organization.name, name: membership.organization.name,
type: membership.organization.type.name, type: membership.organization.type.name,
isOwner: membership.isOwner, isOwner: membership.isOwner,
permissions: membership.isOwner permissions: this.getMembershipPermissions(membership),
? ALL_PERMISSIONS
: membership.permissions?.map(p => p.permission.name) || [],
plan: membership.organization.plan plan: membership.organization.plan
? { ? {
name: membership.organization.plan.name, name: membership.organization.plan.name,
@@ -678,9 +671,7 @@ export class AuthService {
name: membership.organization.name, name: membership.organization.name,
type: membership.organization.type.name, type: membership.organization.type.name,
isOwner: membership.isOwner, isOwner: membership.isOwner,
permissions: membership.isOwner permissions: this.getMembershipPermissions(membership),
? ALL_PERMISSIONS
: membership.permissions?.map(p => p.permission.name) || [],
plan: membership.organization.plan plan: membership.organization.plan
? { ? {
name: membership.organization.plan.name, name: membership.organization.plan.name,
@@ -747,9 +738,7 @@ export class AuthService {
}); });
// 4. Format permissions // 4. Format permissions
const permissions = membership.isOwner const permissions = this.getMembershipPermissions(membership);
? ALL_PERMISSIONS
: membership.permissions.map(p => p.permission.name);
return { return {
success: true, success: true,
@@ -789,6 +778,19 @@ export class AuthService {
return memberships.filter((m) => m.isOwner || m.isActive); return memberships.filter((m) => m.isOwner || m.isActive);
} }
private getMembershipPermissions(membership: {
isOwner: boolean;
organization: {
plan?: { name: string; maxUsers: number; price: number } | null;
};
permissions?: Array<{ permission: { name: string } }>;
}): string[] {
if (membership.isOwner) {
return membership.organization.plan ? ALL_PERMISSIONS : READ_ONLY_PERMISSIONS;
}
return membership.permissions?.map((p) => p.permission.name) || [];
}
/** /**
* Owner-only subscription / seat alerts for the current org (from JWT). * Owner-only subscription / seat alerts for the current org (from JWT).
* Used for a subtle warning indicator in the app shell (not staff-facing banners). * Used for a subtle warning indicator in the app shell (not staff-facing banners).
@@ -799,6 +801,7 @@ export class AuthService {
success: true, success: true,
data: { data: {
showWarning: false, showWarning: false,
noActiveSubscription: false,
seatsLow: false, seatsLow: false,
trialEndingSoon: false, trialEndingSoon: false,
trialExpired: false, trialExpired: false,
@@ -822,6 +825,7 @@ export class AuthService {
success: true, success: true,
data: { data: {
showWarning: false, showWarning: false,
noActiveSubscription: false,
seatsLow: false, seatsLow: false,
trialEndingSoon: false, trialEndingSoon: false,
trialExpired: false, trialExpired: false,
@@ -833,6 +837,24 @@ export class AuthService {
const org = membership.organization; const org = membership.organization;
const plan = org.plan; const plan = org.plan;
if (!plan) {
return {
success: true,
data: {
showWarning: true,
noActiveSubscription: true,
seatsLow: false,
trialEndingSoon: false,
trialExpired: false,
seatsUsed: 0,
seatsLimit: null,
daysUntilTrialEnd: null,
trialEndsAt: null,
daysUntilPlanEnd: null,
planEndsAt: null,
},
};
}
const maxUsers = plan.maxUsers; const maxUsers = plan.maxUsers;
const seatsUsed = await this.prisma.membership.count({ const seatsUsed = await this.prisma.membership.count({
where: { where: {
@@ -863,6 +885,7 @@ export class AuthService {
success: true, success: true,
data: { data: {
showWarning, showWarning,
noActiveSubscription: false,
seatsLow, seatsLow,
trialEndingSoon, trialEndingSoon,
trialExpired, trialExpired,

View File

@@ -1,5 +1,4 @@
import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common'; import { BadRequestException, Injectable, NotFoundException } from '@nestjs/common';
import { Prisma } from '@prisma/client';
import { PrismaService } from '../../../prisma/prisma.service'; import { PrismaService } from '../../../prisma/prisma.service';
import { CreatePatientDto } from './dto/create-patient.dto'; import { CreatePatientDto } from './dto/create-patient.dto';
import { ListPatientsDto } from './dto/list-patients.dto'; import { ListPatientsDto } from './dto/list-patients.dto';
@@ -26,15 +25,15 @@ export class PatientsService {
const { page = 1, limit = 10, q } = query; const { page = 1, limit = 10, q } = query;
const skip = (page - 1) * limit; const skip = (page - 1) * limit;
const where: Prisma.PatientWhereInput = { const where = {
organizationId, organizationId,
...(q ...(q
? { ? {
OR: [ OR: [
{ firstName: { contains: q, mode: 'insensitive' } }, { firstName: { contains: q, mode: 'insensitive' as const } },
{ lastName: { contains: q, mode: 'insensitive' } }, { lastName: { contains: q, mode: 'insensitive' as const } },
{ email: { contains: q, mode: 'insensitive' } }, { email: { contains: q, mode: 'insensitive' as const } },
{ phone: { contains: q, mode: 'insensitive' } }, { phone: { contains: q, mode: 'insensitive' as const } },
], ],
} }
: {}), : {}),

View File

@@ -59,7 +59,7 @@ export class StaffService {
}), }),
]); ]);
const maxUsers = org.plan.maxUsers; const maxUsers = org.plan?.maxUsers ?? 0;
const unlimited = isUnlimitedSeats(maxUsers); const unlimited = isUnlimitedSeats(maxUsers);
return { return {
@@ -119,6 +119,12 @@ export class StaffService {
throw new NotFoundException('Organization not found'); throw new NotFoundException('Organization not found');
} }
if (!org.plan) {
throw new BadRequestException(
'This organization has no active subscription. Please choose a plan before inviting staff.',
);
}
const maxUsers = org.plan.maxUsers; const maxUsers = org.plan.maxUsers;
const seatsUsed = await tx.membership.count({ const seatsUsed = await tx.membership.count({
where: { where: {
@@ -362,7 +368,10 @@ export class StaffService {
private async getActorMembership(userId: string, organizationId: string) { private async getActorMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({ return this.prisma.membership.findFirst({
where: { userId, organizationId }, where: { userId, organizationId },
include: { permissions: { include: { permission: true } } }, include: {
permissions: { include: { permission: true } },
organization: { select: { planId: true } },
},
}); });
} }
@@ -424,6 +433,7 @@ export class StaffService {
private canViewStaff(m: { private canViewStaff(m: {
isOwner: boolean; isOwner: boolean;
organization?: { planId: string | null };
permissions: { permission: { name: string } }[]; permissions: { permission: { name: string } }[];
}): boolean { }): boolean {
if (m.isOwner) return true; if (m.isOwner) return true;
@@ -435,9 +445,10 @@ export class StaffService {
private canEditStaff(m: { private canEditStaff(m: {
isOwner: boolean; isOwner: boolean;
organization?: { planId: string | null };
permissions: { permission: { name: string } }[]; permissions: { permission: { name: string } }[];
}): boolean { }): boolean {
if (m.isOwner) return true; if (m.isOwner) return Boolean(m.organization?.planId);
return m.permissions.some((p) => p.permission.name === 'TAB_STAFF_EDIT'); return m.permissions.some((p) => p.permission.name === 'TAB_STAFF_EDIT');
} }
} }

View File

@@ -1,5 +1,6 @@
{ {
"compilerOptions": { "compilerOptions": {
"types": ["node", "jest"],
"module": "nodenext", "module": "nodenext",
"moduleResolution": "nodenext", "moduleResolution": "nodenext",
"resolvePackageJsonExports": true, "resolvePackageJsonExports": true,

View File

@@ -1,72 +1,53 @@
# Build stage # Build stage — produces `.next/standalone` (see next.config.ts output: standalone)
FROM node:18-alpine AS builder FROM node:20-alpine AS builder
WORKDIR /app WORKDIR /app
# Copy package files
COPY package*.json ./ COPY package*.json ./
RUN npm ci RUN npm ci
# Copy source code
COPY . . COPY . .
# Set build-time environment variables ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_APP_URL
ARG NEXT_PUBLIC_APP_NAME
ENV NEXT_TELEMETRY_DISABLED=1 ENV NEXT_TELEMETRY_DISABLED=1
ENV NODE_ENV=production ENV NODE_ENV=production
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
ENV NEXT_PUBLIC_APP_URL=${NEXT_PUBLIC_APP_URL}
ENV NEXT_PUBLIC_APP_NAME=${NEXT_PUBLIC_APP_NAME}
# Build Next.js application
RUN npm run build RUN npm run build
# Production stage # Production — minimal runtime using Next.js standalone bundle
FROM node:18-alpine FROM node:20-alpine AS runner
RUN apk add --no-cache dumb-init
WORKDIR /app WORKDIR /app
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Create non-root user
RUN addgroup -g 1001 -S nodejs && \ RUN addgroup -g 1001 -S nodejs && \
adduser -S dyolink -u 1001 adduser -S dyolink -u 1001
# Copy package files ENV NODE_ENV=production
COPY package*.json ./ ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Install production dependencies only
RUN npm ci --only=production && \
npm cache clean --force
# Copy built application
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public COPY --from=builder /app/public ./public
COPY --from=builder /app/next.config.js ./next.config.js COPY --from=builder --chown=dyolink:nodejs /app/.next/standalone ./
COPY --from=builder /app/package.json ./package.json COPY --from=builder --chown=dyolink:nodejs /app/.next/static ./.next/static
# Create logs directory COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN mkdir -p /app/logs && \ RUN chmod +x /usr/local/bin/docker-entrypoint.sh
chown -R dyolink:nodejs /app
# Set ownership
RUN chown -R dyolink:nodejs /app
# Switch to non-root user
USER dyolink USER dyolink
# Health check
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://localhost:3000', (r) => {if(r.statusCode!==200)throw new Error()})" || exit 1
EXPOSE 3000 EXPOSE 3000
ENV PORT=3000 HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
ENV HOSTNAME="0.0.0.0" CMD node -e "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"
ENV NODE_ENV=production
# Copy entrypoint script
COPY docker-entrypoint.sh /usr/local/bin/
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Use dumb-init for signal handling
ENTRYPOINT ["dumb-init", "--", "docker-entrypoint.sh"] ENTRYPOINT ["dumb-init", "--", "docker-entrypoint.sh"]
CMD ["npm", "start"] CMD ["node", "server.js"]

View File

@@ -1,36 +1,63 @@
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app). # Dyolink — Frontend (Next.js)
## Getting Started ## Prerequisites
First, run the development server: - **Node.js 20+** and **npm**
## First-time setup
1. **Clone the monorepo** and go to the frontend app:
```bash
git clone <repository-url> dyolink
cd dyolink/frontend
```
2. **Install dependencies**
```bash
npm install
```
3. **Environment**
Create **`.env.local`** in this folder (not committed to git) with the public URLs your browser will use:
```env
NEXT_PUBLIC_APP_URL=http://localhost:3001
NEXT_PUBLIC_API_URL=http://localhost:3000/api
NEXT_PUBLIC_APP_NAME=Dyolink
```
- Adjust **`NEXT_PUBLIC_API_URL`** if the Nest API runs on another host/port.
- These values are baked in at **build time** for production images; for local dev, restart `npm run dev` after changing them.
## Run (development)
```bash ```bash
npm run dev npm run dev
# or
yarn dev
# or
pnpm dev
# or
bun dev
``` ```
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result. Open **`http://localhost:3001`** (dev server uses port **3001** so it does not clash with the API on 3000).
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file. Ensure the backend is running and `NEXT_PUBLIC_API_URL` matches its base URL (including `/api` if your API is mounted there).
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel. ## After pulling latest `main`
## Learn More ```bash
git pull
npm install
```
To learn more about Next.js, take a look at the following resources: ## Useful commands
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API. | Command | Purpose |
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial. |--------|---------|
| `npm run dev` | Development server (port 3001) |
| `npm run build` | Production build |
| `npm run start` | Serve production build (port 3000 — used inside Docker) |
| `npm run lint` | ESLint |
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome! ## Docker
## Deploy on Vercel Image build and build-args (`NEXT_PUBLIC_*`) are documented in the **repository root `README.md`**.
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.

View File

@@ -1,6 +1,17 @@
import type { NextConfig } from "next"; import type { NextConfig } from "next";
// frontend/next.config.js function publicAppHostname(): string | null {
const url = process.env.NEXT_PUBLIC_APP_URL;
if (!url) return null;
try {
return new URL(url).hostname;
} catch {
return null;
}
}
const appHost = publicAppHostname();
/** @type {import('next').NextConfig} */ /** @type {import('next').NextConfig} */
const nextConfig = { const nextConfig = {
// Enable React strict mode // Enable React strict mode
@@ -9,12 +20,19 @@ const nextConfig = {
// Disable x-powered-by header for security // Disable x-powered-by header for security
poweredByHeader: false, poweredByHeader: false,
// Configure allowed remote image sources // Configure allowed remote image sources (hostname derived from NEXT_PUBLIC_APP_URL at build time)
images: { images: {
remotePatterns: remotePatterns: [
process.env.NODE_ENV === 'production' { protocol: "http", hostname: "localhost" },
? [{ protocol: 'https', hostname: 'yourdomain.com' }] ...(appHost
: [{ protocol: 'http', hostname: 'localhost' }], ? [
{ protocol: "http" as const, hostname: appHost },
{ protocol: "https" as const, hostname: appHost },
]
: []),
{ protocol: "https", hostname: "dyolink.com" },
{ protocol: "https", hostname: "www.dyolink.com" },
],
}, },
// Environment variables that will be available at build time // Environment variables that will be available at build time

6797
frontend/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -5,7 +5,7 @@
"scripts": { "scripts": {
"dev": "next dev -p 3001", "dev": "next dev -p 3001",
"build": "next build", "build": "next build",
"start": "next start -p 3001", "start": "next start -p 3000",
"lint": "next lint" "lint": "next lint"
}, },
"dependencies": { "dependencies": {

View File

@@ -5,6 +5,8 @@ import { Search, Filter, Plus } from 'lucide-react';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input'; import { Input } from '@/components/ui/common/Input';
import { Badge } from '@/components/ui/common/Badge'; import { Badge } from '@/components/ui/common/Badge';
import { useAuth } from '@/lib/hooks/useAuth';
import { hasPermission } from '@/shared/permissions';
// Mock data matching your design // Mock data matching your design
const invoices = [ const invoices = [
{ id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' }, { id: '#123456', patient: 'Ali Rahmani', date: '24/9/2026', service: 'Hygiene', amount: 300, paid: 0, status: 'unpaid' },
@@ -25,8 +27,10 @@ interface StatCardProps {
color: StatCardColor; color: StatCardColor;
} }
export default function BillingPage() { export default function BillingPage() {
const { currentOrganization } = useAuth();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [statusFilter, setStatusFilter] = useState('all'); const [statusFilter, setStatusFilter] = useState('all');
const canEditBilling = hasPermission(currentOrganization, 'TAB_BILLING_EDIT');
const stats = { const stats = {
total: { count: 235, amount: 80900 }, total: { count: 235, amount: 80900 },
unpaid: { count: 30, amount: 2800 }, unpaid: { count: 30, amount: 2800 },
@@ -38,7 +42,12 @@ export default function BillingPage() {
{/* Header */} {/* Header */}
<div className="flex justify-between items-center"> <div className="flex justify-between items-center">
<h1 className="text-2xl font-semibold text-text-primary">Billing</h1> <h1 className="text-2xl font-semibold text-text-primary">Billing</h1>
<Button variant="primary" className="flex items-center gap-2"> <Button
variant="primary"
className="flex items-center gap-2"
disabled={!canEditBilling}
title={!canEditBilling ? 'Read-only access for this organization.' : undefined}
>
<Plus className="h-4 w-4 icon-flat" /> <Plus className="h-4 w-4 icon-flat" />
New Invoice New Invoice
</Button> </Button>
@@ -158,7 +167,11 @@ export default function BillingPage() {
</Badge> </Badge>
</td> </td>
<td className="px-6 py-4"> <td className="px-6 py-4">
<button className="text-primary hover:opacity-90 text-sm"> <button
className={`text-sm ${canEditBilling ? 'text-primary hover:opacity-90' : 'text-text-muted cursor-not-allowed'}`}
disabled={!canEditBilling}
title={!canEditBilling ? 'Read-only access for this organization.' : undefined}
>
Edit Edit
</button> </button>
</td> </td>

View File

@@ -4,6 +4,8 @@ import { useEffect, useMemo, useState } from 'react';
import { Plus } from 'lucide-react'; import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/common/Button'; import { Button } from '@/components/ui/common/Button';
import { patientsApi } from '@/lib/api/patients'; import { patientsApi } from '@/lib/api/patients';
import { useAuth } from '@/lib/hooks/useAuth';
import { hasPermission } from '@/shared/permissions';
import { import {
CreatePatientInput, CreatePatientInput,
CreateTreatmentHistoryInput, CreateTreatmentHistoryInput,
@@ -23,6 +25,7 @@ const EMPTY_PATIENT_FORM: CreatePatientInput = {
}; };
export default function PatientsPage() { export default function PatientsPage() {
const { currentOrganization } = useAuth();
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [patients, setPatients] = useState<Patient[]>([]); const [patients, setPatients] = useState<Patient[]>([]);
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>(); const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
@@ -35,6 +38,7 @@ export default function PatientsPage() {
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM); const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
const [errorMessage, setErrorMessage] = useState<string>(''); const [errorMessage, setErrorMessage] = useState<string>('');
const [successMessage, setSuccessMessage] = useState<string>(''); const [successMessage, setSuccessMessage] = useState<string>('');
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
const sortedPatients = useMemo( const sortedPatients = useMemo(
() => () =>
@@ -154,7 +158,16 @@ export default function PatientsPage() {
<div className="relative space-y-6 pb-20"> <div className="relative space-y-6 pb-20">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h1 className="text-2xl font-semibold text-text-primary">Patients</h1> <h1 className="text-2xl font-semibold text-text-primary">Patients</h1>
<Button variant="primary" className="flex items-center gap-2" onClick={() => setIsCreateOpen(true)}> <Button
variant="primary"
className="flex items-center gap-2"
disabled={!canEditPatients}
onClick={() => {
if (!canEditPatients) return;
setIsCreateOpen(true);
}}
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
>
<Plus className="h-4 w-4 icon-flat" /> <Plus className="h-4 w-4 icon-flat" />
New Patient New Patient
</Button> </Button>
@@ -189,9 +202,13 @@ export default function PatientsPage() {
<div className="flex"> <div className="flex">
<Button <Button
variant="secondary" variant="secondary"
disabled={!selectedPatient} disabled={!selectedPatient || !canEditPatients}
isLoading={savingTreatment} isLoading={savingTreatment}
onClick={handleQuickAddTreatment} onClick={() => {
if (!canEditPatients) return;
void handleQuickAddTreatment();
}}
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
> >
Add Quick Treatment Entry Add Quick Treatment Entry
</Button> </Button>

View File

@@ -4,7 +4,7 @@ import Link from 'next/link';
export default function AccountSettingsPage() { export default function AccountSettingsPage() {
return ( return (
<div className="max-w-xl space-y-6"> <div className="space-y-6">
<div> <div>
<Link <Link
href="/today" href="/today"

View File

@@ -5,7 +5,7 @@ import { OrganizationSelectorContent } from '@/components/ui/organization/Organi
export default function DashboardOrganizationsSettingsPage() { export default function DashboardOrganizationsSettingsPage() {
return ( return (
<div className="max-w-3xl space-y-6"> <div className="space-y-6">
<div> <div>
<Link <Link
href="/today" href="/today"

View File

@@ -7,10 +7,20 @@ import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth'; import { authApi } from '@/lib/api/auth';
import type { SubscriptionAlertData } from '@/types/subscription'; import type { SubscriptionAlertData } from '@/types/subscription';
const PLAN_OPTIONS = [
{ id: 'solo', name: 'Solo', maxUsers: 1, price: 19 },
{ id: 'small', name: 'Small', maxUsers: 5, price: 49 },
{ id: 'medium', name: 'Medium', maxUsers: 10, price: 89 },
{ id: 'large', name: 'Large', maxUsers: 15, price: 129 },
{ id: 'enterprise', name: 'Enterprise', maxUsers: null, price: 199 },
] as const;
export default function SubscriptionsSettingsPage() { export default function SubscriptionsSettingsPage() {
const { currentOrganization } = useAuth(); const { currentOrganization } = useAuth();
const router = useRouter(); const router = useRouter();
const [alert, setAlert] = useState<SubscriptionAlertData | null>(null); const [alert, setAlert] = useState<SubscriptionAlertData | null>(null);
const [selectedPlanId, setSelectedPlanId] = useState<string>(PLAN_OPTIONS[0].id);
const [purchaseNotice, setPurchaseNotice] = useState<string | null>(null);
useEffect(() => { useEffect(() => {
if (currentOrganization && !currentOrganization.isOwner) { if (currentOrganization && !currentOrganization.isOwner) {
@@ -38,6 +48,8 @@ export default function SubscriptionsSettingsPage() {
} }
const plan = currentOrganization.plan; const plan = currentOrganization.plan;
const hasActiveSubscription = Boolean(plan);
const selectedPlan = PLAN_OPTIONS.find((option) => option.id === selectedPlanId);
const maxUsers = plan?.maxUsers; const maxUsers = plan?.maxUsers;
const isUnlimited = typeof maxUsers === 'number' && maxUsers >= 999999; const isUnlimited = typeof maxUsers === 'number' && maxUsers >= 999999;
const seatsUsed = alert?.seatsUsed; const seatsUsed = alert?.seatsUsed;
@@ -56,7 +68,7 @@ export default function SubscriptionsSettingsPage() {
: 'text-red-400'; : 'text-red-400';
return ( return (
<div className="max-w-4xl space-y-6"> <div className="space-y-6">
<div> <div>
<Link <Link
href="/today" href="/today"
@@ -74,6 +86,15 @@ export default function SubscriptionsSettingsPage() {
</div> </div>
<div className="surface-card p-6 space-y-4"> <div className="surface-card p-6 space-y-4">
{!hasActiveSubscription && (
<div className="rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
<p className="text-sm text-amber-200">
This organization has no active subscription. Select a plan below to start
the purchase process.
</p>
</div>
)}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5"> <div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-5">
<div> <div>
<p className="text-xs text-text-muted uppercase tracking-wide">Current plan</p> <p className="text-xs text-text-muted uppercase tracking-wide">Current plan</p>
@@ -110,6 +131,9 @@ export default function SubscriptionsSettingsPage() {
{alert?.showWarning && ( {alert?.showWarning && (
<div className="text-sm text-text-secondary space-y-1"> <div className="text-sm text-text-secondary space-y-1">
{alert.noActiveSubscription && (
<p>No active subscription for this organization.</p>
)}
{alert.trialExpired && ( {alert.trialExpired && (
<p>Trial period has ended. Choose a plan when checkout is available.</p> <p>Trial period has ended. Choose a plan when checkout is available.</p>
)} )}
@@ -124,11 +148,52 @@ export default function SubscriptionsSettingsPage() {
</div> </div>
)} )}
<div className="space-y-3 pt-2">
<p className="text-sm text-text-secondary"> <p className="text-sm text-text-secondary">
Payment and plan upgrades will connect here. The warning on the settings Choose a plan to continue. Purchase integration is not active yet, so this
icon is only shown to workspace owners when seats are low or the trial window currently prepares the selection step only.
is ending.
</p> </p>
<div className="grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
{PLAN_OPTIONS.map((option) => {
const selected = selectedPlanId === option.id;
return (
<button
key={option.id}
type="button"
onClick={() => setSelectedPlanId(option.id)}
className={`rounded-[var(--radius-md)] border p-4 text-left transition-colors ${
selected
? 'border-primary/70 bg-primary-soft'
: 'border-border hover:border-border-strong'
}`}
>
<p className="text-base font-medium text-text-primary">{option.name}</p>
<p className="text-sm text-text-secondary mt-1">
{option.maxUsers == null ? 'Unlimited seats' : `${option.maxUsers} seats`}
</p>
<p className="text-sm text-text-secondary mt-1">${option.price} / month</p>
</button>
);
})}
</div>
<button
type="button"
className="inline-flex items-center justify-center rounded-[var(--radius-md)] bg-primary px-4 py-2 text-sm font-medium text-white hover:opacity-90 disabled:opacity-60"
onClick={() => {
const selectedPlanLabel = selectedPlan?.name ?? 'the selected plan';
setPurchaseNotice(
`Purchase flow will be enabled soon. ${selectedPlanLabel} is selected and ready for checkout setup.`,
);
}}
>
Start purchase process
</button>
{purchaseNotice && (
<div className="rounded-[var(--radius-md)] border border-emerald-500/30 bg-emerald-500/10 px-4 py-3">
<p className="text-sm text-emerald-200">{purchaseNotice}</p>
</div>
)}
</div>
</div> </div>
</div> </div>
); );

View File

@@ -1,10 +1,11 @@
/** Feature groups for staff invite/edit UI — matches backend seed */ /** Feature groups for staff invite/edit UI — matches backend seed */
export const STAFF_FEATURE_GROUPS = [ export const STAFF_FEATURE_GROUPS = [
{ label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' }, { label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
{ label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
{ label: 'Labs / Clinics', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' },
{ label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' }, { label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
{ label: 'Appointments', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, { label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
{ label: 'Staff Management', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' }, { label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },
{ label: 'Lab Management', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' },
{ label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' }, { label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' },
{ label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' }, { label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' },
] as const; ] as const;

View File

@@ -12,6 +12,7 @@ import {
permissionNamesFromFeatureState, permissionNamesFromFeatureState,
emptyFeaturePermissionState, emptyFeaturePermissionState,
featureStateFromPermissionNames, featureStateFromPermissionNames,
resolveStaffFeatureLabel,
formatAccessSummary, formatAccessSummary,
type FeaturePermState, type FeaturePermState,
} from './staff-permission-form'; } from './staff-permission-form';
@@ -23,6 +24,33 @@ import { Input } from '@/components/ui/common/Input';
import { Checkbox } from '@/components/ui/common/Checkbox'; import { Checkbox } from '@/components/ui/common/Checkbox';
import type { ApiError } from '@/types/api'; import type { ApiError } from '@/types/api';
type StoredInviteLink = {
membershipId: string;
email: string;
invitationUrl: string;
};
function inviteLinksStorageKey(orgId: string): string {
return `staffInviteLinks:${orgId}`;
}
function readStoredInviteLinks(orgId: string): Record<string, StoredInviteLink> {
if (typeof window === 'undefined') return {};
try {
const raw = window.localStorage.getItem(inviteLinksStorageKey(orgId));
if (!raw) return {};
const parsed = JSON.parse(raw) as Record<string, StoredInviteLink>;
return parsed && typeof parsed === 'object' ? parsed : {};
} catch {
return {};
}
}
function writeStoredInviteLinks(orgId: string, links: Record<string, StoredInviteLink>) {
if (typeof window === 'undefined') return;
window.localStorage.setItem(inviteLinksStorageKey(orgId), JSON.stringify(links));
}
function formatApiMessage(err: unknown): string { function formatApiMessage(err: unknown): string {
if (!err || typeof err !== 'object') return 'Something went wrong'; if (!err || typeof err !== 'object') return 'Something went wrong';
const m = (err as ApiError).message; const m = (err as ApiError).message;
@@ -35,10 +63,12 @@ function PermissionGrid({
state, state,
onChange, onChange,
disabled, disabled,
organizationType,
}: { }: {
state: FeaturePermState; state: FeaturePermState;
onChange: (next: FeaturePermState) => void; onChange: (next: FeaturePermState) => void;
disabled?: boolean; disabled?: boolean;
organizationType?: 'CLINIC' | 'LAB';
}) { }) {
const setRead = (editKey: string, read: boolean) => { const setRead = (editKey: string, read: boolean) => {
const cur = state[editKey] ?? { read: false, edit: false }; const cur = state[editKey] ?? { read: false, edit: false };
@@ -65,7 +95,9 @@ function PermissionGrid({
key={g.edit} key={g.edit}
className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-border/60 bg-background-card/50 px-3 py-3" className="flex flex-col gap-3 rounded-[var(--radius-md)] border border-border/60 bg-background-card/50 px-3 py-3"
> >
<span className="text-sm font-medium text-text-primary">{g.label}</span> <span className="text-sm font-medium text-text-primary">
{resolveStaffFeatureLabel(g, organizationType)}
</span>
<div className="flex flex-col gap-2.5 pl-0.5"> <div className="flex flex-col gap-2.5 pl-0.5">
<Checkbox <Checkbox
checked={cell.read} checked={cell.read}
@@ -105,13 +137,15 @@ export default function StaffPage() {
const [inviteName, setInviteName] = useState(''); const [inviteName, setInviteName] = useState('');
const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState()); const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState());
const [inviteLoading, setInviteLoading] = useState(false); const [inviteLoading, setInviteLoading] = useState(false);
const [copiedInviteLink, setCopiedInviteLink] = useState(false); const [copiedInviteMembershipId, setCopiedInviteMembershipId] = useState<string | null>(null);
const [lastInviteInfo, setLastInviteInfo] = useState<{ const [lastInviteInfo, setLastInviteInfo] = useState<{
membershipId: string;
name: string; name: string;
email: string; email: string;
invitationUrl: string | null; invitationUrl: string | null;
invitationStatus: 'PENDING' | 'ACCEPTED'; invitationStatus: 'PENDING' | 'ACCEPTED';
} | null>(null); } | null>(null);
const [pendingInviteLinks, setPendingInviteLinks] = useState<Record<string, StoredInviteLink>>({});
const [editing, setEditing] = useState<StaffMemberDto | null>(null); const [editing, setEditing] = useState<StaffMemberDto | null>(null);
const [editName, setEditName] = useState(''); const [editName, setEditName] = useState('');
@@ -119,6 +153,7 @@ export default function StaffPage() {
const [editLoading, setEditLoading] = useState(false); const [editLoading, setEditLoading] = useState(false);
const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]); const canEdit = useMemo(() => canEditStaff(currentOrganization), [currentOrganization]);
const hasActivePlan = Boolean(currentOrganization?.plan);
const atSeatLimit = useMemo(() => { const atSeatLimit = useMemo(() => {
if (!seats || seats.unlimited) return false; if (!seats || seats.unlimited) return false;
if (seats.limit == null) return false; if (seats.limit == null) return false;
@@ -139,6 +174,34 @@ export default function StaffPage() {
} }
}, []); }, []);
useEffect(() => {
if (!currentOrganization?.id) return;
setPendingInviteLinks(readStoredInviteLinks(currentOrganization.id));
}, [currentOrganization?.id]);
useEffect(() => {
if (!currentOrganization?.id || loading) return;
const activeMemberIds = new Set(
members
.filter((m) => m.isOwner || m.invitationStatus === 'ACTIVE')
.map((m) => m.id),
);
let changed = false;
const nextLinks: Record<string, StoredInviteLink> = { ...pendingInviteLinks };
for (const memberId of Object.keys(nextLinks)) {
if (activeMemberIds.has(memberId)) {
delete nextLinks[memberId];
changed = true;
}
}
if (!changed) return;
setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}, [currentOrganization?.id, loading, members, pendingInviteLinks]);
useEffect(() => { useEffect(() => {
void load(); void load();
}, [load]); }, [load]);
@@ -170,11 +233,24 @@ export default function StaffPage() {
permissionNames, permissionNames,
}); });
setLastInviteInfo({ setLastInviteInfo({
membershipId: res.data.membershipId,
name: displayName, name: displayName,
email: res.data.email, email: res.data.email,
invitationUrl: res.data.invitationUrl, invitationUrl: res.data.invitationUrl,
invitationStatus: res.data.invitationStatus, invitationStatus: res.data.invitationStatus,
}); });
if (currentOrganization?.id && res.data.invitationUrl) {
const nextLinks = {
...pendingInviteLinks,
[res.data.membershipId]: {
membershipId: res.data.membershipId,
email: res.data.email,
invitationUrl: res.data.invitationUrl,
},
};
setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}
setSuccess(''); setSuccess('');
setInviteOpen(false); setInviteOpen(false);
setInviteEmail(''); setInviteEmail('');
@@ -240,7 +316,7 @@ export default function StaffPage() {
} }
return ( return (
<div className="space-y-6 max-w-5xl"> <div className="space-y-6">
<div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between"> <div className="flex flex-col gap-3 sm:flex-row sm:items-start sm:justify-between">
<div> <div>
<h1 className="text-2xl font-semibold text-text-primary">Staff Management</h1> <h1 className="text-2xl font-semibold text-text-primary">Staff Management</h1>
@@ -248,20 +324,20 @@ export default function StaffPage() {
Invite teammates, set tab access, and stay within your plan seat limit. Invite teammates, set tab access, and stay within your plan seat limit.
</p> </p>
</div> </div>
{canEdit && (
<Button <Button
size="sm" size="sm"
onClick={() => { onClick={() => {
if (!canEdit || atSeatLimit) return;
setInviteOpen(true); setInviteOpen(true);
setLastInviteInfo(null); setLastInviteInfo(null);
}} }}
disabled={atSeatLimit} disabled={!canEdit || atSeatLimit}
className="shrink-0" className="shrink-0"
title={!canEdit ? 'Read-only access for this organization.' : undefined}
> >
<UserPlus className="w-4 h-4 mr-2" /> <UserPlus className="w-4 h-4 mr-2" />
Invite member Invite member
</Button> </Button>
)}
</div> </div>
{seats && ( {seats && (
@@ -273,7 +349,9 @@ export default function StaffPage() {
</span> </span>
{!seats.unlimited && atSeatLimit && ( {!seats.unlimited && atSeatLimit && (
<span className="text-amber-600 dark:text-amber-400 ml-2"> <span className="text-amber-600 dark:text-amber-400 ml-2">
Limit reached remove a member or upgrade your plan. {hasActivePlan
? 'Plan seat limit reached for this organization.'
: 'No active plan selected for this organization. Choose a subscription plan to invite members.'}
</span> </span>
)} )}
</p> </p>
@@ -325,15 +403,21 @@ export default function StaffPage() {
onClick={async () => { onClick={async () => {
try { try {
await navigator.clipboard.writeText(lastInviteInfo.invitationUrl as string); await navigator.clipboard.writeText(lastInviteInfo.invitationUrl as string);
setCopiedInviteLink(true); setCopiedInviteMembershipId(lastInviteInfo.membershipId);
setTimeout(() => setCopiedInviteLink(false), 1500); setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch { } catch {
setError('Could not copy invitation link'); setError('Could not copy invitation link');
} }
}} }}
> >
{copiedInviteLink ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />} {copiedInviteMembershipId === lastInviteInfo.membershipId ? (
<span className="ml-1">{copiedInviteLink ? 'Copied' : 'Copy link'}</span> <Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
<span className="ml-1">
{copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'}
</span>
</Button> </Button>
</div> </div>
<p className="text-xs text-text-muted"> <p className="text-xs text-text-muted">
@@ -356,12 +440,12 @@ export default function StaffPage() {
<th className="p-3 font-medium">Role</th> <th className="p-3 font-medium">Role</th>
<th className="p-3 font-medium">Status</th> <th className="p-3 font-medium">Status</th>
<th className="p-3 font-medium">Access</th> <th className="p-3 font-medium">Access</th>
{canEdit && <th className="p-3 font-medium w-28">Actions</th>} <th className="p-3 font-medium w-28">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{members.map((m) => ( {members.map((m) => (
<tr key={m.id} className="border-b border-border/40 last:border-0"> <tr key={m.id} className="h-14 border-b border-border/40 last:border-0">
<td className="p-3 text-text-primary">{m.name}</td> <td className="p-3 text-text-primary">{m.name}</td>
<td className="p-3 text-text-secondary">{m.email}</td> <td className="p-3 text-text-secondary">{m.email}</td>
<td className="p-3"> <td className="p-3">
@@ -371,7 +455,7 @@ export default function StaffPage() {
<span className="text-text-secondary">Staff</span> <span className="text-text-secondary">Staff</span>
)} )}
</td> </td>
<td className="p-3"> <td className="p-3 align-middle">
{m.isOwner || m.invitationStatus === 'ACTIVE' ? ( {m.isOwner || m.invitationStatus === 'ACTIVE' ? (
<span className="inline-flex items-center rounded-full border border-emerald-600/40 bg-emerald-600/15 px-2 py-0.5 text-xs text-emerald-400"> <span className="inline-flex items-center rounded-full border border-emerald-600/40 bg-emerald-600/15 px-2 py-0.5 text-xs text-emerald-400">
Active Active
@@ -392,34 +476,71 @@ export default function StaffPage() {
<span className="text-text-muted">All features</span> <span className="text-text-muted">All features</span>
) : ( ) : (
<span className="line-clamp-3 text-sm leading-relaxed"> <span className="line-clamp-3 text-sm leading-relaxed">
{formatAccessSummary(m.permissions)} {formatAccessSummary(m.permissions, currentOrganization?.type)}
</span> </span>
)} )}
</td> </td>
{canEdit && ( <td className="p-3 align-middle">
<td className="p-3">
{!m.isOwner && ( {!m.isOwner && (
<div className="flex items-center gap-1"> <div className="flex min-h-[36px] items-center justify-end gap-1">
{m.invitationStatus === 'PENDING' && pendingInviteLinks[m.id]?.invitationUrl && (
<button <button
type="button" type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary" className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary"
onClick={async () => {
try {
await navigator.clipboard.writeText(pendingInviteLinks[m.id].invitationUrl);
setCopiedInviteMembershipId(m.id);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch {
setError('Could not copy invitation link');
}
}}
aria-label="Copy invite link"
title="Copy invite link"
>
{copiedInviteMembershipId === m.id ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
)}
<button
type="button"
className={`p-2 rounded-md ${
canEdit
? 'text-text-secondary hover:bg-background-card/80 hover:text-text-primary'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Edit member" aria-label="Edit member"
onClick={() => openEdit(m)} disabled={!canEdit}
onClick={() => {
if (!canEdit) return;
openEdit(m);
}}
> >
<Pencil className="w-4 h-4" /> <Pencil className="w-4 h-4" />
</button> </button>
<button <button
type="button" type="button"
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600" className={`p-2 rounded-md ${
canEdit
? 'text-text-secondary hover:bg-red-500/15 hover:text-red-600'
: 'text-text-muted opacity-50 cursor-not-allowed'
}`}
aria-label="Remove member" aria-label="Remove member"
onClick={() => void removeMember(m)} disabled={!canEdit}
onClick={() => {
if (!canEdit) return;
void removeMember(m);
}}
> >
<Trash2 className="w-4 h-4" /> <Trash2 className="w-4 h-4" />
</button> </button>
</div> </div>
)} )}
</td> </td>
)}
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -452,7 +573,11 @@ export default function StaffPage() {
/> />
<div> <div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p> <p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<PermissionGrid state={invitePerms} onChange={setInvitePerms} /> <PermissionGrid
state={invitePerms}
onChange={setInvitePerms}
organizationType={currentOrganization?.type}
/>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button variant="outline" type="button" onClick={() => setInviteOpen(false)}> <Button variant="outline" type="button" onClick={() => setInviteOpen(false)}>
@@ -483,7 +608,11 @@ export default function StaffPage() {
<Input label="Display name" value={editName} onChange={(e) => setEditName(e.target.value)} /> <Input label="Display name" value={editName} onChange={(e) => setEditName(e.target.value)} />
<div> <div>
<p className="text-sm font-medium text-text-secondary mb-2">Tab access</p> <p className="text-sm font-medium text-text-secondary mb-2">Tab access</p>
<PermissionGrid state={editPerms} onChange={setEditPerms} /> <PermissionGrid
state={editPerms}
onChange={setEditPerms}
organizationType={currentOrganization?.type}
/>
</div> </div>
<div className="flex justify-end gap-2 pt-2"> <div className="flex justify-end gap-2 pt-2">
<Button variant="outline" type="button" onClick={() => setEditing(null)}> <Button variant="outline" type="button" onClick={() => setEditing(null)}>

View File

@@ -5,15 +5,27 @@
export const STAFF_FEATURE_GROUPS = [ export const STAFF_FEATURE_GROUPS = [
{ label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' }, { label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
{ label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
{ label: 'Labs', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' },
{ label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' }, { label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
{ label: 'Appointments', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' }, { label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
{ label: 'Staff Management', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' }, { label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },
{ label: 'Lab Management', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' },
{ label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' }, { label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' },
{ label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' }, { label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' },
] as const; ] as const;
export type FeaturePermState = Record<string, { read: boolean; edit: boolean }>; export type FeaturePermState = Record<string, { read: boolean; edit: boolean }>;
export type OrgType = 'CLINIC' | 'LAB' | null | undefined;
export function resolveStaffFeatureLabel(
group: (typeof STAFF_FEATURE_GROUPS)[number],
organizationType: OrgType,
): string {
if (group.read === 'TAB_LAB_READ') {
return organizationType === 'LAB' ? 'Clinics' : 'Labs';
}
return group.label;
}
export function emptyFeaturePermissionState(): FeaturePermState { export function emptyFeaturePermissionState(): FeaturePermState {
const s: FeaturePermState = {}; const s: FeaturePermState = {};
@@ -46,7 +58,10 @@ export function permissionNamesFromFeatureState(state: FeaturePermState): string
} }
/** Human-readable access for the team table — feature name, or "Feature (Read only)" */ /** Human-readable access for the team table — feature name, or "Feature (Read only)" */
export function formatAccessSummary(permissionNames: string[] | null | undefined): string { export function formatAccessSummary(
permissionNames: string[] | null | undefined,
organizationType?: OrgType,
): string {
if (!permissionNames?.length) return 'No tab access'; if (!permissionNames?.length) return 'No tab access';
const set = new Set(permissionNames); const set = new Set(permissionNames);
const parts: string[] = []; const parts: string[] = [];
@@ -54,7 +69,8 @@ export function formatAccessSummary(permissionNames: string[] | null | undefined
const hasEdit = set.has(g.edit); const hasEdit = set.has(g.edit);
const hasRead = set.has(g.read) || hasEdit; const hasRead = set.has(g.read) || hasEdit;
if (!hasRead) continue; if (!hasRead) continue;
parts.push(hasEdit ? g.label : `${g.label} (Read only)`); const label = resolveStaffFeatureLabel(g, organizationType);
parts.push(hasEdit ? label : `${label} (Read only)`);
} }
return parts.length ? parts.join(' · ') : 'No tab access'; return parts.length ? parts.join(' · ') : 'No tab access';
} }

View File

@@ -1,10 +1,31 @@
'use client';
import Link from 'next/link';
import { useAuth } from '@/lib/hooks/useAuth';
export default function TodayPage() { export default function TodayPage() {
const { currentOrganization } = useAuth();
const showNoSubscriptionNotice =
Boolean(currentOrganization?.isOwner) && !currentOrganization?.plan;
return ( return (
<div> <div>
<h1 className="text-2xl font-semibold mb-6"> <h1 className="text-2xl font-semibold mb-6">
Welcome back Babak !! Welcome back Babak !!
</h1> </h1>
{showNoSubscriptionNotice && (
<div className="mb-6 rounded-[var(--radius-md)] border border-amber-500/30 bg-amber-500/10 p-4">
<p className="text-sm text-amber-200">
This organization does not have an active subscription yet.{' '}
<Link href="/settings/subscriptions" className="font-medium underline underline-offset-2">
Choose a plan
</Link>{' '}
to start the purchase process.
</p>
</div>
)}
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4"> <div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-4 gap-4">
<Card title="Today's Appointments" value="12" sub="Monday 2/5/2026" /> <Card title="Today's Appointments" value="12" sub="Monday 2/5/2026" />
<Card title="Active Patients" value="675" /> <Card title="Active Patients" value="675" />

View File

@@ -0,0 +1,10 @@
export default function TreatmentPage() {
return (
<div className="space-y-3">
<h1 className="text-2xl font-semibold text-text-primary">Treatment</h1>
<p className="text-sm text-text-secondary">
Treatment module is coming soon.
</p>
</div>
);
}

View File

@@ -17,10 +17,10 @@ import { canViewTab } from '@/shared/permissions';
const menu = [ const menu = [
{ name: 'Today', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const }, { name: 'Today', path: '/today', icon: LayoutDashboard, read: 'TAB_TODAY_READ' as const },
{ name: 'Staff', path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const },
{ name: 'Patients', path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const }, { name: 'Patients', path: '/patients', icon: Users, read: 'TAB_PATIENTS_READ' as const },
{ name: 'Appointments', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const }, { name: 'Appointment', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const },
{ name: 'Staff Management', path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const }, { name: 'Treatment', path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_READ' as const },
{ name: 'Lab Management', path: '/lab', icon: FlaskConical, read: 'TAB_LAB_READ' as const },
{ name: 'Billing', path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ' as const }, { name: 'Billing', path: '/billing', icon: CreditCard, read: 'TAB_BILLING_READ' as const },
{ name: 'Reports', path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ' as const }, { name: 'Reports', path: '/reports', icon: FileText, read: 'TAB_REPORTS_READ' as const },
]; ];
@@ -28,10 +28,23 @@ const menu = [
function Sidebar() { function Sidebar() {
const pathname = usePathname(); const pathname = usePathname();
const { currentOrganization } = useAuth(); const { currentOrganization } = useAuth();
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
const visibleMenu = useMemo( const visibleMenu = useMemo(
() => menu.filter((item) => canViewTab(currentOrganization, item.read)), () => {
[currentOrganization], const withCounterpartTab = [
menu[0],
menu[1],
{ name: counterpartLabel, path: '/lab', icon: FlaskConical, read: 'TAB_LAB_READ' as const },
menu[2],
menu[3],
menu[4],
menu[5],
menu[6],
];
return withCounterpartTab.filter((item) => canViewTab(currentOrganization, item.read));
},
[counterpartLabel, currentOrganization],
); );
return ( return (

View File

@@ -17,6 +17,7 @@ import type { SubscriptionAlertData } from '@/types/subscription';
function warningTooltip(data: SubscriptionAlertData | null): string { function warningTooltip(data: SubscriptionAlertData | null): string {
if (!data?.showWarning) return ''; if (!data?.showWarning) return '';
if (data.noActiveSubscription) return 'No active subscription — review Subscriptions';
if (data.trialExpired) return 'Trial ended — review Subscriptions'; if (data.trialExpired) return 'Trial ended — review Subscriptions';
if (data.trialEndingSoon) return 'Trial ending soon — review Subscriptions'; if (data.trialEndingSoon) return 'Trial ending soon — review Subscriptions';
if (data.seatsLow) return 'Seats running low — review Subscriptions'; if (data.seatsLow) return 'Seats running low — review Subscriptions';

View File

@@ -2,17 +2,17 @@ import type { Organization } from '@/types/organization';
const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [ const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [
{ prefix: '/today', permission: 'TAB_TODAY_READ' }, { prefix: '/today', permission: 'TAB_TODAY_READ' },
{ prefix: '/patients', permission: 'TAB_PATIENTS_READ' },
{ prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ' },
{ prefix: '/staff', permission: 'TAB_STAFF_READ' }, { prefix: '/staff', permission: 'TAB_STAFF_READ' },
{ prefix: '/lab', permission: 'TAB_LAB_READ' }, { prefix: '/lab', permission: 'TAB_LAB_READ' },
{ prefix: '/patients', permission: 'TAB_PATIENTS_READ' },
{ prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ' },
{ prefix: '/treatment', permission: 'TAB_TREATMENT_READ' },
{ prefix: '/billing', permission: 'TAB_BILLING_READ' }, { prefix: '/billing', permission: 'TAB_BILLING_READ' },
{ prefix: '/reports', permission: 'TAB_REPORTS_READ' }, { prefix: '/reports', permission: 'TAB_REPORTS_READ' },
]; ];
export function hasPermission(org: Organization | null, permission: string): boolean { export function hasPermission(org: Organization | null, permission: string): boolean {
if (!org) return false; if (!org) return false;
if (org.isOwner) return true;
return Boolean(org.permissions?.includes(permission)); return Boolean(org.permissions?.includes(permission));
} }
@@ -33,7 +33,6 @@ export function getRequiredReadPermissionForPath(pathname: string): string | nul
/** First dashboard route the user may open (ordered). Fallback: account settings. */ /** First dashboard route the user may open (ordered). Fallback: account settings. */
export function firstAccessibleDashboardPath(org: Organization | null): string { export function firstAccessibleDashboardPath(org: Organization | null): string {
if (!org) return '/today'; if (!org) return '/today';
if (org.isOwner) return '/today';
for (const { prefix, permission } of ROUTE_TAB_READ) { for (const { prefix, permission } of ROUTE_TAB_READ) {
if (hasPermission(org, permission)) return prefix; if (hasPermission(org, permission)) return prefix;
} }

View File

@@ -1,11 +1,12 @@
/** GET /auth/subscription-alert — owners only get meaningful flags */ /** GET /auth/subscription-alert — owners only get meaningful flags */
export interface SubscriptionAlertData { export interface SubscriptionAlertData {
showWarning: boolean; showWarning: boolean;
noActiveSubscription?: boolean;
seatsLow: boolean; seatsLow: boolean;
trialEndingSoon: boolean; trialEndingSoon: boolean;
trialExpired: boolean; trialExpired: boolean;
seatsUsed?: number; seatsUsed?: number;
seatsLimit?: number; seatsLimit?: number | null;
daysUntilTrialEnd?: number | null; daysUntilTrialEnd?: number | null;
trialEndsAt?: string | null; trialEndsAt?: string | null;
daysUntilPlanEnd?: number | null; daysUntilPlanEnd?: number | null;

View File

@@ -20,3 +20,6 @@ DOMAIN=dyolink.com
# NEXT_PUBLIC_API_URL=/api # NEXT_PUBLIC_API_URL=/api
# NEXT_PUBLIC_APP_NAME=Dyolink # NEXT_PUBLIC_APP_NAME=Dyolink
# NEXT_PUBLIC_APP_URL=https://dyolink.com # NEXT_PUBLIC_APP_URL=https://dyolink.com
# --- Staging on your server (docker-compose.staging.yml) ---
# See env.staging.example, database.staging.env.example, backend.staging.env.example

View File

@@ -0,0 +1,12 @@
# Copy to backend.staging.env — DATABASE_URL must match database.staging.env credentials.
NODE_ENV=production
PORT=3000
DATABASE_URL=postgresql://postgres:changeme_staging_strong_password@postgres:5432/dyolink_db
JWT_SECRET=replace_with_a_long_random_secret
JWT_EXPIRES_IN=15m
JWT_REFRESH_SECRET=another_long_random_secret_different_from_JWT_SECRET
JWT_REFRESH_EXPIRES_IN=30d
FRONTEND_URL=http://178.131.50.201:8088

View File

@@ -0,0 +1,4 @@
# Copy to database.staging.env (do not commit real passwords).
POSTGRES_USER=postgres
POSTGRES_PASSWORD=changeme_staging_strong_password
POSTGRES_DB=dyolink_db

View File

@@ -0,0 +1,19 @@
# Template for manual pull-only deploy (when not using CI-generated deploy.registry.env).
# CI workflow generates this file automatically; you normally only need secrets on disk.
#
# docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d
# --- Registry boundary (swap when moving Gitea → Docker Hub) ---
# Gitea: REGISTRY_PREFIX = <host>:<port>/<owner>
# Hub: REGISTRY_PREFIX = docker.io/<user> (or your username for implicit hub)
REGISTRY_PREFIX=178.131.50.201:3000/yourgiteauser
# Short git SHA from CI, or "latest" after a manual pull of :latest
IMAGE_TAG=latest
# Host port published for nginx (URL = http://<your-ip>:<this-port>)
STAGING_HTTP_PORT=8088
# Absolute path on the server where database.staging.env and backend.staging.env live.
# Use forward slashes on Windows. Same variable as Gitea Actions → DEPLOY_SECRETS_DIR.
# DEPLOY_SECRETS_DIR=D:/dyolink/secrets

View File

@@ -53,7 +53,7 @@ services:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000/api/health', (r) => {if(r.statusCode!==200)process.exit(1)})"] test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/api/health', (r) => {if(r.statusCode!==200)process.exit(1)})"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3
@@ -81,7 +81,7 @@ services:
max-size: "10m" max-size: "10m"
max-file: "3" max-file: "3"
healthcheck: healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://localhost:3000', (r) => {if(r.statusCode!==200)process.exit(1)})"] test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"]
interval: 30s interval: 30s
timeout: 10s timeout: 10s
retries: 3 retries: 3

View File

@@ -0,0 +1,105 @@
# Pull-only staging stack — uses images from a registry (Gitea Packages / Docker Hub / etc.).
# No backend/frontend source on the deployment host except this compose file + config + secrets.
#
# Required env (see deploy.registry.env.example):
# REGISTRY_PREFIX e.g. 178.131.50.201:3000/yourgiteauser (no protocol, no trailing slash)
# IMAGE_TAG short sha or "latest" (CI sets this per deploy)
# Optional:
# DEPLOY_SECRETS_DIR absolute path on the server to database/backend *.env files (see below)
#
# Deploy:
# docker compose -f docker-compose.registry.yml --env-file deploy.registry.env pull
# docker compose -f docker-compose.registry.yml --env-file deploy.registry.env up -d
name: dyolink-registry
services:
postgres:
image: postgres:15-alpine
container_name: dyolink_postgres_staging
env_file:
- ${DEPLOY_SECRETS_DIR:-.}/database.staging.env
environment:
TZ: UTC
volumes:
- postgres_data_staging:/var/lib/postgresql/data
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
- ./database/backups:/backups
networks:
- dyolink_staging
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]
interval: 15s
timeout: 10s
retries: 5
start_period: 40s
backend:
image: ${REGISTRY_PREFIX}/dyolink-backend:${IMAGE_TAG:-latest}
container_name: dyolink_backend_staging
depends_on:
postgres:
condition: service_healthy
env_file:
- ${DEPLOY_SECRETS_DIR:-.}/backend.staging.env
environment:
NODE_ENV: production
TZ: UTC
PORT: "3000"
expose:
- "3000"
networks:
- dyolink_staging
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/api/health', (r) => {if(r.statusCode!==200)process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
frontend:
image: ${REGISTRY_PREFIX}/dyolink-frontend:${IMAGE_TAG:-latest}
container_name: dyolink_frontend_staging
depends_on:
- backend
environment:
NODE_ENV: production
TZ: UTC
PORT: "3000"
HOSTNAME: "0.0.0.0"
expose:
- "3000"
networks:
- dyolink_staging
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
nginx:
image: nginx:alpine
container_name: dyolink_nginx_staging
depends_on:
- backend
- frontend
ports:
- "${STAGING_HTTP_PORT:-8088}:80"
volumes:
- ./nginx/http-only.conf:/etc/nginx/conf.d/default.conf:ro
- ./logs/nginx-staging:/var/log/nginx
networks:
- dyolink_staging
restart: unless-stopped
networks:
dyolink_staging:
name: dyolink_staging
volumes:
postgres_data_staging:
name: dyolink_postgres_data_staging

View File

@@ -0,0 +1,107 @@
# Staging stack — builds images from local backend/frontend (needs full repo clone).
# For pull-only images + registry (no app source on server), use docker-compose.registry.yml
# and .gitea/workflows/registry-build-deploy.yml instead.
#
# From this directory:
# docker compose -f docker-compose.staging.yml --env-file .env.staging up -d --build
name: dyolink-staging
services:
postgres:
image: postgres:15-alpine
container_name: dyolink_postgres_staging
env_file:
- database.staging.env
environment:
TZ: UTC
volumes:
- postgres_data_staging:/var/lib/postgresql/data
- ./database/init.sql:/docker-entrypoint-initdb.d/init.sql:ro
- ./database/backups:/backups
networks:
- dyolink_staging
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $$POSTGRES_USER"]
interval: 15s
timeout: 10s
retries: 5
start_period: 40s
backend:
build:
context: ../backend
dockerfile: Dockerfile
container_name: dyolink_backend_staging
depends_on:
postgres:
condition: service_healthy
env_file:
- backend.staging.env
environment:
NODE_ENV: production
TZ: UTC
PORT: "3000"
expose:
- "3000"
networks:
- dyolink_staging
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/api/health', (r) => {if(r.statusCode!==200)process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
frontend:
build:
context: ../frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: ${STAGING_NEXT_PUBLIC_API_URL:-http://178.131.50.201:8088/api}
NEXT_PUBLIC_APP_URL: ${STAGING_NEXT_PUBLIC_APP_URL:-http://178.131.50.201:8088}
NEXT_PUBLIC_APP_NAME: ${STAGING_NEXT_PUBLIC_APP_NAME:-Dyolink}
container_name: dyolink_frontend_staging
depends_on:
- backend
environment:
NODE_ENV: production
TZ: UTC
PORT: "3000"
HOSTNAME: "0.0.0.0"
expose:
- "3000"
networks:
- dyolink_staging
restart: unless-stopped
healthcheck:
test: ["CMD", "node", "-e", "require('http').get('http://127.0.0.1:3000/', (r) => {if(r.statusCode!==200)process.exit(1)})"]
interval: 30s
timeout: 10s
retries: 3
start_period: 60s
nginx:
image: nginx:alpine
container_name: dyolink_nginx_staging
depends_on:
- backend
- frontend
ports:
- "${STAGING_HTTP_PORT:-8088}:80"
volumes:
- ./nginx/http-only.conf:/etc/nginx/conf.d/default.conf:ro
- ./logs/nginx-staging:/var/log/nginx
networks:
- dyolink_staging
restart: unless-stopped
networks:
dyolink_staging:
name: dyolink_staging
volumes:
postgres_data_staging:
name: dyolink_postgres_data_staging

View File

@@ -1,11 +1,14 @@
# Copy .env.docker.example to .env.docker and adjust (optional).
# Defaults below are for local development only.
services: services:
postgres: postgres:
image: postgres:15-alpine image: postgres:15-alpine
container_name: dyolink_db_container container_name: dyolink_db_container
environment: environment:
POSTGRES_USER: postgres POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: 1234 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dyolink_dev_change_me}
POSTGRES_DB: dyolink_db POSTGRES_DB: ${POSTGRES_DB:-dyolink_db}
ports: ports:
- "5433:5432" - "5433:5432"
volumes: volumes:
@@ -16,7 +19,7 @@ services:
- dyolink_network - dyolink_network
restart: unless-stopped restart: unless-stopped
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
interval: 10s interval: 10s
timeout: 5s timeout: 5s
retries: 5 retries: 5
@@ -30,8 +33,8 @@ services:
env_file: env_file:
- ../backend/.env - ../backend/.env
environment: environment:
- DATABASE_URL=postgresql://postgres:1234@postgres:5432/dyolink_db - DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-dyolink_dev_change_me}@postgres:5432/${POSTGRES_DB:-dyolink_db}
- FRONTEND_URL=http://frontend:3000 - FRONTEND_URL=http://localhost:4000
- PORT=3000 - PORT=3000
ports: ports:
- "4001:3000" - "4001:3000"
@@ -53,9 +56,8 @@ services:
environment: environment:
- NEXT_PUBLIC_API_URL=http://localhost:4001/api - NEXT_PUBLIC_API_URL=http://localhost:4001/api
- NEXT_PUBLIC_APP_URL=http://localhost:4000 - NEXT_PUBLIC_APP_URL=http://localhost:4000
- PORT=3000
ports: ports:
- "4000:3000" - "4000:3001"
volumes: volumes:
- ../frontend:/app:rw - ../frontend:/app:rw
- /app/node_modules - /app/node_modules
@@ -73,10 +75,8 @@ services:
- frontend - frontend
ports: ports:
- "8080:80" - "8080:80"
- "8443:443"
volumes: volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./nginx/http-only.dev.conf:/etc/nginx/conf.d/default.conf:ro
- ./ssl:/etc/nginx/ssl:ro
- ./logs/nginx:/var/log/nginx - ./logs/nginx:/var/log/nginx
networks: networks:
- dyolink_network - dyolink_network

View File

@@ -0,0 +1,6 @@
# Optional: save as .env next to infrastructure/docker-compose.yml
# Docker Compose reads this file automatically for variable substitution.
POSTGRES_USER=postgres
POSTGRES_PASSWORD=dyolink_dev_change_me
POSTGRES_DB=dyolink_db

View File

@@ -0,0 +1,13 @@
# Copy to .env.staging next to docker-compose.staging.yml (optional).
# Used only for compose variable substitution (build args, host port).
STAGING_HTTP_PORT=8088
# Public URLs baked into the frontend image at build time — must match how users open the app.
STAGING_NEXT_PUBLIC_API_URL=http://178.131.50.201:8088/api
STAGING_NEXT_PUBLIC_APP_URL=http://178.131.50.201:8088
STAGING_NEXT_PUBLIC_APP_NAME=Dyolink
# Change the IP/port if your server address differs.
# Registry / pull-only deploy (see deploy.registry.env.example + docker-compose.registry.yml).

View File

@@ -1,19 +1,13 @@
FROM nginx:alpine FROM nginx:alpine
# Remove default configuration RUN rm -f /etc/nginx/conf.d/default.conf
RUN rm /etc/nginx/conf.d/default.conf
# Copy custom configuration COPY http-only.conf /etc/nginx/conf.d/default.conf
COPY nginx.conf /etc/nginx/conf.d/
# Create log directory
RUN mkdir -p /var/log/nginx && \ RUN mkdir -p /var/log/nginx && \
chown -R nginx:nginx /var/log/nginx && \ chown -R nginx:nginx /var/log/nginx && \
chmod -R 755 /var/log/nginx chmod -R 755 /var/log/nginx
# Switch to non-root user EXPOSE 80
USER nginx
EXPOSE 80 443
CMD ["nginx", "-g", "daemon off;"] CMD ["nginx", "-g", "daemon off;"]

View File

@@ -0,0 +1,54 @@
# HTTP only — local dev and IP-based staging (no TLS).
# Use with: docker compose and map host port e.g. 8080:80 or 8088:80
upstream dyolink_backend {
server backend:3000;
keepalive 32;
}
upstream dyolink_frontend {
server frontend:3000;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name _;
client_max_body_size 50M;
location / {
proxy_pass http://dyolink_frontend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300;
proxy_connect_timeout 300;
}
location /api {
proxy_pass http://dyolink_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300;
proxy_connect_timeout 300;
}
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}

View File

@@ -0,0 +1,54 @@
# Dev docker-compose only: local `npm run dev` uses port 3001 (see frontend package.json).
# Staging / registry stacks use http-only.conf (frontend:3000).
upstream dyolink_backend {
server backend:3000;
keepalive 32;
}
upstream dyolink_frontend {
server frontend:3001;
keepalive 32;
}
server {
listen 80;
listen [::]:80;
server_name _;
client_max_body_size 50M;
location / {
proxy_pass http://dyolink_frontend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300;
proxy_connect_timeout 300;
}
location /api {
proxy_pass http://dyolink_backend;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection 'upgrade';
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_cache_bypass $http_upgrade;
proxy_read_timeout 300;
proxy_connect_timeout 300;
}
location /health {
access_log off;
return 200 "healthy\n";
add_header Content-Type text/plain;
}
}