Compare commits

..

8 Commits

Author SHA1 Message Date
62d5d94121 review notification logic and fix minor issue.
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 39m57s
Production — tag build, push, deploy / deploy (push) Successful in 13m26s
2026-09-07 23:18:00 +03:30
880809fdbe improvement: users get notified whenever a task is done completely, not per step. 2026-09-07 22:36:33 +03:30
e52701dba3 Merge pull request 'update adminjs with current db schema.' (#77) from feature/ci-cd into master
All checks were successful
Registry — build, push, deploy / build-and-push (push) Successful in 1m26s
Registry — build, push, deploy / deploy (push) Successful in 2m43s
Reviewed-on: #77
Reviewed-by: admin <admin@localhost>
2026-09-07 21:40:17 +03:30
b1bcfc69e4 Lab Case Detail datatset load faliure fixed.
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 26m42s
Production — tag build, push, deploy / deploy (push) Successful in 13m27s
2026-09-07 20:41:18 +03:30
0d2c279f4b Merge branch 'master' into feature/ci-cd
All checks were successful
Production — tag build, push, deploy / build-and-push (push) Successful in 36s
Production — tag build, push, deploy / deploy (push) Successful in 12m8s
2026-09-07 18:33:04 +03:30
fb59aed2f8 Merge pull request 'improvement: Frontend Dockerfile updated so that aseests in public folder loads on the website.' (#76) from improvements/subscription-v1 into master
Some checks failed
Registry — build, push, deploy / deploy (push) Has been cancelled
Registry — build, push, deploy / build-and-push (push) Has been cancelled
Reviewed-on: #76
Reviewed-by: rameen <rameen.naghdi@gmail.com>
2026-09-07 18:31:29 +03:30
4add3ab859 update adming js 2026-09-07 18:21:30 +03:30
ea6976351a Merge pull request 'improvements/subscription-v1' (#75) from improvements/subscription-v1 into master
All checks were successful
Registry — build, push, deploy / build-and-push (push) Successful in 31m6s
Registry — build, push, deploy / deploy (push) Successful in 2m1s
Reviewed-on: #75
Reviewed-by: aminmsvi <amn.mousavi@gmail.com>
2026-09-06 12:55:39 +03:30
35 changed files with 438 additions and 119 deletions

29
.cursor/rules/adminjs.mdc Normal file
View File

@@ -0,0 +1,29 @@
---
description: AdminJS panel must stay in sync with Prisma schema changes
globs: backend/src/admin/**,backend/prisma/schema.prisma,backend/prisma/migrations/**
alwaysApply: false
---
# AdminJS ↔ Prisma sync (required)
Ops panel at `/admin` (`backend/src/admin/`). Resources are a **manual allowlist** — Prisma does **not** auto-update AdminJS.
## When you change `schema.prisma`
**Before finishing the task**, update AdminJS:
1. Open [`backend/src/admin/resources.ts`](backend/src/admin/resources.ts) (`buildAdminResources`).
2. **New model** ops may need to inspect/fix → add `resource(...)` + navigation group + hide secrets.
3. **Renamed / removed model** → update or remove the matching resource (broken `getModelByName` breaks `/admin` boot).
4. **New secret fields** (hashes, tokens, share tokens) → hide via `isVisible: false` (list/filter/show/edit).
5. **Catalog-like reference data** → list/show/edit only; disable `new` / `delete` / `bulkDelete`.
6. **Composite `@@id` only** (no single `@id`) → **do not register** — AdminJS list returns 500 (`Resource does not have an id property`). Examples: `LabCaseDetail`, `MembershipPermission`, read-cursor tables.
7. Skip pure join/cursor tables unless ops need them and they have a single id.
Auth: `ADMINJS_EMAIL` / `ADMINJS_PASSWORD` — production login disabled if password missing or still `admin123`.
Production Docker: `ADMIN_JS_TMP_DIR=/app/adminjs-tmp` (not `.adminjs`) so `components.bundle.js` can be written/served.
## Secrets to hide
`passwordHash`, session `token`/`refreshToken`, invite/OTP `tokenHash`/`codeHash`, `LabCase.accessToken`.

View File

@@ -35,6 +35,7 @@ throw new AppException(ErrorCode.PERMISSION_DENIED, HttpStatus.FORBIDDEN);
- Schema: `backend/prisma/schema.prisma`
- Always add a migration for schema changes (`npm run prisma:migrate` in backend).
- Seed permissions stay in sync with `ALL_TAB_PERMISSIONS` in `common/permissions.ts`.
- **Schema change ⇒ AdminJS:** update `backend/src/admin/resources.ts` in the same change (add/rename/remove resources, hide new secrets). See `.cursor/rules/adminjs.mdc`.
## API responses

View File

@@ -20,6 +20,7 @@ Monorepo: `backend/` (NestJS + Prisma), `frontend/` (Next.js + next-intl), `infr
- **Never commit or push** unless the user explicitly asks.
- Prefer minimal diffs; reuse existing components and API patterns.
- After cross-cutting changes: `backend` → `npm run build`; `frontend` → `npx tsc --noEmit`.
- Prisma `schema.prisma` changes ⇒ update AdminJS allowlist (`backend/src/admin/resources.ts`) — `.cursor/rules/adminjs.mdc`.
## i18n
@@ -45,4 +46,4 @@ Logical failures: `AppException(ErrorCode.X)` → `errors.X` in en/fa/nl. UI: `g
## Notifications (inbox + live tabs)
Header bell: `UserNotification` + Socket.IO. Same `notification.created` also drives sidebar tab badges and soft list refresh on **currently open** Cases/Tasks/Treatment/Orgs pages. Skills: `.cursor/skills/notifications-inbox/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`.
Header bell: `UserNotification` + Socket.IO. Same `notification.created` also drives sidebar tab badges and soft list refresh on **currently open** Cases/Tasks/Treatment/Orgs pages. Inbox + Tasks/Treatment badges use **`CASE_COMPLETED`** (every task in the case done); per-step `TASK_COMPLETED` is timeline-only. Skills: `.cursor/skills/notifications-inbox/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`.

View File

@@ -17,5 +17,6 @@ alwaysApply: false
- **Mobile UX:** `LAB_TASK_STATUS_SELECT_CLASS` (44px tap target on small screens); `TaskCaseGroupHeader` sticky while scrolling grouped tasks; filter selects use same touch sizing on Tasks.
- **Show in case:** `GET /tasks/locate-page` finds page in full list; highlight + scroll.
- **Today deep links:** `parseTasksSearchParams` + `prosthesisTypeCode` / `unassignedOnly` / `overdueOnly` query params on Tasks.
- **Inbox / tab badges:** completing a step writes timeline `TASK_COMPLETED` only; inbox + Tasks/Treatment badges fire on `CASE_COMPLETED` when no in-progress tasks remain.
Full map: `.cursor/skills/lab-tasks/SKILL.md`

View File

@@ -6,7 +6,7 @@ alwaysApply: false
# Tab badges (Cases / Tasks / Treatment)
- **Split counts (Option B):** Lab Cases = sent + clinic comments + important; Lab Tasks = completions + lab comments + assignments (assignee-only); Clinic Treatment = visible lab comments + completions.
- **Split counts (Option B):** Lab Cases = sent + clinic comments + important; Lab Tasks = case fully completed + lab comments + assignments (assignee-only); Clinic Treatment = visible lab comments + case fully completed. Per-step `TASK_COMPLETED` is timeline-only (not badges or inbox).
- **API:** `GET /notifications/tab-counts`; **Cases + Treatment** use per-case read + `hasUnread` on list cards; Tasks marks read on tab visit. Treatment rail uses `TreatmentLabCasesPanel` + `LabCaseTrackerCard` + activity feed.
- **Pattern:** `useTabBadgeCounts` + `notifyTabBadgesChanged()` — same shape as `usePendingConnectionsCount`.
- **Live:** inbox Socket.IO `notification.created` → `notifyTabBadgesChanged()` (and org pending event when relevant). **Mounted** Cases/Tasks/Treatment/Orgs pages soft-refetch lists; unmounted tabs do not. Sidebar badge counts always refetch (hook is always mounted).

View File

@@ -11,7 +11,7 @@ Follow this checklist. Adapt steps if the feature is read-only or org-type-speci
```
- [ ] 1. Permissions & org type
- [ ] 2. Backend module
- [ ] 2. Backend module (+ Prisma / AdminJS if new models)
- [ ] 3. Frontend UI + thin page
- [ ] 4. i18n (en, fa, nl)
- [ ] 5. Verify build / tsc
@@ -40,6 +40,7 @@ backend/src/modules/{feature}/
- Service-level permission checks with `hasEffectivePermission`.
- DTOs use `ErrorCode` validation messages.
- Register in `app.module.ts`.
- If you add/change Prisma models: update AdminJS allowlist in `backend/src/admin/resources.ts` (same PR). See `.cursor/rules/adminjs.mdc`.
## 3. Frontend

View File

@@ -95,7 +95,7 @@ Keep changes minimal — match existing `sm:` breakpoint patterns elsewhere in t
## Tab badges + live soft refresh
See `.cursor/skills/tab-badges/SKILL.md` — split lab Cases/Tasks counts, clinic Treatment; `useTabBadgeCounts` + `notifyTabBadgesChanged`.
See `.cursor/skills/tab-badges/SKILL.md` — split lab Cases/Tasks counts, clinic Treatment; `useTabBadgeCounts` + `notifyTabBadgesChanged`. Inbox and Tasks/Treatment badges fire on `CASE_COMPLETED` (all tasks done), not each workflow step. The case timeline still records `TASK_COMPLETED` per step.
Inbox Socket.IO `notification.created``notifyTabBadgesChanged()` → silent `loadTasks({ silent: true })` on an open Tasks page (filters preserved; no remount). Details: `.cursor/skills/notifications-inbox/SKILL.md`.

View File

@@ -43,7 +43,7 @@ Full tab-badge map: `.cursor/skills/tab-badges/SKILL.md`.
## Emit sites (parallel to LabCaseActivity)
CASE_SENT, CLINIC_COMMENT, LAB_COMMENT (+ LAB_COMMENT_CLINIC), CASE_IMPORTANT, TASK_COMPLETED, TASK_ASSIGNED (assignee only), CONNECTION_REQUEST, STAFF_INVITE — see service call sites.
CASE_SENT, CLINIC_COMMENT, LAB_COMMENT (+ LAB_COMMENT_CLINIC), CASE_IMPORTANT, CASE_COMPLETED (all tasks in the case done — not each step), TASK_ASSIGNED (assignee only), CONNECTION_REQUEST, STAFF_INVITE — see service call sites.
**Inbox card context** is denormalized inside `UserNotificationService.notify()` (`enrichInboxPayload`) from ids already on the payload (`labCaseId`, `taskId`, `fromOrganizationId`). Emit sites stay thin (`{ labCaseId }`, etc.). Inbox list/read does **not** join related tables. Older rows may lack these fields until new events are emitted.

View File

@@ -13,7 +13,7 @@ Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/
## Models
- **`LabCaseActivity`** — append-only events: `CASE_SENT`, `CLINIC_COMMENT`, `LAB_COMMENT`, `CASE_IMPORTANT`, `CASE_AMENDED` (stub for Step 7), `TASK_COMPLETED`, `TASK_ASSIGNED`
- **`LabCaseActivity`** — append-only events: `CASE_SENT`, `CLINIC_COMMENT`, `LAB_COMMENT`, `CASE_IMPORTANT`, `CASE_AMENDED` (stub for Step 7), `TASK_COMPLETED` (timeline only), `TASK_ASSIGNED`, `CASE_COMPLETED`
- **`LabCaseUserTabReadState`** — per user/org/tab cursor (`TASKS`) for sidebar badge clearing on tab visit.
- **`LabCaseUserReadState`** — per user/org/labCase cursor; drives Cases tab count and `hasUnread` on case list cards
@@ -22,8 +22,8 @@ Frontend hook: [`frontend/src/lib/hooks/useTabBadgeCounts.ts`](frontend/src/lib/
| Org | Tab | Activity types |
|-----|-----|----------------|
| LAB | Cases | `CASE_SENT`, `CLINIC_COMMENT`, `CASE_IMPORTANT` |
| LAB | Tasks | `TASK_COMPLETED`, `LAB_COMMENT`, `TASK_ASSIGNED` (assignee only) |
| CLINIC | Treatment | `LAB_COMMENT` (only `visibleToClinic`), `TASK_COMPLETED`**only lab cases for treatments the user provided** |
| LAB | Tasks | `CASE_COMPLETED`, `LAB_COMMENT`, `TASK_ASSIGNED` (assignee only) |
| CLINIC | Treatment | `LAB_COMMENT` (only `visibleToClinic`), `CASE_COMPLETED`**only lab cases for treatments the user provided** |
Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT` counts only when `payload.visibleToClinic === true`.
@@ -43,7 +43,8 @@ Counts exclude events where `actorUserId === current user`. Clinic `LAB_COMMENT`
| First send | `treatments.service` `sendLabCase``CASE_SENT` |
| Comment | `lab-case-comments.service``CLINIC_COMMENT` / `LAB_COMMENT` |
| Mark important | `cases.service` `updateImportant` (only when set true) → `CASE_IMPORTANT` |
| Task completed | `tasks.service` `updateStatus``TASK_COMPLETED` |
| Task completed (step) | `tasks.service` `updateStatus``TASK_COMPLETED` **activity only** (case timeline; not inbox or tab badges) |
| Case fully completed | `tasks.service` `updateStatus` when no in-progress tasks remain → `CASE_COMPLETED` activity + inbox |
| Task assigned | `cases.service` `assignTask``TASK_ASSIGNED` (inbox + Tasks badge for **assignee**, including self-assign) |
After mutations, frontend calls `notifyTabBadgesChanged()` (window event).

View File

@@ -58,7 +58,7 @@ frontend/src/
- Detail treatment type need **not** match appointment purpose — purpose only seeds the **first** line of an empty **appointment** draft (first open, and **Add detail** when the plan is `[]`). Further **Add detail** starts with an empty type. Unscheduled / New treatment still seeds a blank first line.
- **History filters** are client-side only (`treatmentHistoryFilters.ts`): “Not shipped to lab” + single date on already-fetched patient history; includes live current draft when filtering.
- **Lab shipments rail**: unified list with scope toggle **This patient** vs **All updates** (unread across org for **this clinician's cases only**, includes patient name). Opening a case from the rail **opens the lab dispatch view** (same slot as the chart).
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit).
- **Unread semantics**: Treatment tab badge = count of unread cases **for the user's own treatment plans** (per-case read cursor) and clears when a case is opened/marked read (not on tab visit). Completions count only when **every task in the case is done** (`CASE_COMPLETED`) — not each workflow step.
- **Live lab rail**: `notification.created``notifyTabBadgesChanged()` silently refreshes patient lab cases + unread rail (does **not** clear draft/form state).
- **Lab shipment progress + comments**: shown in **Lab dispatch panel** for the active shipment; expanding activity / opening comments marks that case read. Shared UI: `LabCaseCommentsPanel` — newest first; sent = start / received = end (`text-start`/`justify-start`, RTL-safe); pass `viewerSide`.
@@ -68,7 +68,7 @@ frontend/src/
**Staff (quick ref):** Owner / `TAB_STAFF_EDIT` can **remove** a password (`POST /staff/members/:id/clear-password`) and copy a setup link — never set one for someone else. Login page unchanged (`passwordHash: null` cannot sign in). `/accept-invite` `password_setup` is password-only. See `.cursor/rules/staff.mdc`.
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; filter by case source (`origin`: received vs generated); prosthesis colors from catalog; job titles show the picker path to the leaf (`prosthesisJobPath.ts`); task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges:** `LabCaseActivity` + `GET /notifications/tab-counts` (lab Cases/Tasks split, clinic Treatment) — live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
**Lab Tasks tab:** Newest case first; steps ordered 1→N; case grouping when sorted by date; `stepCompleted` filter; filter by case source (`origin`: received vs generated); prosthesis colors from catalog; job titles show the picker path to the leaf (`prosthesisJobPath.ts`); task assignment in **Cases** (compact row: status + assignee + last update); on **Tasks**, all staff see every task but only assignee (or unassigned pool) can change status — others see “Assigned to {name}” instead of the status dropdown; **case due dates** set/edited in clinic Treatment lab dispatch, shown on lab Cases/Tasks with overdue filter + sort; completing **`intraoral_scan`** completes every scan task in that case (case-scoped; catalog first step for all prosthesis types); **mobile:** larger task status controls, sticky case header when grouped; **tab badges / inbox:** Tasks + Treatment badges and the header bell increment on **`CASE_COMPLETED`** (all tasks in the case done), not each step — the case timeline still lists `TASK_COMPLETED` per step; live via inbox Socket.IO → `notifyTabBadgesChanged()` + soft list refresh — see `.cursor/skills/lab-tasks/SKILL.md`, `.cursor/skills/tab-badges/SKILL.md`, `.cursor/skills/notifications-inbox/SKILL.md`.
**Lab Cases tab:** Filter by **prosthesis type** (not treatment type); auto-select newest case on open; list **10 per page**; left rail list fills column height (`flex-1 overflow-y-auto`); list cards use `LabCaseProsthesisGroupsList` (colored type + teeth, shared with Treatment rail) plus **Received** / **Generated** origin badges. Job titles show the picker path to the **leaf** (`prosthesisJobPath.ts` — e.g. Crowns · PFM Crown, not “Crowns”; indirect is Inlay · Layered ceramic, not the long Veneer/Inlay/Onlay/Overlay parent). Deep link: `?caseId=`, `?clinicOrganizationId=`. **Share link:** QR + URL on sent cases (attachment left, QR right); opens `/lab-case/[token]` focus page. **Case Sheet PDF:** client A4 (`jspdf`/`html2canvas`); hex-only print layout; optional `externalCode` replaces order number. Lab-origin Start has no clinic send; comments and mark-read still work (no clinic-visibility toggle). Unstarted generated drafts can be deleted (`DELETE /cases/:id`). **Live:** inbox Socket.IO → `notifyTabBadgesChanged()` soft-refreshes list + selected detail (no remount). See `.cursor/skills/lab-cases/SKILL.md` and `.cursor/skills/lab-case-share-link/SKILL.md`.
@@ -86,11 +86,14 @@ frontend/src/
backend/src/
modules/{feature}/ → controller, service, dto, module
common/ → guards, permissions, errors, utils
admin/ → AdminJS `/admin` panel (curated Prisma resources)
prisma/ → schema, migrations, seed
```
Errors: `AppException` + `ErrorCode` → frontend `getUserFacingError()`. Unexpected 500s: GlitchTip (`SENTRY_DSN`). Never throw raw strings for user-facing failures.
**AdminJS:** Manual resource allowlist in `backend/src/admin/resources.ts`. **Whenever `schema.prisma` changes**, update AdminJS resources in the same task (new/renamed/removed models, hide secrets). Models with only composite `@@id` must not be registered (list 500). Rule: `.cursor/rules/adminjs.mdc`.
## Git & commits
- **Do not commit or push** unless the user explicitly asks.

View File

@@ -96,6 +96,7 @@ Clinics may only dispatch to labs they are linked to: `OrganizationLink` (A↔B,
### Layout conventions worth knowing before you create a file
- **Prisma lives outside `src/`**: `backend/prisma/` holds `schema.prisma`, migrations, seeds *and* `prisma.module.ts` / `prisma.service.ts` — hence imports like `../../../prisma/prisma.service`. Register new Nest modules in `app.module.ts`.
- **AdminJS (`/admin`)** resources are a manual allowlist in `backend/src/admin/resources.ts` — update them whenever `schema.prisma` changes (see `.cursor/rules/adminjs.mdc`).
- **Frontend layering** (`.cursor/rules/frontend-components.mdc`): `app/**/page.tsx` is a thin wrapper only → route logic in `components/ui/{feature}/{Feature}Page.tsx` → JSX in `components/ui/**` → pure helpers in `components/{feature}/` or `components/shared/`. No JSX outside `ui/`, no pure helpers inside it.
- **i18n is mandatory, not a follow-up**: every user-visible string goes into `en.json`, `fa.json`, **and** `nl.json`. `fa` is RTL, so use logical `text-start`/`text-end`, never `text-left`/`text-right`. Dates/times/numbers go through `lib/i18n/format.ts`; form dates use `AppDateInput`, never a native date input.
- Treatment attachments are written to disk at `backend/uploads/treatments` relative to `process.cwd()`.

View File

@@ -53,7 +53,11 @@ COPY docker-entrypoint.sh /usr/local/bin/docker-entrypoint.sh
# Windows git/build context may use CRLF; strip before chmod (fixes dumb-init "No such file or directory").
RUN sed -i 's/\r$//' /usr/local/bin/docker-entrypoint.sh && chmod +x /usr/local/bin/docker-entrypoint.sh
RUN mkdir -p /app/logs && \
# AdminJS writes/serves the custom-components bundle here. Avoid the default
# ".adminjs" path — Express sendFile + nosniff can 500 on dot-directories.
ENV ADMIN_JS_TMP_DIR=/app/adminjs-tmp
RUN mkdir -p /app/logs /app/adminjs-tmp && \
chown -R dyolink:nodejs /app
USER dyolink

View File

@@ -0,0 +1,8 @@
-- AlterEnum
ALTER TYPE "LabCaseActivityType" ADD VALUE 'CASE_COMPLETED';
-- AlterEnum
ALTER TYPE "UserNotificationType" ADD VALUE 'CASE_COMPLETED';
-- Step-level TASK_COMPLETED inbox rows were spam; case-complete uses CASE_COMPLETED going forward.
DELETE FROM "UserNotification" WHERE type = 'TASK_COMPLETED';

View File

@@ -492,6 +492,7 @@ enum LabCaseActivityType {
CASE_AMENDED
TASK_COMPLETED
TASK_ASSIGNED
CASE_COMPLETED
}
enum LabCaseTabReadTarget {
@@ -508,6 +509,7 @@ enum UserNotificationType {
CASE_IMPORTANT
TASK_COMPLETED
TASK_ASSIGNED
CASE_COMPLETED
CONNECTION_REQUEST
STAFF_INVITE
}

View File

@@ -1,14 +1,16 @@
// backend/src/admin/admin.module.ts
import { DynamicModule, Module } from '@nestjs/common';
import { PrismaService } from '../../prisma/prisma.service';
import { componentLoader, Components } from './components';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { Database, Resource, getModelByName } from '@adminjs/prisma'; // 👈 Add getModelByName
import { Database, Resource } from '@adminjs/prisma';
import AdminJS from 'adminjs';
import { buildAdminResources } from './resources';
// Register the adapter
AdminJS.registerAdapter({ Database, Resource });
const LOCAL_DEFAULT_ADMIN_PASSWORD = 'admin123';
const LOCAL_DEFAULT_ADMIN_EMAIL = 'admin@dyolink.com';
@Module({
imports: [ConfigModule],
})
@@ -17,10 +19,24 @@ export class AdminModule {
const { AdminModule: AdminJSModule } = await import('@adminjs/nestjs');
const authenticate = async (email: string, password: string) => {
const isProduction = process.env.NODE_ENV === 'production';
const adminEmail =
process.env.ADMINJS_EMAIL?.trim() || 'admin@dyolink.com';
const adminPassword = process.env.ADMINJS_PASSWORD || 'admin123';
if (email === adminEmail && password === adminPassword) {
process.env.ADMINJS_EMAIL?.trim() || LOCAL_DEFAULT_ADMIN_EMAIL;
const adminPassword = process.env.ADMINJS_PASSWORD;
if (isProduction) {
if (
!adminPassword ||
adminPassword === LOCAL_DEFAULT_ADMIN_PASSWORD
) {
return null;
}
}
const effectivePassword =
adminPassword || LOCAL_DEFAULT_ADMIN_PASSWORD;
if (email === adminEmail && password === effectivePassword) {
return { email, role: 'admin' };
}
return null;
@@ -38,68 +54,54 @@ export class AdminModule {
config.get<string>('jwt.secret') ||
config.get('JWT_SECRET') ||
'secret-key-change-this';
if (process.env.NODE_ENV === 'production') {
const adminPassword = process.env.ADMINJS_PASSWORD;
if (
process.env.NODE_ENV === 'production' &&
!process.env.ADMINJS_PASSWORD
!adminPassword ||
adminPassword === LOCAL_DEFAULT_ADMIN_PASSWORD
) {
console.warn(
'⚠️ ADMINJS_PASSWORD is unset; AdminJS is using the local default. Set it in backend.env.',
console.error(
'❌ AdminJS: ADMINJS_PASSWORD is missing or still the local default. Login is disabled until you set a strong password in backend.env.',
);
}
}
return {
adminJsOptions: {
rootPath: '/admin',
resources: [
// ✅ Use getModelByName helper
{
resource: {
model: getModelByName('User'),
client: prisma,
},
options: {
properties: {
passwordHash: { isVisible: false },
},
},
},
{
resource: {
model: getModelByName('Organization'),
client: prisma,
},
options: {},
},
{
resource: {
model: getModelByName('OrganizationType'),
client: prisma,
},
options: {},
},
{
resource: {
model: getModelByName('Plan'),
client: prisma,
},
options: {},
},
{
resource: {
model: getModelByName('Membership'),
client: prisma,
},
options: {},
},
{
resource: {
model: getModelByName('Session'),
client: prisma,
},
options: {},
},
],
resources: buildAdminResources(prisma),
componentLoader,
dashboard: { component: Components.Dashboard },
dashboard: {
component: Components.Dashboard,
handler: async () => {
const [clinicType, labType] = await Promise.all([
prisma.organizationType.findUnique({
where: { name: 'CLINIC' },
}),
prisma.organizationType.findUnique({
where: { name: 'LAB' },
}),
]);
const [clinics, labs, users, labCases] = await Promise.all([
clinicType
? prisma.organization.count({
where: { typeId: clinicType.id },
})
: Promise.resolve(0),
labType
? prisma.organization.count({
where: { typeId: labType.id },
})
: Promise.resolve(0),
prisma.user.count(),
prisma.labCase.count(),
]);
return { clinics, labs, users, labCases };
},
},
branding: {
companyName: 'DyoLink Admin',
logo: false,

View File

@@ -5,7 +5,6 @@ const componentLoader = new ComponentLoader();
const Components = {
Dashboard: componentLoader.add('Dashboard', './dashboard'),
// You can add more components here as needed
};
export { componentLoader, Components };

View File

@@ -1,29 +1,78 @@
// backend/src/admin/dashboard-simple.tsx
// @ts-nocheck
import React from 'react';
import { Box, H2, Text, Badge } from '@adminjs/design-system';
import React, { useEffect, useState } from 'react';
import { Box, H2, Text } from '@adminjs/design-system';
import { ApiClient } from 'adminjs';
type DashboardStats = {
clinics: number;
labs: number;
users: number;
labCases: number;
};
const StatCard = ({
label,
value,
loading,
}: {
label: string;
value?: number;
loading: boolean;
}) => (
<Box p="lg" bg="primary20" style={{ flex: 1, minWidth: '140px' }}>
<Text>{label}</Text>
<Box mt="default" style={{ fontSize: '2rem', fontWeight: 'bold' }}>
{loading ? '…' : (value ?? '—')}
</Box>
</Box>
);
const Dashboard = () => {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const api = new ApiClient();
api
.getDashboard()
.then((response) => {
setStats(response.data as DashboardStats);
setError(null);
})
.catch(() => {
setError('Could not load dashboard stats.');
})
.finally(() => {
setLoading(false);
});
}, []);
return (
<Box variant="grey">
<Box variant="white" p="xl">
<H2>Welcome to DyoLink Admin Panel</H2>
<Text>Manage your dental clinics, labs, users, and subscriptions.</Text>
<H2>DyoLink Admin</H2>
<Text>Manage clinics, labs, users, and production data.</Text>
<Box mt="xl" style={{ display: 'flex', gap: '20px' }}>
<Box p="lg" bg="primary20" style={{ flex: 1 }}>
<div style={{ fontSize: '1.5rem' }}>🏥 Clinics</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>12</div>
</Box>
<Box p="lg" bg="secondary20" style={{ flex: 1 }}>
<div style={{ fontSize: '1.5rem' }}>🔬 Labs</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>8</div>
</Box>
<Box p="lg" bg="info20" style={{ flex: 1 }}>
<div style={{ fontSize: '1.5rem' }}>👥 Users</div>
<div style={{ fontSize: '2rem', fontWeight: 'bold' }}>45</div>
{error ? (
<Box mt="xl">
<Text>{error}</Text>
</Box>
) : (
<Box
mt="xl"
style={{ display: 'flex', gap: '20px', flexWrap: 'wrap' }}
>
<StatCard label="Clinics" value={stats?.clinics} loading={loading} />
<StatCard label="Labs" value={stats?.labs} loading={loading} />
<StatCard label="Users" value={stats?.users} loading={loading} />
<StatCard
label="Lab cases"
value={stats?.labCases}
loading={loading}
/>
</Box>
)}
</Box>
</Box>
);

View File

@@ -0,0 +1,166 @@
import { getModelByName } from '@adminjs/prisma';
import type { PrismaService } from '../../prisma/prisma.service';
type ResourceOptions = {
navigation?: { name: string; icon?: string };
properties?: Record<string, { isVisible?: boolean | { list?: boolean; filter?: boolean; show?: boolean; edit?: boolean } }>;
actions?: Record<
string,
{ isAccessible?: boolean }
>;
};
type AdminResource = {
resource: { model: ReturnType<typeof getModelByName>; client: PrismaService };
options: ResourceOptions;
};
const hide = (...propertyNames: string[]): ResourceOptions['properties'] =>
Object.fromEntries(
propertyNames.map((name) => [
name,
{ isVisible: { list: false, filter: false, show: false, edit: false } },
]),
);
const catalogActions: ResourceOptions['actions'] = {
new: { isAccessible: false },
delete: { isAccessible: false },
bulkDelete: { isAccessible: false },
};
function resource(
client: PrismaService,
modelName: string,
options: ResourceOptions = {},
): AdminResource {
return {
resource: {
model: getModelByName(modelName),
client,
},
options,
};
}
/** Curated AdminJS allowlist — keep in sync when adding ops-relevant Prisma models. */
export function buildAdminResources(prisma: PrismaService): AdminResource[] {
return [
// Identity
resource(prisma, 'User', {
navigation: { name: 'Identity', icon: 'User' },
properties: hide('passwordHash'),
}),
resource(prisma, 'Session', {
navigation: { name: 'Identity', icon: 'User' },
properties: hide('token', 'refreshToken'),
}),
resource(prisma, 'PhoneVerificationCode', {
navigation: { name: 'Identity', icon: 'User' },
properties: hide('codeHash'),
}),
// Orgs & access
resource(prisma, 'Organization', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
}),
resource(prisma, 'OrganizationType', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
}),
resource(prisma, 'Plan', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
}),
resource(prisma, 'Membership', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
}),
resource(prisma, 'Permission', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
}),
// MembershipPermission omitted: composite @@id — AdminJS list 500s without a single id
resource(prisma, 'Feature', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
}),
resource(prisma, 'StaffInvitation', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
properties: hide('tokenHash'),
}),
resource(prisma, 'OrganizationLink', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
}),
resource(prisma, 'OrganizationInvitation', {
navigation: { name: 'Orgs & access', icon: 'Settings' },
properties: hide('tokenHash'),
}),
// Clinic
resource(prisma, 'Patient', {
navigation: { name: 'Clinic', icon: 'Healthcare' },
}),
resource(prisma, 'Appointment', {
navigation: { name: 'Clinic', icon: 'Healthcare' },
}),
resource(prisma, 'Treatment', {
navigation: { name: 'Clinic', icon: 'Healthcare' },
}),
resource(prisma, 'TreatmentDetail', {
navigation: { name: 'Clinic', icon: 'Healthcare' },
}),
resource(prisma, 'TreatmentDetailAttachment', {
navigation: { name: 'Clinic', icon: 'Healthcare' },
}),
// Lab
resource(prisma, 'LabCase', {
navigation: { name: 'Lab', icon: 'Archive' },
properties: hide('accessToken'),
}),
resource(prisma, 'LabCaseLine', {
navigation: { name: 'Lab', icon: 'Archive' },
}),
// LabCaseDetail omitted: composite @@id — AdminJS list 500s without a single id
// (inspect LabCase + TreatmentDetail instead)
resource(prisma, 'LabCaseSend', {
navigation: { name: 'Lab', icon: 'Archive' },
}),
resource(prisma, 'LabCaseToothProsthesis', {
navigation: { name: 'Lab', icon: 'Archive' },
}),
resource(prisma, 'LabCaseTask', {
navigation: { name: 'Lab', icon: 'Archive' },
}),
resource(prisma, 'LabCaseTaskStatusEvent', {
navigation: { name: 'Lab', icon: 'Archive' },
}),
resource(prisma, 'LabCaseComment', {
navigation: { name: 'Lab', icon: 'Archive' },
}),
resource(prisma, 'LabCaseActivity', {
navigation: { name: 'Lab', icon: 'Archive' },
}),
resource(prisma, 'UserNotification', {
navigation: { name: 'Lab', icon: 'Archive' },
}),
// Catalog — edit OK; create/delete via seed/migrations
resource(prisma, 'TreatmentType', {
navigation: { name: 'Catalog', icon: 'Catalog' },
actions: catalogActions,
}),
resource(prisma, 'ProsthesisType', {
navigation: { name: 'Catalog', icon: 'Catalog' },
actions: catalogActions,
}),
resource(prisma, 'LabWorkflowStep', {
navigation: { name: 'Catalog', icon: 'Catalog' },
actions: catalogActions,
}),
resource(prisma, 'ProsthesisTypeStep', {
navigation: { name: 'Catalog', icon: 'Catalog' },
actions: catalogActions,
}),
resource(prisma, 'CatalogTranslation', {
navigation: { name: 'Catalog', icon: 'Catalog' },
actions: catalogActions,
}),
];
}

