Compare commits

..

21 Commits

Author SHA1 Message Date
f1c97021f8 feature: delete appointment button's UI improved 2026-05-07 01:14:55 +03:30
8123a94a3d feature: a minimal implementation of the appointment feature done. 2026-05-06 23:05:37 +03:30
bc06be3ca6 Merge pull request 'feature/clinic-lab-invitation' (#14) from feature/clinic-lab-invitation into master
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 0s
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/14
2026-05-06 19:28:12 +03:30
2dca609ba1 feature: Organizations link and invitation flow implemented minimally. 2026-05-06 19:25:01 +03:30
bb44270795 feature: Organizations form UX flow now matches the needs. 2026-05-06 18:55:42 +03:30
6258477860 feature: button and badge components updated. 2026-05-06 02:20:19 +03:30
adfb5b436e feature: clinic & lab invitation flow added. minimal ui implemented for organizations tab. 2026-05-05 19:50:07 +03:30
43e39883a4 Merge pull request 'improvement/infrastructure' (#13) from improvement/infrastructure into master
All checks were successful
Registry — build, push, deploy / temp-success (push) Successful in 1s
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/13
2026-05-05 16:34:54 +03:30
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
1392cb1d3d Merge pull request 'improvement/infrastructure' (#12) from improvement/infrastructure into master
Some checks failed
Registry — build, push, deploy / build-and-push (push) Failing after 4s
Registry — build, push, deploy / deploy (push) Has been skipped
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/12
2026-05-05 00:38:17 +03:30
517ce0d5ba improvement: workflow updated for git actions. 2026-05-05 00:33:30 +03:30
eddcc3aded Merge pull request 'improvement: fixed some package.json issues. readme files updated.' (#11) from improvement/infrastructure into master
Some checks failed
Registry — build, push, deploy / build-and-push (push) Failing after 0s
Registry — build, push, deploy / deploy (push) Has been skipped
Reviewed-on: http://178.131.50.201:3000/admin/dyolink/pulls/11
2026-05-05 00:07:52 +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
85 changed files with 27847 additions and 573 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*
yarn-debug.log*
yarn-error.log*
package-lock.json
yarn.lock
# === Build outputs ===
@@ -82,8 +81,10 @@ secrets/
*.db
*.sqlite3
# === Gitea specific ===
.gitea/
# === Gitea specific (keep Actions workflows; ignore other local .gitea noise) ===
.gitea/*
!.gitea/workflows
!.gitea/workflows/**
# Prisma generated files
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
# ============================================
# This stage builds the application and prepares assets
FROM node:18-alpine AS builder
FROM node:20-alpine AS builder
# Set working directory
WORKDIR /app
# Copy package.json and package-lock.json first (for better caching)
COPY package*.json ./
# Copy Prisma schema (needed for Prisma client generation)
COPY .npmrc ./
COPY prisma ./prisma/
# Install ALL dependencies (including dev dependencies for build)
RUN npm ci
# Copy source code
COPY . .
# Generate Prisma client
RUN npx prisma generate
# Build the NestJS application
RUN npm run prisma:generate
RUN npm run build
# Remove development dependencies to reduce size
RUN npm prune --production
# ============================================
# STAGE 2: PRODUCTION STAGE
# ============================================
# This stage creates the final production image
FROM node:18-alpine
FROM node:20-alpine
# Install dumb-init for proper signal handling
RUN apk add --no-cache dumb-init
# Set working directory
WORKDIR /app
# Create non-root user for security
RUN addgroup -g 1001 -S nodejs && \
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 Prisma schema
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 node_modules (already pruned)
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 && \
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
# Expose the application port
EXPOSE 3000
# Health check configuration
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"]
# Start the application
CMD ["node", "dist/main"]
CMD ["node", "dist/main"]

View File

@@ -1,98 +1,88 @@
<p align="center">
<a href="http://nestjs.com/" target="blank"><img src="https://nestjs.com/img/logo-small.svg" width="120" alt="Nest Logo" /></a>
</p>
# Dyolink — Backend (NestJS)
[circleci-image]: https://img.shields.io/circleci/build/github/nestjs/nest/master?token=abc123def456
[circleci-url]: https://circleci.com/gh/nestjs/nest
## Prerequisites
<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>
<p align="center">
<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)-->
- **Node.js 20+** and **npm**
- **PostgreSQL** reachable from your machine (local or remote)
## 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
$ 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
# development
$ npm run start
# watch mode
$ npm run start:dev
# production mode
$ npm run start:prod
git pull
npm install
npm run prisma:generate
npm run prisma:migrate
```
## Run tests
If teammates added migrations, step 4 applies them. Resolve migration conflicts locally before pushing.
```bash
# unit tests
$ npm run test
## Useful commands
# e2e tests
$ npm run test:e2e
| Command | Purpose |
|--------|---------|
| `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
$ npm run test:cov
```
## Docker
## Deployment
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).
Image build is defined in **`Dockerfile`** at this folder. For full-stack deployment and CI, see the **repository root `README.md`**.

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
set -e
# ============================================
# DOCKER ENTRYPOINT SCRIPT
# This script runs BEFORE the application starts
# ============================================
echo "=========================================="
echo " Dyolink Backend - Docker Entrypoint"
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
echo "${GREEN}Running in PRODUCTION mode${NC}"
# Run database migrations
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
echo "Running in PRODUCTION mode"
echo "Running database migrations..."
./node_modules/.bin/prisma migrate deploy
else
echo "${YELLOW}Running in DEVELOPMENT mode${NC}"
# In development, we might want to push schema instead of migrations
echo "${YELLOW}Syncing database schema...${NC}"
npx prisma db push
if [ $? -eq 0 ]; then
echo "${GREEN}✓ Database schema synced successfully${NC}"
else
echo "${RED}✗ Database schema sync failed!${NC}"
exit 1
echo "Running in DEVELOPMENT mode"
echo "Syncing database schema..."
./node_modules/.bin/prisma db push
fi
if [ "$NODE_ENV" != "production" ]; then
if [ -f "prisma/seed.ts" ] || [ -f "prisma/seed.js" ]; then
echo "Running database seed..."
./node_modules/.bin/prisma db seed
fi
fi
# Optional: Run seed script if it exists and NODE_ENV is not production
if [ "$NODE_ENV" != "production" ] && [ -f "prisma/seed.js" ]; then
echo "${YELLOW}Running database seed...${NC}"
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}"
echo "Verifying database connection..."
if ! echo "SELECT 1" | ./node_modules/.bin/prisma db execute --stdin --schema prisma/schema.prisma >/dev/null 2>&1; then
echo "Cannot connect to database or execute query."
exit 1
fi
echo "Database connection OK"
# Print application information
echo "${GREEN}========================================${NC}"
echo "${GREEN}Starting Dyolink Backend Application...${NC}"
echo "${GREEN} • Environment: ${NODE_ENV:-development}${NC}"
echo "${GREEN} • Port: ${PORT:-3000}${NC}"
echo "${GREEN} • Database: ${DATABASE_URL%%@*}@***${NC}"
echo "${GREEN}========================================${NC}"
echo "Starting Dyolink Backend..."
echo " Environment: ${NODE_ENV:-development}"
echo " Port: ${PORT:-3000}"
# 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/throttler": "^6.5.0",
"@prisma/client": "^6.19.2",
"prisma": "^6.19.2",
"adminjs": "^7.8.17",
"axios": "^1.13.5",
"bcrypt": "^6.0.0",
@@ -84,7 +85,6 @@
"globals": "^16.0.0",
"jest": "^30.0.0",
"prettier": "^3.4.2",
"prisma": "^6.19.2",
"source-map-support": "^0.5.21",
"supertest": "^7.0.0",
"ts-jest": "^29.2.5",
@@ -94,6 +94,10 @@
"typescript": "^5.7.3",
"typescript-eslint": "^8.20.0"
},
"overrides": {
"@tiptap/core": "2.27.2",
"@tiptap/pm": "2.27.2"
},
"jest": {
"moduleFileExtensions": [
"js",

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

@@ -0,0 +1,33 @@
-- CreateTable
CREATE TABLE "organization_invitations" (
"id" TEXT NOT NULL,
"inviterOrganizationId" TEXT NOT NULL,
"inviterUserId" TEXT NOT NULL,
"invitedOrganizationId" TEXT,
"invitedOrganizationName" TEXT NOT NULL,
"invitedOwnerEmail" TEXT NOT NULL,
"invitedOrganizationType" TEXT NOT NULL,
"tokenHash" TEXT NOT NULL,
"expiresAt" TIMESTAMP(3) NOT NULL,
"acceptedAt" TIMESTAMP(3),
"revokedAt" TIMESTAMP(3),
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "organization_invitations_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE UNIQUE INDEX "organization_invitations_tokenHash_key" ON "organization_invitations"("tokenHash");
-- CreateIndex
CREATE INDEX "organization_invitations_inviterOrganizationId_createdAt_idx" ON "organization_invitations"("inviterOrganizationId", "createdAt");
-- CreateIndex
CREATE INDEX "organization_invitations_invitedOwnerEmail_createdAt_idx" ON "organization_invitations"("invitedOwnerEmail", "createdAt");
-- AddForeignKey
ALTER TABLE "organization_invitations" ADD CONSTRAINT "organization_invitations_inviterOrganizationId_fkey" FOREIGN KEY ("inviterOrganizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "organization_invitations" ADD CONSTRAINT "organization_invitations_inviterUserId_fkey" FOREIGN KEY ("inviterUserId") REFERENCES "users"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -0,0 +1,7 @@
UPDATE "permissions"
SET "name" = 'TAB_ORGANIZATIONS_READ'
WHERE "name" = 'TAB_LAB_READ';
UPDATE "permissions"
SET "name" = 'TAB_ORGANIZATIONS_EDIT'
WHERE "name" = 'TAB_LAB_EDIT';

View File

@@ -0,0 +1,26 @@
-- CreateTable
CREATE TABLE "appointments" (
"id" TEXT NOT NULL,
"organizationId" TEXT NOT NULL,
"patientId" TEXT NOT NULL,
"providerUserId" TEXT NOT NULL,
"startAt" TIMESTAMP(3) NOT NULL,
"endAt" TIMESTAMP(3) NOT NULL,
"purpose" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "appointments_pkey" PRIMARY KEY ("id")
);
-- CreateIndex
CREATE INDEX "appointments_organizationId_providerUserId_startAt_idx" ON "appointments"("organizationId", "providerUserId", "startAt");
-- CreateIndex
CREATE INDEX "appointments_organizationId_startAt_idx" ON "appointments"("organizationId", "startAt");
-- AddForeignKey
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_organizationId_fkey" FOREIGN KEY ("organizationId") REFERENCES "organizations"("id") ON DELETE CASCADE ON UPDATE CASCADE;
-- AddForeignKey
ALTER TABLE "appointments" ADD CONSTRAINT "appointments_patientId_fkey" FOREIGN KEY ("patientId") REFERENCES "patients"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -21,6 +21,7 @@ model User {
ownedOrganizations Organization[] @relation("OrganizationOwner")
sessions Session[] // 👈 ADD THIS - opposite relation for Session
sentStaffInvites StaffInvitation[]
sentOrganizationInvitations OrganizationInvitation[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -57,7 +58,9 @@ model Organization {
sharedWithMe OrganizationLink[] @relation("OrganizationB")
sharedWithOthers OrganizationLink[] @relation("OrganizationA")
sentOrganizationInvitations OrganizationInvitation[] @relation("OrganizationInvitationInviter")
patients Patient[]
appointments Appointment[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@ -80,6 +83,7 @@ model Patient {
organization Organization @relation(fields: [organizationId], references: [id])
treatments PatientTreatmentHistory[]
appointments Appointment[]
@@index([organizationId, createdAt])
@@index([organizationId, lastName, firstName])
@@ -104,6 +108,26 @@ model PatientTreatmentHistory {
@@map("patient_treatment_histories")
}
model Appointment {
id String @id @default(uuid())
organizationId String
patientId String
providerUserId String
startAt DateTime
endAt DateTime
purpose String
organization Organization @relation(fields: [organizationId], references: [id], onDelete: Cascade)
patient Patient @relation(fields: [patientId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([organizationId, providerUserId, startAt])
@@index([organizationId, startAt])
@@map("appointments")
}
model Plan {
id String @id @default(uuid())
name String @unique // "Solo", "Small", "Medium", "Large", "Enterprise"
@@ -211,6 +235,32 @@ model OrganizationLink {
@@map("organization_links")
}
model OrganizationInvitation {
id String @id @default(uuid())
inviterOrganizationId String
inviterUserId String
invitedOrganizationId String?
invitedOrganizationName String
invitedOwnerEmail String
invitedOrganizationType String
tokenHash String @unique
expiresAt DateTime
acceptedAt DateTime?
revokedAt DateTime?
inviterOrganization Organization @relation("OrganizationInvitationInviter", fields: [inviterOrganizationId], references: [id], onDelete: Cascade)
inviterUser User @relation(fields: [inviterUserId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
@@index([inviterOrganizationId, createdAt])
@@index([invitedOwnerEmail, createdAt])
@@map("organization_invitations")
}
model Session {
id String @id @default(uuid())
userId String

View File

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

View File

@@ -22,4 +22,13 @@ export class AppController {
getHello(): string {
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

@@ -8,6 +8,8 @@ import { AdminModule } from './admin/admin.module';
import { PrismaModule } from '../prisma/prisma.module'; // ✅
import { PatientsModule } from './modules/patients/patients.module';
import { StaffModule } from './modules/staff/staff.module';
import { OrganizationModule } from './modules/organization/organization.module';
import { AppointmentsModule } from './modules/appointments/appointments.module';
@Module({
imports: [
@@ -18,7 +20,9 @@ import { StaffModule } from './modules/staff/staff.module';
PrismaModule, // ✅ ADD THIS
AuthModule,
PatientsModule,
AppointmentsModule,
StaffModule,
OrganizationModule,
AdminModule.forRoot(),
],
controllers: [AppController],

View File

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

View File

@@ -0,0 +1,51 @@
import { Body, Controller, Delete, Get, Param, Post, Query, Req, UseGuards } from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AppointmentsService } from './appointments.service';
import { CreateAppointmentDto } from './dto/create-appointment.dto';
import { ListAppointmentsDto } from './dto/list-appointments.dto';
@ApiTags('appointments')
@ApiBearerAuth('JWT-auth')
@UseGuards(JwtAuthGuard)
@Controller('appointments')
export class AppointmentsController {
constructor(private readonly appointmentsService: AppointmentsService) {}
@Get('column-providers')
@ApiOperation({
summary:
'Staff columns: active non-owner members with TAB_TREATMENT_EDIT. Owners are excluded. Requires TAB_APPOINTMENTS_READ or owner.',
})
columnProviders(@Req() req: { user: { id: string; organizationId?: string } }) {
const organizationId = this.appointmentsService.getOrganizationIdFromUser(req.user);
return this.appointmentsService.listColumnProviders(organizationId, req.user.id);
}
@Get()
@ApiOperation({ summary: 'List appointments intersecting a time range (requires TAB_APPOINTMENTS_READ or owner)' })
list(@Query() query: ListAppointmentsDto, @Req() req: { user: { id: string; organizationId?: string } }) {
const organizationId = this.appointmentsService.getOrganizationIdFromUser(req.user);
return this.appointmentsService.list(query, organizationId, req.user.id);
}
@Post()
@ApiOperation({ summary: 'Create appointment (requires TAB_APPOINTMENTS_EDIT or owner)' })
create(
@Body() dto: CreateAppointmentDto,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.appointmentsService.getOrganizationIdFromUser(req.user);
return this.appointmentsService.create(dto, organizationId, req.user.id);
}
@Delete(':id')
@ApiOperation({ summary: 'Delete appointment (requires TAB_APPOINTMENTS_EDIT or owner)' })
remove(
@Param('id') id: string,
@Req() req: { user: { id: string; organizationId?: string } },
) {
const organizationId = this.appointmentsService.getOrganizationIdFromUser(req.user);
return this.appointmentsService.remove(id, organizationId, req.user.id);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { AppointmentsController } from './appointments.controller';
import { AppointmentsService } from './appointments.service';
@Module({
controllers: [AppointmentsController],
providers: [AppointmentsService, PrismaService],
})
export class AppointmentsModule {}

View File

@@ -0,0 +1,235 @@
import {
BadRequestException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { CreateAppointmentDto } from './dto/create-appointment.dto';
import { ListAppointmentsDto } from './dto/list-appointments.dto';
const MS_PER_DAY = 86_400_000;
@Injectable()
export class AppointmentsService {
constructor(private readonly prisma: PrismaService) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected');
}
return user.organizationId;
}
async listColumnProviders(organizationId: string, actorUserId: string) {
await this.assertCanViewAppointments(actorUserId, organizationId);
const members = await this.prisma.membership.findMany({
where: {
organizationId,
isOwner: false,
isActive: true,
permissions: {
some: {
permission: {
name: 'TAB_TREATMENT_EDIT',
},
},
},
},
include: {
user: { select: { id: true, name: true } },
},
orderBy: [{ createdAt: 'asc' }],
});
const data = members.map((m) => ({ userId: m.user.id, name: m.user.name }));
return { success: true, data };
}
async list(query: ListAppointmentsDto, organizationId: string, actorUserId: string) {
await this.assertCanViewAppointments(actorUserId, organizationId);
const from = new Date(query.from);
const to = new Date(query.to);
if (Number.isNaN(from.getTime()) || Number.isNaN(to.getTime())) {
throw new BadRequestException('Invalid date range');
}
if (to <= from) {
throw new BadRequestException('Range "to" must be after "from"');
}
const items = await this.prisma.appointment.findMany({
where: {
organizationId,
startAt: { lt: to },
endAt: { gt: from },
},
include: {
patient: {
select: { id: true, firstName: true, lastName: true, phone: true },
},
},
orderBy: [{ startAt: 'asc' }],
});
return { success: true, data: items };
}
async create(
dto: CreateAppointmentDto,
organizationId: string,
actorUserId: string,
) {
await this.assertCanEditAppointments(actorUserId, organizationId);
const startAt = new Date(dto.startAt);
const endAt = new Date(dto.endAt);
if (Number.isNaN(startAt.getTime()) || Number.isNaN(endAt.getTime())) {
throw new BadRequestException('Invalid start or end time');
}
if (endAt <= startAt) {
throw new BadRequestException('End time must be after start time');
}
if (endAt.getTime() - startAt.getTime() > MS_PER_DAY) {
throw new BadRequestException('Appointment cannot span more than 24 hours');
}
const now = Date.now();
if (startAt.getTime() < now) {
throw new BadRequestException('Cannot schedule appointments in the past');
}
await this.ensurePatientInOrg(dto.patientId, organizationId);
await this.ensureProviderIsTreatmentEditor(dto.providerUserId, organizationId);
const overlap = await this.prisma.appointment.findFirst({
where: {
organizationId,
providerUserId: dto.providerUserId,
startAt: { lt: endAt },
endAt: { gt: startAt },
},
select: { id: true },
});
if (overlap) {
throw new BadRequestException('This time slot overlaps an existing appointment for that provider');
}
const appointment = await this.prisma.appointment.create({
data: {
organizationId,
patientId: dto.patientId,
providerUserId: dto.providerUserId,
startAt,
endAt,
purpose: dto.purpose,
},
include: {
patient: {
select: { id: true, firstName: true, lastName: true, phone: true },
},
},
});
return { success: true, data: appointment };
}
async remove(id: string, organizationId: string, actorUserId: string) {
await this.assertCanEditAppointments(actorUserId, organizationId);
const existing = await this.prisma.appointment.findFirst({
where: { id, organizationId },
select: { id: true },
});
if (!existing) {
throw new NotFoundException('Appointment not found');
}
await this.prisma.appointment.delete({
where: { id },
});
return { success: true };
}
private async assertCanViewAppointments(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new ForbiddenException('You are not a member of this organization');
}
if (m.isOwner) {
return;
}
const names = m.permissions.map((p) => p.permission.name);
if (names.includes('TAB_APPOINTMENTS_READ')) {
return;
}
if (names.includes('TAB_TREATMENT_EDIT')) {
return;
}
throw new ForbiddenException('You do not have access to appointments');
}
private async assertCanEditAppointments(userId: string, organizationId: string) {
const m = await this.getMembership(userId, organizationId);
if (!m) {
throw new ForbiddenException('You are not a member of this organization');
}
if (m.isOwner) {
return;
}
const names = m.permissions.map((p) => p.permission.name);
if (names.includes('TAB_APPOINTMENTS_EDIT')) {
return;
}
if (names.includes('TAB_TREATMENT_EDIT')) {
return;
}
throw new ForbiddenException('You cannot create or modify appointments');
}
private async ensureProviderIsTreatmentEditor(providerUserId: string, organizationId: string) {
const m = await this.getMembership(providerUserId, organizationId);
if (!m) {
throw new BadRequestException('Provider is not a member of this organization');
}
if (m.isOwner) {
throw new BadRequestException(
'Appointments must be assigned to staff with treatment access, not the organization owner',
);
}
if (!m.isActive) {
throw new BadRequestException('Provider is not an active staff member');
}
const names = m.permissions.map((p) => p.permission.name);
if (!names.includes('TAB_TREATMENT_EDIT')) {
throw new BadRequestException('Provider does not have treatment edit access');
}
}
private async ensurePatientInOrg(patientId: string, organizationId: string) {
const patient = await this.prisma.patient.findFirst({
where: { id: patientId, organizationId },
select: { id: true },
});
if (!patient) {
throw new NotFoundException('Patient not found');
}
}
private async getMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId },
include: { permissions: { include: { permission: true } } },
});
}
}

View File

@@ -0,0 +1,28 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDateString, IsIn, IsUUID } from 'class-validator';
const APPOINTMENT_PURPOSES = ['consultation', 'filling', 'endo', 'visit', 'hygiene'] as const;
export type AppointmentPurpose = (typeof APPOINTMENT_PURPOSES)[number];
export class CreateAppointmentDto {
@ApiProperty()
@IsUUID()
patientId: string;
@ApiProperty({ description: 'Staff user id (column provider)' })
@IsUUID()
providerUserId: string;
@ApiProperty({ example: '2026-05-06T09:15:00.000Z' })
@IsDateString()
startAt: string;
@ApiProperty({ example: '2026-05-06T09:45:00.000Z' })
@IsDateString()
endAt: string;
@ApiProperty({ enum: APPOINTMENT_PURPOSES })
@IsIn([...APPOINTMENT_PURPOSES])
purpose: AppointmentPurpose;
}

View File

@@ -0,0 +1,12 @@
import { ApiProperty } from '@nestjs/swagger';
import { IsDateString } from 'class-validator';
export class ListAppointmentsDto {
@ApiProperty({ description: 'Range start (ISO 8601), e.g. local midnight as instant' })
@IsDateString()
from: string;
@ApiProperty({ description: 'Range end (ISO 8601), e.g. next local midnight' })
@IsDateString()
to: string;
}

View File

@@ -18,14 +18,16 @@ import { JwtPayload } from './interfaces/jwt-payload.interface';
const ALL_PERMISSIONS = [
'TAB_TODAY_READ',
'TAB_TODAY_EDIT',
'TAB_STAFF_READ',
'TAB_STAFF_EDIT',
'TAB_ORGANIZATIONS_READ',
'TAB_ORGANIZATIONS_EDIT',
'TAB_PATIENTS_READ',
'TAB_PATIENTS_EDIT',
'TAB_APPOINTMENTS_READ',
'TAB_APPOINTMENTS_EDIT',
'TAB_STAFF_READ',
'TAB_STAFF_EDIT',
'TAB_LAB_READ',
'TAB_LAB_EDIT',
'TAB_TREATMENT_READ',
'TAB_TREATMENT_EDIT',
'TAB_BILLING_READ',
'TAB_BILLING_EDIT',
'TAB_REPORTS_READ',
@@ -34,10 +36,11 @@ const ALL_PERMISSIONS = [
const READ_ONLY_PERMISSIONS = [
'TAB_TODAY_READ',
'TAB_STAFF_READ',
'TAB_ORGANIZATIONS_READ',
'TAB_PATIENTS_READ',
'TAB_APPOINTMENTS_READ',
'TAB_STAFF_READ',
'TAB_LAB_READ',
'TAB_TREATMENT_READ',
'TAB_BILLING_READ',
'TAB_REPORTS_READ',
];

View File

@@ -0,0 +1,19 @@
import { IsString, MinLength } from 'class-validator';
export class AcceptOrganizationInviteDto {
@IsString()
@MinLength(1)
token: string;
@IsString()
@MinLength(1)
organizationName: string;
@IsString()
@MinLength(1)
ownerName: string;
@IsString()
@MinLength(8)
password: string;
}

View File

@@ -0,0 +1,6 @@
import { IsUUID } from 'class-validator';
export class CreateLinkRequestDto {
@IsUUID()
targetOrganizationId: string;
}

View File

@@ -0,0 +1,14 @@
import { IsEmail, IsOptional, IsString, MinLength } from 'class-validator';
export class InviteOrganizationDto {
@IsString()
@MinLength(1)
organizationName: string;
@IsEmail()
ownerEmail: string;
@IsOptional()
@IsString()
phone?: string;
}

View File

@@ -0,0 +1,7 @@
import { IsString, MinLength } from 'class-validator';
export class PreviewOrganizationInviteDto {
@IsString()
@MinLength(1)
token: string;
}

View File

@@ -0,0 +1,6 @@
import { IsIn } from 'class-validator';
export class RespondLinkRequestDto {
@IsIn(['ACCEPT', 'REJECT'])
action: 'ACCEPT' | 'REJECT';
}

View File

@@ -0,0 +1,124 @@
import {
Body,
Controller,
Delete,
Get,
Param,
Patch,
Post,
Query,
Req,
UseGuards,
} from '@nestjs/common';
import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger';
import { JwtAuthGuard } from '../auth/guards/jwt-auth.guard';
import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
import { CreateLinkRequestDto } from './dto/create-link-request.dto';
import { InviteOrganizationDto } from './dto/invite-organization.dto';
import { PreviewOrganizationInviteDto } from './dto/preview-organization-invite.dto';
import { RespondLinkRequestDto } from './dto/respond-link-request.dto';
import { OrganizationService } from './organization.service';
@ApiTags('organizations')
@ApiBearerAuth('JWT-auth')
@Controller('organizations')
export class OrganizationController {
constructor(private readonly organizationService: OrganizationService) {}
@Get('invitations/preview')
@ApiOperation({ summary: 'Preview organization invite by token (public)' })
previewInvite(@Query() query: PreviewOrganizationInviteDto) {
return this.organizationService.previewInvite(query.token);
}
@Get('invitations')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'List invitation history for current organization' })
listInvitations(@Req() req: { user: { id: string; organizationId?: string } }) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.listInvitationHistory(req.user.id, organizationId);
}
@Post('invitations/accept')
@ApiOperation({ summary: 'Accept organization invite and create/link counterpart org (public)' })
acceptInvite(@Body() dto: AcceptOrganizationInviteDto) {
return this.organizationService.acceptInvite(dto);
}
@Get('search')
@UseGuards(JwtAuthGuard)
@ApiOperation({
summary: 'Search counterpart organizations (active subscription only)',
})
search(
@Req() req: { user: { id: string; organizationId?: string } },
@Query('q') q = '',
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.searchCounterpartOrganizations(req.user.id, organizationId, q);
}
@Get('links')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'List counterpart links and invitations for current org' })
list(@Req() req: { user: { id: string; organizationId?: string } }) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.list(req.user.id, organizationId);
}
@Post('links')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Create pending link request to an existing subscribed counterpart org' })
createLinkRequest(
@Req() req: { user: { id: string; organizationId?: string } },
@Body() dto: CreateLinkRequestDto,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.createLinkRequest(req.user.id, organizationId, dto);
}
@Patch('links/:linkId/respond')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Accept or reject a pending link request for current organization' })
respondToLinkRequest(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('linkId') linkId: string,
@Body() dto: RespondLinkRequestDto,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.respondToLinkRequest(req.user.id, organizationId, linkId, dto);
}
@Delete('links/:linkId')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Delete linked organization record' })
deleteLink(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('linkId') linkId: string,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.deleteLink(req.user.id, organizationId, linkId);
}
@Post('invitations/:invitationId/link')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Get a shareable invite link for a pending invitation' })
getInvitationLink(
@Req() req: { user: { id: string; organizationId?: string } },
@Param('invitationId') invitationId: string,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.getInvitationLink(req.user.id, organizationId, invitationId);
}
@Post('invite')
@UseGuards(JwtAuthGuard)
@ApiOperation({ summary: 'Create invite link for owner of not-yet-subscribed counterpart org' })
inviteOrganization(
@Req() req: { user: { id: string; organizationId?: string } },
@Body() dto: InviteOrganizationDto,
) {
const organizationId = this.organizationService.getOrganizationIdFromUser(req.user);
return this.organizationService.inviteOrganization(req.user.id, organizationId, dto);
}
}

View File

@@ -0,0 +1,10 @@
import { Module } from '@nestjs/common';
import { PrismaService } from '../../../prisma/prisma.service';
import { OrganizationController } from './organization.controller';
import { OrganizationService } from './organization.service';
@Module({
controllers: [OrganizationController],
providers: [OrganizationService, PrismaService],
})
export class OrganizationModule {}

View File

@@ -0,0 +1,630 @@
import {
BadRequestException,
ConflictException,
ForbiddenException,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { LinkStatus } from '@prisma/client';
import * as bcrypt from 'bcrypt';
import { createHash, randomBytes } from 'crypto';
import { PrismaService } from '../../../prisma/prisma.service';
import { AcceptOrganizationInviteDto } from './dto/accept-organization-invite.dto';
import { CreateLinkRequestDto } from './dto/create-link-request.dto';
import { InviteOrganizationDto } from './dto/invite-organization.dto';
import { RespondLinkRequestDto } from './dto/respond-link-request.dto';
@Injectable()
export class OrganizationService {
constructor(private readonly prisma: PrismaService) {}
getOrganizationIdFromUser(user: { organizationId?: string }) {
if (!user?.organizationId) {
throw new BadRequestException('Organization is not selected');
}
return user.organizationId;
}
async searchCounterpartOrganizations(userId: string, organizationId: string, query: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const targetType = this.getCounterpartType(actor.organization.type.name);
const q = query.trim();
const organizations = await this.prisma.organization.findMany({
where: {
type: { name: targetType },
planId: { not: null },
...(q
? {
OR: [
{ name: { contains: q, mode: 'insensitive' } },
{ email: { contains: q, mode: 'insensitive' } },
{ phone: { contains: q, mode: 'insensitive' } },
],
}
: {}),
},
select: {
id: true,
name: true,
email: true,
phone: true,
owner: { select: { email: true, name: true } },
},
orderBy: { name: 'asc' },
take: 25,
});
return { success: true, data: organizations };
}
async list(userId: string, organizationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const [linksA, linksB] = await Promise.all([
this.prisma.organizationLink.findMany({
where: { organizationAId: organizationId },
include: {
organizationB: { select: { id: true, name: true, email: true, phone: true, type: true } },
},
orderBy: { createdAt: 'desc' },
}),
this.prisma.organizationLink.findMany({
where: { organizationBId: organizationId },
include: {
organizationA: { select: { id: true, name: true, email: true, phone: true, type: true } },
},
orderBy: { createdAt: 'desc' },
}),
]);
const linkItems = [
...linksA.map((l) => ({
requestedByOrganizationId: this.getRequesterOrganizationId(l.sharedDataTypes),
id: l.id,
counterpartOrganizationId: l.organizationB.id,
organizationName: l.organizationB.name,
ownerEmail: l.organizationB.email,
phone: l.organizationB.phone,
status: l.status,
createdAt: l.createdAt.toISOString(),
acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null,
})),
...linksB.map((l) => ({
requestedByOrganizationId: this.getRequesterOrganizationId(l.sharedDataTypes),
id: l.id,
counterpartOrganizationId: l.organizationA.id,
organizationName: l.organizationA.name,
ownerEmail: l.organizationA.email,
phone: l.organizationA.phone,
status: l.status,
createdAt: l.createdAt.toISOString(),
acceptedAt: l.status === LinkStatus.ACTIVE ? l.updatedAt.toISOString() : null,
})),
];
return {
success: true,
data: {
items: linkItems.sort((a, b) =>
a.createdAt < b.createdAt ? 1 : -1,
),
},
};
}
async listInvitationHistory(userId: string, organizationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const invitations = await this.prisma.organizationInvitation.findMany({
where: { inviterOrganizationId: organizationId },
orderBy: { createdAt: 'desc' },
});
return {
success: true,
data: {
items: invitations.map((i) => ({
id: i.id,
organizationName: i.invitedOrganizationName,
ownerEmail: i.invitedOwnerEmail,
status: this.mapInvitationStatus(i.acceptedAt, i.revokedAt, i.expiresAt),
createdAt: i.createdAt.toISOString(),
acceptedAt: i.acceptedAt?.toISOString() ?? null,
})),
},
};
}
async createLinkRequest(userId: string, organizationId: string, dto: CreateLinkRequestDto) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
if (dto.targetOrganizationId === organizationId) {
throw new BadRequestException('You cannot link organization to itself');
}
const sourceType = actor.organization.type.name;
const targetType = this.getCounterpartType(sourceType);
const target = await this.prisma.organization.findUnique({
where: { id: dto.targetOrganizationId },
select: { id: true, type: true, planId: true },
});
if (!target) {
throw new NotFoundException('Organization not found');
}
if (target.type.name !== targetType) {
throw new BadRequestException(`You can only link to ${targetType} organizations`);
}
if (!target.planId) {
throw new BadRequestException('Target organization does not have an active subscription');
}
const [aId, bId] =
organizationId < dto.targetOrganizationId
? [organizationId, dto.targetOrganizationId]
: [dto.targetOrganizationId, organizationId];
const existing = await this.prisma.organizationLink.findUnique({
where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } },
});
if (existing) {
throw new ConflictException('Link already exists for these organizations');
}
const created = await this.prisma.organizationLink.create({
data: {
organizationAId: aId,
organizationBId: bId,
status: LinkStatus.PENDING,
sharedDataTypes: [`requested_by:${organizationId}`],
},
});
return {
success: true,
data: { id: created.id, status: created.status },
message: 'Link request created',
};
}
async respondToLinkRequest(
userId: string,
organizationId: string,
linkId: string,
dto: RespondLinkRequestDto,
) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const link = await this.prisma.organizationLink.findFirst({
where: {
id: linkId,
OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }],
},
});
if (!link) {
throw new NotFoundException('Link request not found');
}
if (link.status !== LinkStatus.PENDING) {
throw new BadRequestException('Only pending link requests can be responded to');
}
const requesterOrgId = this.getRequesterOrganizationId(link.sharedDataTypes);
if (requesterOrgId && requesterOrgId === organizationId) {
throw new ForbiddenException('You cannot respond to your own link request');
}
const nextStatus = dto.action === 'ACCEPT' ? LinkStatus.ACTIVE : LinkStatus.REJECTED;
const updated = await this.prisma.organizationLink.update({
where: { id: link.id },
data: { status: nextStatus },
});
return {
success: true,
data: { id: updated.id, status: updated.status },
message: nextStatus === LinkStatus.ACTIVE ? 'Link request accepted' : 'Link request rejected',
};
}
async deleteLink(userId: string, organizationId: string, linkId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const link = await this.prisma.organizationLink.findFirst({
where: {
id: linkId,
status: LinkStatus.ACTIVE,
OR: [{ organizationAId: organizationId }, { organizationBId: organizationId }],
},
select: { id: true },
});
if (!link) {
throw new NotFoundException('Linked organization not found');
}
await this.prisma.organizationLink.delete({ where: { id: link.id } });
return {
success: true,
data: { id: link.id },
message: 'Linked organization removed',
};
}
async getInvitationLink(userId: string, organizationId: string, invitationId: string) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const invitation = await this.prisma.organizationInvitation.findFirst({
where: {
id: invitationId,
inviterOrganizationId: organizationId,
},
select: {
id: true,
acceptedAt: true,
revokedAt: true,
},
});
if (!invitation) {
throw new NotFoundException('Invitation not found');
}
if (invitation.acceptedAt || invitation.revokedAt) {
throw new BadRequestException('Only pending invitations can provide a link');
}
const plainToken = this.generateInviteToken();
const tokenHash = this.hashInviteToken(plainToken);
await this.prisma.organizationInvitation.update({
where: { id: invitation.id },
data: {
tokenHash,
expiresAt: this.getInviteExpiryDate(),
},
});
return {
success: true,
data: {
invitationId: invitation.id,
invitationUrl: this.buildInviteUrl(plainToken),
},
};
}
async inviteOrganization(userId: string, organizationId: string, dto: InviteOrganizationDto) {
const actor = await this.getActorMembership(userId, organizationId);
if (!actor || !this.canEditOrganizations(actor)) {
throw new ForbiddenException('You do not have permission to manage organizations');
}
const ownerEmail = dto.ownerEmail.trim().toLowerCase();
const inviterType = actor.organization.type.name;
const invitedType = this.getCounterpartType(inviterType);
const plainToken = this.generateInviteToken();
const tokenHash = this.hashInviteToken(plainToken);
const existingOwnerWithPlan = await this.prisma.organization.findFirst({
where: {
owner: { email: ownerEmail },
planId: { not: null },
},
select: { id: true },
});
if (existingOwnerWithPlan) {
throw new BadRequestException(
'This owner already has an organization with active subscription. Select that organization from search instead of sending invitation.',
);
}
const invitation = await this.prisma.$transaction(async (tx) => {
let owner = await tx.user.findUnique({ where: { email: ownerEmail } });
if (!owner) {
owner = await tx.user.create({
data: {
email: ownerEmail,
name: dto.organizationName.trim(),
passwordHash: null,
},
});
}
let invitedOrg = await tx.organization.findFirst({
where: {
ownerId: owner.id,
type: { name: invitedType },
},
select: { id: true },
orderBy: { createdAt: 'desc' },
});
if (!invitedOrg) {
invitedOrg = await tx.organization.create({
data: {
name: dto.organizationName.trim(),
email: `pending-${plainToken.slice(0, 12)}@dyolink.local`,
owner: { connect: { id: owner.id } },
type: { connect: { name: invitedType } },
},
select: { id: true },
});
}
const [aId, bId] =
organizationId < invitedOrg.id
? [organizationId, invitedOrg.id]
: [invitedOrg.id, organizationId];
const existingLink = await tx.organizationLink.findUnique({
where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } },
});
if (existingLink?.status === LinkStatus.ACTIVE) {
throw new ConflictException('These organizations are already linked');
}
await tx.organizationLink.upsert({
where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } },
update: {
status: LinkStatus.PENDING,
sharedDataTypes: [`requested_by:${organizationId}`],
},
create: {
organizationAId: aId,
organizationBId: bId,
status: LinkStatus.PENDING,
sharedDataTypes: [`requested_by:${organizationId}`],
},
});
return tx.organizationInvitation.create({
data: {
inviterOrganizationId: organizationId,
inviterUserId: userId,
invitedOrganizationId: invitedOrg.id,
invitedOrganizationName: dto.organizationName.trim(),
invitedOwnerEmail: ownerEmail,
invitedOrganizationType: invitedType,
tokenHash,
expiresAt: this.getInviteExpiryDate(),
},
});
});
return {
success: true,
data: {
invitationId: invitation.id,
invitationUrl: this.buildInviteUrl(plainToken),
status: 'PENDING',
},
};
}
async previewInvite(token: string) {
const invitation = await this.findValidInvitation(token);
return {
success: true,
data: {
ownerEmail: invitation.invitedOwnerEmail,
organizationName: invitation.invitedOrganizationName,
organizationType: invitation.invitedOrganizationType,
inviterOrganizationName: invitation.inviterOrganization.name,
expiresAt: invitation.expiresAt.toISOString(),
status: invitation.acceptedAt ? 'ACCEPTED' : 'PENDING',
},
};
}
async acceptInvite(dto: AcceptOrganizationInviteDto) {
const invitation = await this.findValidInvitation(dto.token);
if (invitation.acceptedAt) {
throw new BadRequestException('This invitation has already been accepted');
}
const organization = await this.prisma.$transaction(async (tx) => {
const passwordHash = await bcrypt.hash(dto.password, 10);
const ownerEmail = invitation.invitedOwnerEmail;
let owner = await tx.user.findUnique({ where: { email: ownerEmail } });
if (!owner) {
owner = await tx.user.create({
data: {
email: ownerEmail,
name: dto.ownerName.trim(),
passwordHash,
trialUsedAt: new Date(),
},
});
} else if (!owner.passwordHash) {
owner = await tx.user.update({
where: { id: owner.id },
data: { passwordHash, name: dto.ownerName.trim(), trialUsedAt: owner.trialUsedAt ?? new Date() },
});
}
let targetOrganizationId = invitation.invitedOrganizationId;
if (targetOrganizationId) {
await tx.organization.update({
where: { id: targetOrganizationId },
data: {
name: dto.organizationName.trim(),
email: ownerEmail,
owner: { connect: { id: owner.id } },
plan: { connect: { name: 'trial' } },
},
});
} else {
const createdOrg = await tx.organization.create({
data: {
name: dto.organizationName.trim(),
email: ownerEmail,
owner: { connect: { id: owner.id } },
type: { connect: { name: invitation.invitedOrganizationType } },
plan: { connect: { name: 'trial' } },
},
});
targetOrganizationId = createdOrg.id;
}
const ownerMembership = await tx.membership.findFirst({
where: { userId: owner.id, organizationId: targetOrganizationId },
select: { id: true },
});
if (!ownerMembership) {
await tx.membership.create({
data: {
userId: owner.id,
organizationId: targetOrganizationId,
isOwner: true,
isActive: true,
},
});
}
const [aId, bId] =
invitation.inviterOrganizationId < targetOrganizationId
? [invitation.inviterOrganizationId, targetOrganizationId]
: [targetOrganizationId, invitation.inviterOrganizationId];
await tx.organizationLink.upsert({
where: { organizationAId_organizationBId: { organizationAId: aId, organizationBId: bId } },
update: { status: LinkStatus.ACTIVE },
create: {
organizationAId: aId,
organizationBId: bId,
status: LinkStatus.ACTIVE,
sharedDataTypes: [],
},
});
await tx.organizationInvitation.update({
where: { id: invitation.id },
data: {
acceptedAt: new Date(),
invitedOrganizationId: targetOrganizationId,
invitedOrganizationName: dto.organizationName.trim(),
},
});
return targetOrganizationId;
});
return {
success: true,
data: { organizationId: organization },
message: 'Invitation accepted. Organization trial has started and link is active.',
};
}
private async getActorMembership(userId: string, organizationId: string) {
return this.prisma.membership.findFirst({
where: { userId, organizationId },
include: {
organization: {
select: {
id: true,
type: true,
},
},
permissions: { include: { permission: true } },
},
});
}
private canEditOrganizations(m: {
isOwner: boolean;
permissions: { permission: { name: string } }[];
}): boolean {
if (m.isOwner) return true;
return m.permissions.some((p) => p.permission.name === 'TAB_ORGANIZATIONS_EDIT');
}
private getCounterpartType(orgType: string): 'CLINIC' | 'LAB' {
if (orgType === 'CLINIC') return 'LAB';
if (orgType === 'LAB') return 'CLINIC';
throw new BadRequestException('Unknown organization type');
}
private mapInvitationStatus(
acceptedAt: Date | null,
revokedAt: Date | null,
expiresAt: Date,
): LinkStatus | 'EXPIRED' {
if (acceptedAt) return LinkStatus.ACTIVE;
if (revokedAt) return LinkStatus.REJECTED;
return expiresAt.getTime() > Date.now() ? LinkStatus.PENDING : 'EXPIRED';
}
private generateInviteToken(): string {
return randomBytes(32).toString('hex');
}
private hashInviteToken(token: string): string {
return createHash('sha256').update(token).digest('hex');
}
private getInviteExpiryDate(): Date {
const d = new Date();
d.setDate(d.getDate() + 7);
return d;
}
private buildInviteUrl(token: string): string {
const appUrl = process.env.FRONTEND_URL || 'http://localhost:3001';
return `${appUrl}/accept-organization-invite?token=${encodeURIComponent(token)}`;
}
private buildInviteUrlFromTokenHashPlaceholder(): null {
// Raw token cannot be reconstructed from hash, so pending links are preserved client-side after creation.
return null;
}
private getRequesterOrganizationId(sharedDataTypes: unknown): string | null {
if (!Array.isArray(sharedDataTypes)) return null;
for (const v of sharedDataTypes) {
if (typeof v !== 'string') continue;
if (!v.startsWith('requested_by:')) continue;
const id = v.slice('requested_by:'.length).trim();
if (id) return id;
}
return null;
}
private async findValidInvitation(token: string) {
const invitation = await this.prisma.organizationInvitation.findUnique({
where: { tokenHash: this.hashInviteToken(token) },
include: {
inviterOrganization: { select: { id: true, name: true } },
},
});
if (!invitation) {
throw new NotFoundException('Invitation not found');
}
if (invitation.revokedAt) {
throw new BadRequestException('Invitation has been revoked');
}
if (invitation.expiresAt.getTime() <= Date.now()) {
throw new BadRequestException('Invitation has expired');
}
return invitation;
}
}

View File

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

View File

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

View File

@@ -1,72 +1,53 @@
# Build stage
FROM node:18-alpine AS builder
# Build stage — produces `.next/standalone` (see next.config.ts output: standalone)
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package files
COPY package*.json ./
RUN npm ci
# Copy source code
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 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
# Production stage
FROM node:18-alpine
# Production — minimal runtime using Next.js standalone bundle
FROM node:20-alpine AS runner
RUN apk add --no-cache dumb-init
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 && \
adduser -S dyolink -u 1001
# Copy package files
COPY package*.json ./
ENV NODE_ENV=production
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/next.config.js ./next.config.js
COPY --from=builder /app/package.json ./package.json
COPY --from=builder --chown=dyolink:nodejs /app/.next/standalone ./
COPY --from=builder --chown=dyolink:nodejs /app/.next/static ./.next/static
# Create logs directory
RUN mkdir -p /app/logs && \
chown -R dyolink:nodejs /app
COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
# Set ownership
RUN chown -R dyolink:nodejs /app
# Switch to non-root user
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
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"
ENV NODE_ENV=production
HEALTHCHECK --interval=30s --timeout=5s --start-period=40s --retries=3 \
CMD node -e "require('http').get('http://127.0.0.1:3000/', (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 for signal handling
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
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.
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
| Command | Purpose |
|--------|---------|
| `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
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.
Image build and build-args (`NEXT_PUBLIC_*`) are documented in the **repository root `README.md`**.

View File

@@ -1,6 +1,17 @@
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} */
const nextConfig = {
// Enable React strict mode
@@ -9,12 +20,19 @@ const nextConfig = {
// Disable x-powered-by header for security
poweredByHeader: false,
// Configure allowed remote image sources
// Configure allowed remote image sources (hostname derived from NEXT_PUBLIC_APP_URL at build time)
images: {
remotePatterns:
process.env.NODE_ENV === 'production'
? [{ protocol: 'https', hostname: 'yourdomain.com' }]
: [{ protocol: 'http', hostname: 'localhost' }],
remotePatterns: [
{ protocol: "http", hostname: "localhost" },
...(appHost
? [
{ 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

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": {
"dev": "next dev -p 3001",
"build": "next build",
"start": "next start -p 3001",
"start": "next start -p 3000",
"lint": "next lint"
},
"dependencies": {

View File

@@ -1,10 +1,349 @@
'use client';
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { appointmentsApi } from '@/lib/api/appointments';
import { patientsApi } from '@/lib/api/patients';
import { useAuth } from '@/lib/hooks/useAuth';
import { canEditAppointments, hasPermission } from '@/shared/permissions';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import type { CreatePatientInput, Patient } from '@/types/patient';
import { CreatePatientModal } from '@/components/ui/patient/CreatePatientModal';
import { PatientSummaryCard } from '@/components/ui/patient/PatientSummaryCard';
import { AppointmentBookingModal } from '@/components/ui/appointments/AppointmentBookingModal';
import { AppointmentScheduleGrid } from '@/components/ui/appointments/AppointmentScheduleGrid';
import { AppointmentsPatientSearch } from '@/components/ui/appointments/AppointmentsPatientSearch';
import { AppointmentScheduleLegend } from '@/components/ui/appointments/AppointmentScheduleLegend';
import { ScheduleDayPicker } from '@/components/ui/common/ScheduleDayPicker';
import type { AppointmentPurpose } from '@/types/appointment';
import { formatApiErrorMessage } from '@/lib/formatApiError';
import { getLocalDayIsoRange, startOfLocalDay } from '@/lib/appointmentTime';
const EMPTY_PATIENT_FORM: CreatePatientInput = {
firstName: '',
lastName: '',
phone: '',
email: '',
};
export default function AppointmentsPage() {
const { currentOrganization } = useAuth();
const [scheduleDate, setScheduleDate] = useState(() => startOfLocalDay(new Date()));
const [providers, setProviders] = useState<AppointmentColumnProvider[]>([]);
const [appointments, setAppointments] = useState<AppointmentRecord[]>([]);
const [loadingSchedule, setLoadingSchedule] = useState(false);
const [scheduleError, setScheduleError] = useState('');
const [search, setSearch] = useState('');
const [patients, setPatients] = useState<Patient[]>([]);
const [selectedPatient, setSelectedPatient] = useState<Patient | undefined>();
const [loadingPatients, setLoadingPatients] = useState(false);
const [isCreateOpen, setIsCreateOpen] = useState(false);
const [savingPatient, setSavingPatient] = useState(false);
const [patientForm, setPatientForm] = useState<CreatePatientInput>(EMPTY_PATIENT_FORM);
const [bookingOpen, setBookingOpen] = useState(false);
const [bookingHour, setBookingHour] = useState(9);
const [bookingProviderId, setBookingProviderId] = useState<string | null>(null);
const [bookingProviderName, setBookingProviderName] = useState('');
const [savingAppointment, setSavingAppointment] = useState(false);
const [toastError, setToastError] = useState('');
const [toastSuccess, setToastSuccess] = useState('');
const [toastInfo, setToastInfo] = useState('');
const canManageAppointments = canEditAppointments(currentOrganization);
const canEditPatients = hasPermission(currentOrganization, 'TAB_PATIENTS_EDIT');
const todayStart = useMemo(() => startOfLocalDay(new Date()), []);
const scheduleLoadGen = useRef(0);
const sortedPatients = useMemo(
() =>
[...patients].sort((a, b) =>
`${a.firstName} ${a.lastName}`.localeCompare(`${b.firstName} ${b.lastName}`),
),
[patients],
);
const loadSchedule = useCallback(async () => {
if (!currentOrganization?.id) {
return;
}
const gen = ++scheduleLoadGen.current;
setLoadingSchedule(true);
setScheduleError('');
try {
const range = getLocalDayIsoRange(scheduleDate);
const [pRes, aRes] = await Promise.all([
appointmentsApi.columnProviders(),
appointmentsApi.list(range),
]);
if (gen !== scheduleLoadGen.current) {
return;
}
setProviders(pRes.data);
setAppointments(aRes.data);
} catch (err: unknown) {
if (gen !== scheduleLoadGen.current) {
return;
}
setScheduleError(formatApiErrorMessage(err, 'Failed to load schedule.'));
} finally {
if (gen === scheduleLoadGen.current) {
setLoadingSchedule(false);
}
}
}, [currentOrganization?.id, scheduleDate]);
useEffect(() => {
void loadSchedule();
}, [loadSchedule]);
useEffect(() => {
const t = setTimeout(() => {
void loadPatientsSearch(search);
}, 300);
return () => clearTimeout(t);
}, [search]);
async function loadPatientsSearch(q: string) {
if (!currentOrganization) {
return;
}
setLoadingPatients(true);
try {
const response = await patientsApi.list({ q, page: 1, limit: 25 });
const items = response.data.items;
setPatients(items);
if (selectedPatient) {
const stillThere = items.find((p) => p.id === selectedPatient.id);
if (stillThere) {
setSelectedPatient(stillThere);
}
}
} catch {
setPatients([]);
} finally {
setLoadingPatients(false);
}
}
async function handleCreatePatient() {
setSavingPatient(true);
setToastError('');
setToastSuccess('');
try {
const response = await patientsApi.create(patientForm);
setIsCreateOpen(false);
setPatientForm(EMPTY_PATIENT_FORM);
await loadPatientsSearch(search);
setSelectedPatient(response.data);
setToastSuccess(`Patient ${response.data.firstName} ${response.data.lastName} was saved.`);
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: 'Failed to save patient.';
setToastError(message);
} finally {
setSavingPatient(false);
}
}
function handleSlotClick(hour: number, providerUserId: string, providerName: string) {
if (!selectedPatient) {
setToastSuccess('');
setToastError('');
setToastInfo('Select a patient before booking.');
return;
}
setBookingHour(hour);
setBookingProviderId(providerUserId);
setBookingProviderName(providerName);
setBookingOpen(true);
}
async function handleSaveAppointment(payload: {
patientId: string;
providerUserId: string;
startAt: string;
endAt: string;
purpose: AppointmentPurpose;
}) {
setSavingAppointment(true);
setToastError('');
setToastSuccess('');
setToastInfo('');
try {
await appointmentsApi.create(payload);
setBookingOpen(false);
setToastSuccess('Appointment saved.');
await loadSchedule();
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: 'Could not save appointment.';
setToastError(message);
} finally {
setSavingAppointment(false);
}
}
async function handleDeleteAppointment(id: string) {
if (!window.confirm('Remove this appointment?')) {
return;
}
setToastError('');
setToastSuccess('');
setToastInfo('');
try {
await appointmentsApi.remove(id);
setToastSuccess('Appointment removed.');
await loadSchedule();
} catch (err: unknown) {
const message =
err && typeof err === 'object' && 'message' in err
? String((err as { message: unknown }).message)
: 'Could not delete appointment.';
setToastError(message);
}
}
useEffect(() => {
if (!toastSuccess) {
return;
}
const id = setTimeout(() => setToastSuccess(''), 3200);
return () => clearTimeout(id);
}, [toastSuccess]);
useEffect(() => {
if (!toastError) {
return;
}
const id = setTimeout(() => setToastError(''), 4000);
return () => clearTimeout(id);
}, [toastError]);
useEffect(() => {
if (!toastInfo) {
return;
}
const id = setTimeout(() => setToastInfo(''), 4000);
return () => clearTimeout(id);
}, [toastInfo]);
return (
<div className="space-y-3">
<h1 className="text-2xl font-semibold text-text-primary">Appointments</h1>
<p className="text-sm text-text-secondary">
Appointments module is coming soon.
</p>
<div className="relative space-y-6 pb-24">
<div className="grid grid-cols-1 xl:grid-cols-3 gap-6">
<div className="xl:col-span-1 space-y-4">
<div className="flex flex-col gap-1">
<h1 className="text-2xl font-semibold text-text-primary">Appointments</h1>
<p className="text-sm text-text-secondary">
Search a patient, pick a date, then click a time slot under a provider to book.
</p>
</div>
<AppointmentsPatientSearch
search={search}
onSearchChange={setSearch}
patients={sortedPatients}
selectedPatientId={selectedPatient?.id}
onSelectPatient={setSelectedPatient}
loading={loadingPatients}
canAddPatient={canEditPatients}
onAddPatient={() => {
if (!canEditPatients) {
return;
}
setIsCreateOpen(true);
}}
/>
<PatientSummaryCard patient={selectedPatient} />
</div>
<div className="xl:col-span-2 space-y-4">
<AppointmentScheduleLegend />
<div className="flex flex-col sm:flex-row sm:items-end gap-4 sm:justify-between">
<ScheduleDayPicker
value={scheduleDate}
minDate={todayStart}
onChange={(d) => setScheduleDate(startOfLocalDay(d))}
/>
{loadingSchedule && (
<p className="text-sm text-text-muted pb-2">Loading schedule</p>
)}
</div>
{scheduleError && (
<div className="rounded-[var(--radius-sm)] border border-red-500/50 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{scheduleError}
</div>
)}
<AppointmentScheduleGrid
day={scheduleDate}
providers={providers}
appointments={appointments}
canBook={canManageAppointments}
canDelete={canManageAppointments}
onDeleteAppointment={(id) => void handleDeleteAppointment(id)}
onSlotClick={(hour, uid, name) => handleSlotClick(hour, uid, name)}
/>
</div>
</div>
<AppointmentBookingModal
open={bookingOpen}
scheduleDate={scheduleDate}
patient={selectedPatient}
providerUserId={bookingProviderId}
providerName={bookingProviderName}
initialHour={bookingHour}
onClose={() => setBookingOpen(false)}
onSubmit={handleSaveAppointment}
loading={savingAppointment}
/>
{isCreateOpen && (
<div className="fixed inset-0 z-[60] flex items-center justify-center p-4 bg-black/55">
<CreatePatientModal
isOpen={isCreateOpen}
formData={patientForm}
onChange={(patch) => setPatientForm((prev) => ({ ...prev, ...patch }))}
onSubmit={() => void handleCreatePatient()}
onClose={() => setIsCreateOpen(false)}
loading={savingPatient}
/>
</div>
)}
{(toastError || toastSuccess || toastInfo) && (
<div className="fixed bottom-4 left-4 right-4 z-[70] flex justify-center pointer-events-none">
<div className="pointer-events-auto w-full max-w-lg space-y-2">
{toastError && (
<div className="rounded-[var(--radius-sm)] border border-red-500/50 bg-red-500/10 px-3 py-2 text-sm text-red-300 shadow-lg">
{toastError}
</div>
)}
{toastInfo && (
<div className="rounded-[var(--radius-sm)] border border-amber-500/45 bg-amber-500/10 px-3 py-2 text-sm text-amber-100 shadow-lg">
{toastInfo}
</div>
)}
{toastSuccess && (
<div className="rounded-[var(--radius-sm)] border border-emerald-500/50 bg-emerald-500/10 px-3 py-2 text-sm text-emerald-300 shadow-lg">
{toastSuccess}
</div>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,10 +1,11 @@
// src/app/(dashboard)/billing/page.tsx
'use client';
import { useState } from 'react';
import { Search, Filter, Plus } from 'lucide-react';
import { Pencil } from 'lucide-react';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { Badge } from '@/components/ui/common/Badge';
import { Table } from '@/components/ui/common/Table';
import { SearchBar } from '@/components/ui/common/SearchBar';
import { useAuth } from '@/lib/hooks/useAuth';
import { hasPermission } from '@/shared/permissions';
// Mock data matching your design
@@ -44,11 +45,9 @@ export default function BillingPage() {
<h1 className="text-2xl font-semibold text-text-primary">Billing</h1>
<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" />
New Invoice
</Button>
</div>
@@ -80,17 +79,12 @@ export default function BillingPage() {
/>
</div>
{/* Filters */}
<div className="surface-card p-4">
<div className="flex gap-4 items-center">
<div className="flex-1">
<Input
placeholder="Search patients..."
value={search}
onChange={(e) => setSearch(e.target.value)}
icon={<Search className="h-4 w-4 icon-flat" />}
/>
</div>
<div className="flex gap-2">
<SearchBar
value={search}
onChange={setSearch}
placeholder="Search patients..."
actions={(
<>
{['all', 'paid', 'unpaid', 'overdue'].map((status) => (
<button
key={status}
@@ -103,14 +97,13 @@ export default function BillingPage() {
{status}
</button>
))}
</div>
</div>
</div>
</>
)}
/>
{/* Invoices Table - Matching your design */}
<div className="surface-card overflow-hidden">
<table className="w-full">
<thead className="bg-background-secondary/70 border-b border-border">
<tr>
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Invoice ID
</th>
@@ -129,36 +122,37 @@ export default function BillingPage() {
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Paid
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Action
</th>
</tr>
</thead>
<tbody className="divide-y divide-border/60">
}
body={
<>
{invoices.map((invoice) => (
<tr key={invoice.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-4 text-sm font-medium text-text-primary">
<tr key={invoice.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm font-medium text-text-primary">
{invoice.id}
</td>
<td className="px-6 py-4 text-sm text-text-primary">
<td className="px-6 py-1.5 text-sm text-text-primary">
{invoice.patient}
</td>
<td className="px-6 py-4 text-sm text-text-secondary">
<td className="px-6 py-1.5 text-sm text-text-secondary">
{invoice.date}
</td>
<td className="px-6 py-4 text-sm text-text-primary">
<td className="px-6 py-1.5 text-sm text-text-primary">
{invoice.service}
</td>
<td className="px-6 py-4 text-sm text-text-primary">
<td className="px-6 py-1.5 text-sm text-text-primary">
${invoice.amount}
</td>
<td className="px-6 py-4 text-sm text-text-primary">
<td className="px-6 py-1.5 text-sm text-text-primary">
${invoice.paid}
</td>
<td className="px-6 py-4">
<td className="px-6 py-1.5 text-center align-middle">
<Badge
variant={statusColors[invoice.status as keyof typeof statusColors]}
className="capitalize"
@@ -166,21 +160,24 @@ export default function BillingPage() {
{invoice.status}
</Badge>
</td>
<td className="px-6 py-4">
<td className="px-6 py-1.5">
<button
className={`text-sm ${canEditBilling ? 'text-primary hover:opacity-90' : 'text-text-muted cursor-not-allowed'}`}
className={`p-2 rounded-md ${canEditBilling
? 'text-text-secondary hover:bg-background-card/80 hover:text-text-primary'
: 'text-text-muted cursor-not-allowed opacity-50'}`}
disabled={!canEditBilling}
title={!canEditBilling ? 'Read-only access for this organization.' : undefined}
aria-label="Edit invoice"
>
Edit
<Pencil className="w-4 h-4" />
</button>
</td>
</tr>
</tr>
))}
</tbody>
</table>
{/* Pagination - Matching your design */}
<div className="px-6 py-4 border-t border-border/60 flex justify-between items-center bg-background-secondary/70">
</>
}
footer={(
<>
<button className="text-sm text-text-secondary hover:text-text-primary">
Previous
</button>
@@ -190,8 +187,9 @@ export default function BillingPage() {
<button className="text-sm text-text-secondary hover:text-text-primary">
Next
</button>
</div>
</div>
</>
)}
/>
</div>
);
}

View File

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

View File

@@ -7,6 +7,7 @@ import Sidebar from '@/components/ui/common/Sidebar';
import { ThemeToggle } from '@/components/ui/common/ThemeToggle';
import { DashboardAccountMenu } from '@/components/ui/dashboard/DashboardAccountMenu';
import {
canAccessAppointmentsSection,
firstAccessibleDashboardPath,
getRequiredReadPermissionForPath,
hasPermission,
@@ -32,8 +33,14 @@ export default function DashboardLayout({ children }: { children: React.ReactNod
}
const required = getRequiredReadPermissionForPath(pathname);
if (required && !hasPermission(currentOrganization, required)) {
router.replace(firstAccessibleDashboardPath(currentOrganization));
if (required) {
const allowed =
hasPermission(currentOrganization, required) ||
(required === 'TAB_APPOINTMENTS_READ' &&
canAccessAppointmentsSection(currentOrganization));
if (!allowed) {
router.replace(firstAccessibleDashboardPath(currentOrganization));
}
}
}, [isAuthReady, user, currentOrganization, router, pathname]);

View File

@@ -0,0 +1,602 @@
'use client';
import { useEffect, useState } from 'react';
import { Check, Copy, Link2, Trash2, X } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import {
organizationApi,
type CounterpartItemDto,
type CounterpartSearchResultDto,
type OrganizationInvitationHistoryItemDto,
} from '@/lib/api/organization';
import { Button } from '@/components/ui/common/Button';
import { Badge, organizationLinkStatusVariant } from '@/components/ui/common/Badge';
import { Input } from '@/components/ui/common/Input';
import { SearchBar } from '@/components/ui/common/SearchBar';
import { Table } from '@/components/ui/common/Table';
import type { ApiError } from '@/types/api';
type StoredInviteLink = {
invitationId: string;
ownerEmail: string;
invitationUrl: string;
};
function inviteLinksStorageKey(orgId: string): string {
return `counterpartInviteLinks:${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 formatOrganizationStatusLabel(status: string): string {
if (!status) return status;
const lower = status.toLowerCase();
return lower.charAt(0).toUpperCase() + lower.slice(1);
}
function formatLinkStatusLabel(status: CounterpartItemDto['status']): string {
if (status === 'PENDING') return 'Link request pending';
if (status === 'ACTIVE') return 'Linked';
if (status === 'REJECTED') return 'Link request rejected';
return formatOrganizationStatusLabel(status);
}
function formatInvitationStatusLabel(status: OrganizationInvitationHistoryItemDto['status']): string {
if (status === 'PENDING') return 'Invitation pending';
if (status === 'ACTIVE') return 'Invitation Accepted';
if (status === 'REJECTED') return 'Invitation rejected';
return formatOrganizationStatusLabel(status);
}
function formatApiMessage(err: unknown): string {
if (!err || typeof err !== 'object') return 'Something went wrong';
const m = (err as ApiError).message;
if (Array.isArray(m)) return m.join(', ');
if (typeof m === 'string') return m;
return 'Something went wrong';
}
function formatTableDate(value: string): string {
const d = new Date(value);
if (Number.isNaN(d.getTime())) return '—';
return d.toLocaleDateString();
}
type TableMode = 'existing' | 'search';
export default function OrganizationsPage() {
const { currentOrganization } = useAuth();
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [query, setQuery] = useState('');
const [mode, setMode] = useState<TableMode>('existing');
const [searching, setSearching] = useState(false);
const [searchResults, setSearchResults] = useState<CounterpartSearchResultDto[]>([]);
const [requestLinkRowId, setRequestLinkRowId] = useState<string | null>(null);
const [deleteLinkRowId, setDeleteLinkRowId] = useState<string | null>(null);
const [items, setItems] = useState<CounterpartItemDto[]>([]);
const [manualOrganizationName, setManualOrganizationName] = useState('');
const [manualOwnerEmail, setManualOwnerEmail] = useState('');
const [inviteLoading, setInviteLoading] = useState(false);
const [copiedId, setCopiedId] = useState<string | null>(null);
const [pendingInviteLinks, setPendingInviteLinks] = useState<Record<string, StoredInviteLink>>({});
const [showInviteForm, setShowInviteForm] = useState(false);
const [historyOpen, setHistoryOpen] = useState(false);
const [historyLoading, setHistoryLoading] = useState(false);
const [historyItems, setHistoryItems] = useState<OrganizationInvitationHistoryItemDto[]>([]);
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinic' : 'Lab';
const tabLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
const existingRows = items;
async function loadList() {
setLoading(true);
setError('');
try {
const res = await organizationApi.list();
setItems(res.data.items);
} catch (e) {
setError(formatApiMessage(e));
} finally {
setLoading(false);
}
}
useEffect(() => {
if (!currentOrganization?.id) return;
setPendingInviteLinks(readStoredInviteLinks(currentOrganization.id));
}, [currentOrganization?.id]);
useEffect(() => {
void loadList();
}, []);
useEffect(() => {
if (!success) return;
const t = setTimeout(() => setSuccess(''), 4000);
return () => clearTimeout(t);
}, [success]);
async function runSearch() {
const q = query.trim();
if (!q) {
setMode('existing');
setSearchResults([]);
setShowInviteForm(false);
return;
}
setSearching(true);
setError('');
setMode('search');
setShowInviteForm(false);
try {
const res = await organizationApi.search(q);
setSearchResults(res.data);
} catch (e) {
setError(formatApiMessage(e));
setSearchResults([]);
} finally {
setSearching(false);
}
}
async function submitRequestLink(targetOrganizationId: string) {
setRequestLinkRowId(targetOrganizationId);
setError('');
try {
await organizationApi.createLink(targetOrganizationId);
setSuccess(`${counterpartLabel} link request sent`);
setSearchResults([]);
setQuery('');
setMode('existing');
await loadList();
} catch (e) {
setError(formatApiMessage(e));
} finally {
setRequestLinkRowId(null);
}
}
async function sendInvite() {
setInviteLoading(true);
setError('');
try {
const res = await organizationApi.invite({
organizationName: manualOrganizationName.trim(),
ownerEmail: manualOwnerEmail.trim(),
});
if (currentOrganization?.id) {
const nextLinks = {
...pendingInviteLinks,
[res.data.invitationId]: {
invitationId: res.data.invitationId,
ownerEmail: manualOwnerEmail.trim().toLowerCase(),
invitationUrl: res.data.invitationUrl,
},
};
setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}
setSuccess(`Invitation link created for ${manualOwnerEmail.trim()}`);
setManualOrganizationName('');
setManualOwnerEmail('');
setShowInviteForm(false);
setMode('existing');
setQuery('');
setSearchResults([]);
await loadList();
} catch (e) {
setError(formatApiMessage(e));
} finally {
setInviteLoading(false);
}
}
async function openInvitationHistory() {
setHistoryOpen(true);
setHistoryLoading(true);
setError('');
try {
const res = await organizationApi.listInvitations();
setHistoryItems(res.data.items);
} catch (e) {
setError(formatApiMessage(e));
} finally {
setHistoryLoading(false);
}
}
async function respondToPendingLink(linkId: string, action: 'ACCEPT' | 'REJECT') {
setRequestLinkRowId(linkId);
setError('');
try {
await organizationApi.respondLink(linkId, action);
setSuccess(action === 'ACCEPT' ? 'Link request accepted' : 'Link request rejected');
await loadList();
} catch (e) {
setError(formatApiMessage(e));
} finally {
setRequestLinkRowId(null);
}
}
async function deleteLinkedOrganization(linkId: string) {
setDeleteLinkRowId(linkId);
setError('');
try {
await organizationApi.deleteLink(linkId);
setSuccess('Linked organization removed');
await loadList();
} catch (e) {
setError(formatApiMessage(e));
} finally {
setDeleteLinkRowId(null);
}
}
async function copyInvitationLink(invitationId: string) {
setError('');
try {
let invitationUrl = pendingInviteLinks[invitationId]?.invitationUrl;
if (!invitationUrl) {
const res = await organizationApi.getInvitationLink(invitationId);
invitationUrl = res.data.invitationUrl;
if (currentOrganization?.id) {
const nextLinks = {
...pendingInviteLinks,
[invitationId]: {
invitationId,
ownerEmail:
historyItems.find((item) => item.id === invitationId)?.ownerEmail?.toLowerCase() ?? '',
invitationUrl,
},
};
setPendingInviteLinks(nextLinks);
writeStoredInviteLinks(currentOrganization.id, nextLinks);
}
}
await navigator.clipboard.writeText(invitationUrl);
setCopiedId(invitationId);
setTimeout(() => setCopiedId(null), 1500);
} catch {
setError('Could not copy invitation link');
}
}
function clearSearchView() {
setMode('existing');
setQuery('');
setSearchResults([]);
setShowInviteForm(false);
}
if (!currentOrganization) {
return <p className="text-sm text-text-secondary">Loading organization...</p>;
}
return (
<div className="space-y-6">
<div className="flex flex-col gap-1 sm:flex-row sm:items-start sm:justify-between">
<div>
<h1 className="text-2xl font-semibold text-text-primary">{tabLabel}</h1>
<p className="text-sm text-text-secondary mt-1">
Search organizations and send link requests or invitation links in one place.
</p>
</div>
<Button type="button" size="sm" onClick={() => void openInvitationHistory()}>
Invitation History
</Button>
</div>
{error && (
<div className="rounded-[var(--radius-md)] border border-red-500/40 bg-red-500/10 px-4 py-3 text-sm text-red-700 dark:text-red-300">
{error}
</div>
)}
{success && (
<div className="rounded-[var(--radius-md)] border border-primary/30 bg-primary-soft/40 px-4 py-3 text-sm text-text-primary">
{success}
</div>
)}
<SearchBar
value={query}
onChange={setQuery}
onSubmit={() => void runSearch()}
placeholder={`Search ${counterpartLabel.toLowerCase()} by name, email, or phone...`}
actions={
<>
<button
type="button"
onClick={() => void runSearch()}
disabled={searching}
className="px-4 py-2 rounded-[var(--radius-sm)] text-sm font-medium border bg-primary-soft text-primary border-primary/50 disabled:opacity-60"
>
Search
</button>
{mode === 'search' && (
<button
type="button"
onClick={clearSearchView}
className="px-4 py-2 rounded-[var(--radius-sm)] text-sm font-medium border text-text-secondary border-border/40 hover:bg-background-card/70 hover:border-border"
>
Back to list
</button>
)}
</>
}
/>
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Organization
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Owner email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">
Action
</th>
</tr>
}
body={
<>
{loading ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-sm text-text-secondary">
Loading...
</td>
</tr>
) : mode === 'existing' ? (
existingRows.length === 0 ? (
<tr>
<td colSpan={5} className="px-6 py-8 text-sm text-text-secondary">
No organizations linked or pending yet. Use search to find and connect.
</td>
</tr>
) : (
existingRows.map((row) => {
const canRespond =
row.status === 'PENDING' &&
row.requestedByOrganizationId !== null &&
row.requestedByOrganizationId !== currentOrganization.id;
return (
<tr key={row.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm font-medium text-text-primary">
{row.organizationName}
</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{row.ownerEmail}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">
{formatTableDate(row.createdAt)}
</td>
<td className="px-6 py-1.5 text-center align-middle">
<Badge variant={organizationLinkStatusVariant(row.status)} fixedWidth={false}>
{formatLinkStatusLabel(row.status)}
</Badge>
</td>
<td className="px-6 py-1.5 text-right">
<div className="inline-flex items-center gap-2">
{canRespond && (
<>
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:text-text-muted disabled:opacity-50"
disabled={requestLinkRowId !== null && requestLinkRowId !== row.id}
onClick={() => void respondToPendingLink(row.id, 'ACCEPT')}
aria-label="Accept link request"
title="Accept link request"
>
<Check className="w-4 h-4" />
</button>
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50"
disabled={requestLinkRowId !== null && requestLinkRowId !== row.id}
onClick={() => void respondToPendingLink(row.id, 'REJECT')}
aria-label="Reject link request"
title="Reject link request"
>
<X className="w-4 h-4" />
</button>
</>
)}
{row.status === 'ACTIVE' && (
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-red-500/15 hover:text-red-600 disabled:text-text-muted disabled:opacity-50"
disabled={deleteLinkRowId !== null && deleteLinkRowId !== row.id}
onClick={() => void deleteLinkedOrganization(row.id)}
aria-label="Delete link"
title="Delete link"
>
<Trash2 className="w-4 h-4" />
</button>
)}
</div>
</td>
</tr>
);
})
)
) : searchResults.length > 0 ? (
searchResults.map((r) => (
<tr key={r.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm font-medium text-text-primary">{r.name}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{r.owner.email}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">Today</td>
<td className="px-6 py-1.5 text-center align-middle">
<Badge variant="default" fixedWidth={false}>Found</Badge>
</td>
<td className="px-6 py-1.5 text-right">
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary disabled:text-text-muted disabled:opacity-50"
disabled={requestLinkRowId !== null && requestLinkRowId !== r.id}
onClick={() => void submitRequestLink(r.id)}
aria-label="Send link request"
title="Send link request"
>
<Link2 className="w-4 h-4" />
</button>
</td>
</tr>
))
) : (
<tr>
<td colSpan={5} className="px-6 py-6">
<div className="flex flex-col gap-3">
<p className="text-sm text-text-secondary">
No organization found in directory search.
</p>
<div className="flex flex-wrap items-center gap-2">
<Button type="button" onClick={() => setShowInviteForm((v) => !v)}>
{showInviteForm ? 'Hide invitation fields' : 'Send invitation link'}
</Button>
</div>
{showInviteForm && (
<div className="grid gap-3 sm:grid-cols-3 mt-1">
<Input
label={`${counterpartLabel} name`}
value={manualOrganizationName}
onChange={(e) => setManualOrganizationName(e.target.value)}
/>
<Input
label="Owner email"
type="email"
value={manualOwnerEmail}
onChange={(e) => setManualOwnerEmail(e.target.value)}
/>
<div className="flex items-end">
<Button
type="button"
isLoading={inviteLoading}
disabled={!manualOrganizationName.trim() || !manualOwnerEmail.trim()}
onClick={() => void sendInvite()}
className="w-full"
>
Send invitation
</Button>
</div>
</div>
)}
</div>
</td>
</tr>
)}
</>
}
/>
{historyOpen && (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/50">
<div
className="w-full max-w-3xl max-h-[90vh] overflow-y-auto rounded-[var(--radius-md)] border border-border bg-background-secondary p-6 shadow-xl space-y-4"
role="dialog"
aria-modal="true"
>
<div className="flex items-center justify-between">
<h2 className="text-lg font-semibold text-text-primary">Invitation History</h2>
<Button type="button" size="sm" onClick={() => setHistoryOpen(false)}>
Close
</Button>
</div>
{historyLoading ? (
<p className="text-sm text-text-secondary">Loading invitation history...</p>
) : historyItems.length === 0 ? (
<p className="text-sm text-text-secondary">No invitations yet.</p>
) : (
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Organization
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Owner email
</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">
Date
</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider">
Status
</th>
<th className="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider">
Action
</th>
</tr>
}
body={
<>
{historyItems.map((inv) => (
<tr key={inv.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm text-text-primary">{inv.organizationName}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{inv.ownerEmail}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">
{formatTableDate(inv.createdAt)}
</td>
<td className="px-6 py-1.5 text-center align-middle">
<Badge variant={organizationLinkStatusVariant(inv.status)} fixedWidth={false}>
{formatInvitationStatusLabel(inv.status)}
</Badge>
</td>
<td className="px-6 py-1.5 text-right">
{inv.status === 'PENDING' ? (
<button
type="button"
className="p-2 rounded-md text-text-secondary hover:bg-background-card/80 hover:text-text-primary"
onClick={() => void copyInvitationLink(inv.id)}
aria-label="Copy invitation link"
title="Copy invitation link"
>
{copiedId === inv.id ? (
<Check className="w-4 h-4" />
) : (
<Copy className="w-4 h-4" />
)}
</button>
) : (
<span className="text-xs text-text-muted"></span>
)}
</td>
</tr>
))}
</>
}
/>
)}
</div>
</div>
)}
</div>
);
}

View File

@@ -1,7 +1,6 @@
'use client';
import { useEffect, useMemo, useState } from 'react';
import { Plus } from 'lucide-react';
import { Button } from '@/components/ui/common/Button';
import { patientsApi } from '@/lib/api/patients';
import { useAuth } from '@/lib/hooks/useAuth';
@@ -160,7 +159,6 @@ export default function PatientsPage() {
<h1 className="text-2xl font-semibold text-text-primary">Patients</h1>
<Button
variant="primary"
className="flex items-center gap-2"
disabled={!canEditPatients}
onClick={() => {
if (!canEditPatients) return;
@@ -168,7 +166,6 @@ export default function PatientsPage() {
}}
title={!canEditPatients ? 'Read-only access for this organization.' : undefined}
>
<Plus className="h-4 w-4 icon-flat" />
New Patient
</Button>
</div>

View File

@@ -5,6 +5,7 @@ import Link from 'next/link';
import { useRouter } from 'next/navigation';
import { useAuth } from '@/lib/hooks/useAuth';
import { authApi } from '@/lib/api/auth';
import { Button } from '@/components/ui/common/Button';
import type { SubscriptionAlertData } from '@/types/subscription';
const PLAN_OPTIONS = [
@@ -176,9 +177,9 @@ export default function SubscriptionsSettingsPage() {
);
})}
</div>
<button
<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"
variant="primary"
onClick={() => {
const selectedPlanLabel = selectedPlan?.name ?? 'the selected plan';
setPurchaseNotice(
@@ -187,7 +188,7 @@ export default function SubscriptionsSettingsPage() {
}}
>
Start purchase process
</button>
</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>

View File

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

View File

@@ -12,17 +12,47 @@ import {
permissionNamesFromFeatureState,
emptyFeaturePermissionState,
featureStateFromPermissionNames,
resolveStaffFeatureLabel,
formatAccessSummary,
type FeaturePermState,
} from './staff-permission-form';
import { UserPlus, Pencil, Trash2, Copy, Check, X, Clock3 } from 'lucide-react';
import { Pencil, Trash2, Copy, Check, X } from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { staffApi, type StaffMemberDto } from '@/lib/api/staff';
import { Button } from '@/components/ui/common/Button';
import { Badge } from '@/components/ui/common/Badge';
import { Input } from '@/components/ui/common/Input';
import { Checkbox } from '@/components/ui/common/Checkbox';
import { Table } from '@/components/ui/common/Table';
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 {
if (!err || typeof err !== 'object') return 'Something went wrong';
const m = (err as ApiError).message;
@@ -35,10 +65,12 @@ function PermissionGrid({
state,
onChange,
disabled,
organizationType,
}: {
state: FeaturePermState;
onChange: (next: FeaturePermState) => void;
disabled?: boolean;
organizationType?: 'CLINIC' | 'LAB';
}) {
const setRead = (editKey: string, read: boolean) => {
const cur = state[editKey] ?? { read: false, edit: false };
@@ -65,7 +97,9 @@ function PermissionGrid({
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"
>
<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">
<Checkbox
checked={cell.read}
@@ -105,13 +139,15 @@ export default function StaffPage() {
const [inviteName, setInviteName] = useState('');
const [invitePerms, setInvitePerms] = useState(() => emptyFeaturePermissionState());
const [inviteLoading, setInviteLoading] = useState(false);
const [copiedInviteLink, setCopiedInviteLink] = useState(false);
const [copiedInviteMembershipId, setCopiedInviteMembershipId] = useState<string | null>(null);
const [lastInviteInfo, setLastInviteInfo] = useState<{
membershipId: string;
name: string;
email: string;
invitationUrl: string | null;
invitationStatus: 'PENDING' | 'ACCEPTED';
} | null>(null);
const [pendingInviteLinks, setPendingInviteLinks] = useState<Record<string, StoredInviteLink>>({});
const [editing, setEditing] = useState<StaffMemberDto | null>(null);
const [editName, setEditName] = useState('');
@@ -140,6 +176,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(() => {
void load();
}, [load]);
@@ -171,11 +235,24 @@ export default function StaffPage() {
permissionNames,
});
setLastInviteInfo({
membershipId: res.data.membershipId,
name: displayName,
email: res.data.email,
invitationUrl: res.data.invitationUrl,
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('');
setInviteOpen(false);
setInviteEmail('');
@@ -260,7 +337,6 @@ export default function StaffPage() {
className="shrink-0"
title={!canEdit ? 'Read-only access for this organization.' : undefined}
>
<UserPlus className="w-4 h-4 mr-2" />
Invite member
</Button>
</div>
@@ -328,15 +404,14 @@ export default function StaffPage() {
onClick={async () => {
try {
await navigator.clipboard.writeText(lastInviteInfo.invitationUrl as string);
setCopiedInviteLink(true);
setTimeout(() => setCopiedInviteLink(false), 1500);
setCopiedInviteMembershipId(lastInviteInfo.membershipId);
setTimeout(() => setCopiedInviteMembershipId(null), 1500);
} catch {
setError('Could not copy invitation link');
}
}}
>
{copiedInviteLink ? <Check className="w-4 h-4" /> : <Copy className="w-4 h-4" />}
<span className="ml-1">{copiedInviteLink ? 'Copied' : 'Copy link'}</span>
{copiedInviteMembershipId === lastInviteInfo.membershipId ? 'Copied' : 'Copy link'}
</Button>
</div>
<p className="text-xs text-text-muted">
@@ -350,58 +425,74 @@ export default function StaffPage() {
{loading ? (
<p className="text-sm text-text-secondary">Loading team</p>
) : (
<div className="overflow-x-auto rounded-[var(--radius-md)] border border-border/70">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-border/70 text-left text-text-secondary">
<th className="p-3 font-medium">Name</th>
<th className="p-3 font-medium">Email</th>
<th className="p-3 font-medium">Role</th>
<th className="p-3 font-medium">Status</th>
<th className="p-3 font-medium">Access</th>
<th className="p-3 font-medium w-28">Actions</th>
</tr>
</thead>
<tbody>
<Table
headers={
<tr>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">Name</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">Email</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">Role</th>
<th className="px-6 py-3 text-center text-xs font-medium text-text-muted uppercase tracking-wider">Status</th>
<th className="px-6 py-3 text-left text-xs font-medium text-text-muted uppercase tracking-wider">Access</th>
<th className="px-6 py-3 text-right text-xs font-medium text-text-muted uppercase tracking-wider w-28">Actions</th>
</tr>
}
body={
<>
{members.map((m) => (
<tr key={m.id} className="border-b border-border/40 last:border-0">
<td className="p-3 text-text-primary">{m.name}</td>
<td className="p-3 text-text-secondary">{m.email}</td>
<td className="p-3">
<tr key={m.id} className="hover:bg-background-secondary/45">
<td className="px-6 py-1.5 text-sm text-text-primary">{m.name}</td>
<td className="px-6 py-1.5 text-sm text-text-secondary">{m.email}</td>
<td className="px-6 py-1.5 text-sm">
{m.isOwner ? (
<span className="text-primary font-medium">Owner</span>
) : (
<span className="text-text-secondary">Staff</span>
)}
</td>
<td className="p-3">
<td className="px-6 py-1.5 align-middle text-center">
{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">
Active
</span>
<Badge variant="success">Active</Badge>
) : m.invitationStatus === 'PENDING' ? (
<span className="inline-flex items-center gap-1 rounded-full border border-amber-500/40 bg-amber-500/15 px-2 py-0.5 text-xs text-amber-300">
<Clock3 className="h-3 w-3" />
Pending
</span>
<Badge variant="warning">Pending</Badge>
) : (
<span className="inline-flex items-center rounded-full border border-red-500/40 bg-red-500/15 px-2 py-0.5 text-xs text-red-300">
Expired
</span>
<Badge variant="danger">Expired</Badge>
)}
</td>
<td className="p-3 text-text-secondary max-w-md">
<td className="px-6 py-1.5 text-sm text-text-secondary max-w-md">
{m.isOwner ? (
<span className="text-text-muted">All features</span>
) : (
<span className="line-clamp-3 text-sm leading-relaxed">
{formatAccessSummary(m.permissions)}
{formatAccessSummary(m.permissions, currentOrganization?.type)}
</span>
)}
</td>
<td className="p-3">
<td className="px-6 py-1.5 align-middle">
{!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
type="button"
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 ${
@@ -439,9 +530,9 @@ export default function StaffPage() {
</td>
</tr>
))}
</tbody>
</table>
</div>
</>
}
/>
)}
{inviteOpen && (
@@ -469,7 +560,11 @@ export default function StaffPage() {
/>
<div>
<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 className="flex justify-end gap-2 pt-2">
<Button variant="outline" type="button" onClick={() => setInviteOpen(false)}>
@@ -500,7 +595,11 @@ export default function StaffPage() {
<Input label="Display name" value={editName} onChange={(e) => setEditName(e.target.value)} />
<div>
<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 className="flex justify-end gap-2 pt-2">
<Button variant="outline" type="button" onClick={() => setEditing(null)}>

View File

@@ -5,15 +5,27 @@
export const STAFF_FEATURE_GROUPS = [
{ label: 'Today', read: 'TAB_TODAY_READ', edit: 'TAB_TODAY_EDIT' },
{ label: 'Staff', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
{ label: 'Organizations', read: 'TAB_ORGANIZATIONS_READ', edit: 'TAB_ORGANIZATIONS_EDIT' },
{ label: 'Patients', read: 'TAB_PATIENTS_READ', edit: 'TAB_PATIENTS_EDIT' },
{ label: 'Appointments', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
{ label: 'Staff Management', read: 'TAB_STAFF_READ', edit: 'TAB_STAFF_EDIT' },
{ label: 'Lab Management', read: 'TAB_LAB_READ', edit: 'TAB_LAB_EDIT' },
{ label: 'Appointment', read: 'TAB_APPOINTMENTS_READ', edit: 'TAB_APPOINTMENTS_EDIT' },
{ label: 'Treatment', read: 'TAB_TREATMENT_READ', edit: 'TAB_TREATMENT_EDIT' },
{ label: 'Billing', read: 'TAB_BILLING_READ', edit: 'TAB_BILLING_EDIT' },
{ label: 'Reports', read: 'TAB_REPORTS_READ', edit: 'TAB_REPORTS_EDIT' },
] as const;
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_ORGANIZATIONS_READ') {
return organizationType === 'LAB' ? 'Clinics' : 'Labs';
}
return group.label;
}
export function emptyFeaturePermissionState(): 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)" */
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';
const set = new Set(permissionNames);
const parts: string[] = [];
@@ -54,7 +69,8 @@ export function formatAccessSummary(permissionNames: string[] | null | undefined
const hasEdit = set.has(g.edit);
const hasRead = set.has(g.read) || hasEdit;
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';
}

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

@@ -78,7 +78,7 @@ function AcceptInviteContent() {
name: name.trim(),
password,
});
setSuccess('Invitation accepted. Redirecting to login...');
setSuccess('Invitation Accepted. Redirecting to login...');
setTimeout(() => {
router.replace('/login');
}, 1000);

View File

@@ -0,0 +1,159 @@
'use client';
import { Suspense, useEffect, useMemo, useState } from 'react';
import Link from 'next/link';
import { useRouter, useSearchParams } from 'next/navigation';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import { organizationApi } from '@/lib/api/organization';
function AcceptOrganizationInviteContent() {
const params = useSearchParams();
const router = useRouter();
const token = useMemo(() => params.get('token') || '', [params]);
const [loading, setLoading] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const [inviteInfo, setInviteInfo] = useState<{
ownerEmail: string;
organizationName: string;
organizationType: 'CLINIC' | 'LAB';
inviterOrganizationName: string;
expiresAt: string;
status: 'PENDING' | 'ACCEPTED';
} | null>(null);
const [ownerName, setOwnerName] = useState('');
const [organizationName, setOrganizationName] = useState('');
const [password, setPassword] = useState('');
const [confirmPassword, setConfirmPassword] = useState('');
useEffect(() => {
if (!token) {
setLoading(false);
setError('Invalid invitation link');
return;
}
void (async () => {
setLoading(true);
setError('');
try {
const res = await organizationApi.previewInvite(token);
setInviteInfo(res.data);
setOrganizationName(res.data.organizationName || '');
if (res.data.status === 'ACCEPTED') {
setSuccess('This invitation is already accepted. You can log in now.');
}
} catch (e: any) {
setError(e?.message || 'Could not load invitation');
} finally {
setLoading(false);
}
})();
}, [token]);
async function onAccept() {
if (!token) return;
setError('');
setSuccess('');
if (!ownerName.trim()) return setError('Owner name is required');
if (!organizationName.trim()) return setError('Organization name is required');
if (password.length < 8) return setError('Password must be at least 8 characters');
if (password !== confirmPassword) return setError('Passwords do not match');
setSubmitting(true);
try {
await organizationApi.acceptInvite({
token,
ownerName: ownerName.trim(),
organizationName: organizationName.trim(),
password,
});
setSuccess('Invitation Accepted. Redirecting to login...');
setTimeout(() => router.replace('/login'), 1000);
} catch (e: any) {
setError(e?.message || 'Could not accept invitation');
} finally {
setSubmitting(false);
}
}
return (
<div className="min-h-screen app-web-bg flex items-center justify-center p-4">
<div className="w-full max-w-md surface-card p-6 space-y-5">
<h1 className="text-xl font-semibold text-text-primary">Accept organization invitation</h1>
{loading ? (
<p className="text-sm text-text-secondary">Loading invitation...</p>
) : (
<>
{inviteInfo && (
<div className="rounded-[var(--radius-md)] border border-border/70 bg-background-secondary/70 px-3 py-2 text-sm text-text-secondary space-y-1">
<p>
Invited by: <span className="text-text-primary">{inviteInfo.inviterOrganizationName}</span>
</p>
<p>
Owner email: <span className="text-text-primary">{inviteInfo.ownerEmail}</span>
</p>
</div>
)}
{error && (
<div className="rounded-[var(--radius-md)] border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-300">
{error}
</div>
)}
{success && (
<div className="rounded-[var(--radius-md)] border border-primary/30 bg-primary-soft/40 px-3 py-2 text-sm text-text-primary">
{success}
</div>
)}
{inviteInfo?.status !== 'ACCEPTED' && (
<div className="space-y-3">
<Input label="Owner name" value={ownerName} onChange={(e) => setOwnerName(e.target.value)} />
<Input
label="Organization name"
value={organizationName}
onChange={(e) => setOrganizationName(e.target.value)}
/>
<Input
label="Create password"
type="password"
value={password}
onChange={(e) => setPassword(e.target.value)}
/>
<Input
label="Confirm password"
type="password"
value={confirmPassword}
onChange={(e) => setConfirmPassword(e.target.value)}
/>
<Button type="button" fullWidth isLoading={submitting} onClick={() => void onAccept()}>
Activate organization
</Button>
</div>
)}
<p className="text-xs text-text-muted">
Already have access? <Link href="/login" className="text-primary">Go to login</Link>
</p>
</>
)}
</div>
</div>
);
}
export default function AcceptOrganizationInvitePage() {
return (
<Suspense
fallback={
<div className="min-h-screen app-web-bg flex items-center justify-center">
<p className="text-sm text-text-secondary">Loading invitation...</p>
</div>
}
>
<AcceptOrganizationInviteContent />
</Suspense>
);
}

View File

@@ -0,0 +1,199 @@
'use client';
import { useEffect, useState } from 'react';
import { X } from 'lucide-react';
import { Button } from '@/components/ui/common/Button';
import { Dropdown } from '@/components/ui/common/Dropdown';
import { APPOINTMENT_PURPOSES, type AppointmentPurpose } from '@/types/appointment';
import { APPOINTMENT_PURPOSE_LABEL } from '@/components/ui/appointments/appointmentPurposeStyles';
import type { Patient } from '@/types/patient';
import {
combineLocalDateAndTime,
formatTimeForInput,
isSameLocalCalendarDay,
} from '@/lib/appointmentTime';
interface AppointmentBookingModalProps {
open: boolean;
scheduleDate: Date;
patient: Patient | undefined;
providerUserId: string | null;
providerName: string;
initialHour: number;
onClose: () => void;
onSubmit: (payload: {
patientId: string;
providerUserId: string;
startAt: string;
endAt: string;
purpose: AppointmentPurpose;
}) => Promise<void>;
loading?: boolean;
}
export function AppointmentBookingModal({
open,
scheduleDate,
patient,
providerUserId,
providerName,
initialHour,
onClose,
onSubmit,
loading = false,
}: AppointmentBookingModalProps) {
const [startTime, setStartTime] = useState('09:00');
const [endTime, setEndTime] = useState('10:00');
const [purpose, setPurpose] = useState<AppointmentPurpose>('consultation');
const [error, setError] = useState('');
useEffect(() => {
if (!open) {
return;
}
const start = new Date(
scheduleDate.getFullYear(),
scheduleDate.getMonth(),
scheduleDate.getDate(),
initialHour,
0,
0,
0,
);
const end = new Date(
scheduleDate.getFullYear(),
scheduleDate.getMonth(),
scheduleDate.getDate(),
initialHour < 23 ? initialHour + 1 : 23,
initialHour < 23 ? 0 : 59,
0,
0,
);
setStartTime(formatTimeForInput(start));
setEndTime(formatTimeForInput(end));
setPurpose('consultation');
setError('');
}, [open, scheduleDate, initialHour]);
if (!open || !providerUserId) {
return null;
}
const inputClass =
'w-full rounded-[var(--radius-md)] border border-border bg-background-secondary/90 text-text-primary px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-primary/35';
async function handleSubmit() {
setError('');
if (!providerUserId) {
return;
}
if (!patient) {
setError('Select a patient first.');
return;
}
const startAt = combineLocalDateAndTime(scheduleDate, startTime);
const endAt = combineLocalDateAndTime(scheduleDate, endTime);
if (endAt <= startAt) {
setError('End time must be after start time.');
return;
}
const now = new Date();
if (isSameLocalCalendarDay(scheduleDate, now) && startAt.getTime() < now.getTime()) {
setError('Cannot schedule in the past.');
return;
}
await onSubmit({
patientId: patient.id,
providerUserId,
startAt: startAt.toISOString(),
endAt: endAt.toISOString(),
purpose,
});
}
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/55">
<div
className="surface-card w-full max-w-md p-5 space-y-4 shadow-xl"
role="dialog"
aria-modal="true"
aria-labelledby="appointment-modal-title"
>
<div className="flex items-start justify-between gap-2">
<h2 id="appointment-modal-title" className="text-lg font-semibold text-text-primary pr-2">
New appointment
</h2>
<button
type="button"
onClick={onClose}
className="rounded-[var(--radius-sm)] p-1.5 text-text-muted hover:text-text-primary hover:bg-background-secondary/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
aria-label="Close"
>
<X className="h-5 w-5 icon-flat" />
</button>
</div>
<p className="text-sm text-text-secondary">
Provider: <span className="text-text-primary font-medium">{providerName}</span>
</p>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Patient</label>
<p className="text-sm text-text-primary rounded-[var(--radius-md)] border border-border bg-background-secondary/60 px-3 py-2">
{patient ? `${patient.firstName} ${patient.lastName}` : '—'}
</p>
</div>
<div className="grid grid-cols-2 gap-3">
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">Start</label>
<input
type="time"
step={60}
className={inputClass}
value={startTime}
onChange={(e) => setStartTime(e.target.value)}
/>
</div>
<div>
<label className="block text-sm font-medium text-text-secondary mb-1">End</label>
<input
type="time"
step={60}
className={inputClass}
value={endTime}
onChange={(e) => setEndTime(e.target.value)}
/>
</div>
</div>
<Dropdown
label="Purpose"
value={purpose}
onChange={(e) => setPurpose(e.target.value as AppointmentPurpose)}
>
{APPOINTMENT_PURPOSES.map((p) => (
<option key={p} value={p}>
{APPOINTMENT_PURPOSE_LABEL[p]}
</option>
))}
</Dropdown>
{error && <p className="text-sm text-red-400">{error}</p>}
<div className="flex gap-2 justify-end">
<Button type="button" variant="ghost" onClick={onClose} disabled={loading}>
Cancel
</Button>
<Button type="button" variant="primary" onClick={() => void handleSubmit()} isLoading={loading}>
Save
</Button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,157 @@
'use client';
import { Trash2 } from 'lucide-react';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
import { formatHourLabel } from '@/lib/appointmentTime';
import { purposeDeleteIconClass, purposeStyle } from '@/components/ui/appointments/appointmentPurposeStyles';
const HOUR_PX = 40;
const HOURS = Array.from({ length: 24 }, (_, i) => i);
function layoutBlock(apt: AppointmentRecord, day: Date): { top: string; height: string } | null {
const dayStart = new Date(day.getFullYear(), day.getMonth(), day.getDate(), 0, 0, 0, 0);
const dayEnd = new Date(day.getFullYear(), day.getMonth(), day.getDate() + 1, 0, 0, 0, 0);
const start = new Date(apt.startAt);
const end = new Date(apt.endAt);
const ms = dayEnd.getTime() - dayStart.getTime();
const clipStart = Math.max(start.getTime(), dayStart.getTime());
const clipEnd = Math.min(end.getTime(), dayEnd.getTime());
if (clipEnd <= clipStart) {
return null;
}
const top = ((clipStart - dayStart.getTime()) / ms) * 100;
const height = ((clipEnd - clipStart) / ms) * 100;
return { top: `${top}%`, height: `${height}%` };
}
interface AppointmentScheduleGridProps {
day: Date;
providers: AppointmentColumnProvider[];
appointments: AppointmentRecord[];
canBook: boolean;
canDelete?: boolean;
onDeleteAppointment?: (id: string) => void;
onSlotClick: (hour: number, providerUserId: string, providerName: string) => void;
}
export function AppointmentScheduleGrid({
day,
providers,
appointments,
canBook,
canDelete = false,
onDeleteAppointment,
onSlotClick,
}: AppointmentScheduleGridProps) {
const gridHeight = HOURS.length * HOUR_PX;
if (providers.length === 0) {
return (
<div className="surface-card p-6 text-sm text-text-muted">
No providers available. Add staff with treatment edit access to see columns here.
</div>
);
}
return (
<div className="surface-card overflow-x-auto">
<div className="min-w-[640px]">
<div className="flex border-b border-border">
<div className="w-14 flex-shrink-0" />
{providers.map((p) => (
<div
key={p.userId}
className="flex-1 min-w-[130px] text-center text-sm font-medium text-text-primary py-2.5 px-1 border-l border-border"
>
{p.name}
</div>
))}
</div>
<div className="flex">
<div className="w-14 flex-shrink-0 border-r border-border bg-background-secondary/40">
{HOURS.map((h) => (
<div
key={h}
className="text-[11px] text-text-muted flex items-start justify-end pr-1.5 pt-0.5 border-b border-border/50"
style={{ height: HOUR_PX }}
>
{formatHourLabel(h)}
</div>
))}
</div>
<div className="flex-1 flex min-w-0">
{providers.map((p) => (
<div
key={p.userId}
className="flex-1 min-w-[130px] border-l border-border relative"
style={{ height: gridHeight }}
>
{HOURS.map((h) => {
const slotDisabled = !canBook;
return (
<button
key={h}
type="button"
disabled={slotDisabled}
title={
slotDisabled ? 'You cannot create appointments' : `Book ${formatHourLabel(h)}`
}
className={`absolute left-0 right-0 border-b border-border/50 transition-colors ${
slotDisabled
? 'cursor-not-allowed opacity-50'
: 'hover:bg-primary/8 cursor-pointer'
}`}
style={{ top: h * HOUR_PX, height: HOUR_PX }}
onClick={() => onSlotClick(h, p.userId, p.name)}
/>
);
})}
{appointments
.filter((a) => a.providerUserId === p.userId)
.map((apt) => {
const pos = layoutBlock(apt, day);
if (!pos) {
return null;
}
return (
<div
key={apt.id}
className={`absolute left-0.5 right-0.5 rounded-[var(--radius-sm)] border pointer-events-none z-10 flex flex-row items-center gap-1.5 px-1.5 py-1 min-h-[36px] ${purposeStyle(apt.purpose)}`}
style={{ top: pos.top, height: pos.height, minHeight: 36 }}
>
<div className="pointer-events-none flex-1 min-w-0 overflow-hidden text-left">
<p className="text-[11px] font-medium leading-tight truncate">
{apt.patient.firstName} {apt.patient.lastName}
</p>
{apt.patient.phone && (
<p className="text-[10px] opacity-90 truncate">{apt.patient.phone}</p>
)}
</div>
{canDelete && onDeleteAppointment && (
<button
type="button"
className="group pointer-events-auto shrink-0 self-center z-20 mr-2 ml-1 inline-flex cursor-pointer items-center justify-center rounded-[var(--radius-sm)] border-0 bg-transparent p-1 outline-none focus-visible:ring-2 focus-visible:ring-primary/35"
aria-label="Delete appointment"
title="Delete appointment"
onClick={(e) => {
e.stopPropagation();
onDeleteAppointment(apt.id);
}}
>
<Trash2 className={`w-4 h-4 ${purposeDeleteIconClass(apt.purpose)}`} />
</button>
)}
</div>
);
})}
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,23 @@
import {
APPOINTMENT_PURPOSE_LABEL,
APPOINTMENT_PURPOSE_LEGEND_SWATCH,
} from '@/components/ui/appointments/appointmentPurposeStyles';
import { APPOINTMENT_PURPOSES } from '@/types/appointment';
export function AppointmentScheduleLegend() {
return (
<div className="surface-panel px-4 py-3">
<p className="text-xs font-medium text-text-secondary mb-2">Legend</p>
<div className="flex flex-wrap gap-3">
{APPOINTMENT_PURPOSES.map((p) => (
<div key={p} className="flex items-center gap-1.5 text-xs text-text-secondary">
<span
className={`inline-block h-3 w-3 rounded-sm border ${APPOINTMENT_PURPOSE_LEGEND_SWATCH[p]}`}
/>
{APPOINTMENT_PURPOSE_LABEL[p]}
</div>
))}
</div>
</div>
);
}

View File

@@ -0,0 +1,87 @@
'use client';
import { Search } from 'lucide-react';
import { Button } from '@/components/ui/common/Button';
import { Input } from '@/components/ui/common/Input';
import type { Patient } from '@/types/patient';
interface AppointmentsPatientSearchProps {
search: string;
onSearchChange: (value: string) => void;
patients: Patient[];
selectedPatientId?: string;
onSelectPatient: (patient: Patient) => void;
loading?: boolean;
canAddPatient: boolean;
onAddPatient: () => void;
}
export function AppointmentsPatientSearch({
search,
onSearchChange,
patients,
selectedPatientId,
onSelectPatient,
loading = false,
canAddPatient,
onAddPatient,
}: AppointmentsPatientSearchProps) {
const trimmed = search.trim();
const showAddForEmptyResults =
trimmed.length > 0 && !loading && patients.length === 0;
return (
<div className="surface-card p-4 space-y-4">
<div className="flex flex-col sm:flex-row gap-3 sm:items-center">
<div className="flex-1">
<Input
placeholder="Search existing patients"
value={search}
onChange={(e) => onSearchChange(e.target.value)}
icon={<Search className="h-4 w-4 icon-flat" />}
/>
</div>
{showAddForEmptyResults && (
<Button
type="button"
variant="primary"
disabled={!canAddPatient}
onClick={onAddPatient}
title={!canAddPatient ? 'You do not have permission to add patients.' : undefined}
>
+ Add New Patient
</Button>
)}
</div>
<div className="space-y-2 max-h-72 overflow-y-auto">
{loading && <p className="text-sm text-text-muted">Searching</p>}
{!loading && trimmed.length === 0 && (
<p className="text-sm text-text-muted">Type to search patients by name, phone, or email.</p>
)}
{patients.map((patient) => {
const isSelected = selectedPatientId === patient.id;
return (
<button
key={patient.id}
type="button"
onClick={() => onSelectPatient(patient)}
className={`w-full text-left rounded-[var(--radius-sm)] border px-3 py-2 transition-colors ${
isSelected
? 'bg-primary-soft border-primary/60'
: 'border-border/60 hover:bg-background-card/70'
}`}
>
<p className="text-sm font-medium text-text-primary">
{patient.firstName} {patient.lastName}
</p>
<p className="text-xs text-text-muted">{patient.phone || patient.email || 'No contact'}</p>
</button>
);
})}
</div>
</div>
);
}

View File

@@ -0,0 +1,45 @@
import type { AppointmentPurpose } from '@/types/appointment';
export const APPOINTMENT_PURPOSE_LABEL: Record<AppointmentPurpose, string> = {
consultation: 'Consultation',
filling: 'Filling',
endo: 'Endo',
visit: 'Visit',
hygiene: 'Hygiene',
};
/** Background + border for blocks / legend (matches reference palette). */
export const APPOINTMENT_PURPOSE_STYLES: Record<AppointmentPurpose, string> = {
consultation: 'bg-violet-500/25 border-violet-400/50 text-violet-100',
filling: 'bg-orange-500/25 border-orange-400/50 text-orange-100',
endo: 'bg-red-500/25 border-red-400/50 text-red-100',
visit: 'bg-sky-500/25 border-sky-400/50 text-sky-100',
hygiene: 'bg-lime-500/20 border-lime-400/45 text-lime-100',
};
export function purposeStyle(purpose: string): string {
const p = purpose as AppointmentPurpose;
return APPOINTMENT_PURPOSE_STYLES[p] ?? 'bg-surface-elevated border-border text-text-secondary';
}
/** Trash icon — legend hues; `!` overrides global `.lucide { color: var(--color-icon) }`. */
export function purposeDeleteIconClass(purpose: string): string {
const p = purpose as AppointmentPurpose;
const map: Record<AppointmentPurpose, string> = {
consultation: '!text-violet-400 group-hover:!text-violet-300',
filling: '!text-orange-400 group-hover:!text-orange-300',
endo: '!text-red-400 group-hover:!text-red-300',
visit: '!text-sky-400 group-hover:!text-sky-300',
hygiene: '!text-lime-600 group-hover:!text-lime-500',
};
return map[p] ?? '!text-text-muted group-hover:!text-text-secondary';
}
/** Small swatch for legend (background + border only). */
export const APPOINTMENT_PURPOSE_LEGEND_SWATCH: Record<AppointmentPurpose, string> = {
consultation: 'bg-violet-500/85 border-violet-400/75',
filling: 'bg-orange-500/85 border-orange-400/75',
endo: 'bg-red-500/85 border-red-400/75',
visit: 'bg-sky-500/85 border-sky-400/75',
hygiene: 'bg-lime-500/80 border-lime-400/70',
};

View File

@@ -1,31 +1,59 @@
//src/components/ui/Badge.tsx
import React from 'react';
type BadgeVariant = 'success' | 'warning' | 'danger' | 'default';
export type BadgeVariant = 'success' | 'warning' | 'danger' | 'default';
interface BadgeProps {
children: React.ReactNode;
variant?: BadgeVariant;
className?: string;
children: React.ReactNode;
variant?: BadgeVariant;
className?: string;
/**
* Same pixel width for every badge (table columns).
* Set false only when the pill should shrink to the label.
*/
fixedWidth?: boolean;
}
const variantStyles: Record<BadgeVariant, string> = {
success: 'bg-emerald-900/30 text-emerald-300 border-emerald-700/60',
warning: 'bg-amber-900/30 text-amber-300 border-amber-700/60',
danger: 'bg-red-950/30 text-red-300 border-red-700/60',
default: 'bg-background-secondary text-text-secondary border-border',
success: 'bg-emerald-900/30 text-emerald-300 border-emerald-700/60',
warning: 'bg-amber-900/30 text-amber-300 border-amber-700/60',
danger: 'bg-red-950/30 text-red-300 border-red-700/60',
default: 'bg-background-secondary text-text-secondary border-border',
};
/** Explicit width + height so every row matches; flex centers label optically. */
const FIXED_LAYOUT_CLASS =
'w-[8rem] min-w-[8rem] max-w-[8rem] shrink-0 h-7 px-2 py-0';
export function Badge({
children,
variant = 'default',
className,
children,
variant = 'default',
className,
fixedWidth = true,
}: BadgeProps) {
return (
<span
className={`px-2 py-1 text-xs font-medium rounded-lg border inline-block ${variantStyles[variant]} ${className || ''}`}
>
{children}
</span>
);
}
const layoutClass = fixedWidth
? `${FIXED_LAYOUT_CLASS} justify-center text-center`
: 'min-h-[1.75rem] px-2.5 py-1 justify-center';
return (
<span
className={`inline-flex items-center box-border rounded-md border text-xs font-medium leading-none whitespace-nowrap ${variantStyles[variant]} ${layoutClass} ${className ?? ''}`}
>
{children}
</span>
);
}
/** Map organization link / invitation row status to badge variant. */
export function organizationLinkStatusVariant(status: string): BadgeVariant {
switch (status) {
case 'ACTIVE':
return 'success';
case 'PENDING':
return 'warning';
case 'REJECTED':
case 'EXPIRED':
return 'danger';
default:
return 'default';
}
}

View File

@@ -1,6 +1,4 @@
// src/components/ui/Button.tsx
import React from 'react';
import { Loader2 } from 'lucide-react';
type ButtonVariant = 'primary' | 'secondary' | 'outline' | 'danger' | 'ghost';
type ButtonSize = 'sm' | 'md' | 'lg';
@@ -25,23 +23,22 @@ export const Button: React.FC<ButtonProps> = ({
}) => {
const baseClasses =
'inline-flex items-center justify-center rounded-[var(--radius-md)] font-medium transition-all duration-200 ' +
'focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:opacity-50 disabled:cursor-not-allowed';
'focus:outline-none focus:ring-2 focus:ring-primary/40 disabled:cursor-not-allowed';
const variantClasses: Record<ButtonVariant, string> = {
primary:
'bg-primary text-primary-contrast hover:brightness-105 shadow-[0_0_0_1px_var(--color-primary-soft)]',
'bg-primary text-white hover:opacity-90 disabled:opacity-60',
secondary:
'bg-surface-elevated text-text-primary border border-border hover:border-border-strong',
'bg-surface-elevated text-text-primary border border-border hover:border-border-strong disabled:opacity-50',
outline:
'border border-border text-text-primary hover:bg-background-card/70',
'border border-border text-text-primary hover:bg-background-card/70 disabled:opacity-50',
danger:
'bg-red-600 text-white hover:bg-red-700',
danger: 'bg-red-600 text-white hover:bg-red-700 disabled:opacity-50',
ghost:
'text-text-secondary hover:text-text-primary hover:bg-background-card/70',
'text-text-secondary hover:text-text-primary hover:bg-background-card/70 disabled:opacity-50',
};
const sizeClasses: Record<ButtonSize, string> = {
@@ -51,17 +48,16 @@ export const Button: React.FC<ButtonProps> = ({
};
const widthClass = fullWidth ? 'w-full' : '';
const loadingClass = isLoading ? 'opacity-70 animate-pulse pointer-events-none' : '';
return (
<button
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${className}`}
className={`${baseClasses} ${variantClasses[variant]} ${sizeClasses[size]} ${widthClass} ${loadingClass} ${className}`}
disabled={disabled || isLoading}
aria-busy={isLoading || undefined}
{...props}
>
{isLoading && (
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
)}
{children}
</button>
);
};
};

View File

@@ -0,0 +1,68 @@
'use client';
import { ChevronDown } from 'lucide-react';
import React, { forwardRef } from 'react';
interface DropdownProps extends React.SelectHTMLAttributes<HTMLSelectElement> {
label?: string;
error?: string;
}
export const Dropdown = forwardRef<HTMLSelectElement, DropdownProps>(
({ label, error, className = '', id, children, ...props }, ref) => {
const selectId = id || `dropdown-${Math.random().toString(36).slice(2, 9)}`;
return (
<div className="w-full">
{label && (
<label
htmlFor={selectId}
className="block text-sm font-medium text-text-secondary mb-1"
>
{label}
</label>
)}
<div className="relative">
<select
ref={ref}
id={selectId}
className={`
w-full appearance-none rounded-[var(--radius-md)] border
${error ? 'border-red-500' : 'border-border'}
bg-background-secondary/90 text-text-primary
pl-4 pr-14 py-2 text-sm
focus:outline-none focus:ring-2 focus:ring-primary/35 focus:border-border-strong
disabled:opacity-50 disabled:cursor-not-allowed
transition-all duration-200 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]
${className}
`}
{...props}
>
{children}
</select>
<div
className="pointer-events-none absolute inset-y-0 right-5 flex items-center text-text-muted"
aria-hidden
>
<ChevronDown className="h-4 w-4 icon-flat" />
</div>
</div>
{error && (
<p className="mt-1 text-sm text-red-500">
{error}
</p>
)}
</div>
);
},
);
Dropdown.displayName = 'Dropdown';

View File

@@ -0,0 +1,51 @@
'use client';
import { ChevronLeft, ChevronRight } from 'lucide-react';
import { addCalendarDays, compareLocalDayStart } from '@/lib/appointmentTime';
interface ScheduleDayPickerProps {
value: Date;
onChange: (day: Date) => void;
/** Inclusive minimum calendar day (typically today at local midnight). */
minDate: Date;
label?: string;
}
export function ScheduleDayPicker({ value, onChange, minDate, label = 'Schedule date' }: ScheduleDayPickerProps) {
const canGoPrev = compareLocalDayStart(value, minDate) > 0;
const labelText = value.toLocaleDateString(undefined, {
weekday: 'short',
month: 'short',
day: 'numeric',
year: 'numeric',
});
return (
<div className="w-full max-w-md">
<p className="text-sm font-medium text-text-secondary mb-2">{label}</p>
<div className="flex items-center gap-1 rounded-[var(--radius-md)] border border-border bg-background-secondary/90 px-1 py-1 shadow-[inset_0_1px_0_rgba(255,255,255,0.02)]">
<button
type="button"
disabled={!canGoPrev}
onClick={() => onChange(addCalendarDays(value, -1))}
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 disabled:opacity-35 disabled:pointer-events-none focus:outline-none focus:ring-2 focus:ring-primary/35"
aria-label="Previous day"
>
<ChevronLeft className="h-4 w-4 icon-flat" />
</button>
<div className="flex-1 min-w-0 text-center text-sm font-medium text-text-primary tabular-nums px-2 py-1.5">
{labelText}
</div>
<button
type="button"
onClick={() => onChange(addCalendarDays(value, 1))}
className="shrink-0 rounded-[var(--radius-sm)] p-2 text-text-muted hover:text-text-primary hover:bg-background-card/80 focus:outline-none focus:ring-2 focus:ring-primary/35"
aria-label="Next day"
>
<ChevronRight className="h-4 w-4 icon-flat" />
</button>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
import { Search } from 'lucide-react';
import type { ReactNode } from 'react';
import { Input } from './Input';
interface SearchBarProps {
value: string;
onChange: (value: string) => void;
placeholder: string;
onSubmit?: () => void;
actions?: ReactNode;
}
export function SearchBar({
value,
onChange,
placeholder,
onSubmit,
actions,
}: SearchBarProps) {
return (
<div className="surface-card p-4">
<div className="flex gap-4 items-center">
<div className="flex-1">
<Input
placeholder={placeholder}
value={value}
onChange={(e) => onChange(e.target.value)}
onKeyDown={(e) => {
if (e.key === 'Enter') onSubmit?.();
}}
icon={<Search className="h-4 w-4 icon-flat" />}
/>
</div>
{actions && <div className="flex gap-2">{actions}</div>}
</div>
</div>
);
}

View File

@@ -13,14 +13,14 @@ import {
CreditCard,
} from 'lucide-react';
import { useAuth } from '@/lib/hooks/useAuth';
import { canViewTab } from '@/shared/permissions';
import { canAccessAppointmentsSection, canViewTab } from '@/shared/permissions';
const menu = [
{ 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: 'Appointments', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const },
{ name: 'Staff Management', path: '/staff', icon: UserCog, read: 'TAB_STAFF_READ' as const },
{ name: 'Lab Management', path: '/lab', icon: FlaskConical, read: 'TAB_LAB_READ' as const },
{ name: 'Appointment', path: '/appointments', icon: Calendar, read: 'TAB_APPOINTMENTS_READ' as const },
{ name: 'Treatment', path: '/treatment', icon: FlaskConical, read: 'TAB_TREATMENT_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 },
];
@@ -28,10 +28,33 @@ const menu = [
function Sidebar() {
const pathname = usePathname();
const { currentOrganization } = useAuth();
const counterpartLabel = currentOrganization?.type === 'LAB' ? 'Clinics' : 'Labs';
const visibleMenu = useMemo(
() => menu.filter((item) => canViewTab(currentOrganization, item.read)),
[currentOrganization],
() => {
const withCounterpartTab = [
menu[0],
menu[1],
{
name: counterpartLabel,
path: '/organizations',
icon: FlaskConical,
read: 'TAB_ORGANIZATIONS_READ' as const,
},
menu[2],
menu[3],
menu[4],
menu[5],
menu[6],
];
return withCounterpartTab.filter((item) => {
if (item.path === '/appointments') {
return canAccessAppointmentsSection(currentOrganization);
}
return canViewTab(currentOrganization, item.read);
});
},
[counterpartLabel, currentOrganization],
);
return (

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from 'react';
interface TableProps {
headers: ReactNode;
body: ReactNode;
footer?: ReactNode;
}
export function Table({ headers, body, footer }: TableProps) {
return (
<div className="surface-card overflow-hidden">
<table className="w-full">
<thead className="bg-background-secondary/70 border-b border-border">
{headers}
</thead>
<tbody className="divide-y divide-border/60">
{body}
</tbody>
</table>
{footer && (
<div className="px-6 py-3 border-t border-border/60 flex justify-between items-center bg-background-secondary/70">
{footer}
</div>
)}
</div>
);
}

View File

@@ -2,7 +2,7 @@
import { useState } from 'react';
import { useAuth } from '@/lib/hooks/useAuth';
import { Building2, Beaker, Mail, Plus } from 'lucide-react';
import { Building2, Beaker, Mail } from 'lucide-react';
import { Input } from '@/components/ui/common/Input';
import { Button } from '@/components/ui/common/Button';
@@ -55,7 +55,6 @@ export function OrganizationSelectorContent() {
setIsCreateOpen((prev) => !prev);
}}
>
<Plus className="h-4 w-4 mr-2 icon-flat" />
{isCreateOpen ? 'Cancel' : 'Create Organization'}
</Button>
</div>

View File

@@ -0,0 +1,32 @@
import { apiClient } from './client';
import type { AppointmentColumnProvider, AppointmentRecord } from '@/types/appointment';
export interface CreateAppointmentBody {
patientId: string;
providerUserId: string;
startAt: string;
endAt: string;
purpose: string;
}
export const appointmentsApi = {
columnProviders: async (): Promise<{ success: boolean; data: AppointmentColumnProvider[] }> => {
const response = await apiClient.get('/appointments/column-providers');
return response.data;
},
list: async (params: { from: string; to: string }): Promise<{ success: boolean; data: AppointmentRecord[] }> => {
const response = await apiClient.get('/appointments', { params });
return response.data;
},
create: async (body: CreateAppointmentBody): Promise<{ success: boolean; data: AppointmentRecord }> => {
const response = await apiClient.post('/appointments', body);
return response.data;
},
remove: async (id: string): Promise<{ success: boolean }> => {
const response = await apiClient.delete(`/appointments/${id}`);
return response.data;
},
};

View File

@@ -0,0 +1,115 @@
import { apiClient } from './client';
export interface CounterpartSearchResultDto {
id: string;
name: string;
email: string;
phone: string | null;
owner: { email: string; name: string };
}
export interface CounterpartItemDto {
id: string;
requestedByOrganizationId: string | null;
counterpartOrganizationId: string | null;
organizationName: string;
ownerEmail: string;
phone: string | null;
status: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'EXPIRED';
createdAt: string;
acceptedAt: string | null;
}
export interface OrganizationInvitationHistoryItemDto {
id: string;
organizationName: string;
ownerEmail: string;
status: 'PENDING' | 'ACTIVE' | 'REJECTED' | 'EXPIRED';
createdAt: string;
acceptedAt: string | null;
}
export const organizationApi = {
search: async (q: string): Promise<{ success: boolean; data: CounterpartSearchResultDto[] }> => {
const response = await apiClient.get(`/organizations/search?q=${encodeURIComponent(q)}`);
return response.data;
},
list: async (): Promise<{ success: boolean; data: { items: CounterpartItemDto[] } }> => {
const response = await apiClient.get('/organizations/links');
return response.data;
},
listInvitations: async (): Promise<{
success: boolean;
data: { items: OrganizationInvitationHistoryItemDto[] };
}> => {
const response = await apiClient.get('/organizations/invitations');
return response.data;
},
createLink: async (
targetOrganizationId: string,
): Promise<{ success: boolean; data: { id: string; status: 'PENDING' | 'ACTIVE' | 'REJECTED' } }> => {
const response = await apiClient.post('/organizations/links', { targetOrganizationId });
return response.data;
},
respondLink: async (
linkId: string,
action: 'ACCEPT' | 'REJECT',
): Promise<{ success: boolean; data: { id: string; status: 'PENDING' | 'ACTIVE' | 'REJECTED' } }> => {
const response = await apiClient.patch(`/organizations/links/${linkId}/respond`, { action });
return response.data;
},
deleteLink: async (linkId: string): Promise<{ success: boolean; data: { id: string }; message: string }> => {
const response = await apiClient.delete(`/organizations/links/${linkId}`);
return response.data;
},
invite: async (body: {
organizationName: string;
ownerEmail: string;
phone?: string;
}): Promise<{ success: boolean; data: { invitationId: string; invitationUrl: string; status: 'PENDING' } }> => {
const response = await apiClient.post('/organizations/invite', body);
return response.data;
},
getInvitationLink: async (
invitationId: string,
): Promise<{ success: boolean; data: { invitationId: string; invitationUrl: string } }> => {
const response = await apiClient.post(`/organizations/invitations/${invitationId}/link`);
return response.data;
},
previewInvite: async (
token: string,
): Promise<{
success: boolean;
data: {
ownerEmail: string;
organizationName: string;
organizationType: 'CLINIC' | 'LAB';
inviterOrganizationName: string;
expiresAt: string;
status: 'PENDING' | 'ACCEPTED';
};
}> => {
const response = await apiClient.get(
`/organizations/invitations/preview?token=${encodeURIComponent(token)}`,
);
return response.data;
},
acceptInvite: async (body: {
token: string;
organizationName: string;
ownerName: string;
password: string;
}): Promise<{ success: boolean; message: string; data: { organizationId: string } }> => {
const response = await apiClient.post('/organizations/invitations/accept', body);
return response.data;
},
};

View File

@@ -0,0 +1,63 @@
/** Normalize to local midnight; invalid input falls back to today. */
export function startOfLocalDay(d: Date): Date {
if (Number.isNaN(d.getTime())) {
const t = new Date();
return new Date(t.getFullYear(), t.getMonth(), t.getDate(), 0, 0, 0, 0);
}
return new Date(d.getFullYear(), d.getMonth(), d.getDate(), 0, 0, 0, 0);
}
/** Local calendar bounds for a date (browser timezone). */
export function getLocalDayIsoRange(day: Date): { from: string; to: string } {
const start = startOfLocalDay(day);
const end = new Date(start.getFullYear(), start.getMonth(), start.getDate() + 1, 0, 0, 0, 0);
return { from: start.toISOString(), to: end.toISOString() };
}
export function toDateInputValue(d: Date): string {
const y = d.getFullYear();
const m = String(d.getMonth() + 1).padStart(2, '0');
const day = String(d.getDate()).padStart(2, '0');
return `${y}-${m}-${day}`;
}
export function parseDateInput(value: string): Date {
const [y, m, d] = value.split('-').map(Number);
return new Date(y, m - 1, d, 0, 0, 0, 0);
}
export function combineLocalDateAndTime(day: Date, timeHHmm: string): Date {
const [h, min] = timeHHmm.split(':').map(Number);
return new Date(day.getFullYear(), day.getMonth(), day.getDate(), h, min, 0, 0);
}
export function formatTimeForInput(d: Date): string {
const h = String(d.getHours()).padStart(2, '0');
const m = String(d.getMinutes()).padStart(2, '0');
return `${h}:${m}`;
}
export function isSameLocalCalendarDay(a: Date, b: Date): boolean {
return (
a.getFullYear() === b.getFullYear() &&
a.getMonth() === b.getMonth() &&
a.getDate() === b.getDate()
);
}
export function formatHourLabel(hour: number): string {
const d = new Date(2000, 0, 1, hour, 0, 0, 0);
return d.toLocaleTimeString(undefined, { hour: 'numeric', hour12: true });
}
/** Local midnight + delta calendar days. */
export function addCalendarDays(day: Date, delta: number): Date {
return new Date(day.getFullYear(), day.getMonth(), day.getDate() + delta, 0, 0, 0, 0);
}
/** Compare two calendar days at local midnight (ordering by date only). */
export function compareLocalDayStart(a: Date, b: Date): number {
const ta = new Date(a.getFullYear(), a.getMonth(), a.getDate()).getTime();
const tb = new Date(b.getFullYear(), b.getMonth(), b.getDate()).getTime();
return ta - tb;
}

View File

@@ -0,0 +1,12 @@
export function formatApiErrorMessage(err: unknown, fallback: string): string {
if (err && typeof err === 'object' && 'message' in err) {
const m = (err as { message: unknown }).message;
if (Array.isArray(m)) {
return m.filter(Boolean).join(', ');
}
if (typeof m === 'string' && m.trim()) {
return m;
}
}
return fallback;
}

View File

@@ -2,10 +2,11 @@ import type { Organization } from '@/types/organization';
const ROUTE_TAB_READ: { prefix: string; permission: string }[] = [
{ prefix: '/today', permission: 'TAB_TODAY_READ' },
{ prefix: '/staff', permission: 'TAB_STAFF_READ' },
{ prefix: '/organizations', permission: 'TAB_ORGANIZATIONS_READ' },
{ prefix: '/patients', permission: 'TAB_PATIENTS_READ' },
{ prefix: '/appointments', permission: 'TAB_APPOINTMENTS_READ' },
{ prefix: '/staff', permission: 'TAB_STAFF_READ' },
{ prefix: '/lab', permission: 'TAB_LAB_READ' },
{ prefix: '/treatment', permission: 'TAB_TREATMENT_READ' },
{ prefix: '/billing', permission: 'TAB_BILLING_READ' },
{ prefix: '/reports', permission: 'TAB_REPORTS_READ' },
];
@@ -47,3 +48,35 @@ export function canViewStaff(org: Organization | null): boolean {
hasPermission(org, 'TAB_STAFF_READ') || hasPermission(org, 'TAB_STAFF_EDIT')
);
}
/**
* Create/delete/book slots: owners, appointment editors, or treatment editors (schedule columns).
* Aligns with backend appointment mutations.
*/
export function canEditAppointments(org: Organization | null): boolean {
if (!org) {
return false;
}
if (org.isOwner) {
return true;
}
return (
hasPermission(org, 'TAB_APPOINTMENTS_EDIT') ||
hasPermission(org, 'TAB_TREATMENT_EDIT')
);
}
/** Route + sidebar: view appointments page if user can read appointments or manage treatment (column staff). */
export function canAccessAppointmentsSection(org: Organization | null): boolean {
if (!org) {
return false;
}
if (org.isOwner) {
return true;
}
return (
hasPermission(org, 'TAB_APPOINTMENTS_READ') ||
hasPermission(org, 'TAB_APPOINTMENTS_EDIT') ||
hasPermission(org, 'TAB_TREATMENT_EDIT')
);
}

View File

@@ -0,0 +1,27 @@
import type { Patient } from './patient';
export const APPOINTMENT_PURPOSES = [
'consultation',
'filling',
'endo',
'visit',
'hygiene',
] as const;
export type AppointmentPurpose = (typeof APPOINTMENT_PURPOSES)[number];
export interface AppointmentColumnProvider {
userId: string;
name: string;
}
export interface AppointmentRecord {
id: string;
organizationId: string;
patientId: string;
providerUserId: string;
startAt: string;
endAt: string;
purpose: string;
patient: Pick<Patient, 'id' | 'firstName' | 'lastName' | 'phone'>;
}

View File

@@ -19,4 +19,7 @@ DOMAIN=dyolink.com
# Frontend Environment (create frontend.env from this)
# NEXT_PUBLIC_API_URL=/api
# 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-file: "3"
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
timeout: 10s
retries: 3
@@ -81,7 +81,7 @@ services:
max-size: "10m"
max-file: "3"
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
timeout: 10s
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:
postgres:
image: postgres:15-alpine
container_name: dyolink_db_container
environment:
POSTGRES_USER: postgres
POSTGRES_PASSWORD: 1234
POSTGRES_DB: dyolink_db
POSTGRES_USER: ${POSTGRES_USER:-postgres}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-dyolink_dev_change_me}
POSTGRES_DB: ${POSTGRES_DB:-dyolink_db}
ports:
- "5433:5432"
volumes:
@@ -16,7 +19,7 @@ services:
- dyolink_network
restart: unless-stopped
healthcheck:
test: ["CMD-SHELL", "pg_isready -U postgres"]
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-postgres}"]
interval: 10s
timeout: 5s
retries: 5
@@ -30,8 +33,8 @@ services:
env_file:
- ../backend/.env
environment:
- DATABASE_URL=postgresql://postgres:1234@postgres:5432/dyolink_db
- FRONTEND_URL=http://frontend:3000
- DATABASE_URL=postgresql://${POSTGRES_USER:-postgres}:${POSTGRES_PASSWORD:-dyolink_dev_change_me}@postgres:5432/${POSTGRES_DB:-dyolink_db}
- FRONTEND_URL=http://localhost:4000
- PORT=3000
ports:
- "4001:3000"
@@ -53,9 +56,8 @@ services:
environment:
- NEXT_PUBLIC_API_URL=http://localhost:4001/api
- NEXT_PUBLIC_APP_URL=http://localhost:4000
- PORT=3000
ports:
- "4000:3000"
- "4000:3001"
volumes:
- ../frontend:/app:rw
- /app/node_modules
@@ -73,10 +75,8 @@ services:
- frontend
ports:
- "8080:80"
- "8443:443"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./ssl:/etc/nginx/ssl:ro
- ./nginx/http-only.dev.conf:/etc/nginx/conf.d/default.conf:ro
- ./logs/nginx:/var/log/nginx
networks:
- 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
# Remove default configuration
RUN rm /etc/nginx/conf.d/default.conf
RUN rm -f /etc/nginx/conf.d/default.conf
# Copy custom configuration
COPY nginx.conf /etc/nginx/conf.d/
COPY http-only.conf /etc/nginx/conf.d/default.conf
# Create log directory
RUN mkdir -p /var/log/nginx && \
chown -R nginx:nginx /var/log/nginx && \
chmod -R 755 /var/log/nginx
# Switch to non-root user
USER nginx
EXPOSE 80
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;
}
}