View File

@@ -7,15 +7,15 @@ export const LAB_CASES_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
LabCaseActivityType.CASE_IMPORTANT,
];
/** Lab Tasks tab — task completions, lab-side comments, and assignments (assignee-scoped in counts). */
/** Lab Tasks tab — case fully completed, lab-side comments, and assignments (assignee-scoped in counts). */
export const LAB_TASKS_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
LabCaseActivityType.TASK_COMPLETED,
LabCaseActivityType.CASE_COMPLETED,
LabCaseActivityType.LAB_COMMENT,
LabCaseActivityType.TASK_ASSIGNED,
];
/** Clinic Treatment tab — visible lab comments and task progress. */
/** Clinic Treatment tab — visible lab comments and case fully completed. */
export const CLINIC_TREATMENT_TAB_ACTIVITY_TYPES: LabCaseActivityType[] = [
LabCaseActivityType.LAB_COMMENT,
LabCaseActivityType.TASK_COMPLETED,
LabCaseActivityType.CASE_COMPLETED,
];

View File

@@ -303,6 +303,7 @@ export class TasksService {
}
}
let caseCompleted = false;
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
await this.labCaseActivity.record(
{
@@ -321,18 +322,37 @@ export class TasksService {
},
tx,
);
const remainingIncomplete = await tx.labCaseTask.count({
where: {
labCaseId: task.labCaseId,
status: { not: LabTaskStatus.COMPLETED },
},
});
caseCompleted = remainingIncomplete === 0;
if (caseCompleted) {
await this.labCaseActivity.record(
{
labCaseId: task.labCaseId,
type: LabCaseActivityType.CASE_COMPLETED,
actorUserId,
payload: { labCaseId: task.labCaseId },
},
tx,
);
}
}
return result;
return { result, caseCompleted };
});
if (dto.status === LabTaskStatus.COMPLETED && task.status !== LabTaskStatus.COMPLETED) {
if (updated.caseCompleted) {
void this.userNotifications.notify({
organizationId: labOrganizationId,
type: UserNotificationType.TASK_COMPLETED,
href: `/tasks?taskId=${encodeURIComponent(taskId)}&labCaseId=${encodeURIComponent(task.labCaseId)}`,
type: UserNotificationType.CASE_COMPLETED,
href: `/cases?caseId=${encodeURIComponent(task.labCaseId)}`,
actorUserId,
payload: { labCaseId: task.labCaseId, taskId },
payload: { labCaseId: task.labCaseId },
requiredPermission: 'TAB_TASKS_READ',
});
@@ -340,10 +360,10 @@ export class TasksService {
if (clinicOrgId) {
void this.userNotifications.notify({
organizationId: clinicOrgId,
type: UserNotificationType.TASK_COMPLETED,
type: UserNotificationType.CASE_COMPLETED,
href: `/treatment?labCaseId=${encodeURIComponent(task.labCaseId)}`,
actorUserId,
payload: { labCaseId: task.labCaseId, taskId },
payload: { labCaseId: task.labCaseId },
requiredPermission: 'TAB_TREATMENT_READ',
labCaseIdForProviderScope: task.labCaseId,
});
@@ -353,11 +373,11 @@ export class TasksService {
const locale = normalizeCatalogLocale(localeInput);
const prosthesisLabels = await this.catalogLabels.resolveLabels(
CatalogEntityKind.PROSTHESIS_TYPE,
atomicProsthesisCodes([updated.prosthesisTypeCode]),
atomicProsthesisCodes([updated.result.prosthesisTypeCode]),
locale,
);
return { success: true, data: this.mapTaskListItem(updated, prosthesisLabels) };
return { success: true, data: this.mapTaskListItem(updated.result, prosthesisLabels) };
}
async listFilterOptions(

View File

@@ -846,6 +846,7 @@
"activityTaskCompleted": "{step} completed by {actor} · {date}",
"activityTaskAssigned": "{step} assigned by {actor} · {date}",
"activityCaseImportant": "Marked important by {actor} · {date}",
"activityCaseCompleted": "All lab work completed by {actor} · {date}",
"activityCaseAmended": "Case updated by {actor} · {date}",
"activityGeneric": "Update · {date}",
"loadingHistory": "Loading history…",
@@ -1105,6 +1106,7 @@
"typeCaseImportant": "Case marked as important",
"typeTaskCompleted": "Lab task completed",
"typeTaskAssigned": "A task was assigned to you",
"typeCaseCompleted": "Lab case completed",
"typeConnectionRequest": "New organization connection request",
"typeStaffInvite": "Staff invitation created",
"typeUnknown": "Notification",

View File

@@ -847,6 +847,7 @@
"activityTaskCompleted": "{step} توسط {actor} تکمیل شد · {date}",
"activityTaskAssigned": "{step} توسط {actor} اختصاص داده شد · {date}",
"activityCaseImportant": "مهم علامت‌گذاری شد توسط {actor} · {date}",
"activityCaseCompleted": "تمام کارهای لابراتوار توسط {actor} تکمیل شد · {date}",
"activityCaseAmended": "پرونده به‌روزرسانی شد توسط {actor} · {date}",
"activityGeneric": "به‌روزرسانی · {date}",
"loadingHistory": "در حال بارگذاری تاریخچه...",
@@ -1106,6 +1107,7 @@
"typeCaseImportant": "پرونده به‌عنوان مهم علامت خورد",
"typeTaskCompleted": "وظیفه لابراتوار تکمیل شد",
"typeTaskAssigned": "یک وظیفه به شما اختصاص داده شد",
"typeCaseCompleted": "پرونده لابراتوار تکمیل شد",
"typeConnectionRequest": "درخواست اتصال سازمان جدید",
"typeStaffInvite": "دعوتنامه کارکنان ایجاد شد",
"typeUnknown": "اعلان",

View File

@@ -846,6 +846,7 @@
"activityTaskCompleted": "{step} voltooid door {actor} · {date}",
"activityTaskAssigned": "{step} toegewezen door {actor} · {date}",
"activityCaseImportant": "Als belangrijk gemarkeerd door {actor} · {date}",
"activityCaseCompleted": "Al het labwerk voltooid door {actor} · {date}",
"activityCaseAmended": "Case bijgewerkt door {actor} · {date}",
"activityGeneric": "Update · {date}",
"loadingHistory": "Geschiedenis laden...",
@@ -1105,6 +1106,7 @@
"typeCaseImportant": "Case gemarkeerd als belangrijk",
"typeTaskCompleted": "Labtaak voltooid",
"typeTaskAssigned": "Er is een taak aan u toegewezen",
"typeCaseCompleted": "Labcase voltooid",
"typeConnectionRequest": "Nieuw organisatieverzoek",
"typeStaffInvite": "Personeelsuitnodiging aangemaakt",
"typeUnknown": "Melding",

View File

@@ -54,7 +54,7 @@ export default async function LocaleLayout({
setRequestLocale(locale);
const messages = await getMessages();
const themeInit = `(function(){try{var k=${JSON.stringify(THEME_STORAGE_KEY)};var t=localStorage.getItem(k);document.documentElement.setAttribute('data-theme',t==='light'||t==='dark'?t:'dark');}catch(e){document.documentElement.setAttribute('data-theme','dark');}})();`;
const themeInit = `(function(){try{var k=${JSON.stringify(THEME_STORAGE_KEY)};var t=localStorage.getItem(k);document.documentElement.setAttribute('data-theme',t==='light'||t==='dark'?t:'light');}catch(e){document.documentElement.setAttribute('data-theme','light');}})();`;
const dir = isRtlLocale(locale) ? 'rtl' : 'ltr';
const fontSans = isRtlLocale(locale)
? 'var(--font-vazirmatn), var(--font-noto-sans-arabic), system-ui, -apple-system, Segoe UI, Roboto, Arial, sans-serif'
@@ -65,6 +65,7 @@ export default async function LocaleLayout({
lang={localeHtmlLang(locale)}
dir={dir}
data-locale={locale}
data-theme="light"
className={`${vazirmatn.variable} ${notoSansArabic.variable}`}
style={{ ['--font-sans' as never]: fontSans }}
suppressHydrationWarning

View File

@@ -13,6 +13,7 @@ const TYPE_I18N: Record<UserNotificationType, string> = {
CASE_IMPORTANT: 'typeCaseImportant',
TASK_COMPLETED: 'typeTaskCompleted',
TASK_ASSIGNED: 'typeTaskAssigned',
CASE_COMPLETED: 'typeCaseCompleted',
CONNECTION_REQUEST: 'typeConnectionRequest',
STAFF_INVITE: 'typeStaffInvite',
};
@@ -74,6 +75,17 @@ export function notificationContextLine(
if (clinicName) parts.push(clinicName);
if (prosthesisLabel) parts.push(prosthesisLabel);
break;
case 'CASE_COMPLETED':
if (patientName) parts.push(patientName);
if (item.href.startsWith('/treatment')) {
if (labName) parts.push(labName);
} else if (clinicName) {
parts.push(clinicName);
} else if (labName) {
parts.push(labName);
}
if (prosthesisLabel) parts.push(prosthesisLabel);
break;
case 'LAB_COMMENT_CLINIC':
if (patientName) parts.push(patientName);
if (labName) parts.push(labName);

View File

@@ -43,6 +43,8 @@ export function formatLabCaseActivityLine(
});
case 'CASE_IMPORTANT':
return t('activityCaseImportant', { actor, date });
case 'CASE_COMPLETED':
return t('activityCaseCompleted', { actor, date });
case 'CASE_AMENDED':
return t('activityCaseAmended', { actor, date });
default:

View File

@@ -3,14 +3,14 @@ export const THEME_STORAGE_KEY = 'dyolink-theme';
export type ThemeMode = 'light' | 'dark';
export function getStoredTheme(): ThemeMode {
if (typeof window === 'undefined') return 'dark';
if (typeof window === 'undefined') return 'light';
try {
const v = localStorage.getItem(THEME_STORAGE_KEY);
if (v === 'light' || v === 'dark') return v;
} catch {
/* ignore */
}
return 'dark';
return 'light';
}
export function applyTheme(mode: ThemeMode) {

View File

@@ -262,13 +262,17 @@ body {
}
html {
color-scheme: dark;
color-scheme: light;
}
html[data-theme='light'] {
color-scheme: light;
}
html[data-theme='dark'] {
color-scheme: dark;
}
/* Minimal RTL layer — refine incrementally. */
html[dir='rtl'] body {
direction: rtl;
@@ -338,9 +342,7 @@ select option {
}
:root[data-theme='dark'] select.form-select,
:root[data-theme='dark'] select,
:root:not([data-theme='light']) select.form-select,
:root:not([data-theme='light']) select {
:root[data-theme='dark'] select {
color-scheme: dark;
}
@@ -358,8 +360,7 @@ select option {
}
}
:root[data-theme='dark'] .surface-card,
:root:not([data-theme='light']) .surface-card {
:root[data-theme='dark'] .surface-card {
background: color-mix(in srgb, var(--color-card-background) 82%, var(--color-background-primary));
}

View File

@@ -5,7 +5,8 @@ export type LabCaseActivityType =
| 'CASE_IMPORTANT'
| 'CASE_AMENDED'
| 'TASK_COMPLETED'
| 'TASK_ASSIGNED';
| 'TASK_ASSIGNED'
| 'CASE_COMPLETED';
export interface LabCaseActivityItem {
id: string;

View File

@@ -6,6 +6,7 @@ export type UserNotificationType =
| 'CASE_IMPORTANT'
| 'TASK_COMPLETED'
| 'TASK_ASSIGNED'
| 'CASE_COMPLETED'
| 'CONNECTION_REQUEST'
| 'STAFF_INVITE';

View File

@@ -18,6 +18,8 @@ FRONTEND_URL=https://nudentic.ir
# Change these — the code defaults are only for local development.
ADMINJS_EMAIL=admin@nudentic.ir
ADMINJS_PASSWORD=CHANGE_ME_STRONG_ADMINJS_PASSWORD
# Optional override; Docker image sets /app/adminjs-tmp (do not use ".adminjs").
# ADMIN_JS_TMP_DIR=/app/adminjs-tmp
# Required for HTTPS — browsers reject Secure cookies over plain HTTP
COOKIE_SECURE=true

View File

@@ -15,6 +15,8 @@ FRONTEND_URL=https://wixur.ir
# AdminJS at https://wixur.ir/admin (nginx proxies /admin to the API).
ADMINJS_EMAIL=admin@wixur.ir
ADMINJS_PASSWORD=CHANGE_ME_STRONG_ADMINJS_PASSWORD
# Optional override; Docker image sets /app/adminjs-tmp (do not use ".adminjs").
# ADMIN_JS_TMP_DIR=/app/adminjs-tmp
# TLS is terminated on Windows nginx :443 — cookies must be Secure
COOKIE_SECURE=true

View File

@@ -48,6 +48,7 @@ services:
NODE_ENV: production
TZ: UTC
PORT: "3000"
ADMIN_JS_TMP_DIR: /app/adminjs-tmp
SENTRY_ENVIRONMENT: production
SENTRY_RELEASE: ${TAG:-latest}
expose:

View File

@@ -47,6 +47,7 @@ services:
NODE_ENV: production
TZ: UTC
PORT: "3000"
ADMIN_JS_TMP_DIR: /app/adminjs-tmp
SENTRY_ENVIRONMENT: staging
SENTRY_RELEASE: ${IMAGE_TAG:-latest}
expose:

View File

@@ -43,6 +43,7 @@ services:
NODE_ENV: production
TZ: UTC
PORT: "3000"
ADMIN_JS_TMP_DIR: /app/adminjs-tmp
SENTRY_ENVIRONMENT: staging
expose:
- "3000"