Compare commits
61 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 039e251c6a | |||
| 192f412f2a | |||
| 56335c73ec | |||
| 2f591a6ba8 | |||
| bc7938e002 | |||
| 80429920b9 | |||
| b5efd9eb12 | |||
| 191e072353 | |||
| 2aa4a12ddc | |||
| c40384b0d9 | |||
| dc30d75e11 | |||
| 21dda3d372 | |||
| 27384d019a | |||
| d002670e4e | |||
| 4b151c50a8 | |||
| d64a035cc3 | |||
| 4c57c58f8c | |||
| c3e42fd73e | |||
| 4e5bed602e | |||
| 620ab284e2 | |||
| 5834036707 | |||
| 0f71ae4102 | |||
| 42b881355b | |||
| 629069b1fe | |||
| 9fa40f00ab | |||
| c1e3a81cae | |||
| 51c3d8a8ba | |||
| f4253d4d9d | |||
| 05446afb5d | |||
| 55d19298e7 | |||
| d78818b88a | |||
| b5720c9aac | |||
| 54ca20a00f | |||
| 0740971a96 | |||
| bf39a6eda6 | |||
| 8f3b750961 | |||
| 243ccb3e00 | |||
| d91a68bc68 | |||
| e9d7b8dc76 | |||
| 51a401b6e5 | |||
| 66f063a978 | |||
| a6d5988421 | |||
| b69bcd4241 | |||
| 3c8bfbf84d | |||
| 46ac734a8e | |||
| 92425aafe6 | |||
| 097276f8cc | |||
| 9eab759acb | |||
| c69e6903c1 | |||
| 86b264f20a | |||
| 2679096e63 | |||
| b10ee5392c | |||
| 66e0e5697f | |||
| 2889148a0e | |||
| a5a35ef217 | |||
| 77f8096deb | |||
| fa31fb6374 | |||
| a01e102a08 | |||
| 7d13e8e345 | |||
| f16f4763df | |||
| e0a4652947 |
|
Before Width: | Height: | Size: 29 KiB |
|
Before Width: | Height: | Size: 9.1 KiB |
@@ -22,11 +22,30 @@ MOCKUP_PORT=8081
|
||||
# In compose this is set automatically; required when running natively.
|
||||
PORT=8080
|
||||
# Public base URL the SPA + API are reachable at. Used to build absolute
|
||||
# upload URLs for the local-FS storage driver and to validate CORS origins.
|
||||
# upload URLs for the local-FS storage driver and to derive whether the
|
||||
# session cookie should be marked `secure`. Match the URL operators
|
||||
# actually open in the browser (e.g. https://tx.example.com).
|
||||
PUBLIC_BASE_URL=http://localhost:3000
|
||||
# Comma-separated list of origins allowed by the API server's CORS layer.
|
||||
# Must include PUBLIC_BASE_URL and any additional reverse-proxy domains.
|
||||
# MUST include every URL the SPA can be reached at — the public domain,
|
||||
# any reverse-proxy hostnames, the LAN IP for tablets/phones, and any
|
||||
# tunneled URL (Tailscale `*.ts.net`, ngrok, Cloudflare Tunnel, etc).
|
||||
# Examples:
|
||||
# ALLOWED_ORIGINS=http://localhost:3000
|
||||
# ALLOWED_ORIGINS=https://tx.example.com,https://it-demo.tail70b2bc.ts.net
|
||||
# ALLOWED_ORIGINS=http://localhost:3000,http://192.168.1.42:3000
|
||||
ALLOWED_ORIGINS=http://localhost:3000
|
||||
# Set to `true` when Tx OS sits behind a TLS-terminating proxy that
|
||||
# does NOT forward `X-Forwarded-Proto: https` (notably `tailscale serve`,
|
||||
# the ngrok free tier, and some Cloudflare Tunnel setups). Without this,
|
||||
# the session cookie is silently dropped and login bounces back to the
|
||||
# login screen even on a successful POST /auth/login. Leave unset for
|
||||
# direct localhost access or when your proxy forwards the header.
|
||||
# SECURITY: only enable when the API is not reachable directly over plain
|
||||
# HTTP from outside — i.e. only your TLS edge proxy can hit it. Otherwise
|
||||
# anyone on the same network can submit plain-HTTP requests that the app
|
||||
# trusts as HTTPS.
|
||||
TRUST_PROXY_HTTPS=false
|
||||
# pino log level: trace | debug | info | warn | error | fatal
|
||||
LOG_LEVEL=info
|
||||
# 32+ random bytes. Generate with `openssl rand -hex 32`. REQUIRED.
|
||||
@@ -125,6 +144,11 @@ LOCAL_STORAGE_SIGNING_SECRET=
|
||||
# the first-run wizard at /setup.
|
||||
SEED_ADMIN_PASSWORD=
|
||||
SEED_USER_PASSWORD=
|
||||
# Set to `true` to populate the Executive Meetings module with a full
|
||||
# day of realistic-looking demo meetings on first boot (useful for
|
||||
# screenshots, sales demos, and UI walkthroughs). Leave unset on real
|
||||
# self-hosted installs — the module should start empty.
|
||||
SEED_DEMO_MEETINGS=false
|
||||
|
||||
# -----------------------------------------------------------------------------
|
||||
# Email (optional)
|
||||
|
||||
@@ -18,18 +18,35 @@ author = "agent"
|
||||
|
||||
[[workflows.workflow.tasks]]
|
||||
task = "workflow.run"
|
||||
args = "test"
|
||||
args = "Start application"
|
||||
|
||||
[[workflows.workflow.tasks]]
|
||||
task = "workflow.run"
|
||||
args = "API"
|
||||
|
||||
[[workflows.workflow]]
|
||||
name = "test"
|
||||
name = "Start application"
|
||||
author = "agent"
|
||||
|
||||
[workflows.workflow.metadata]
|
||||
outputType = "console"
|
||||
|
||||
[[workflows.workflow.tasks]]
|
||||
task = "shell.exec"
|
||||
args = "pnpm --filter @workspace/api-server test:wait && pnpm --filter @workspace/api-server test && pnpm --filter @workspace/tx-os test:e2e"
|
||||
args = "PORT=5000 BASE_PATH=/ VITE_API_PROXY_TARGET=http://localhost:8080 pnpm --filter @workspace/tx-os run dev"
|
||||
waitForPort = 5000
|
||||
|
||||
[workflows.workflow.metadata]
|
||||
outputType = "webview"
|
||||
|
||||
[[workflows.workflow]]
|
||||
name = "API"
|
||||
author = "agent"
|
||||
|
||||
[[workflows.workflow.tasks]]
|
||||
task = "shell.exec"
|
||||
args = "PORT=8080 pnpm --filter @workspace/api-server run dev"
|
||||
waitForPort = 8080
|
||||
|
||||
[workflows.workflow.metadata]
|
||||
outputType = "console"
|
||||
|
||||
[agent]
|
||||
stack = "PNPM_WORKSPACE"
|
||||
@@ -39,6 +56,14 @@ expertMode = true
|
||||
path = "scripts/post-merge.sh"
|
||||
timeoutMs = 120000
|
||||
|
||||
[[ports]]
|
||||
localPort = 5000
|
||||
externalPort = 4200
|
||||
|
||||
[[ports]]
|
||||
localPort = 5001
|
||||
externalPort = 3001
|
||||
|
||||
[[ports]]
|
||||
localPort = 8080
|
||||
externalPort = 8080
|
||||
|
||||
@@ -106,6 +106,8 @@ and running the documented commands produces a working deployment.
|
||||
|
||||
## Operational notes for first-time deploy
|
||||
|
||||
> **Updating an existing deploy after `git pull`?** Use `./scripts/redeploy.sh` — it rebuilds the `api` and `web` images, runs migrations + seed, and restarts the stack in the right order. See `replit.md` → "Redeploying after `git pull`".
|
||||
|
||||
1. `cp .env.example .env` and fill in every value.
|
||||
2. `docker compose build`
|
||||
3. `docker compose up -d db minio minio-init` — Postgres + MinIO + create
|
||||
|
||||
@@ -92,3 +92,35 @@ http://localhost:3000 → nginx (web container)
|
||||
git pull
|
||||
./start.sh rebuild
|
||||
```
|
||||
|
||||
## تنبيه التحديثات (System Updates)
|
||||
|
||||
صفحة **Admin → System Updates** تعرض الإصدار الحالي وتقدر تفحص لو في إصدار أحدث على Gitea. للتفعيل:
|
||||
|
||||
1. تأكد أن ملف `latest.json` موجود في جذر الريبو على Gitea بنفس بنية `version.json`:
|
||||
|
||||
```json
|
||||
{ "version": "0.2.0", "build": "20260601.abc1234" }
|
||||
```
|
||||
|
||||
2. أضف في `.env` (أو `docker-compose.yml` تحت `services.api.environment`):
|
||||
|
||||
```
|
||||
LATEST_VERSION_URL=https://desktop-11cj93j.tail866923.ts.net/rafraa/TX/raw/branch/main/latest.json
|
||||
```
|
||||
|
||||
3. أعد التشغيل: `./start.sh stop && ./start.sh`.
|
||||
|
||||
عند كل إصدار جديد: حدّث `latest.json` في الريبو وادفع. عملاء Tx OS سيرون شارة "Update available" عند ضغطهم على Check Now.
|
||||
|
||||
> رقم البناء (`build`) يُولَّد تلقائياً من git قبل كل `pnpm run build` (انظر `scripts/update-version.mjs`)، فلا تحتاج تعديله يدوياً في `version.json`.
|
||||
|
||||
## تفعيل تطبيقي التقويم والإشعارات
|
||||
|
||||
افتراضياً يصلان مُعطَّلَين في البيئات الجديدة. لتفعيلهما على بيئة موجودة (مرة واحدة):
|
||||
|
||||
```bash
|
||||
docker compose exec api pnpm --filter @workspace/scripts run enable-default-apps
|
||||
```
|
||||
|
||||
السكربت idempotent (آمن لإعادة التشغيل) ويعيد فقط `is_active=true` لـ `calendar` و `notifications`.
|
||||
|
||||
@@ -116,6 +116,43 @@ Front the SPA (port `WEB_PORT`, default `3000`) with Caddy / Nginx / Traefik
|
||||
for TLS and HTTP/2; bind the API port (`API_PORT`, default `8080`) to
|
||||
`127.0.0.1` in production so the edge proxy is the sole external entry.
|
||||
|
||||
### Reverse proxy / Tailscale / external URL
|
||||
|
||||
If you front Tx OS with a TLS-terminating proxy that you don't control
|
||||
(Tailscale `serve`, ngrok, Cloudflare Tunnel, …), two things matter:
|
||||
|
||||
1. **Add the public URL to `ALLOWED_ORIGINS` in `.env`.** If you skip
|
||||
this, the browser blocks every API call from the SPA with a CORS
|
||||
error and the app appears frozen on the login screen.
|
||||
|
||||
```bash
|
||||
ALLOWED_ORIGINS=http://localhost:3000,https://it-demo.tail70b2bc.ts.net
|
||||
PUBLIC_BASE_URL=https://it-demo.tail70b2bc.ts.net
|
||||
```
|
||||
|
||||
2. **Set `TRUST_PROXY_HTTPS=true` in `.env`.** Tunnels like Tailscale
|
||||
serve forward HTTPS traffic to the upstream as plain HTTP without
|
||||
sending an `X-Forwarded-Proto: https` header. Without this flag,
|
||||
`req.secure` is false, the session cookie is silently dropped, and
|
||||
the user is bounced back to the login screen on every refresh.
|
||||
|
||||
> **Security note**: only enable `TRUST_PROXY_HTTPS=true` when the
|
||||
> API container/port is **not** reachable directly over plain HTTP
|
||||
> from outside (i.e. only the TLS edge proxy can hit it). The
|
||||
> default Docker compose only exposes the SPA port (`3000`) to the
|
||||
> host and keeps the API on the internal `tx-net` network, which
|
||||
> satisfies this requirement. If you change the compose file to
|
||||
> expose the API port publicly, do not enable this flag.
|
||||
|
||||
After editing `.env`, restart the stack:
|
||||
|
||||
```bash
|
||||
docker compose up -d
|
||||
```
|
||||
|
||||
You should be able to log in via the public HTTPS URL with no other
|
||||
changes.
|
||||
|
||||
### Default seeded accounts
|
||||
|
||||
| Username | Password | Role |
|
||||
@@ -171,7 +208,9 @@ complete list with comments. Highlights:
|
||||
| --------------------------------- | ---------------------------------------------------------------------- |
|
||||
| `DATABASE_URL` | Postgres connection string. **Required.** |
|
||||
| `SESSION_SECRET` | HMAC key for session cookies + local-driver upload tokens. **Required in production.** |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated CORS allow-list. Defaults to `*` (dev only). |
|
||||
| `ALLOWED_ORIGINS` | Comma-separated CORS allow-list. MUST list every URL the SPA is reached at (LAN IP, Tailscale name, custom domain, ...). |
|
||||
| `TRUST_PROXY_HTTPS` | `true` when fronted by a TLS-terminating proxy that doesn't forward `X-Forwarded-Proto` (Tailscale serve, ngrok free, some Cloudflare Tunnels). Required for the session cookie to persist on those setups. |
|
||||
| `SEED_DEMO_MEETINGS` | `true` to populate Executive Meetings with a day of demo data on first boot. Default off so real installs start empty. |
|
||||
| `STORAGE_DRIVER` | `s3` or `local`. Defaults to `s3` if `S3_ENDPOINT` set, else `local`. |
|
||||
| `S3_ENDPOINT` etc. | MinIO / S3 connection. Required when `STORAGE_DRIVER=s3`. |
|
||||
| `PRIVATE_OBJECT_DIR` | Path inside the bucket for private uploads, e.g. `/tx-private/private`.|
|
||||
@@ -200,15 +239,29 @@ The route layer is unaware of which driver is active — both expose the same
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
pnpm --filter @workspace/api-server build
|
||||
pnpm --filter @workspace/api-server start & # boot API on 8080
|
||||
pnpm --filter @workspace/api-server test # node --test suite
|
||||
pnpm test # full suite, auto-boots API
|
||||
pnpm --filter @workspace/api-server test # api-server tests only
|
||||
pnpm --filter @workspace/tx-os test:e2e # Playwright UI tests
|
||||
```
|
||||
|
||||
The `test` workflow chains all of the above. Tests use the same database
|
||||
specified in `DATABASE_URL` and clean up after themselves with `LIKE`-prefixed
|
||||
fixture rows.
|
||||
Both `pnpm test` and `pnpm --filter @workspace/api-server test` go through
|
||||
`artifacts/api-server/scripts/with-server.mjs`, which builds the API server,
|
||||
spawns it on `PORT` (default `8080`), waits for `/api/healthz`, runs the
|
||||
wrapped command with `TEST_API_BASE` exported, and tears the server down on
|
||||
exit (including `Ctrl+C` and failures). If startup fails the helper prints
|
||||
the tail of the server's stderr/stdout so the root cause is visible.
|
||||
|
||||
To run the tests against an already-running API server (e.g. the dev
|
||||
workflow), set `TEST_API_BASE` before invoking the script — the helper will
|
||||
skip spawning and use that URL instead:
|
||||
|
||||
```bash
|
||||
TEST_API_BASE=http://localhost:8080 pnpm --filter @workspace/api-server test
|
||||
```
|
||||
|
||||
Tests use the same database specified in `DATABASE_URL` and clean up after
|
||||
themselves with `LIKE`-prefixed fixture rows. The `test:wait` script remains
|
||||
available for anyone who only wants the bare health-check wait behaviour.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -9,7 +9,8 @@
|
||||
"start": "node --enable-source-maps ./dist/index.mjs",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test:wait": "node ./scripts/wait-for-server.mjs",
|
||||
"test": "node --test 'tests/*.test.mjs' && node --test --test-concurrency=1 'tests/serial/**/*.test.mjs'",
|
||||
"test:run": "node --test 'tests/*.test.mjs' && node --import tsx --test --experimental-test-module-mocks 'tests/*.test.ts' && node --test --test-concurrency=1 'tests/serial/**/*.test.mjs'",
|
||||
"test": "node ./scripts/with-server.mjs -- pnpm run test:run",
|
||||
"migrate": "pnpm --filter @workspace/db run push && pnpm --filter scripts run seed"
|
||||
},
|
||||
"dependencies": {
|
||||
@@ -34,7 +35,9 @@
|
||||
"pino-http": "^10",
|
||||
"playwright-core": "^1.59.1",
|
||||
"sanitize-html": "^2.17.3",
|
||||
"semver": "^7.6.3",
|
||||
"socket.io": "^4.8.3",
|
||||
"web-push": "^3.6.7",
|
||||
"zod": "catalog:"
|
||||
},
|
||||
"devDependencies": {
|
||||
@@ -48,11 +51,14 @@
|
||||
"@types/nodemailer": "^8.0.0",
|
||||
"@types/pdfkit": "^0.17.6",
|
||||
"@types/sanitize-html": "^2.16.1",
|
||||
"@types/semver": "^7.5.8",
|
||||
"@types/web-push": "^3.6.4",
|
||||
"esbuild": "^0.27.3",
|
||||
"esbuild-plugin-pino": "^2.3.3",
|
||||
"pg": "^8.20.0",
|
||||
"pino-pretty": "^13",
|
||||
"socket.io-client": "^4.8.3",
|
||||
"thread-stream": "3.1.0"
|
||||
"thread-stream": "3.1.0",
|
||||
"tsx": "catalog:"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,363 @@
|
||||
#!/usr/bin/env node
|
||||
// Spawns the built API server, waits for /api/healthz, runs a wrapped
|
||||
// command, and tears the server down on exit. If TEST_API_BASE is set in
|
||||
// the environment, the spawn is skipped and the wrapped command runs
|
||||
// against that externally-managed server. If a healthy API server is
|
||||
// already running on the target port (e.g. the dev API Server workflow),
|
||||
// the helper reuses it instead of trying to bind a second process.
|
||||
//
|
||||
// Usage:
|
||||
// node ./scripts/with-server.mjs -- <command> [args...]
|
||||
//
|
||||
// Env knobs:
|
||||
// TEST_API_BASE If set, skip spawning and use this URL.
|
||||
// PORT Port to bind the spawned server to (default 8080).
|
||||
// TEST_API_WAIT_TIMEOUT_MS Health-wait timeout (default 60000 here).
|
||||
// TEST_API_WAIT_INTERVAL_MS Poll interval (default 500).
|
||||
// API_SERVER_LOG_TAIL Number of stderr/stdout lines to dump on
|
||||
// startup failure (default 60).
|
||||
// WITH_SERVER_FORCE_BUILD=1 Force a rebuild even if dist/index.mjs exists.
|
||||
|
||||
import { spawn } from "node:child_process";
|
||||
import { existsSync } from "node:fs";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
|
||||
const scriptDir = path.dirname(fileURLToPath(import.meta.url));
|
||||
const apiServerDir = path.resolve(scriptDir, "..");
|
||||
|
||||
function parseArgs(argv) {
|
||||
const sep = argv.indexOf("--");
|
||||
const rest = sep === -1 ? argv : argv.slice(sep + 1);
|
||||
if (rest.length === 0) {
|
||||
console.error(
|
||||
"with-server.mjs: missing wrapped command. Usage: with-server.mjs -- <cmd> [args...]",
|
||||
);
|
||||
process.exit(2);
|
||||
}
|
||||
return { cmd: rest[0], cmdArgs: rest.slice(1) };
|
||||
}
|
||||
|
||||
function tail(buffer, n) {
|
||||
const lines = buffer.split(/\r?\n/);
|
||||
return lines.slice(-n).join("\n");
|
||||
}
|
||||
|
||||
async function isAlreadyHealthy(url) {
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 1_500);
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
if (!res.ok) return false;
|
||||
const body = await res.json().catch(() => ({}));
|
||||
return body && body.status === "ok";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForHealth(url, timeoutMs, intervalMs, isChildAlive) {
|
||||
const started = Date.now();
|
||||
let attempt = 0;
|
||||
let lastError = null;
|
||||
while (Date.now() - started < timeoutMs) {
|
||||
if (!isChildAlive()) {
|
||||
return {
|
||||
ok: false,
|
||||
reason: "API server child exited before becoming healthy",
|
||||
lastError,
|
||||
attempts: attempt,
|
||||
};
|
||||
}
|
||||
attempt += 1;
|
||||
try {
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), 2_000);
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
clearTimeout(timer);
|
||||
if (res.ok) {
|
||||
const body = await res.json().catch(() => ({}));
|
||||
if (body && body.status === "ok") {
|
||||
return { ok: true, attempts: attempt, elapsedMs: Date.now() - started };
|
||||
}
|
||||
lastError = new Error(`Unexpected health payload: ${JSON.stringify(body)}`);
|
||||
} else {
|
||||
lastError = new Error(`HTTP ${res.status} from ${url}`);
|
||||
}
|
||||
} catch (err) {
|
||||
lastError = err;
|
||||
}
|
||||
await new Promise((r) => setTimeout(r, intervalMs));
|
||||
}
|
||||
return {
|
||||
ok: false,
|
||||
reason: `API server did not become healthy within ${timeoutMs}ms`,
|
||||
lastError,
|
||||
attempts: attempt,
|
||||
};
|
||||
}
|
||||
|
||||
// Centralized lifecycle so signal handlers, top-level errors, and the
|
||||
// happy-path exit all funnel through the same shutdown sequence. This
|
||||
// guarantees the spawned API server is killed exactly once and that
|
||||
// repeated signals never short-circuit an in-flight teardown.
|
||||
function createLifecycle() {
|
||||
let server = null;
|
||||
let serverExited = false;
|
||||
let serverExitCode = null;
|
||||
let serverExitSignal = null;
|
||||
let shutdownPromise = null;
|
||||
let activeChild = null;
|
||||
|
||||
function setServer(proc) {
|
||||
server = proc;
|
||||
server.on("exit", (code, signal) => {
|
||||
serverExited = true;
|
||||
serverExitCode = code;
|
||||
serverExitSignal = signal;
|
||||
});
|
||||
}
|
||||
|
||||
function setActiveChild(proc) {
|
||||
activeChild = proc;
|
||||
}
|
||||
|
||||
function clearActiveChild(proc) {
|
||||
if (activeChild === proc) activeChild = null;
|
||||
}
|
||||
|
||||
function shutdown(signal = "SIGTERM") {
|
||||
if (shutdownPromise) return shutdownPromise;
|
||||
shutdownPromise = (async () => {
|
||||
// Forward signal to wrapped command first so it can flush.
|
||||
if (activeChild && !activeChild.killed) {
|
||||
try {
|
||||
activeChild.kill(signal);
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}
|
||||
if (!server || serverExited) return;
|
||||
await new Promise((resolve) => {
|
||||
const onExit = () => resolve();
|
||||
server.once("exit", onExit);
|
||||
try {
|
||||
server.kill(signal);
|
||||
} catch {
|
||||
server.removeListener("exit", onExit);
|
||||
resolve();
|
||||
return;
|
||||
}
|
||||
const force = setTimeout(() => {
|
||||
try {
|
||||
if (!serverExited) server.kill("SIGKILL");
|
||||
} catch {
|
||||
/* noop */
|
||||
}
|
||||
}, 5_000);
|
||||
server.once("exit", () => clearTimeout(force));
|
||||
});
|
||||
})();
|
||||
return shutdownPromise;
|
||||
}
|
||||
|
||||
return {
|
||||
setServer,
|
||||
setActiveChild,
|
||||
clearActiveChild,
|
||||
shutdown,
|
||||
get serverExited() {
|
||||
return serverExited;
|
||||
},
|
||||
get serverExitCode() {
|
||||
return serverExitCode;
|
||||
},
|
||||
get serverExitSignal() {
|
||||
return serverExitSignal;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function runWrappedCommand(cmd, cmdArgs, env, lifecycle) {
|
||||
return new Promise((resolve) => {
|
||||
const child = spawn(cmd, cmdArgs, {
|
||||
stdio: "inherit",
|
||||
env,
|
||||
shell: false,
|
||||
});
|
||||
if (lifecycle) lifecycle.setActiveChild(child);
|
||||
child.on("exit", (code, signal) => {
|
||||
if (lifecycle) lifecycle.clearActiveChild(child);
|
||||
resolve({ code: code ?? (signal ? 1 : 0), signal });
|
||||
});
|
||||
child.on("error", (err) => {
|
||||
if (lifecycle) lifecycle.clearActiveChild(child);
|
||||
console.error(`with-server.mjs: failed to spawn wrapped command: ${err.message}`);
|
||||
resolve({ code: 1, signal: null });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function buildIfNeeded() {
|
||||
const distEntry = path.join(apiServerDir, "dist", "index.mjs");
|
||||
if (existsSync(distEntry) && process.env.WITH_SERVER_FORCE_BUILD !== "1") {
|
||||
return Promise.resolve();
|
||||
}
|
||||
return new Promise((resolve, reject) => {
|
||||
const child = spawn("node", ["./build.mjs"], {
|
||||
cwd: apiServerDir,
|
||||
stdio: "inherit",
|
||||
env: process.env,
|
||||
});
|
||||
child.on("exit", (code) => {
|
||||
if (code === 0) resolve();
|
||||
else reject(new Error(`API server build failed with exit code ${code}`));
|
||||
});
|
||||
child.on("error", (err) => reject(err));
|
||||
});
|
||||
}
|
||||
|
||||
async function main() {
|
||||
const { cmd, cmdArgs } = parseArgs(process.argv.slice(2));
|
||||
|
||||
// Externally-managed server: skip spawn entirely.
|
||||
if (process.env.TEST_API_BASE) {
|
||||
console.log(
|
||||
`with-server.mjs: TEST_API_BASE=${process.env.TEST_API_BASE} set; using existing server.`,
|
||||
);
|
||||
const { code } = await runWrappedCommand(cmd, cmdArgs, process.env, null);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
const port = process.env.PORT ?? "8080";
|
||||
const apiBase = `http://localhost:${port}`;
|
||||
const healthUrl = `${apiBase}/api/healthz`;
|
||||
|
||||
if (await isAlreadyHealthy(healthUrl)) {
|
||||
console.log(
|
||||
`with-server.mjs: detected healthy API server at ${healthUrl}; reusing it (skipping spawn).`,
|
||||
);
|
||||
const reuseEnv = { ...process.env, TEST_API_BASE: apiBase };
|
||||
const { code } = await runWrappedCommand(cmd, cmdArgs, reuseEnv, null);
|
||||
process.exit(code);
|
||||
}
|
||||
|
||||
await buildIfNeeded();
|
||||
|
||||
const tailLines = Number(process.env.API_SERVER_LOG_TAIL ?? 60);
|
||||
const timeoutMs = Number(process.env.TEST_API_WAIT_TIMEOUT_MS ?? 60_000);
|
||||
const intervalMs = Number(process.env.TEST_API_WAIT_INTERVAL_MS ?? 500);
|
||||
|
||||
const serverEnv = {
|
||||
...process.env,
|
||||
PORT: port,
|
||||
NODE_ENV: process.env.NODE_ENV ?? "development",
|
||||
};
|
||||
|
||||
const lifecycle = createLifecycle();
|
||||
|
||||
// Install centralized signal/error handlers ONCE so repeated signals
|
||||
// share the same in-flight shutdown promise instead of racing.
|
||||
const onSignal = (sig) => {
|
||||
lifecycle.shutdown(sig).finally(() => {
|
||||
const exitCode = sig === "SIGINT" ? 130 : 143;
|
||||
process.exit(exitCode);
|
||||
});
|
||||
};
|
||||
process.on("SIGINT", () => onSignal("SIGINT"));
|
||||
process.on("SIGTERM", () => onSignal("SIGTERM"));
|
||||
process.on("uncaughtException", async (err) => {
|
||||
console.error("with-server.mjs: uncaught exception", err);
|
||||
try {
|
||||
await lifecycle.shutdown("SIGTERM");
|
||||
} finally {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
process.on("unhandledRejection", async (err) => {
|
||||
console.error("with-server.mjs: unhandled rejection", err);
|
||||
try {
|
||||
await lifecycle.shutdown("SIGTERM");
|
||||
} finally {
|
||||
process.exit(1);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`with-server.mjs: starting API server on ${apiBase} ...`);
|
||||
const server = spawn("node", ["--enable-source-maps", "./dist/index.mjs"], {
|
||||
cwd: apiServerDir,
|
||||
env: serverEnv,
|
||||
stdio: ["ignore", "pipe", "pipe"],
|
||||
});
|
||||
lifecycle.setServer(server);
|
||||
|
||||
let stdoutBuf = "";
|
||||
let stderrBuf = "";
|
||||
const MAX_BUF = 256 * 1024;
|
||||
server.stdout.on("data", (chunk) => {
|
||||
stdoutBuf += chunk.toString();
|
||||
if (stdoutBuf.length > MAX_BUF) stdoutBuf = stdoutBuf.slice(-MAX_BUF);
|
||||
process.stdout.write(chunk);
|
||||
});
|
||||
server.stderr.on("data", (chunk) => {
|
||||
stderrBuf += chunk.toString();
|
||||
if (stderrBuf.length > MAX_BUF) stderrBuf = stderrBuf.slice(-MAX_BUF);
|
||||
process.stderr.write(chunk);
|
||||
});
|
||||
|
||||
const health = await waitForHealth(
|
||||
healthUrl,
|
||||
timeoutMs,
|
||||
intervalMs,
|
||||
() => !lifecycle.serverExited,
|
||||
);
|
||||
|
||||
if (!health.ok) {
|
||||
console.error(`\nwith-server.mjs: ${health.reason}`);
|
||||
if (health.lastError) {
|
||||
console.error(
|
||||
`Last health-check error: ${health.lastError.message ?? health.lastError}`,
|
||||
);
|
||||
}
|
||||
if (lifecycle.serverExited) {
|
||||
console.error(
|
||||
`API server exited prematurely (code=${lifecycle.serverExitCode}, signal=${lifecycle.serverExitSignal}).`,
|
||||
);
|
||||
}
|
||||
if (stderrBuf.trim()) {
|
||||
console.error(`\n----- API server stderr (last ${tailLines} lines) -----`);
|
||||
console.error(tail(stderrBuf, tailLines));
|
||||
console.error(`----- end stderr -----`);
|
||||
}
|
||||
if (!stderrBuf.trim() && stdoutBuf.trim()) {
|
||||
console.error(`\n----- API server stdout (last ${tailLines} lines) -----`);
|
||||
console.error(tail(stdoutBuf, tailLines));
|
||||
console.error(`----- end stdout -----`);
|
||||
}
|
||||
console.error(
|
||||
"\nHint: API server failed to start. Check the logs above, or set TEST_API_BASE to point at a running instance.",
|
||||
);
|
||||
await lifecycle.shutdown("SIGTERM");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
console.log(
|
||||
`with-server.mjs: API server ready at ${healthUrl} after ${health.elapsedMs}ms (attempt ${health.attempts}).`,
|
||||
);
|
||||
|
||||
const wrappedEnv = { ...process.env, TEST_API_BASE: apiBase };
|
||||
const { code, signal } = await runWrappedCommand(cmd, cmdArgs, wrappedEnv, lifecycle);
|
||||
|
||||
await lifecycle.shutdown("SIGTERM");
|
||||
|
||||
if (signal) {
|
||||
process.exit(128 + (signal === "SIGINT" ? 2 : 15));
|
||||
}
|
||||
process.exit(code ?? 0);
|
||||
}
|
||||
|
||||
main().catch(async (err) => {
|
||||
console.error("with-server.mjs: unexpected error", err);
|
||||
process.exit(1);
|
||||
});
|
||||
@@ -13,6 +13,25 @@ const app: Express = express();
|
||||
|
||||
app.set("trust proxy", 1);
|
||||
|
||||
// Self-hosted deployments are commonly fronted by a TLS-terminating
|
||||
// proxy that does NOT forward `X-Forwarded-Proto: https` to the
|
||||
// upstream (e.g. `tailscale serve`, ngrok free tier, some Cloudflare
|
||||
// Tunnel configs). Without that header, `req.secure` stays false and
|
||||
// the session cookie (when marked `secure: true`) is silently dropped
|
||||
// — login appears to succeed but the very next request is unauth'd.
|
||||
//
|
||||
// Setting `TRUST_PROXY_HTTPS=true` tells us to treat every incoming
|
||||
// request as if it arrived over HTTPS. Only enable this when the
|
||||
// edge proxy you control actually terminates TLS in front of Tx OS.
|
||||
const trustProxyHttps =
|
||||
(process.env.TRUST_PROXY_HTTPS ?? "").toLowerCase() === "true";
|
||||
if (trustProxyHttps) {
|
||||
app.use((req, _res, next) => {
|
||||
req.headers["x-forwarded-proto"] = "https";
|
||||
next();
|
||||
});
|
||||
}
|
||||
|
||||
app.use(
|
||||
pinoHttp({
|
||||
logger,
|
||||
@@ -55,11 +74,30 @@ app.use(
|
||||
app.use(express.json());
|
||||
app.use(express.urlencoded({ extended: true }));
|
||||
|
||||
// The session cookie should only be marked `secure` when the user is
|
||||
// actually reaching us over HTTPS. We derive that from PUBLIC_BASE_URL
|
||||
// instead of NODE_ENV so a self-hosted production install that's only
|
||||
// reachable over plain HTTP (e.g. internal LAN, no TLS yet) still
|
||||
// works without operator surgery.
|
||||
const publicBaseUrl = process.env.PUBLIC_BASE_URL ?? "";
|
||||
const sessionCookieSecure =
|
||||
publicBaseUrl.startsWith("https://") || trustProxyHttps;
|
||||
|
||||
export const sessionMiddleware = session({
|
||||
store: new PgSession({
|
||||
pool,
|
||||
tableName: "user_sessions",
|
||||
// Auto-create the session table on first boot. Drizzle's migrations
|
||||
// intentionally exclude `user_sessions` (see lib/db/drizzle.config.ts)
|
||||
// because the schema is owned by connect-pg-simple, not by us. Without
|
||||
// this flag, the very first login on a fresh install 500s.
|
||||
createTableIfMissing: true,
|
||||
}),
|
||||
// Honour `X-Forwarded-Proto` from upstream proxies when deciding
|
||||
// whether to set the secure cookie. Combined with `app.set("trust
|
||||
// proxy", 1)` above, this makes the cookie work end-to-end behind
|
||||
// Caddy / nginx / Cloudflare / Tailscale.
|
||||
proxy: true,
|
||||
secret: process.env.SESSION_SECRET ?? (() => {
|
||||
if (process.env.NODE_ENV === "production") {
|
||||
throw new Error("SESSION_SECRET must be set in production");
|
||||
@@ -69,7 +107,7 @@ export const sessionMiddleware = session({
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
cookie: {
|
||||
secure: process.env.NODE_ENV === "production",
|
||||
secure: sessionCookieSecure,
|
||||
httpOnly: true,
|
||||
maxAge: 7 * 24 * 60 * 60 * 1000,
|
||||
sameSite: "lax",
|
||||
|
||||
@@ -69,5 +69,13 @@ ensureSystemSettingsBootstrap()
|
||||
.finally(() => {
|
||||
httpServer.listen(port, () => {
|
||||
logger.info({ port }, "Server listening");
|
||||
// Fire-and-forget background tick: every 60s, scan today's
|
||||
// meetings and push the 5-minute reminder to attendees whose
|
||||
// alert hasn't been pushed yet. See #558.
|
||||
import("./lib/executive-meeting-scheduler.js")
|
||||
.then((m) => m.startExecutiveMeetingScheduler())
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "Failed to start executive-meeting scheduler");
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
usersTable,
|
||||
} from "@workspace/db";
|
||||
import { logger } from "./logger";
|
||||
import { sendPushToUser } from "./push";
|
||||
|
||||
/**
|
||||
* Canonical list of notification event types the Executive Meetings
|
||||
@@ -131,11 +132,30 @@ export async function recordExecutiveMeetingNotifications(
|
||||
|
||||
// Drop anyone who has explicitly muted the in-app channel for this
|
||||
// event type. Users with no preference row stay in (default = on).
|
||||
const recipients = await filterRecipientsByNotificationPref(
|
||||
const prefFiltered = await filterRecipientsByNotificationPref(
|
||||
candidates,
|
||||
input.notificationType,
|
||||
"inApp",
|
||||
);
|
||||
if (prefFiltered.length === 0) return [];
|
||||
|
||||
// Verify the recipient user rows still exist in the SAME executor
|
||||
// (transaction or pool) we're about to INSERT against, AND take a
|
||||
// FOR KEY SHARE lock on each one so any concurrent DELETE of a
|
||||
// recipient must wait for our enclosing transaction to commit.
|
||||
// Without the key-share lock a parallel request that deletes a
|
||||
// user between our recipient resolution and the bulk INSERT below
|
||||
// commits its DELETE under READ COMMITTED — our INSERT then trips
|
||||
// the executive_meeting_notifications.user_id FK and aborts the
|
||||
// whole meeting create/update tx (#312/#283 surfaced this in
|
||||
// parallel test runs that delete-and-recreate users).
|
||||
const existingRows = await executor
|
||||
.select({ id: usersTable.id })
|
||||
.from(usersTable)
|
||||
.where(inArray(usersTable.id, prefFiltered))
|
||||
.for("share");
|
||||
const existing = new Set(existingRows.map((r) => r.id));
|
||||
const recipients = prefFiltered.filter((id) => existing.has(id));
|
||||
if (recipients.length === 0) return [];
|
||||
|
||||
const now = new Date();
|
||||
@@ -192,6 +212,18 @@ export async function broadcastExecutiveMeetingNotifications(
|
||||
};
|
||||
for (const uid of recipientUserIds) {
|
||||
io.to(`user:${uid}`).emit("notification_created", payload);
|
||||
void sendPushToUser(uid, {
|
||||
title: "تنبيه اجتماع",
|
||||
body: "لديك تنبيه اجتماع تنفيذي",
|
||||
titleAr: "تنبيه اجتماع",
|
||||
titleEn: "Meeting alert",
|
||||
bodyAr: "لديك تنبيه اجتماع تنفيذي",
|
||||
bodyEn: "You have an executive meeting alert",
|
||||
type: "executive_meeting",
|
||||
relatedId: meetingId,
|
||||
tag: meetingId ? `meeting-${meetingId}-${notificationType}` : `meeting-${notificationType}`,
|
||||
url: "/meetings",
|
||||
});
|
||||
}
|
||||
io.emit("executive_meeting_notifications_changed", {
|
||||
notificationType,
|
||||
|
||||
@@ -0,0 +1,196 @@
|
||||
import { and, eq, inArray, ne, sql } from "drizzle-orm";
|
||||
import { db } from "@workspace/db";
|
||||
import {
|
||||
executiveMeetingAlertStateTable,
|
||||
executiveMeetingsTable,
|
||||
} from "@workspace/db";
|
||||
import { logger } from "./logger";
|
||||
import { sendPushToUser } from "./push";
|
||||
import { getUserIdsForRoleNames } from "./executive-meeting-notify";
|
||||
|
||||
const TICK_MS = 60_000;
|
||||
const LEAD_MINUTES = 5;
|
||||
const EM_NOTIFY_ROLES = [
|
||||
"admin",
|
||||
"executive_ceo",
|
||||
"executive_office_manager",
|
||||
] as const;
|
||||
|
||||
function pad2(n: number): string {
|
||||
return String(n).padStart(2, "0");
|
||||
}
|
||||
|
||||
function dateKey(d: Date): string {
|
||||
return `${d.getFullYear()}-${pad2(d.getMonth() + 1)}-${pad2(d.getDate())}`;
|
||||
}
|
||||
|
||||
function parseHHMM(s: string | null | undefined): { h: number; m: number } | null {
|
||||
if (!s) return null;
|
||||
const m = /^(\d{1,2}):(\d{2})/.exec(s);
|
||||
if (!m) return null;
|
||||
const h = Number(m[1]);
|
||||
const mm = Number(m[2]);
|
||||
if (!Number.isFinite(h) || !Number.isFinite(mm)) return null;
|
||||
return { h, m: mm };
|
||||
}
|
||||
|
||||
/**
|
||||
* Convert a meeting's (meetingDate, startTime) into an absolute Date in
|
||||
* the server's local timezone (matches how the client computes the
|
||||
* 5-minute window). Returns null if either field is malformed.
|
||||
*/
|
||||
function meetingStartLocal(meetingDate: string, startTime: string | null): Date | null {
|
||||
const t = parseHHMM(startTime);
|
||||
if (!t) return null;
|
||||
const d = new Date(`${meetingDate}T${pad2(t.h)}:${pad2(t.m)}:00`);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return d;
|
||||
}
|
||||
|
||||
async function tickOnce(): Promise<void> {
|
||||
const now = new Date();
|
||||
// Query today AND tomorrow so a meeting at 00:02 is still picked up
|
||||
// when the previous calendar day is finishing at 23:57. The eligible
|
||||
// filter below trims to the actual 5-minute window so this is cheap.
|
||||
const todayKey = dateKey(now);
|
||||
const tomorrow = new Date(now.getTime() + 24 * 60 * 60 * 1000);
|
||||
const tomorrowKey = dateKey(tomorrow);
|
||||
|
||||
const meetings = await db
|
||||
.select({
|
||||
id: executiveMeetingsTable.id,
|
||||
titleAr: executiveMeetingsTable.titleAr,
|
||||
titleEn: executiveMeetingsTable.titleEn,
|
||||
meetingDate: executiveMeetingsTable.meetingDate,
|
||||
startTime: executiveMeetingsTable.startTime,
|
||||
status: executiveMeetingsTable.status,
|
||||
})
|
||||
.from(executiveMeetingsTable)
|
||||
.where(
|
||||
and(
|
||||
inArray(executiveMeetingsTable.meetingDate, [todayKey, tomorrowKey]),
|
||||
ne(executiveMeetingsTable.status, "cancelled"),
|
||||
ne(executiveMeetingsTable.status, "completed"),
|
||||
),
|
||||
);
|
||||
|
||||
const eligible = meetings
|
||||
.map((m) => {
|
||||
const start = meetingStartLocal(m.meetingDate, m.startTime);
|
||||
if (!start) return null;
|
||||
const deltaMs = start.getTime() - now.getTime();
|
||||
if (deltaMs <= 0) return null;
|
||||
const deltaMin = Math.ceil(deltaMs / 60_000);
|
||||
if (deltaMin > LEAD_MINUTES) return null;
|
||||
return { meeting: m, deltaMin };
|
||||
})
|
||||
.filter((x): x is { meeting: typeof meetings[number]; deltaMin: number } => !!x);
|
||||
|
||||
if (eligible.length === 0) return;
|
||||
|
||||
const recipients = await getUserIdsForRoleNames(EM_NOTIFY_ROLES);
|
||||
if (recipients.length === 0) return;
|
||||
|
||||
for (const { meeting, deltaMin } of eligible) {
|
||||
// Atomic claim: ON CONFLICT only updates pushed_at when it's still
|
||||
// NULL, and we RETURN only the rows we actually mutated. Two ticks
|
||||
// (or two instances) racing on the same (meeting,user) can never
|
||||
// both win — Postgres serialises the conflict resolution. Rows
|
||||
// that the user has already acknowledged or dismissed are excluded
|
||||
// from the update so we don't ring after the user said "Done".
|
||||
const claimed = await db
|
||||
.insert(executiveMeetingAlertStateTable)
|
||||
.values(
|
||||
recipients.map((userId) => ({
|
||||
meetingId: meeting.id,
|
||||
userId,
|
||||
pushedAt: now,
|
||||
})),
|
||||
)
|
||||
.onConflictDoUpdate({
|
||||
target: [
|
||||
executiveMeetingAlertStateTable.meetingId,
|
||||
executiveMeetingAlertStateTable.userId,
|
||||
],
|
||||
set: { pushedAt: now },
|
||||
setWhere: and(
|
||||
sql`${executiveMeetingAlertStateTable.pushedAt} IS NULL`,
|
||||
eq(executiveMeetingAlertStateTable.dismissed, false),
|
||||
eq(executiveMeetingAlertStateTable.acknowledged, false),
|
||||
),
|
||||
})
|
||||
.returning({ userId: executiveMeetingAlertStateTable.userId });
|
||||
|
||||
if (claimed.length === 0) continue;
|
||||
|
||||
const titleAr = "تذكير اجتماع";
|
||||
const titleEn = "Meeting reminder";
|
||||
const subjectAr = meeting.titleAr || meeting.titleEn || "اجتماع";
|
||||
const subjectEn = meeting.titleEn || meeting.titleAr || "Meeting";
|
||||
const bodyAr = `${subjectAr} يبدأ خلال ${deltaMin} دقيقة`;
|
||||
const bodyEn = `${subjectEn} starts in ${deltaMin} min`;
|
||||
|
||||
for (const { userId } of claimed) {
|
||||
void sendPushToUser(
|
||||
userId,
|
||||
{
|
||||
title: subjectAr,
|
||||
body: bodyAr,
|
||||
titleAr,
|
||||
titleEn,
|
||||
bodyAr,
|
||||
bodyEn,
|
||||
tag: `em-reminder-${meeting.id}`,
|
||||
url: "/meetings",
|
||||
type: "executive_meeting",
|
||||
relatedId: meeting.id,
|
||||
},
|
||||
{ ignoreConnected: true },
|
||||
).catch((err) => {
|
||||
logger.warn(
|
||||
{ err, meetingId: meeting.id, userId },
|
||||
"executive-meeting-scheduler push failed",
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
logger.info(
|
||||
{ meetingId: meeting.id, deltaMin, recipients: claimed.length },
|
||||
"executive-meeting-scheduler reminder fired",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
let started = false;
|
||||
let timer: NodeJS.Timeout | null = null;
|
||||
let running = false;
|
||||
|
||||
export function startExecutiveMeetingScheduler(): void {
|
||||
if (started) return;
|
||||
started = true;
|
||||
const run = () => {
|
||||
// In-process guard: if the previous tick is still running (slow
|
||||
// DB, long push round-trip), skip this tick so we never overlap
|
||||
// and re-claim/re-send. Multi-process deployments still rely on
|
||||
// the atomic Postgres claim above for correctness.
|
||||
if (running) return;
|
||||
running = true;
|
||||
tickOnce()
|
||||
.catch((err) => {
|
||||
logger.error({ err }, "executive-meeting-scheduler tick failed");
|
||||
})
|
||||
.finally(() => {
|
||||
running = false;
|
||||
});
|
||||
};
|
||||
setImmediate(run);
|
||||
timer = setInterval(run, TICK_MS);
|
||||
logger.info({ tickMs: TICK_MS }, "executive-meeting-scheduler started");
|
||||
}
|
||||
|
||||
export function stopExecutiveMeetingScheduler(): void {
|
||||
if (timer) clearInterval(timer);
|
||||
timer = null;
|
||||
started = false;
|
||||
running = false;
|
||||
}
|
||||
@@ -0,0 +1,399 @@
|
||||
import { promises as fs } from "fs";
|
||||
import path from "path";
|
||||
import { eq, and } from "drizzle-orm";
|
||||
import webpush from "web-push";
|
||||
import { db } from "@workspace/db";
|
||||
import {
|
||||
pushSubscriptionsTable,
|
||||
usersTable,
|
||||
} from "@workspace/db";
|
||||
import { logger } from "./logger";
|
||||
|
||||
let initPromise: Promise<{
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
subject: string;
|
||||
} | null> | null = null;
|
||||
|
||||
function keysFilePath(): string {
|
||||
// LOCAL_STORAGE_ROOT is set in Docker prod (mounted volume at
|
||||
// /app/storage). In dev on Replit it's unset, and PRIVATE_OBJECT_DIR
|
||||
// points to an object-store path that isn't a real local filesystem,
|
||||
// so we fall back to /tmp.
|
||||
const root = process.env.LOCAL_STORAGE_ROOT || "/tmp";
|
||||
return path.join(root, "vapid-keys.json");
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve VAPID keys. Priority:
|
||||
* 1. env (VAPID_PUBLIC_KEY + VAPID_PRIVATE_KEY)
|
||||
* 2. cached on-disk file (auto-generated on first boot)
|
||||
* 3. generate, persist, return
|
||||
*
|
||||
* Returns null only if write + generate both fail, in which case
|
||||
* Web Push is disabled but the rest of the app keeps running.
|
||||
*/
|
||||
async function loadVapid(): Promise<{
|
||||
publicKey: string;
|
||||
privateKey: string;
|
||||
subject: string;
|
||||
} | null> {
|
||||
// Use `||` (not `??`) because docker-compose passes unset vars as
|
||||
// empty strings, not undefined. An empty subject fails web-push's
|
||||
// validateSubject() with "No subject set in vapidDetails.subject".
|
||||
const subject =
|
||||
process.env.VAPID_SUBJECT ||
|
||||
`mailto:admin@${process.env.LOCAL_DOMAIN || "tx.local"}`;
|
||||
|
||||
const envPub = process.env.VAPID_PUBLIC_KEY;
|
||||
const envPriv = process.env.VAPID_PRIVATE_KEY;
|
||||
if (envPub && envPriv) {
|
||||
try {
|
||||
webpush.setVapidDetails(subject, envPub, envPriv);
|
||||
return { publicKey: envPub, privateKey: envPriv, subject };
|
||||
} catch (err) {
|
||||
logger.error({ err }, "Invalid VAPID env keys — falling back to disk");
|
||||
}
|
||||
}
|
||||
|
||||
const file = keysFilePath();
|
||||
try {
|
||||
const raw = await fs.readFile(file, "utf8");
|
||||
const parsed = JSON.parse(raw) as {
|
||||
publicKey?: string;
|
||||
privateKey?: string;
|
||||
};
|
||||
if (parsed.publicKey && parsed.privateKey) {
|
||||
webpush.setVapidDetails(subject, parsed.publicKey, parsed.privateKey);
|
||||
return {
|
||||
publicKey: parsed.publicKey,
|
||||
privateKey: parsed.privateKey,
|
||||
subject,
|
||||
};
|
||||
}
|
||||
} catch {
|
||||
/* missing — fall through to generate */
|
||||
}
|
||||
|
||||
const { publicKey, privateKey } = webpush.generateVAPIDKeys();
|
||||
// Attempt to persist so the key survives restarts. If the target
|
||||
// directory isn't writable (e.g. Replit dev where LOCAL_STORAGE_ROOT
|
||||
// is unset), fall back to /tmp. If even that fails, run with an
|
||||
// ephemeral in-memory key — Web Push still works for this process,
|
||||
// existing subscriptions will just be invalidated on next restart.
|
||||
const candidates = [file, path.join("/tmp", "vapid-keys.json")];
|
||||
let persistedAt: string | null = null;
|
||||
for (const target of candidates) {
|
||||
try {
|
||||
await fs.mkdir(path.dirname(target), { recursive: true });
|
||||
await fs.writeFile(
|
||||
target,
|
||||
JSON.stringify({ publicKey, privateKey }, null, 2),
|
||||
{ mode: 0o600 },
|
||||
);
|
||||
persistedAt = target;
|
||||
break;
|
||||
} catch {
|
||||
/* try next candidate */
|
||||
}
|
||||
}
|
||||
webpush.setVapidDetails(subject, publicKey, privateKey);
|
||||
if (persistedAt) {
|
||||
logger.warn(
|
||||
{ file: persistedAt },
|
||||
"Generated VAPID keys (no VAPID_PUBLIC_KEY/PRIVATE_KEY env set). " +
|
||||
"Persisted to disk so they survive restarts. Copy them into env to " +
|
||||
"share across hosts.",
|
||||
);
|
||||
} else {
|
||||
logger.error(
|
||||
"Generated ephemeral VAPID keys (could not write to disk). " +
|
||||
"Push subscriptions will be invalidated on next restart.",
|
||||
);
|
||||
}
|
||||
return { publicKey, privateKey, subject };
|
||||
}
|
||||
|
||||
export function getVapid() {
|
||||
if (!initPromise) initPromise = loadVapid();
|
||||
return initPromise;
|
||||
}
|
||||
|
||||
/**
|
||||
* Push payload. Two shapes are accepted so call sites can either:
|
||||
* 1. Pass `title` + `body` directly (legacy / single-language), or
|
||||
* 2. Pass `titleAr`+`titleEn` and `bodyAr`+`bodyEn` and let
|
||||
* `sendPushToUser` pick the matching strings based on the
|
||||
* recipient's `preferredLanguage` column. This way an English-
|
||||
* preferring user no longer gets Arabic on their lock screen.
|
||||
*
|
||||
* When both shapes are provided, the bilingual fields win.
|
||||
*/
|
||||
export type PushPayload = {
|
||||
title: string;
|
||||
body: string;
|
||||
titleAr?: string;
|
||||
titleEn?: string;
|
||||
bodyAr?: string;
|
||||
bodyEn?: string;
|
||||
tag?: string;
|
||||
url?: string;
|
||||
type?: string;
|
||||
relatedId?: number | null;
|
||||
};
|
||||
|
||||
type ChannelType = "order" | "executive_meeting" | "note" | "info";
|
||||
|
||||
type UserPushPrefs = {
|
||||
muted: boolean;
|
||||
orders: boolean;
|
||||
meetings: boolean;
|
||||
notes: boolean;
|
||||
language: string;
|
||||
};
|
||||
|
||||
/**
|
||||
* Load the user's notification gating prefs AND their preferred UI
|
||||
* language in a single round-trip. The language is used downstream to
|
||||
* localise the push title/body when the caller supplied bilingual
|
||||
* fields. Mirrors the client-side gating in `useNotificationsSocket`.
|
||||
*/
|
||||
async function loadUserPushPrefs(userId: number): Promise<UserPushPrefs | null> {
|
||||
const [u] = await db
|
||||
.select({
|
||||
muted: usersTable.notificationsMuted,
|
||||
orders: usersTable.notifyOrdersEnabled,
|
||||
meetings: usersTable.notifyMeetingsEnabled,
|
||||
notes: usersTable.notifyNotesEnabled,
|
||||
language: usersTable.preferredLanguage,
|
||||
})
|
||||
.from(usersTable)
|
||||
.where(eq(usersTable.id, userId))
|
||||
.limit(1);
|
||||
if (!u) return null;
|
||||
return u;
|
||||
}
|
||||
|
||||
function userAllowsChannel(prefs: UserPushPrefs, channel: ChannelType): boolean {
|
||||
if (prefs.muted) return false;
|
||||
if (channel === "order") return prefs.orders;
|
||||
if (channel === "executive_meeting") return prefs.meetings;
|
||||
if (channel === "note") return prefs.notes;
|
||||
return true;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the title/body in the recipient's preferred language. Falls
|
||||
* back to the legacy `title`/`body` fields when the caller didn't pass
|
||||
* bilingual copies, and falls back to the other language when only one
|
||||
* side is provided (so a half-localised emit site is still readable).
|
||||
*/
|
||||
function localisePayload(
|
||||
payload: PushPayload,
|
||||
language: string,
|
||||
): { title: string; body: string } {
|
||||
const preferEn = language === "en";
|
||||
const title = preferEn
|
||||
? payload.titleEn ?? payload.titleAr ?? payload.title
|
||||
: payload.titleAr ?? payload.titleEn ?? payload.title;
|
||||
const body = preferEn
|
||||
? payload.bodyEn ?? payload.bodyAr ?? payload.body
|
||||
: payload.bodyAr ?? payload.bodyEn ?? payload.body;
|
||||
return { title, body };
|
||||
}
|
||||
|
||||
/**
|
||||
* Send a Web Push notification to every active subscription a user
|
||||
* owns. Drops 404/410 subscriptions on the fly. Honours the user's
|
||||
* mute + per-channel preferences. Safe to fire-and-forget.
|
||||
*/
|
||||
/**
|
||||
* Returns true if the user has at least one active Socket.IO connection
|
||||
* (any tab/device). Used to gate Web Push so a connected user doesn't
|
||||
* get the in-app chime AND a system notification for the same event.
|
||||
*
|
||||
* Design note (intentional, user-wide rather than per-device): the
|
||||
* primary deployment is a single user with one iPad PWA plus an
|
||||
* occasional desktop browser. The product preference is "don't
|
||||
* double-ring me on the desktop I'm staring at, even if the iPad is
|
||||
* also subscribed" — so when ANY socket is live we trust the in-app
|
||||
* chime to surface the notification, and skip system push for every
|
||||
* device the user owns. If multi-device per-session push becomes a
|
||||
* requirement, narrow this to the specific socket-id / device-id
|
||||
* registered alongside each push subscription.
|
||||
*/
|
||||
async function isUserConnected(userId: number): Promise<boolean> {
|
||||
try {
|
||||
const { io } = await import("../index.js");
|
||||
const sockets = await io.in(`user:${userId}`).fetchSockets();
|
||||
return sockets.length > 0;
|
||||
} catch {
|
||||
// If the socket layer isn't reachable, fall through and send push —
|
||||
// better to over-notify than to silently drop.
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function sendPushToUser(
|
||||
userId: number,
|
||||
payload: PushPayload,
|
||||
options: { ignoreConnected?: boolean } = {},
|
||||
): Promise<void> {
|
||||
if (!Number.isInteger(userId) || userId <= 0) return;
|
||||
const channel = (payload.type as ChannelType) ?? "info";
|
||||
const prefs = await loadUserPushPrefs(userId);
|
||||
if (!prefs) return;
|
||||
if (!userAllowsChannel(prefs, channel)) return;
|
||||
|
||||
// De-dup: if the user is online (any tab/device with an active socket)
|
||||
// they're already getting the in-app notification via Socket.IO, so
|
||||
// we skip Web Push to avoid double-alerts. Push is for the
|
||||
// backgrounded/closed PWA case.
|
||||
//
|
||||
// `ignoreConnected` overrides this for time-critical reminders (e.g.
|
||||
// the 5-minute upcoming-meeting scheduler) where missing the ring
|
||||
// because a stale tab is open is worse than ringing twice.
|
||||
if (!options.ignoreConnected && (await isUserConnected(userId))) return;
|
||||
|
||||
const vapid = await getVapid();
|
||||
if (!vapid) return;
|
||||
|
||||
const subs = await db
|
||||
.select()
|
||||
.from(pushSubscriptionsTable)
|
||||
.where(eq(pushSubscriptionsTable.userId, userId));
|
||||
if (subs.length === 0) return;
|
||||
|
||||
// Localise once, up-front: every subscription for this user is the
|
||||
// same human, so they all get the same language. The bilingual
|
||||
// fields (titleAr/titleEn/bodyAr/bodyEn) win over the legacy
|
||||
// single-language ones when both are present.
|
||||
const localised = localisePayload(payload, prefs.language);
|
||||
|
||||
// Web Push payloads are capped at 4096 bytes after encryption padding.
|
||||
// We aim for ~3500 to leave headroom and truncate the (likely-Arabic,
|
||||
// multi-byte) body if needed so a long note doesn't blow up delivery
|
||||
// for every device the user owns.
|
||||
const MAX_BYTES = 3500;
|
||||
// Strip the bilingual fields from the wire payload — the SW only
|
||||
// needs the resolved title/body — and override with the localised
|
||||
// pair. This also keeps the payload small.
|
||||
const {
|
||||
titleAr: _ta,
|
||||
titleEn: _te,
|
||||
bodyAr: _ba,
|
||||
bodyEn: _be,
|
||||
...rest
|
||||
} = payload;
|
||||
void _ta;
|
||||
void _te;
|
||||
void _ba;
|
||||
void _be;
|
||||
let safePayload: PushPayload = { ...rest, title: localised.title, body: localised.body };
|
||||
let json = JSON.stringify(safePayload);
|
||||
if (Buffer.byteLength(json, "utf8") > MAX_BYTES) {
|
||||
const overhead = Buffer.byteLength(
|
||||
JSON.stringify({ ...safePayload, body: "" }),
|
||||
"utf8",
|
||||
);
|
||||
const budget = Math.max(0, MAX_BYTES - overhead - 1);
|
||||
let truncated = safePayload.body;
|
||||
while (Buffer.byteLength(truncated, "utf8") > budget && truncated.length > 0) {
|
||||
truncated = truncated.slice(0, -1);
|
||||
}
|
||||
safePayload = { ...safePayload, body: truncated + "…" };
|
||||
json = JSON.stringify(safePayload);
|
||||
}
|
||||
|
||||
await Promise.all(
|
||||
subs.map(async (sub) => {
|
||||
try {
|
||||
await webpush.sendNotification(
|
||||
{
|
||||
endpoint: sub.endpoint,
|
||||
keys: { p256dh: sub.p256dh, auth: sub.auth },
|
||||
},
|
||||
json,
|
||||
{ TTL: 60 },
|
||||
);
|
||||
await db
|
||||
.update(pushSubscriptionsTable)
|
||||
.set({ lastUsedAt: new Date() })
|
||||
.where(eq(pushSubscriptionsTable.id, sub.id));
|
||||
} catch (err) {
|
||||
const status = (err as { statusCode?: number })?.statusCode;
|
||||
if (status === 404 || status === 410) {
|
||||
await db
|
||||
.delete(pushSubscriptionsTable)
|
||||
.where(eq(pushSubscriptionsTable.id, sub.id));
|
||||
logger.info(
|
||||
{ userId, endpoint: sub.endpoint, status },
|
||||
"Pruned stale push subscription",
|
||||
);
|
||||
} else {
|
||||
logger.warn({ err, userId, status }, "Web Push send failed");
|
||||
}
|
||||
}
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export async function upsertSubscription(
|
||||
userId: number,
|
||||
input: {
|
||||
endpoint: string;
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
userAgent?: string | null;
|
||||
},
|
||||
): Promise<void> {
|
||||
const ua = input.userAgent ? input.userAgent.slice(0, 400) : null;
|
||||
// If the same browser endpoint already exists under a different user
|
||||
// (shared device, account switch), delete the stale row first so the
|
||||
// previous user's notifications can't leak onto this device. The
|
||||
// unique constraint on `endpoint` would otherwise quietly hand the
|
||||
// subscription over via UPDATE.
|
||||
const [existing] = await db
|
||||
.select({ id: pushSubscriptionsTable.id, userId: pushSubscriptionsTable.userId })
|
||||
.from(pushSubscriptionsTable)
|
||||
.where(eq(pushSubscriptionsTable.endpoint, input.endpoint))
|
||||
.limit(1);
|
||||
if (existing && existing.userId !== userId) {
|
||||
await db
|
||||
.delete(pushSubscriptionsTable)
|
||||
.where(eq(pushSubscriptionsTable.id, existing.id));
|
||||
}
|
||||
await db
|
||||
.insert(pushSubscriptionsTable)
|
||||
.values({
|
||||
userId,
|
||||
endpoint: input.endpoint,
|
||||
p256dh: input.p256dh,
|
||||
auth: input.auth,
|
||||
userAgent: ua,
|
||||
})
|
||||
.onConflictDoUpdate({
|
||||
target: pushSubscriptionsTable.endpoint,
|
||||
set: {
|
||||
userId,
|
||||
p256dh: input.p256dh,
|
||||
auth: input.auth,
|
||||
userAgent: ua,
|
||||
lastUsedAt: new Date(),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function removeSubscription(
|
||||
userId: number,
|
||||
endpoint: string,
|
||||
): Promise<void> {
|
||||
await db
|
||||
.delete(pushSubscriptionsTable)
|
||||
.where(
|
||||
and(
|
||||
eq(pushSubscriptionsTable.userId, userId),
|
||||
eq(pushSubscriptionsTable.endpoint, endpoint),
|
||||
),
|
||||
);
|
||||
}
|
||||
@@ -44,6 +44,7 @@ import {
|
||||
emitExecutiveMeetingsDaysChanged,
|
||||
emitExecutiveMeetingAlertStateChanged,
|
||||
} from "../lib/realtime";
|
||||
import { logger } from "../lib/logger";
|
||||
import { renderSchedulePdf, type PdfFontPrefs } from "../lib/pdf-renderer";
|
||||
import { getPdfLabels } from "../lib/pdf-labels";
|
||||
import { ObjectStorageService } from "../lib/objectStorage";
|
||||
@@ -116,12 +117,14 @@ const FONT_SIZE_MAX = 22;
|
||||
|
||||
const MUTATE_ROLES = [
|
||||
"admin",
|
||||
"executive_ceo",
|
||||
"executive_office_manager",
|
||||
"executive_coord_lead",
|
||||
] as const;
|
||||
const EM_ADMIN_ROLES = ["admin", "executive_office_manager"] as const;
|
||||
const ADMIN_AUDIT_ROLES = [
|
||||
"admin",
|
||||
"executive_ceo",
|
||||
"executive_office_manager",
|
||||
"executive_coord_lead",
|
||||
] as const;
|
||||
@@ -386,6 +389,21 @@ async function logAudit(
|
||||
|
||||
// ---------- Common helpers ----------
|
||||
|
||||
// Serialize concurrent transactions that mutate `executive_meetings`
|
||||
// rows for the same `meeting_date`. Without this, two parallel POSTs
|
||||
// for the same date can either (a) deadlock in the negate-then-renumber
|
||||
// dance inside `renumberDayByStartTime`, or (b) race `nextDailyNumber`
|
||||
// and trip the `(meeting_date, daily_number)` unique index. Postgres
|
||||
// advisory locks scoped to the current transaction are reentrant per
|
||||
// session, so callers can call this multiple times within one tx (e.g.
|
||||
// once at the top of POST create and again inside renumberDayByStartTime)
|
||||
// without self-deadlocking.
|
||||
async function lockMeetingDate(executor: DbExecutor, date: string): Promise<void> {
|
||||
await executor.execute(
|
||||
sql`SELECT pg_advisory_xact_lock(hashtextextended(${date}, 0))`,
|
||||
);
|
||||
}
|
||||
|
||||
async function nextDailyNumber(
|
||||
executor: DbExecutor,
|
||||
date: string,
|
||||
@@ -443,6 +461,11 @@ async function renumberDayByStartTime(
|
||||
executor: DbExecutor,
|
||||
date: string,
|
||||
): Promise<DayOrderShift> {
|
||||
// Serialize per-date so the negate-then-renumber dance below cannot
|
||||
// deadlock with a concurrent transaction running the same dance for
|
||||
// the same `meeting_date`. Reentrant — safe if the caller already
|
||||
// grabbed this lock.
|
||||
await lockMeetingDate(executor, date);
|
||||
// Snapshot the visible order BEFORE the renumber so callers can audit
|
||||
// a true "from -> to" diff (#312). Cheap single SELECT inside the same
|
||||
// transaction the caller passes in.
|
||||
@@ -623,6 +646,10 @@ router.post(
|
||||
const userId = req.session.userId!;
|
||||
try {
|
||||
const inserted = await db.transaction(async (tx) => {
|
||||
// Serialize per-date so concurrent POSTs for the same date can't
|
||||
// race `nextDailyNumber` into a unique-index violation, and so
|
||||
// the renumber dance below cannot deadlock with a parallel one.
|
||||
await lockMeetingDate(tx, data.meetingDate);
|
||||
const dailyNumber =
|
||||
data.dailyNumber ?? (await nextDailyNumber(tx, data.meetingDate));
|
||||
const meetingValues: typeof executiveMeetingsTable.$inferInsert = {
|
||||
@@ -719,6 +746,7 @@ router.post(
|
||||
});
|
||||
return;
|
||||
}
|
||||
logger.error({ err }, "executive_meetings.create failed");
|
||||
res.status(500).json({ error: "Could not create meeting", code: "create_failed" });
|
||||
}
|
||||
},
|
||||
@@ -744,6 +772,19 @@ router.patch(
|
||||
const userId = req.session.userId!;
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
// Serialize per-date for both the existing date and (if moving)
|
||||
// the destination date so concurrent PATCH/POST/duplicate flows
|
||||
// can't race the renumber + dailyNumber allocation. Acquire in
|
||||
// sorted order to prevent deadlock between opposite-direction
|
||||
// cross-day PATCHes (A->B vs B->A).
|
||||
const datesToLock = Array.from(
|
||||
new Set(
|
||||
data.meetingDate !== undefined && data.meetingDate !== existing.meetingDate
|
||||
? [existing.meetingDate, data.meetingDate]
|
||||
: [existing.meetingDate],
|
||||
),
|
||||
).sort();
|
||||
for (const d of datesToLock) await lockMeetingDate(tx, d);
|
||||
const updateValues: Partial<typeof executiveMeetingsTable.$inferInsert> = {
|
||||
updatedBy: userId,
|
||||
};
|
||||
@@ -1514,6 +1555,16 @@ router.post(
|
||||
| StaleConflict
|
||||
| CascadeBlock;
|
||||
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
|
||||
// Acquire the per-date advisory lock BEFORE the row-level FOR
|
||||
// UPDATE so we can never deadlock with a concurrent POST create
|
||||
// (which grabs the date lock first, then the row lock implicitly
|
||||
// via renumber). Pre-fetch the date with a non-locking SELECT so
|
||||
// we know which date to lock without acquiring a row lock first.
|
||||
const [meta] = await tx
|
||||
.select({ meetingDate: executiveMeetingsTable.meetingDate })
|
||||
.from(executiveMeetingsTable)
|
||||
.where(eq(executiveMeetingsTable.id, id));
|
||||
if (meta) await lockMeetingDate(tx, meta.meetingDate);
|
||||
const [locked] = await tx
|
||||
.select()
|
||||
.from(executiveMeetingsTable)
|
||||
@@ -1761,6 +1812,20 @@ router.post(
|
||||
| { ok: false; status: number; error: string; code: string }
|
||||
| CascadeBlock;
|
||||
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
|
||||
// Acquire per-date advisory lock(s) BEFORE the row-level FOR
|
||||
// UPDATE. Pre-fetch the current date with a non-locking SELECT so
|
||||
// we never grab the row lock first (which would deadlock against
|
||||
// concurrent POST create on the same date).
|
||||
const [meta] = await tx
|
||||
.select({ meetingDate: executiveMeetingsTable.meetingDate })
|
||||
.from(executiveMeetingsTable)
|
||||
.where(eq(executiveMeetingsTable.id, id));
|
||||
if (meta) {
|
||||
const datesToLock = Array.from(
|
||||
new Set([meta.meetingDate, body.meetingDate].filter(Boolean) as string[]),
|
||||
).sort();
|
||||
for (const d of datesToLock) await lockMeetingDate(tx, d);
|
||||
}
|
||||
const [locked] = await tx
|
||||
.select()
|
||||
.from(executiveMeetingsTable)
|
||||
@@ -1913,6 +1978,13 @@ router.post(
|
||||
| { ok: true; meetingDate: string; changed: boolean }
|
||||
| { ok: false; status: number; error: string; code: string };
|
||||
const txResult = await db.transaction(async (tx): Promise<TxResult> => {
|
||||
// Per-date advisory lock BEFORE row FOR UPDATE — see comment on
|
||||
// postpone-minutes for the deadlock pattern this prevents.
|
||||
const [meta] = await tx
|
||||
.select({ meetingDate: executiveMeetingsTable.meetingDate })
|
||||
.from(executiveMeetingsTable)
|
||||
.where(eq(executiveMeetingsTable.id, id));
|
||||
if (meta) await lockMeetingDate(tx, meta.meetingDate);
|
||||
const [locked] = await tx
|
||||
.select()
|
||||
.from(executiveMeetingsTable)
|
||||
@@ -1970,6 +2042,9 @@ router.delete(
|
||||
}
|
||||
const userId = req.session.userId!;
|
||||
await db.transaction(async (tx) => {
|
||||
// Serialize per-date so DELETE + renumber can't deadlock with a
|
||||
// concurrent POST/PATCH that grabbed the advisory lock first.
|
||||
await lockMeetingDate(tx, existing.meetingDate);
|
||||
await tx.delete(executiveMeetingsTable).where(eq(executiveMeetingsTable.id, id));
|
||||
await logAudit(tx, {
|
||||
action: "delete",
|
||||
@@ -2066,6 +2141,9 @@ router.post(
|
||||
const userId = req.session.userId!;
|
||||
try {
|
||||
const inserted = await db.transaction(async (tx) => {
|
||||
// Serialize per-date so the renumber + insert flow can't race
|
||||
// a concurrent POST/PATCH on the same target date.
|
||||
await lockMeetingDate(tx, data.targetDate);
|
||||
const nextNumber = await nextDailyNumber(tx, data.targetDate);
|
||||
const meetingValues: typeof executiveMeetingsTable.$inferInsert = {
|
||||
dailyNumber: nextNumber,
|
||||
@@ -2199,6 +2277,19 @@ router.post(
|
||||
// concurrent swap-times calls touch the same pair from opposite
|
||||
// sides (A↔B vs B↔A).
|
||||
const ids = [data.aId, data.bId].slice().sort((x, y) => x - y);
|
||||
// Per-date advisory lock(s) BEFORE the row-level FOR UPDATE so a
|
||||
// concurrent POST create on the same date cannot deadlock against
|
||||
// us. Pre-fetch dates without locking, then lock distinct dates
|
||||
// in sorted order.
|
||||
const metas = await tx
|
||||
.select({
|
||||
id: executiveMeetingsTable.id,
|
||||
meetingDate: executiveMeetingsTable.meetingDate,
|
||||
})
|
||||
.from(executiveMeetingsTable)
|
||||
.where(inArray(executiveMeetingsTable.id, ids));
|
||||
const datesToLock = Array.from(new Set(metas.map((m) => m.meetingDate))).sort();
|
||||
for (const d of datesToLock) await lockMeetingDate(tx, d);
|
||||
const lockedRows = await tx
|
||||
.select()
|
||||
.from(executiveMeetingsTable)
|
||||
@@ -2409,6 +2500,9 @@ router.post(
|
||||
// would silently leave stale time tuples on whatever rows the
|
||||
// client omitted.
|
||||
const ids = data.orderedIds.slice().sort((a, b) => a - b);
|
||||
// Per-date advisory lock BEFORE row FOR UPDATE so we don't
|
||||
// deadlock with a concurrent POST create on the same date.
|
||||
await lockMeetingDate(tx, data.meetingDate);
|
||||
const lockedRows = await tx
|
||||
.select()
|
||||
.from(executiveMeetingsTable)
|
||||
@@ -2626,6 +2720,9 @@ router.post(
|
||||
}
|
||||
try {
|
||||
await db.transaction(async (tx) => {
|
||||
// Serialize per-date so concurrent reorder/POST/PATCH on the
|
||||
// same day cannot deadlock with each other's renumber dance.
|
||||
await lockMeetingDate(tx, data.meetingDate);
|
||||
const allRows = await tx
|
||||
.select()
|
||||
.from(executiveMeetingsTable)
|
||||
|
||||
@@ -16,6 +16,8 @@ import groupsRouter from "./groups";
|
||||
import rolesRouter from "./roles";
|
||||
import auditRouter from "./audit";
|
||||
import executiveMeetingsRouter from "./executive-meetings";
|
||||
import pushRouter from "./push";
|
||||
import systemRouter from "./system";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -36,5 +38,7 @@ router.use(groupsRouter);
|
||||
router.use(rolesRouter);
|
||||
router.use(auditRouter);
|
||||
router.use(executiveMeetingsRouter);
|
||||
router.use(pushRouter);
|
||||
router.use(systemRouter);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -13,6 +13,7 @@ import {
|
||||
notificationsTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAuth, getUserRoles } from "../middlewares/auth";
|
||||
import { sendPushToUser } from "../lib/push";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
@@ -1132,6 +1133,18 @@ router.post("/notes/:id/send", requireAuth, async (req, res): Promise<void> => {
|
||||
})
|
||||
.returning();
|
||||
await emitToUser(r.recipientUserId, "notification_created", { ...notif, type: "note" });
|
||||
void sendPushToUser(r.recipientUserId, {
|
||||
title: "ملاحظة جديدة",
|
||||
body: `${senderNameAr} أرسل لك ملاحظة`,
|
||||
titleAr: "ملاحظة جديدة",
|
||||
titleEn: "New note",
|
||||
bodyAr: `${senderNameAr} أرسل لك ملاحظة`,
|
||||
bodyEn: `${senderName} sent you a note`,
|
||||
type: "note",
|
||||
relatedId: note.id,
|
||||
tag: `note-${note.id}`,
|
||||
url: "/notes",
|
||||
});
|
||||
await emitToUser(r.recipientUserId, "note_received", {
|
||||
noteId: note.id,
|
||||
recipientRowId: r.id,
|
||||
@@ -1478,6 +1491,18 @@ router.post("/notes/:id/reply", requireAuth, async (req, res): Promise<void> =>
|
||||
})
|
||||
.returning();
|
||||
await emitToUser(otherPartyUserId, "notification_created", { ...notif, type: "note" });
|
||||
void sendPushToUser(otherPartyUserId, {
|
||||
title: isOwner ? "رد جديد على ملاحظة" : "رد على ملاحظتك",
|
||||
body: `${replierNameAr} رد على ملاحظة`,
|
||||
titleAr: isOwner ? "رد جديد على ملاحظة" : "رد على ملاحظتك",
|
||||
titleEn: isOwner ? "New reply on a note" : "Reply to your note",
|
||||
bodyAr: `${replierNameAr} رد على ملاحظة`,
|
||||
bodyEn: `${replierName} replied on a note`,
|
||||
type: "note",
|
||||
relatedId: id.id,
|
||||
tag: `note-reply-${reply.id}`,
|
||||
url: "/notes",
|
||||
});
|
||||
// Enrich the realtime payload so the recipient's client can render the
|
||||
// floating reply card without an extra round-trip. We truncate the body
|
||||
// to keep the socket frame small; the full reply is still in /notes.
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import { z } from "zod";
|
||||
import { requireAuth } from "../middlewares/auth";
|
||||
import {
|
||||
getVapid,
|
||||
upsertSubscription,
|
||||
removeSubscription,
|
||||
} from "../lib/push";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
const SubscribeBody = z.object({
|
||||
endpoint: z.string().url().max(2048),
|
||||
keys: z.object({
|
||||
p256dh: z.string().min(1).max(512),
|
||||
auth: z.string().min(1).max(512),
|
||||
}),
|
||||
});
|
||||
|
||||
const UnsubscribeBody = z.object({
|
||||
endpoint: z.string().url().max(2048),
|
||||
});
|
||||
|
||||
router.get("/push/vapid-public-key", async (_req, res): Promise<void> => {
|
||||
const vapid = await getVapid();
|
||||
if (!vapid) {
|
||||
res.status(503).json({ error: "Web Push not configured" });
|
||||
return;
|
||||
}
|
||||
res.json({ publicKey: vapid.publicKey });
|
||||
});
|
||||
|
||||
router.post("/push/subscribe", requireAuth, async (req, res): Promise<void> => {
|
||||
const parsed = SubscribeBody.safeParse(req.body);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const userId = req.session.userId!;
|
||||
const ua = (req.headers["user-agent"] as string | undefined) ?? null;
|
||||
await upsertSubscription(userId, {
|
||||
endpoint: parsed.data.endpoint,
|
||||
p256dh: parsed.data.keys.p256dh,
|
||||
auth: parsed.data.keys.auth,
|
||||
userAgent: ua,
|
||||
});
|
||||
res.json({ success: true });
|
||||
});
|
||||
|
||||
async function handleUnsubscribe(
|
||||
req: Parameters<typeof requireAuth>[0] extends never ? never : Parameters<Parameters<typeof router.post>[2]>[0],
|
||||
res: Parameters<Parameters<typeof router.post>[2]>[1],
|
||||
): Promise<void> {
|
||||
// Accept the endpoint from either the JSON body (POST) or the query
|
||||
// string (DELETE). Browsers don't always send a body with DELETE, so
|
||||
// the query-string form keeps the spec-friendly verb usable.
|
||||
const candidate = {
|
||||
endpoint:
|
||||
(req.body && typeof req.body.endpoint === "string"
|
||||
? req.body.endpoint
|
||||
: undefined) ?? (req.query.endpoint as string | undefined),
|
||||
};
|
||||
const parsed = UnsubscribeBody.safeParse(candidate);
|
||||
if (!parsed.success) {
|
||||
res.status(400).json({ error: parsed.error.message });
|
||||
return;
|
||||
}
|
||||
const userId = req.session.userId!;
|
||||
await removeSubscription(userId, parsed.data.endpoint);
|
||||
res.json({ success: true });
|
||||
}
|
||||
|
||||
router.post("/push/unsubscribe", requireAuth, handleUnsubscribe);
|
||||
// Spec-aligned alias: DELETE /api/push/subscribe?endpoint=... removes
|
||||
// the caller's subscription. The POST form above stays for backward
|
||||
// compat with clients that already shipped.
|
||||
router.delete("/push/subscribe", requireAuth, handleUnsubscribe);
|
||||
|
||||
export default router;
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
permissionsTable,
|
||||
} from "@workspace/db";
|
||||
import { requireAuth, requirePermission } from "../middlewares/auth";
|
||||
import { sendPushToUser } from "../lib/push";
|
||||
import {
|
||||
CreateServiceOrderBody,
|
||||
UpdateServiceOrderStatusParams as ServiceOrderIdParams,
|
||||
@@ -133,6 +134,18 @@ async function notifyUser(
|
||||
})
|
||||
.returning();
|
||||
await emitToUser(userId, "notification_created", notification);
|
||||
void sendPushToUser(userId, {
|
||||
title: titleAr,
|
||||
body: bodyAr,
|
||||
titleAr,
|
||||
titleEn,
|
||||
bodyAr,
|
||||
bodyEn,
|
||||
type: "order",
|
||||
relatedId: orderId,
|
||||
tag: `order-${orderId}`,
|
||||
url: "/my-orders",
|
||||
});
|
||||
}
|
||||
|
||||
async function broadcastIncomingChanged(receiverIds: number[]) {
|
||||
|
||||
@@ -0,0 +1,113 @@
|
||||
import { Router, type IRouter } from "express";
|
||||
import semver from "semver";
|
||||
import { requireAdmin } from "../middlewares/auth";
|
||||
import versionFile from "../../../../version.json" with { type: "json" };
|
||||
import { GetSystemVersionResponse } from "@workspace/api-zod";
|
||||
|
||||
const router: IRouter = Router();
|
||||
|
||||
const CURRENT_VERSION: string = versionFile.version;
|
||||
const CURRENT_BUILD: string = versionFile.build;
|
||||
|
||||
const LATEST_URL = process.env.LATEST_VERSION_URL ?? "";
|
||||
const FETCH_TIMEOUT_MS = 5000;
|
||||
|
||||
type LatestPayload = {
|
||||
version?: unknown;
|
||||
build?: unknown;
|
||||
};
|
||||
|
||||
router.get("/system/version", requireAdmin, async (_req, res): Promise<void> => {
|
||||
const checkedAt = new Date().toISOString();
|
||||
|
||||
if (!LATEST_URL) {
|
||||
res.json(
|
||||
GetSystemVersionResponse.parse({
|
||||
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
|
||||
latest: null,
|
||||
status: "not-configured",
|
||||
errorMessage: null,
|
||||
checkedAt,
|
||||
configured: false,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
const timer = setTimeout(() => controller.abort(), FETCH_TIMEOUT_MS);
|
||||
try {
|
||||
const response = await fetch(LATEST_URL, {
|
||||
signal: controller.signal,
|
||||
headers: { Accept: "application/json" },
|
||||
});
|
||||
if (!response.ok) {
|
||||
res.json(
|
||||
GetSystemVersionResponse.parse({
|
||||
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
|
||||
latest: null,
|
||||
status: "error",
|
||||
errorMessage: `HTTP ${response.status}`,
|
||||
checkedAt,
|
||||
configured: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
const body = (await response.json()) as LatestPayload;
|
||||
const rawVersion = typeof body.version === "string" ? body.version : "";
|
||||
const rawBuild = typeof body.build === "string" ? body.build : "";
|
||||
const cleaned = semver.valid(semver.coerce(rawVersion));
|
||||
const localCleaned = semver.valid(semver.coerce(CURRENT_VERSION));
|
||||
if (!cleaned || !localCleaned) {
|
||||
res.json(
|
||||
GetSystemVersionResponse.parse({
|
||||
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
|
||||
latest: { version: rawVersion, build: rawBuild },
|
||||
status: "error",
|
||||
errorMessage: "Invalid version format",
|
||||
checkedAt,
|
||||
configured: true,
|
||||
}),
|
||||
);
|
||||
return;
|
||||
}
|
||||
// semver.gt: strictly greater → update available. Equal or older →
|
||||
// up to date (we never tell the user to "downgrade" — the desktop
|
||||
// build is the source of truth for that scenario).
|
||||
const status = semver.gt(cleaned, localCleaned)
|
||||
? "update-available"
|
||||
: "up-to-date";
|
||||
res.json(
|
||||
GetSystemVersionResponse.parse({
|
||||
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
|
||||
latest: { version: rawVersion, build: rawBuild },
|
||||
status,
|
||||
errorMessage: null,
|
||||
checkedAt,
|
||||
configured: true,
|
||||
}),
|
||||
);
|
||||
} catch (err) {
|
||||
const message =
|
||||
err instanceof Error
|
||||
? err.name === "AbortError"
|
||||
? "Request timed out"
|
||||
: err.message
|
||||
: "Unknown error";
|
||||
res.json(
|
||||
GetSystemVersionResponse.parse({
|
||||
current: { version: CURRENT_VERSION, build: CURRENT_BUILD },
|
||||
latest: null,
|
||||
status: "error",
|
||||
errorMessage: message,
|
||||
checkedAt,
|
||||
configured: true,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
clearTimeout(timer);
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,327 @@
|
||||
// True unit test for the 404/410 cleanup path in sendPushToUser.
|
||||
//
|
||||
// Runs in-process via `tsx` so we can `mock.module('web-push', ...)` to
|
||||
// stub the actual delivery call. We:
|
||||
// 1. Insert a real row in `push_subscriptions` for a freshly-created
|
||||
// throwaway user.
|
||||
// 2. Force the mocked `webpush.sendNotification` to throw a 410 once
|
||||
// (and 404 once) the way the FCM/APNs bridges do.
|
||||
// 3. Call `sendPushToUser` directly and assert the row was pruned.
|
||||
//
|
||||
// This is the closest we can get to exercising the production prune
|
||||
// path without standing up a real push gateway, and it exercises the
|
||||
// real DB + real lib code (only `web-push` and the socket-presence
|
||||
// check are stubbed).
|
||||
//
|
||||
// Run with:
|
||||
// pnpm --filter @workspace/api-server exec \
|
||||
// node --import tsx --test tests/push-410-unit.test.ts
|
||||
import { test, before, after, mock } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pg from "pg";
|
||||
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run these tests");
|
||||
}
|
||||
|
||||
// Track sendNotification calls and let each test rig the next response.
|
||||
const sendCalls: Array<{ endpoint: string; payload: string }> = [];
|
||||
let nextThrow: { statusCode: number } | null = null;
|
||||
|
||||
mock.module("web-push", {
|
||||
defaultExport: {
|
||||
setVapidDetails: () => {},
|
||||
generateVAPIDKeys: () => ({
|
||||
publicKey: "B".padEnd(87, "A"),
|
||||
privateKey: "C".padEnd(43, "A"),
|
||||
}),
|
||||
sendNotification: async (sub: { endpoint: string }, payload: string) => {
|
||||
sendCalls.push({ endpoint: sub.endpoint, payload });
|
||||
if (nextThrow) {
|
||||
const err: Error & { statusCode?: number } = new Error("stubbed");
|
||||
err.statusCode = nextThrow.statusCode;
|
||||
nextThrow = null;
|
||||
throw err;
|
||||
}
|
||||
return { statusCode: 201, body: "", headers: {} };
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Block the socket-presence dedup gate from short-circuiting the send.
|
||||
// `sendPushToUser` lazy-imports `../index.js` to read `io`; throwing
|
||||
// here makes `isUserConnected` swallow the error and return false.
|
||||
mock.module("../src/index.js", {
|
||||
namedExports: {
|
||||
get io() {
|
||||
throw new Error("io not available in unit test");
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Ensure VAPID is configured (the lib has its own fallback, but being
|
||||
// explicit avoids surprise re-keying between runs).
|
||||
process.env.VAPID_PUBLIC_KEY =
|
||||
process.env.VAPID_PUBLIC_KEY ?? "B".padEnd(87, "A");
|
||||
process.env.VAPID_PRIVATE_KEY =
|
||||
process.env.VAPID_PRIVATE_KEY ?? "C".padEnd(43, "A");
|
||||
process.env.VAPID_SUBJECT = process.env.VAPID_SUBJECT ?? "mailto:test@example.com";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
const createdUserIds: number[] = [];
|
||||
|
||||
async function createUser(prefix: string): Promise<number> {
|
||||
const username = `${prefix}_${Date.now().toString(36)}_${Math.random()
|
||||
.toString(36)
|
||||
.slice(2, 8)}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, '$2b$10$xxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx', $3, 'en', true)
|
||||
RETURNING id`,
|
||||
[username, `${username}@example.com`, prefix],
|
||||
);
|
||||
const id = rows[0].id as number;
|
||||
createdUserIds.push(id);
|
||||
return id;
|
||||
}
|
||||
|
||||
async function insertSub(userId: number, endpoint: string): Promise<void> {
|
||||
await pool.query(
|
||||
`INSERT INTO push_subscriptions (user_id, endpoint, p256dh, auth)
|
||||
VALUES ($1, $2, 'p256dh_test', 'auth_test')`,
|
||||
[userId, endpoint],
|
||||
);
|
||||
}
|
||||
|
||||
async function rowExists(endpoint: string): Promise<boolean> {
|
||||
const r = await pool.query(
|
||||
`SELECT 1 FROM push_subscriptions WHERE endpoint = $1`,
|
||||
[endpoint],
|
||||
);
|
||||
return (r.rowCount ?? 0) > 0;
|
||||
}
|
||||
|
||||
// Import AFTER mocks are registered.
|
||||
const { sendPushToUser } = await import("../src/lib/push.js");
|
||||
|
||||
before(() => {
|
||||
// Touch sendCalls so the import is exercised.
|
||||
sendCalls.length = 0;
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (createdUserIds.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM push_subscriptions WHERE user_id = ANY($1::int[])`,
|
||||
[createdUserIds],
|
||||
);
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||||
createdUserIds,
|
||||
]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
test("410 Gone response prunes the stale push subscription", async () => {
|
||||
const userId = await createUser("p410");
|
||||
const endpoint = `https://push.example.test/410-${Date.now()}`;
|
||||
await insertSub(userId, endpoint);
|
||||
assert.equal(await rowExists(endpoint), true);
|
||||
|
||||
nextThrow = { statusCode: 410 };
|
||||
await sendPushToUser(userId, {
|
||||
title: "t",
|
||||
body: "b",
|
||||
type: "note",
|
||||
});
|
||||
|
||||
assert.equal(sendCalls.at(-1)?.endpoint, endpoint, "stub should have been called");
|
||||
assert.equal(
|
||||
await rowExists(endpoint),
|
||||
false,
|
||||
"row must be pruned after a 410 response",
|
||||
);
|
||||
});
|
||||
|
||||
test("404 Not Found response prunes the stale push subscription", async () => {
|
||||
const userId = await createUser("p404");
|
||||
const endpoint = `https://push.example.test/404-${Date.now()}`;
|
||||
await insertSub(userId, endpoint);
|
||||
assert.equal(await rowExists(endpoint), true);
|
||||
|
||||
nextThrow = { statusCode: 404 };
|
||||
await sendPushToUser(userId, {
|
||||
title: "t",
|
||||
body: "b",
|
||||
type: "note",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
await rowExists(endpoint),
|
||||
false,
|
||||
"row must be pruned after a 404 response",
|
||||
);
|
||||
});
|
||||
|
||||
test("non-410/404 errors leave the subscription intact", async () => {
|
||||
const userId = await createUser("p500");
|
||||
const endpoint = `https://push.example.test/500-${Date.now()}`;
|
||||
await insertSub(userId, endpoint);
|
||||
|
||||
nextThrow = { statusCode: 500 };
|
||||
await sendPushToUser(userId, {
|
||||
title: "t",
|
||||
body: "b",
|
||||
type: "note",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
await rowExists(endpoint),
|
||||
true,
|
||||
"transient 5xx must NOT delete the subscription",
|
||||
);
|
||||
});
|
||||
|
||||
test("notificationsMuted=true silences every channel", async () => {
|
||||
const userId = await createUser("pmute");
|
||||
const endpoint = `https://push.example.test/mute-${Date.now()}`;
|
||||
await insertSub(userId, endpoint);
|
||||
await pool.query(
|
||||
`UPDATE users SET notifications_muted = true WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
const before = sendCalls.length;
|
||||
await sendPushToUser(userId, {
|
||||
title: "t",
|
||||
body: "b",
|
||||
type: "note",
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
sendCalls.length,
|
||||
before,
|
||||
"muted user must not trigger sendNotification at all",
|
||||
);
|
||||
assert.equal(
|
||||
await rowExists(endpoint),
|
||||
true,
|
||||
"muting must not delete the subscription",
|
||||
);
|
||||
});
|
||||
|
||||
test("per-channel flag off skips that channel only", async () => {
|
||||
const userId = await createUser("pchan");
|
||||
const endpoint = `https://push.example.test/chan-${Date.now()}`;
|
||||
await insertSub(userId, endpoint);
|
||||
await pool.query(
|
||||
`UPDATE users SET notify_notes_enabled = false WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
|
||||
const before = sendCalls.length;
|
||||
await sendPushToUser(userId, {
|
||||
title: "t",
|
||||
body: "b",
|
||||
type: "note",
|
||||
});
|
||||
assert.equal(
|
||||
sendCalls.length,
|
||||
before,
|
||||
"notes-disabled user must not receive a note push",
|
||||
);
|
||||
|
||||
// Same user, different channel — order push must still fire.
|
||||
await sendPushToUser(userId, {
|
||||
title: "t",
|
||||
body: "b",
|
||||
type: "order",
|
||||
});
|
||||
assert.equal(
|
||||
sendCalls.length,
|
||||
before + 1,
|
||||
"order channel must still fire when only notes is off",
|
||||
);
|
||||
});
|
||||
|
||||
test("oversized body is truncated so the wire payload stays under 4096 bytes", async () => {
|
||||
const userId = await createUser("ptrunc");
|
||||
const endpoint = `https://push.example.test/trunc-${Date.now()}`;
|
||||
await insertSub(userId, endpoint);
|
||||
|
||||
// 8000 bytes of ASCII — comfortably above the 3500-byte budget and
|
||||
// way past the 4096-byte web-push cap, so truncation MUST kick in.
|
||||
const huge = "x".repeat(8000);
|
||||
await sendPushToUser(userId, {
|
||||
title: "t",
|
||||
body: huge,
|
||||
type: "note",
|
||||
});
|
||||
|
||||
const sent = sendCalls.at(-1);
|
||||
assert.ok(sent, "send stub should have been called");
|
||||
const size = Buffer.byteLength(sent.payload, "utf8");
|
||||
assert.ok(
|
||||
size < 4096,
|
||||
`truncated payload must fit under 4096 bytes, got ${size}`,
|
||||
);
|
||||
const parsed = JSON.parse(sent.payload) as { body: string };
|
||||
assert.ok(
|
||||
parsed.body.length < huge.length,
|
||||
"body must have been shortened",
|
||||
);
|
||||
});
|
||||
|
||||
test("preferredLanguage=en picks the English title/body", async () => {
|
||||
const userId = await createUser("plangen");
|
||||
await pool.query(
|
||||
`UPDATE users SET preferred_language = 'en' WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
const endpoint = `https://push.example.test/langen-${Date.now()}`;
|
||||
await insertSub(userId, endpoint);
|
||||
|
||||
await sendPushToUser(userId, {
|
||||
title: "ar-fallback",
|
||||
body: "ar-fallback",
|
||||
titleAr: "ملاحظة جديدة",
|
||||
titleEn: "New note",
|
||||
bodyAr: "أرسل لك ملاحظة",
|
||||
bodyEn: "sent you a note",
|
||||
type: "note",
|
||||
});
|
||||
|
||||
const sent = sendCalls.at(-1);
|
||||
assert.ok(sent, "send stub should have been called");
|
||||
const parsed = JSON.parse(sent.payload) as { title: string; body: string };
|
||||
assert.equal(parsed.title, "New note");
|
||||
assert.equal(parsed.body, "sent you a note");
|
||||
});
|
||||
|
||||
test("preferredLanguage=ar picks the Arabic title/body", async () => {
|
||||
const userId = await createUser("plangar");
|
||||
// Default is already 'ar' but be explicit so the test documents intent.
|
||||
await pool.query(
|
||||
`UPDATE users SET preferred_language = 'ar' WHERE id = $1`,
|
||||
[userId],
|
||||
);
|
||||
const endpoint = `https://push.example.test/langar-${Date.now()}`;
|
||||
await insertSub(userId, endpoint);
|
||||
|
||||
await sendPushToUser(userId, {
|
||||
title: "ignored",
|
||||
body: "ignored",
|
||||
titleAr: "ملاحظة جديدة",
|
||||
titleEn: "New note",
|
||||
bodyAr: "أرسل لك ملاحظة",
|
||||
bodyEn: "sent you a note",
|
||||
type: "note",
|
||||
});
|
||||
|
||||
const sent = sendCalls.at(-1);
|
||||
assert.ok(sent, "send stub should have been called");
|
||||
const parsed = JSON.parse(sent.payload) as { title: string; body: string };
|
||||
assert.equal(parsed.title, "ملاحظة جديدة");
|
||||
assert.equal(parsed.body, "أرسل لك ملاحظة");
|
||||
});
|
||||
@@ -0,0 +1,280 @@
|
||||
// API tests for the Web Push subsystem:
|
||||
// - GET /api/push/vapid-public-key
|
||||
// - POST /api/push/subscribe (auth + upsert)
|
||||
// - POST /api/push/unsubscribe (auth + removal)
|
||||
// - account-switch row reassignment for the same browser endpoint
|
||||
//
|
||||
// The full delivery path (web-push -> 410 cleanup) is exercised via a
|
||||
// direct DB-level simulation: we insert a subscription with a known
|
||||
// endpoint, then assert it can be looked up + removed through the
|
||||
// public API. The 410 cleanup itself runs in the server when push
|
||||
// providers reject — covered by reading the helper from
|
||||
// `dist/index.mjs` would require a separate integration runner, so
|
||||
// here we cover the DB/HTTP contract.
|
||||
import { test, before, after } from "node:test";
|
||||
import assert from "node:assert/strict";
|
||||
import pg from "pg";
|
||||
|
||||
const API_BASE = process.env.TEST_API_BASE ?? "http://localhost:8080";
|
||||
const DATABASE_URL = process.env.DATABASE_URL;
|
||||
if (!DATABASE_URL) {
|
||||
throw new Error("DATABASE_URL must be set to run these tests");
|
||||
}
|
||||
|
||||
const TEST_PASSWORD = "TestPass123!";
|
||||
const TEST_PASSWORD_HASH =
|
||||
"$2b$10$Bs636ukPMyz01nKrsi.5m.JlDXSN22AVCvn8cgPWWDbo5yJRQX2vu";
|
||||
|
||||
const pool = new pg.Pool({ connectionString: DATABASE_URL });
|
||||
|
||||
const createdUserIds = [];
|
||||
const createdEndpoints = [];
|
||||
|
||||
function uniq() {
|
||||
return `${Date.now().toString(36)}_${Math.random().toString(36).slice(2, 8)}`;
|
||||
}
|
||||
|
||||
async function createUser(prefix) {
|
||||
const username = `${prefix}_${uniq()}`;
|
||||
const { rows } = await pool.query(
|
||||
`INSERT INTO users (username, email, password_hash, display_name_en, preferred_language, is_active)
|
||||
VALUES ($1, $2, $3, $4, 'en', true) RETURNING id`,
|
||||
[username, `${username}@example.com`, TEST_PASSWORD_HASH, prefix],
|
||||
);
|
||||
const id = rows[0].id;
|
||||
createdUserIds.push(id);
|
||||
await pool.query(
|
||||
`INSERT INTO user_roles (user_id, role_id)
|
||||
SELECT $1, id FROM roles WHERE name = 'user'`,
|
||||
[id],
|
||||
);
|
||||
return { id, username };
|
||||
}
|
||||
|
||||
async function login(username) {
|
||||
const res = await fetch(`${API_BASE}/api/auth/login`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ username, password: TEST_PASSWORD }),
|
||||
});
|
||||
assert.equal(res.status, 200, `login expected 200, got ${res.status}`);
|
||||
const setCookie = res.headers.get("set-cookie");
|
||||
const sid = setCookie
|
||||
.split(",")
|
||||
.map((c) => c.split(";")[0].trim())
|
||||
.find((c) => c.startsWith("connect.sid="));
|
||||
assert.ok(sid, "expected connect.sid cookie");
|
||||
return sid;
|
||||
}
|
||||
|
||||
function fakeSub(suffix) {
|
||||
const endpoint = `https://fcm.example.test/push/${suffix}_${uniq()}`;
|
||||
createdEndpoints.push(endpoint);
|
||||
return {
|
||||
endpoint,
|
||||
keys: {
|
||||
p256dh: "BPubKey-" + suffix,
|
||||
auth: "auth-" + suffix,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
// Sanity: server up?
|
||||
const r = await fetch(`${API_BASE}/api/healthz`).catch(() => null);
|
||||
if (!r || !r.ok) {
|
||||
throw new Error(`API at ${API_BASE} is not reachable`);
|
||||
}
|
||||
});
|
||||
|
||||
after(async () => {
|
||||
if (createdEndpoints.length > 0) {
|
||||
await pool.query(
|
||||
`DELETE FROM push_subscriptions WHERE endpoint = ANY($1::text[])`,
|
||||
[createdEndpoints],
|
||||
);
|
||||
}
|
||||
if (createdUserIds.length > 0) {
|
||||
await pool.query(`DELETE FROM users WHERE id = ANY($1::int[])`, [
|
||||
createdUserIds,
|
||||
]);
|
||||
}
|
||||
await pool.end();
|
||||
});
|
||||
|
||||
test("GET /api/push/vapid-public-key returns a base64url key", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/push/vapid-public-key`);
|
||||
assert.equal(res.status, 200);
|
||||
const body = await res.json();
|
||||
assert.ok(
|
||||
typeof body.publicKey === "string" && body.publicKey.length > 40,
|
||||
"expected non-empty VAPID public key",
|
||||
);
|
||||
});
|
||||
|
||||
test("POST /api/push/subscribe requires auth", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify(fakeSub("noauth")),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
test("POST /api/push/unsubscribe requires auth", async () => {
|
||||
const res = await fetch(`${API_BASE}/api/push/unsubscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint: "https://example.test/x" }),
|
||||
});
|
||||
assert.equal(res.status, 401);
|
||||
});
|
||||
|
||||
test("subscribe persists a row and unsubscribe removes it", async () => {
|
||||
const user = await createUser("push_basic");
|
||||
const cookie = await login(user.username);
|
||||
const sub = fakeSub("basic");
|
||||
|
||||
const subRes = await fetch(`${API_BASE}/api/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookie },
|
||||
body: JSON.stringify(sub),
|
||||
});
|
||||
assert.equal(subRes.status, 200);
|
||||
|
||||
const rowsAfter = await pool.query(
|
||||
`SELECT user_id, p256dh, auth FROM push_subscriptions WHERE endpoint = $1`,
|
||||
[sub.endpoint],
|
||||
);
|
||||
assert.equal(rowsAfter.rowCount, 1, "expected one row after subscribe");
|
||||
assert.equal(rowsAfter.rows[0].user_id, user.id);
|
||||
assert.equal(rowsAfter.rows[0].p256dh, sub.keys.p256dh);
|
||||
assert.equal(rowsAfter.rows[0].auth, sub.keys.auth);
|
||||
|
||||
const unsubRes = await fetch(`${API_BASE}/api/push/unsubscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookie },
|
||||
body: JSON.stringify({ endpoint: sub.endpoint }),
|
||||
});
|
||||
assert.equal(unsubRes.status, 200);
|
||||
|
||||
const rowsGone = await pool.query(
|
||||
`SELECT 1 FROM push_subscriptions WHERE endpoint = $1`,
|
||||
[sub.endpoint],
|
||||
);
|
||||
assert.equal(rowsGone.rowCount, 0, "expected row removed after unsubscribe");
|
||||
});
|
||||
|
||||
test("subscribe is idempotent — re-subscribing updates keys without duplicating", async () => {
|
||||
const user = await createUser("push_idem");
|
||||
const cookie = await login(user.username);
|
||||
const sub = fakeSub("idem");
|
||||
|
||||
for (const variant of [sub, { ...sub, keys: { p256dh: "rotated", auth: "rotated" } }]) {
|
||||
const res = await fetch(`${API_BASE}/api/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookie },
|
||||
body: JSON.stringify(variant),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
}
|
||||
|
||||
const rows = await pool.query(
|
||||
`SELECT p256dh, auth FROM push_subscriptions WHERE endpoint = $1`,
|
||||
[sub.endpoint],
|
||||
);
|
||||
assert.equal(rows.rowCount, 1);
|
||||
assert.equal(rows.rows[0].p256dh, "rotated");
|
||||
assert.equal(rows.rows[0].auth, "rotated");
|
||||
});
|
||||
|
||||
test("subscribe reassigns endpoint to the new user when a different user signs in on the same browser", async () => {
|
||||
const userA = await createUser("push_a");
|
||||
const userB = await createUser("push_b");
|
||||
const cookieA = await login(userA.username);
|
||||
const cookieB = await login(userB.username);
|
||||
const sub = fakeSub("switch");
|
||||
|
||||
let res = await fetch(`${API_BASE}/api/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookieA },
|
||||
body: JSON.stringify(sub),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
res = await fetch(`${API_BASE}/api/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookieB },
|
||||
body: JSON.stringify(sub),
|
||||
});
|
||||
assert.equal(res.status, 200);
|
||||
|
||||
const rows = await pool.query(
|
||||
`SELECT user_id FROM push_subscriptions WHERE endpoint = $1`,
|
||||
[sub.endpoint],
|
||||
);
|
||||
assert.equal(rows.rowCount, 1, "endpoint should never duplicate across users");
|
||||
assert.equal(
|
||||
rows.rows[0].user_id,
|
||||
userB.id,
|
||||
"endpoint should now belong to the most recent signer-in",
|
||||
);
|
||||
});
|
||||
|
||||
test("subscribe rejects malformed input", async () => {
|
||||
const user = await createUser("push_bad");
|
||||
const cookie = await login(user.username);
|
||||
const res = await fetch(`${API_BASE}/api/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookie },
|
||||
body: JSON.stringify({ endpoint: "not-a-url", keys: {} }),
|
||||
});
|
||||
assert.equal(res.status, 400);
|
||||
});
|
||||
|
||||
test("DELETE /api/push/subscribe?endpoint=... removes the row (spec alias)", async () => {
|
||||
const user = await createUser("push_del");
|
||||
const cookie = await login(user.username);
|
||||
const sub = fakeSub("del");
|
||||
|
||||
const subRes = await fetch(`${API_BASE}/api/push/subscribe`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", Cookie: cookie },
|
||||
body: JSON.stringify(sub),
|
||||
});
|
||||
assert.equal(subRes.status, 200);
|
||||
|
||||
const before = await pool.query(
|
||||
`SELECT 1 FROM push_subscriptions WHERE endpoint = $1`,
|
||||
[sub.endpoint],
|
||||
);
|
||||
assert.equal(before.rowCount, 1);
|
||||
|
||||
const delRes = await fetch(
|
||||
`${API_BASE}/api/push/subscribe?endpoint=${encodeURIComponent(sub.endpoint)}`,
|
||||
{ method: "DELETE", headers: { Cookie: cookie } },
|
||||
);
|
||||
assert.equal(delRes.status, 200);
|
||||
|
||||
const after = await pool.query(
|
||||
`SELECT 1 FROM push_subscriptions WHERE endpoint = $1`,
|
||||
[sub.endpoint],
|
||||
);
|
||||
assert.equal(after.rowCount, 0, "DELETE alias must remove the subscription row");
|
||||
});
|
||||
|
||||
test("DELETE /api/push/subscribe requires auth and a valid endpoint", async () => {
|
||||
const noAuth = await fetch(
|
||||
`${API_BASE}/api/push/subscribe?endpoint=https://x.example/abc`,
|
||||
{ method: "DELETE" },
|
||||
);
|
||||
assert.equal(noAuth.status, 401);
|
||||
|
||||
const user = await createUser("push_del_bad");
|
||||
const cookie = await login(user.username);
|
||||
const bad = await fetch(
|
||||
`${API_BASE}/api/push/subscribe?endpoint=not-a-url`,
|
||||
{ method: "DELETE", headers: { Cookie: cookie } },
|
||||
);
|
||||
assert.equal(bad.status, 400);
|
||||
});
|
||||
@@ -4,7 +4,28 @@
|
||||
<meta charset="UTF-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1, interactive-widget=resizes-content" />
|
||||
<title>Tx OS</title>
|
||||
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
|
||||
<link rel="icon" type="image/x-icon" href="/favicon.ico?v=itp1" />
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="/icons/favicon-16.png?v=itp1" />
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="/icons/favicon-32.png?v=itp1" />
|
||||
<link rel="icon" type="image/png" sizes="48x48" href="/icons/favicon-48.png?v=itp1" />
|
||||
<link rel="icon" type="image/png" sizes="192x192" href="/icons/icon-192.png?v=itp1" />
|
||||
<link rel="icon" type="image/png" sizes="512x512" href="/icons/icon-512.png?v=itp1" />
|
||||
<!--
|
||||
PWA / "Add to Home Screen" — when the user installs Tx OS to the
|
||||
iPad/iPhone home screen (Share → Add to Home Screen) the app
|
||||
launches full-screen with no Safari URL bar. The same manifest
|
||||
also makes the app installable from Chrome (Add to Dock / Install).
|
||||
Note: existing home-screen shortcuts created BEFORE these tags
|
||||
shipped will keep opening in regular Safari with the URL bar —
|
||||
remove and re-add the icon to pick up the manifest.
|
||||
-->
|
||||
<link rel="manifest" href="/manifest.webmanifest" />
|
||||
<link rel="apple-touch-icon" href="/apple-touch-icon.png?v=itp1" />
|
||||
<meta name="apple-mobile-web-app-capable" content="yes" />
|
||||
<meta name="mobile-web-app-capable" content="yes" />
|
||||
<meta name="apple-mobile-web-app-status-bar-style" content="default" />
|
||||
<meta name="apple-mobile-web-app-title" content="Tx OS" />
|
||||
<meta name="theme-color" content="#0B1E3F" />
|
||||
<!--
|
||||
Fonts are now bundled locally via `src/custom-fonts.css`
|
||||
(DIN Next LT Arabic, Tajawal, Helvetica Neue LT Arabic,
|
||||
|
||||
|
After Width: | Height: | Size: 58 KiB |
|
After Width: | Height: | Size: 201 KiB |
|
After Width: | Height: | Size: 15 KiB |
@@ -1,7 +0,0 @@
|
||||
<svg width="64" height="64" viewBox="0 0 64 64" xmlns="http://www.w3.org/2000/svg">
|
||||
<rect width="64" height="64" rx="14" fill="#0B1E3F"/>
|
||||
<rect x="10" y="14" width="44" height="10" rx="2" fill="#FFFFFF"/>
|
||||
<rect x="27" y="14" width="10" height="28" rx="2" fill="#FFFFFF"/>
|
||||
<path d="M27 42 L15 54" stroke="#38BDF8" stroke-width="8" stroke-linecap="round"/>
|
||||
<path d="M37 42 L49 54" stroke="#38BDF8" stroke-width="8" stroke-linecap="round"/>
|
||||
</svg>
|
||||
|
Before Width: | Height: | Size: 455 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 3.5 KiB |
|
After Width: | Height: | Size: 6.2 KiB |
|
After Width: | Height: | Size: 64 KiB |
|
After Width: | Height: | Size: 335 KiB |
|
After Width: | Height: | Size: 235 KiB |
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"name": "Tx OS",
|
||||
"short_name": "Tx OS",
|
||||
"start_url": "/",
|
||||
"scope": "/",
|
||||
"display": "standalone",
|
||||
"orientation": "any",
|
||||
"background_color": "#0B1E3F",
|
||||
"theme_color": "#0B1E3F",
|
||||
"icons": [
|
||||
{
|
||||
"src": "/icons/icon-192.png?v=itp1",
|
||||
"sizes": "192x192",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-512.png?v=itp1",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "any"
|
||||
},
|
||||
{
|
||||
"src": "/icons/icon-maskable-512.png?v=itp1",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png",
|
||||
"purpose": "maskable"
|
||||
}
|
||||
]
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
/* Tx OS service worker — Web Push only.
|
||||
*
|
||||
* Scope: registered with `scope: BASE_URL`, so the SW only controls
|
||||
* paths under the SPA's base path (e.g. `/`, or `/tx-os/` in a future
|
||||
* subpath deployment). Every URL the SW touches — icons, navigation
|
||||
* targets — is resolved against `self.registration.scope` so the same
|
||||
* code works at root and at a subpath without code changes.
|
||||
*
|
||||
* We deliberately do NOT cache app assets here — Tx OS is self-hosted
|
||||
* on a single machine, so the network is fast and reliable. Adding
|
||||
* cache layers would risk shipping stale React bundles after an
|
||||
* upgrade.
|
||||
*/
|
||||
|
||||
function scopedUrl(relative) {
|
||||
// self.registration.scope is the full origin + base path with a
|
||||
// trailing slash (e.g. "https://host/" or "https://host/tx-os/").
|
||||
// Strip a leading slash from `relative` so URL() treats it as a
|
||||
// path under scope rather than origin-relative.
|
||||
const cleaned = String(relative || "").replace(/^\/+/, "");
|
||||
return new URL(cleaned, self.registration.scope).pathname;
|
||||
}
|
||||
|
||||
self.addEventListener("install", () => {
|
||||
self.skipWaiting();
|
||||
});
|
||||
|
||||
self.addEventListener("activate", (event) => {
|
||||
event.waitUntil(self.clients.claim());
|
||||
});
|
||||
|
||||
self.addEventListener("push", (event) => {
|
||||
/** @type {{title?: string, body?: string, tag?: string, url?: string, type?: string}} */
|
||||
let payload = {};
|
||||
try {
|
||||
payload = event.data ? event.data.json() : {};
|
||||
} catch {
|
||||
payload = { title: event.data ? event.data.text() : "Tx OS" };
|
||||
}
|
||||
|
||||
const title = payload.title || "Tx OS";
|
||||
const iconUrl = scopedUrl("icons/icon-192.png");
|
||||
const options = {
|
||||
body: payload.body || "",
|
||||
tag: payload.tag || payload.type || "tx-os",
|
||||
renotify: true,
|
||||
icon: iconUrl,
|
||||
badge: iconUrl,
|
||||
data: {
|
||||
url: payload.url || "/",
|
||||
type: payload.type || null,
|
||||
},
|
||||
};
|
||||
|
||||
event.waitUntil(self.registration.showNotification(title, options));
|
||||
});
|
||||
|
||||
self.addEventListener("notificationclick", (event) => {
|
||||
event.notification.close();
|
||||
const rawTarget = (event.notification.data && event.notification.data.url) || "/";
|
||||
const targetPath = scopedUrl(rawTarget);
|
||||
|
||||
event.waitUntil(
|
||||
(async () => {
|
||||
const allClients = await self.clients.matchAll({
|
||||
type: "window",
|
||||
includeUncontrolled: true,
|
||||
});
|
||||
// Prefer an already-open Tx OS tab under our scope; navigate it
|
||||
// to the target and focus.
|
||||
for (const client of allClients) {
|
||||
try {
|
||||
await client.focus();
|
||||
if ("navigate" in client) {
|
||||
await client.navigate(targetPath);
|
||||
}
|
||||
return;
|
||||
} catch {
|
||||
/* try next */
|
||||
}
|
||||
}
|
||||
if (self.clients.openWindow) {
|
||||
await self.clients.openWindow(targetPath);
|
||||
}
|
||||
})(),
|
||||
);
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { useNotificationsSocket } from "@/hooks/use-notifications-socket";
|
||||
import { useAudioUnlock } from "@/hooks/use-audio-unlock";
|
||||
import { useAutoplayHint } from "@/hooks/use-autoplay-hint";
|
||||
import { UpcomingMeetingAlert } from "@/components/executive-meetings/upcoming-meeting-alert";
|
||||
import { PushEnablePrompt } from "@/components/push-enable-prompt";
|
||||
import NotFound from "@/pages/not-found";
|
||||
import LoginPage from "@/pages/login";
|
||||
import RegisterPage from "@/pages/register";
|
||||
@@ -55,6 +56,7 @@ function Router() {
|
||||
<NotificationsSocketBridge />
|
||||
<UpcomingMeetingAlert />
|
||||
<IncomingNotePopup />
|
||||
<PushEnablePrompt />
|
||||
<Switch>
|
||||
<Route path="/login" component={LoginPage} />
|
||||
<Route path="/register" component={RegisterPage} />
|
||||
|
||||
@@ -0,0 +1,70 @@
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type AnalogClockProps = {
|
||||
size?: number;
|
||||
className?: string;
|
||||
};
|
||||
|
||||
export function AnalogClock({ size = 44, className = "" }: AnalogClockProps) {
|
||||
const [now, setNow] = useState<Date>(() => new Date());
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(new Date()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
|
||||
const hours = now.getHours() % 12;
|
||||
const minutes = now.getMinutes();
|
||||
const seconds = now.getSeconds();
|
||||
const hourAngle = (hours + minutes / 60) * 30;
|
||||
const minuteAngle = (minutes + seconds / 60) * 6;
|
||||
const secondAngle = seconds * 6;
|
||||
|
||||
const cx = 50;
|
||||
const cy = 50;
|
||||
const navy = "#0B1E3F";
|
||||
const gold = "#C9A04C";
|
||||
|
||||
return (
|
||||
<svg
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 100 100"
|
||||
className={className}
|
||||
role="img"
|
||||
aria-label="analog clock"
|
||||
data-testid="em-analog-clock"
|
||||
>
|
||||
<circle cx={cx} cy={cy} r="46" fill="#ffffff" stroke={navy} strokeWidth="3" />
|
||||
{Array.from({ length: 12 }).map((_, i) => {
|
||||
const angle = (i * 30 * Math.PI) / 180;
|
||||
const x1 = cx + Math.sin(angle) * 40;
|
||||
const y1 = cy - Math.cos(angle) * 40;
|
||||
const x2 = cx + Math.sin(angle) * 44;
|
||||
const y2 = cy - Math.cos(angle) * 44;
|
||||
return (
|
||||
<line
|
||||
key={i}
|
||||
x1={x1}
|
||||
y1={y1}
|
||||
x2={x2}
|
||||
y2={y2}
|
||||
stroke={navy}
|
||||
strokeWidth={i % 3 === 0 ? 2.5 : 1.5}
|
||||
strokeLinecap="round"
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<g transform={`rotate(${hourAngle} ${cx} ${cy})`}>
|
||||
<line x1={cx} y1={cy + 6} x2={cx} y2={cy - 24} stroke={navy} strokeWidth="4" strokeLinecap="round" />
|
||||
</g>
|
||||
<g transform={`rotate(${minuteAngle} ${cx} ${cy})`}>
|
||||
<line x1={cx} y1={cy + 8} x2={cx} y2={cy - 34} stroke={navy} strokeWidth="2.75" strokeLinecap="round" />
|
||||
</g>
|
||||
<g transform={`rotate(${secondAngle} ${cx} ${cy})`}>
|
||||
<line x1={cx} y1={cy + 10} x2={cx} y2={cy - 38} stroke={gold} strokeWidth="1.5" strokeLinecap="round" />
|
||||
</g>
|
||||
<circle cx={cx} cy={cy} r="3" fill={navy} />
|
||||
<circle cx={cx} cy={cy} r="1.25" fill={gold} />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -1436,7 +1436,12 @@ function PostponeDialog({
|
||||
data-testid="postpone-dialog"
|
||||
className="sm:max-w-2xl"
|
||||
>
|
||||
<DialogHeader className="space-y-1">
|
||||
{/* #563: reserve pr-8 (~2rem) so the Radix close button
|
||||
(positioned right-4 top-4 inside DialogContent) never
|
||||
overlaps the title/description. Physical right padding is
|
||||
correct for BOTH AR (RTL) and LTR because Radix uses CSS
|
||||
physical positioning for the close. */}
|
||||
<DialogHeader className="space-y-1 pr-8">
|
||||
<DialogTitle className="text-base">
|
||||
{t("executiveMeetings.alert.postponeTitle")}
|
||||
</DialogTitle>
|
||||
@@ -1741,11 +1746,21 @@ function PostponeDialog({
|
||||
onBack={() => setCascadePrompt(null)}
|
||||
/>
|
||||
) : null}
|
||||
{/* Responsive grid: 1 col on narrow / 2 cols on sm /
|
||||
3 cols on md+. Prevents horizontal overflow inside the
|
||||
dialog on phones and split panes. */}
|
||||
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 lg:grid-cols-3">
|
||||
<div className="space-y-1">
|
||||
{/* #563: responsive grid — single column up through iPad
|
||||
portrait, then 3 columns in a single row on iPad
|
||||
landscape / desktop. We use `lg:` (≥1024px) because
|
||||
Tailwind's `md:` breakpoint is 768px, which iPad
|
||||
portrait already hits (768–820 CSS px) and would
|
||||
re-introduce the same date/time collision the task
|
||||
targets. The previous `sm:grid-cols-2 lg:grid-cols-3`
|
||||
packed date + start onto one row on iPad portrait
|
||||
where Safari's native date/time pickers carry wide
|
||||
internal icons + Arabic labels and visibly collided.
|
||||
`min-w-0` lets each column shrink inside its grid
|
||||
track so inputs respect their cell, and `w-full` on
|
||||
the Input ensures it fills the cell. */}
|
||||
<div className="grid grid-cols-1 gap-3 lg:grid-cols-3">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<Label className="text-xs">
|
||||
{t("executiveMeetings.alert.rescheduleDate")}
|
||||
</Label>
|
||||
@@ -1755,9 +1770,10 @@ function PostponeDialog({
|
||||
onChange={(e) => setResDate(e.target.value)}
|
||||
disabled={busy !== null}
|
||||
data-testid="reschedule-date"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<Label className="text-xs">
|
||||
{t("executiveMeetings.alert.rescheduleStart")}
|
||||
</Label>
|
||||
@@ -1767,9 +1783,10 @@ function PostponeDialog({
|
||||
onChange={(e) => setResStart(e.target.value)}
|
||||
disabled={busy !== null}
|
||||
data-testid="reschedule-start"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
<div className="space-y-1">
|
||||
<div className="space-y-1 min-w-0">
|
||||
<Label className="text-xs">
|
||||
{t("executiveMeetings.alert.rescheduleEnd")}
|
||||
</Label>
|
||||
@@ -1779,6 +1796,7 @@ function PostponeDialog({
|
||||
onChange={(e) => setResEnd(e.target.value)}
|
||||
disabled={busy !== null}
|
||||
data-testid="reschedule-end"
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
@@ -1811,9 +1829,9 @@ function PostponeDialog({
|
||||
className="space-y-2 focus:outline-none"
|
||||
data-testid="postpone-cancel-panel"
|
||||
>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("executiveMeetings.alert.cancelHint")}
|
||||
</p>
|
||||
{/* #563: cancelHint paragraph removed per user request —
|
||||
the destructive button + confirm prompt convey intent
|
||||
without the long disclaimer. */}
|
||||
{!confirmCancel ? (
|
||||
<Button
|
||||
type="button"
|
||||
|
||||
@@ -8,6 +8,7 @@ import {
|
||||
type NotificationSoundId,
|
||||
} from "@workspace/api-client-react";
|
||||
import { Bell, BellOff, Check, Volume2, VolumeX, Play } from "lucide-react";
|
||||
import { usePushSubscription } from "@/hooks/use-push-subscription";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
import {
|
||||
Popover,
|
||||
@@ -315,6 +316,10 @@ export function NotificationSettingsContent() {
|
||||
|
||||
<div className="h-px bg-foreground/10 mx-1 my-1" />
|
||||
|
||||
<PushToggleRow />
|
||||
|
||||
<div className="h-px bg-foreground/10 mx-1 my-1" />
|
||||
|
||||
<SoundList
|
||||
current={user[cfg.soundKey]}
|
||||
onPick={(id) =>
|
||||
@@ -325,6 +330,69 @@ export function NotificationSettingsContent() {
|
||||
);
|
||||
}
|
||||
|
||||
function PushToggleRow() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const { status, busy, enable, disable } = usePushSubscription();
|
||||
const subscribed = status === "subscribed";
|
||||
const disabled = busy || status === "unsupported" || status === "denied";
|
||||
const subtitle =
|
||||
status === "unsupported"
|
||||
? t("notifSettings.push.unsupported")
|
||||
: status === "denied"
|
||||
? t("notifSettings.push.denied")
|
||||
: subscribed
|
||||
? t("notifSettings.push.enabled")
|
||||
: t("notifSettings.push.desc");
|
||||
|
||||
return (
|
||||
<div className="px-2 py-2">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-xs font-semibold text-foreground truncate">
|
||||
{t("notifSettings.push.title")}
|
||||
</div>
|
||||
<div className="text-[11px] text-muted-foreground mt-0.5 leading-snug">
|
||||
{subtitle}
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
disabled={disabled}
|
||||
data-testid="notif-push-toggle"
|
||||
onClick={async () => {
|
||||
if (subscribed) {
|
||||
await disable();
|
||||
} else {
|
||||
const ok = await enable();
|
||||
if (!ok) {
|
||||
toast({ title: t("notifSettings.push.enableFailed") });
|
||||
}
|
||||
}
|
||||
}}
|
||||
className={`relative inline-flex h-5 w-9 shrink-0 items-center rounded-full transition-colors ${
|
||||
subscribed ? "bg-primary" : "bg-foreground/15"
|
||||
} ${disabled ? "opacity-50" : ""}`}
|
||||
aria-pressed={subscribed}
|
||||
aria-label={
|
||||
subscribed
|
||||
? t("notifSettings.push.disable")
|
||||
: t("notifSettings.push.enable")
|
||||
}
|
||||
>
|
||||
<span
|
||||
className={`inline-block h-4 w-4 transform rounded-full bg-white shadow transition-transform ${
|
||||
subscribed
|
||||
? "translate-x-[18px] rtl:translate-x-0.5"
|
||||
: "translate-x-0.5 rtl:translate-x-[18px]"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function NotificationSettingsPicker() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { useEffect, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Bell, X } from "lucide-react";
|
||||
import { useAuth } from "@/contexts/AuthContext";
|
||||
import { usePushSubscription } from "@/hooks/use-push-subscription";
|
||||
import { useToast } from "@/hooks/use-toast";
|
||||
|
||||
const DISMISS_KEY = "tx.pushPrompt.dismissedAt";
|
||||
// Re-prompt after 14 days if the user dismissed but never opted in.
|
||||
const RE_PROMPT_AFTER_MS = 14 * 24 * 60 * 60 * 1000;
|
||||
|
||||
/**
|
||||
* One-tap "Enable lock-screen alerts" card surfaced after the user logs
|
||||
* in for the first time on a device. Renders only when:
|
||||
* - the user is authenticated
|
||||
* - Web Push is supported (Notifications API + SW + PushManager)
|
||||
* - permission is still "default" (we haven't asked yet)
|
||||
* - the user has no active subscription on this device
|
||||
* - they haven't dismissed the prompt recently
|
||||
*
|
||||
* On unsupported devices (notably iOS Safari before "Add to Home
|
||||
* Screen") we stay silent — the toggle inside Notification Settings
|
||||
* exposes the relevant copy for power users. We don't want a
|
||||
* permanent "not supported" banner cluttering the home screen.
|
||||
*/
|
||||
export function PushEnablePrompt() {
|
||||
const { t } = useTranslation();
|
||||
const { user } = useAuth();
|
||||
const { toast } = useToast();
|
||||
const { status, busy, enable } = usePushSubscription();
|
||||
const [hidden, setHidden] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!user) {
|
||||
setHidden(true);
|
||||
return;
|
||||
}
|
||||
if (status !== "default") {
|
||||
setHidden(true);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const raw = localStorage.getItem(DISMISS_KEY);
|
||||
if (raw) {
|
||||
const ts = Number(raw);
|
||||
if (
|
||||
Number.isFinite(ts) &&
|
||||
Date.now() - ts < RE_PROMPT_AFTER_MS
|
||||
) {
|
||||
setHidden(true);
|
||||
return;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
/* localStorage unavailable — just show the prompt */
|
||||
}
|
||||
setHidden(false);
|
||||
}, [user, status]);
|
||||
|
||||
if (hidden) return null;
|
||||
|
||||
const dismiss = () => {
|
||||
try {
|
||||
localStorage.setItem(DISMISS_KEY, String(Date.now()));
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
setHidden(true);
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
role="dialog"
|
||||
aria-live="polite"
|
||||
data-testid="push-enable-prompt"
|
||||
className="fixed inset-x-3 bottom-3 z-50 md:left-auto md:right-4 md:bottom-4 md:max-w-sm rounded-2xl border border-foreground/10 bg-background/95 backdrop-blur shadow-lg p-3"
|
||||
>
|
||||
<div className="flex items-start gap-3">
|
||||
<div className="shrink-0 rounded-full bg-primary/15 p-2">
|
||||
<Bell size={18} className="text-primary" />
|
||||
</div>
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-foreground">
|
||||
{t("notifSettings.push.title")}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-0.5 leading-snug">
|
||||
{t("notifSettings.push.desc")}
|
||||
</div>
|
||||
<div className="mt-2 flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
disabled={busy}
|
||||
data-testid="push-enable-prompt-enable"
|
||||
onClick={async () => {
|
||||
const ok = await enable();
|
||||
if (ok) {
|
||||
dismiss();
|
||||
} else {
|
||||
toast({ title: t("notifSettings.push.enableFailed") });
|
||||
}
|
||||
}}
|
||||
className="text-xs font-semibold px-3 py-1.5 rounded-md bg-primary text-primary-foreground hover:opacity-90 disabled:opacity-50"
|
||||
>
|
||||
{t("notifSettings.push.enable")}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
data-testid="push-enable-prompt-dismiss"
|
||||
onClick={dismiss}
|
||||
className="text-xs font-medium px-3 py-1.5 rounded-md text-muted-foreground hover:bg-foreground/5"
|
||||
>
|
||||
{t("common.later")}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("common.close")}
|
||||
onClick={dismiss}
|
||||
className="shrink-0 text-muted-foreground hover:text-foreground p-1 rounded-md hover:bg-foreground/5"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -17,7 +17,7 @@ const Switch = React.forwardRef<
|
||||
>
|
||||
<SwitchPrimitives.Thumb
|
||||
className={cn(
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:ltr:translate-x-4 data-[state=checked]:rtl:-translate-x-4 data-[state=unchecked]:translate-x-0"
|
||||
"pointer-events-none block h-4 w-4 rounded-full bg-background shadow-lg ring-0 transition-transform data-[state=checked]:ltr:translate-x-4 data-[state=checked]:rtl:-translate-x-4 data-[state=unchecked]:ltr:translate-x-0 data-[state=unchecked]:rtl:translate-x-4"
|
||||
)}
|
||||
/>
|
||||
</SwitchPrimitives.Root>
|
||||
|
||||
@@ -0,0 +1,162 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
|
||||
const BASE = import.meta.env.BASE_URL.replace(/\/$/, "");
|
||||
|
||||
type PushStatus =
|
||||
| "unsupported"
|
||||
| "denied"
|
||||
| "default"
|
||||
| "subscribed";
|
||||
|
||||
function urlBase64ToUint8Array(base64: string): Uint8Array {
|
||||
const padding = "=".repeat((4 - (base64.length % 4)) % 4);
|
||||
const b64 = (base64 + padding).replace(/-/g, "+").replace(/_/g, "/");
|
||||
const raw = atob(b64);
|
||||
const out = new Uint8Array(raw.length);
|
||||
for (let i = 0; i < raw.length; i++) out[i] = raw.charCodeAt(i);
|
||||
return out;
|
||||
}
|
||||
|
||||
function isSupported(): boolean {
|
||||
return (
|
||||
typeof window !== "undefined" &&
|
||||
"serviceWorker" in navigator &&
|
||||
"PushManager" in window &&
|
||||
"Notification" in window
|
||||
);
|
||||
}
|
||||
|
||||
async function getRegistration(): Promise<ServiceWorkerRegistration | null> {
|
||||
if (!("serviceWorker" in navigator)) return null;
|
||||
// Wait for the SPA's registration in main.tsx to settle. `ready`
|
||||
// resolves once an active SW controls the page.
|
||||
return navigator.serviceWorker.ready;
|
||||
}
|
||||
|
||||
/**
|
||||
* Hook that exposes the user's current Web Push subscription state and
|
||||
* `enable()` / `disable()` actions. Talks to the backend at
|
||||
* `${BASE}/api/push/*`.
|
||||
*
|
||||
* Web Push on iOS Safari only works when the page is installed as a
|
||||
* PWA (Add to Home Screen). The hook surfaces "unsupported" in that
|
||||
* case so the UI can prompt the user to install first.
|
||||
*/
|
||||
export function usePushSubscription() {
|
||||
const [status, setStatus] = useState<PushStatus>(() =>
|
||||
!isSupported()
|
||||
? "unsupported"
|
||||
: Notification.permission === "denied"
|
||||
? "denied"
|
||||
: "default",
|
||||
);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
if (!isSupported()) {
|
||||
setStatus("unsupported");
|
||||
return;
|
||||
}
|
||||
if (Notification.permission === "denied") {
|
||||
setStatus("denied");
|
||||
return;
|
||||
}
|
||||
const reg = await getRegistration();
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
if (sub) setStatus("subscribed");
|
||||
else setStatus(Notification.permission === "granted" ? "default" : "default");
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
|
||||
const enable = useCallback(async (): Promise<boolean> => {
|
||||
if (!isSupported()) return false;
|
||||
setBusy(true);
|
||||
try {
|
||||
const perm = await Notification.requestPermission();
|
||||
if (perm !== "granted") {
|
||||
setStatus(perm === "denied" ? "denied" : "default");
|
||||
return false;
|
||||
}
|
||||
const reg = await getRegistration();
|
||||
if (!reg) return false;
|
||||
|
||||
const keyRes = await fetch(`${BASE}/api/push/vapid-public-key`, {
|
||||
credentials: "include",
|
||||
});
|
||||
if (!keyRes.ok) return false;
|
||||
const { publicKey } = (await keyRes.json()) as { publicKey: string };
|
||||
|
||||
// If a stale subscription exists from a previous VAPID key,
|
||||
// unsubscribe locally first so applicationServerKey isn't
|
||||
// mismatched.
|
||||
const existing = await reg.pushManager.getSubscription();
|
||||
if (existing) {
|
||||
try {
|
||||
await existing.unsubscribe();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
}
|
||||
|
||||
const sub = await reg.pushManager.subscribe({
|
||||
userVisibleOnly: true,
|
||||
applicationServerKey: urlBase64ToUint8Array(publicKey),
|
||||
});
|
||||
const json = sub.toJSON();
|
||||
const subRes = await fetch(`${BASE}/api/push/subscribe`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
endpoint: json.endpoint,
|
||||
keys: json.keys,
|
||||
}),
|
||||
});
|
||||
if (!subRes.ok) {
|
||||
try {
|
||||
await sub.unsubscribe();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
return false;
|
||||
}
|
||||
setStatus("subscribed");
|
||||
return true;
|
||||
} catch {
|
||||
return false;
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
const disable = useCallback(async (): Promise<void> => {
|
||||
if (!isSupported()) return;
|
||||
setBusy(true);
|
||||
try {
|
||||
const reg = await getRegistration();
|
||||
const sub = await reg?.pushManager.getSubscription();
|
||||
if (sub) {
|
||||
const endpoint = sub.endpoint;
|
||||
try {
|
||||
await sub.unsubscribe();
|
||||
} catch {
|
||||
/* ignore */
|
||||
}
|
||||
await fetch(`${BASE}/api/push/unsubscribe`, {
|
||||
method: "POST",
|
||||
credentials: "include",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ endpoint }),
|
||||
}).catch(() => {});
|
||||
}
|
||||
setStatus(Notification.permission === "denied" ? "denied" : "default");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
return { status, busy, enable, disable, refresh };
|
||||
}
|
||||
@@ -4,6 +4,19 @@
|
||||
@plugin "@tailwindcss/typography";
|
||||
|
||||
@custom-variant dark (&:is(.dark *));
|
||||
/*
|
||||
* Tailwind v4 doesn't ship `rtl:` / `ltr:` as built-in variants. The
|
||||
* shared <Switch> (and a couple other components) rely on them to
|
||||
* mirror the thumb translation in the Arabic UI — without these
|
||||
* declarations, classes like `rtl:-translate-x-4` are silently
|
||||
* dropped at build time and every toggle in the app shows the
|
||||
* checked-state thumb on the LTR side regardless of document
|
||||
* direction. The selectors match both the element that carries
|
||||
* `dir="rtl"` itself and any descendant, which mirrors the
|
||||
* tailwindcss-rtl plugin's behaviour.
|
||||
*/
|
||||
@custom-variant rtl (&:where([dir="rtl"], [dir="rtl"] *));
|
||||
@custom-variant ltr (&:where([dir="ltr"], [dir="ltr"] *));
|
||||
|
||||
@theme inline {
|
||||
--color-background: hsl(var(--background));
|
||||
|
||||
@@ -127,7 +127,6 @@
|
||||
"notesLabel": "ملاحظات:",
|
||||
"cancel": "إلغاء الطلب",
|
||||
"cancelConfirmTitle": "إلغاء هذا الطلب؟",
|
||||
"cancelConfirmBody": "ستتاح لك ثوانٍ قليلة للتراجع.",
|
||||
"cancelConfirm": "نعم، إلغاء",
|
||||
"keep": "احتفظ به",
|
||||
"cancelled": "تم إلغاء الطلب",
|
||||
@@ -135,22 +134,9 @@
|
||||
"restoreFailed": "تعذّر استعادة الطلب",
|
||||
"delete": "حذف",
|
||||
"reorder": "اطلب مجددًا",
|
||||
"deleteConfirmTitle": "حذف هذا الطلب؟",
|
||||
"deleteConfirmBody": "سيتم حذف الطلب نهائياً من سجلّك.",
|
||||
"deleteConfirm": "نعم، احذف",
|
||||
"deleted": "تم حذف الطلب",
|
||||
"deleteFailed": "تعذّر حذف الطلب",
|
||||
"undo": "تراجع",
|
||||
"undone": "تمت الاستعادة",
|
||||
"clearFinished_one": "مسح طلب منتهٍ",
|
||||
"clearFinished_other": "مسح {{count}} طلبات منتهية",
|
||||
"clearConfirmTitle": "مسح الطلبات المنتهية؟",
|
||||
"clearConfirmBody_one": "سيتم حذف طلب واحد منتهٍ نهائياً من سجلّك.",
|
||||
"clearConfirmBody_other": "سيتم حذف {{count}} طلبات منتهية نهائياً من سجلّك.",
|
||||
"clearConfirm": "نعم، امسح",
|
||||
"clearedCount_one": "تم حذف طلب واحد",
|
||||
"clearedCount_other": "تم حذف {{count}} طلبات",
|
||||
"clearedPartial": "تم حذف {{ok}} وفشل {{fail}}"
|
||||
"undone": "تمت الاستعادة"
|
||||
},
|
||||
"orderStatus": {
|
||||
"pending": "بانتظار المستلِم",
|
||||
@@ -240,6 +226,16 @@
|
||||
"autoplayHintTitle": "تحتاج الأصوات إلى نقرة",
|
||||
"autoplayHint": "انقر في أي مكان لتفعيل أصوات الإشعارات.",
|
||||
"autoplayHintIos": "اضغط في أي مكان لتفعيل الأصوات. إذا ما زلت لا تسمعها، تأكد إن مستوى صوت الوسائط مرفوع (نفس الصوت اللي يشغّل اليوتيوب).",
|
||||
"push": {
|
||||
"title": "إشعارات شاشة القفل",
|
||||
"desc": "استقبل التنبيهات على الشاشة الرئيسية أو شاشة القفل حتى لو كان Tx مغلقاً.",
|
||||
"enable": "تفعيل",
|
||||
"disable": "إيقاف",
|
||||
"enabled": "مفعّل",
|
||||
"denied": "محظور — فعّله من إعدادات الجهاز",
|
||||
"unsupported": "غير مدعوم على هذا الجهاز. على الآيباد، أضف Tx إلى الشاشة الرئيسية أولاً.",
|
||||
"enableFailed": "تعذّر تفعيل الإشعارات. حاول مرة أخرى."
|
||||
},
|
||||
"slot": {
|
||||
"order": "الطلبات",
|
||||
"meeting": "الاجتماعات",
|
||||
@@ -532,8 +528,27 @@
|
||||
"auditLog": "سجل التدقيق",
|
||||
"userManagement": "إدارة المستخدمين",
|
||||
"settings": "الإعدادات",
|
||||
"systemUpdates": "تحديثات النظام",
|
||||
"menu": "القائمة"
|
||||
},
|
||||
"systemUpdates": {
|
||||
"title": "تحديثات النظام",
|
||||
"subtitle": "تحقّق فقط من توفر إصدار أحدث — لا يقوم النظام بتثبيت أي تحديث.",
|
||||
"currentVersion": "الإصدار الحالي",
|
||||
"buildNumber": "رقم البناء",
|
||||
"latestVersion": "أحدث إصدار",
|
||||
"updateStatus": "حالة التحديث",
|
||||
"statusUpToDate": "النظام محدّث",
|
||||
"statusUpdateAvailable": "يتوفّر تحديث جديد",
|
||||
"statusNotConfigured": "لم يتم تهيئة مصدر التحديثات",
|
||||
"statusError": "تعذّر التحقق من التحديثات",
|
||||
"notConfiguredHint": "اطلب من المسؤول ضبط متغيّر البيئة LATEST_VERSION_URL على رابط ملف latest.json الخارجي لتفعيل التحقق.",
|
||||
"checkNow": "تحقّق الآن",
|
||||
"loading": "جاري التحقق من الإصدار…",
|
||||
"lastChecked": "آخر تحقق",
|
||||
"toastErrorTitle": "تعذّر التحقق",
|
||||
"toastErrorDesc": "لم نتمكن من الوصول إلى مصدر التحديثات. حاول مرة أخرى لاحقاً."
|
||||
},
|
||||
"apps": {
|
||||
"counts": {
|
||||
"groups": "{{count}} مجموعة",
|
||||
@@ -920,6 +935,7 @@
|
||||
"edit": "تعديل",
|
||||
"add": "إضافة",
|
||||
"close": "إغلاق",
|
||||
"later": "لاحقاً",
|
||||
"loading": "جاري التحميل...",
|
||||
"error": "حدث خطأ",
|
||||
"success": "تمت العملية بنجاح",
|
||||
@@ -1238,7 +1254,6 @@
|
||||
"rescheduleEnd": "النهاية",
|
||||
"rescheduleNote": "ملاحظة (اختياري)",
|
||||
"rescheduleApply": "إعادة الجدولة",
|
||||
"cancelHint": "يحدد الاجتماع كمُلغى مع الاحتفاظ بسجل الحضور.",
|
||||
"cancelApply": "إلغاء الاجتماع",
|
||||
"cancelConfirmPrompt": "هل أنت متأكد؟ سيتم وضع الاجتماع كمُلغى.",
|
||||
"cancelConfirmYes": "نعم، إلغاء",
|
||||
@@ -1360,7 +1375,8 @@
|
||||
"label": "إجراءات سريعة",
|
||||
"moveUp": "نقل لأعلى",
|
||||
"moveDown": "نقل لأسفل",
|
||||
"postpone": "تأجيل"
|
||||
"postpone": "تأجيل",
|
||||
"edit": "تعديل"
|
||||
},
|
||||
"merge": {
|
||||
"label": "دمج الخلايا",
|
||||
|
||||
@@ -127,7 +127,6 @@
|
||||
"notesLabel": "Notes:",
|
||||
"cancel": "Cancel order",
|
||||
"cancelConfirmTitle": "Cancel this order?",
|
||||
"cancelConfirmBody": "You'll have a few seconds to undo this.",
|
||||
"cancelConfirm": "Yes, cancel",
|
||||
"keep": "Keep it",
|
||||
"cancelled": "Order cancelled",
|
||||
@@ -135,22 +134,9 @@
|
||||
"restoreFailed": "Could not restore the order",
|
||||
"delete": "Delete",
|
||||
"reorder": "Reorder",
|
||||
"deleteConfirmTitle": "Delete this order?",
|
||||
"deleteConfirmBody": "This will permanently remove the order from your history.",
|
||||
"deleteConfirm": "Yes, delete",
|
||||
"deleted": "Order deleted",
|
||||
"deleteFailed": "Could not delete the order",
|
||||
"undo": "Undo",
|
||||
"undone": "Restored",
|
||||
"clearFinished_one": "Clear 1 finished",
|
||||
"clearFinished_other": "Clear {{count}} finished",
|
||||
"clearConfirmTitle": "Clear finished orders?",
|
||||
"clearConfirmBody_one": "1 finished order will be permanently removed from your history.",
|
||||
"clearConfirmBody_other": "{{count}} finished orders will be permanently removed from your history.",
|
||||
"clearConfirm": "Yes, clear",
|
||||
"clearedCount_one": "1 order deleted",
|
||||
"clearedCount_other": "{{count}} orders deleted",
|
||||
"clearedPartial": "{{ok}} deleted, {{fail}} failed"
|
||||
"undone": "Restored"
|
||||
},
|
||||
"orderStatus": {
|
||||
"pending": "Awaiting receiver",
|
||||
@@ -246,6 +232,16 @@
|
||||
"autoplayHintTitle": "Sounds need a click",
|
||||
"autoplayHint": "Click anywhere on the page to enable notification sounds.",
|
||||
"autoplayHintIos": "Tap anywhere to enable sounds. If you still don't hear them, raise the *media* volume (the same one that controls YouTube).",
|
||||
"push": {
|
||||
"title": "Lock-screen notifications",
|
||||
"desc": "Get alerts on the home/lock screen when Tx OS is closed.",
|
||||
"enable": "Enable",
|
||||
"disable": "Disable",
|
||||
"enabled": "Enabled",
|
||||
"denied": "Blocked — enable from device settings",
|
||||
"unsupported": "Not supported on this device. On iPad, add Tx OS to the Home Screen first.",
|
||||
"enableFailed": "Could not enable notifications. Try again."
|
||||
},
|
||||
"slot": {
|
||||
"order": "Orders",
|
||||
"meeting": "Meetings",
|
||||
@@ -508,8 +504,27 @@
|
||||
"auditLog": "Audit log",
|
||||
"userManagement": "User Management",
|
||||
"settings": "Settings",
|
||||
"systemUpdates": "System Updates",
|
||||
"menu": "Menu"
|
||||
},
|
||||
"systemUpdates": {
|
||||
"title": "System Updates",
|
||||
"subtitle": "Check whether a newer version is available — this page never installs an update.",
|
||||
"currentVersion": "Current Version",
|
||||
"buildNumber": "Build Number",
|
||||
"latestVersion": "Latest Version",
|
||||
"updateStatus": "Update Status",
|
||||
"statusUpToDate": "System is up to date",
|
||||
"statusUpdateAvailable": "New update available",
|
||||
"statusNotConfigured": "Update source not configured",
|
||||
"statusError": "Unable to check for updates",
|
||||
"notConfiguredHint": "Ask the administrator to set the LATEST_VERSION_URL environment variable to the external latest.json URL to enable checking.",
|
||||
"checkNow": "Check Now",
|
||||
"loading": "Checking version…",
|
||||
"lastChecked": "Last checked",
|
||||
"toastErrorTitle": "Check failed",
|
||||
"toastErrorDesc": "Could not reach the update source. Please try again later."
|
||||
},
|
||||
"apps": {
|
||||
"counts": {
|
||||
"groups": "{{count}} groups",
|
||||
@@ -832,6 +847,7 @@
|
||||
"edit": "Edit",
|
||||
"add": "Add",
|
||||
"close": "Close",
|
||||
"later": "Later",
|
||||
"loading": "Loading...",
|
||||
"error": "An error occurred",
|
||||
"success": "Success",
|
||||
@@ -1085,7 +1101,8 @@
|
||||
"label": "Quick actions",
|
||||
"moveUp": "Move up",
|
||||
"moveDown": "Move down",
|
||||
"postpone": "Postpone"
|
||||
"postpone": "Postpone",
|
||||
"edit": "Edit"
|
||||
},
|
||||
"merge": {
|
||||
"label": "Merge cells",
|
||||
@@ -1150,7 +1167,6 @@
|
||||
"rescheduleEnd": "End",
|
||||
"rescheduleNote": "Note (optional)",
|
||||
"rescheduleApply": "Reschedule",
|
||||
"cancelHint": "Marks the meeting as cancelled. Attendees keep history.",
|
||||
"cancelApply": "Cancel meeting",
|
||||
"cancelConfirmPrompt": "Are you sure? The meeting will be marked as cancelled.",
|
||||
"cancelConfirmYes": "Yes, cancel",
|
||||
|
||||
@@ -17,4 +17,26 @@ if (typeof document !== "undefined" && document.fonts?.load) {
|
||||
void document.fonts.load('700 1em "DIN Next LT Arabic"');
|
||||
}
|
||||
|
||||
// Register the Web Push service worker scoped to the SPA's base path.
|
||||
// The SW only handles `push` + `notificationclick` — no asset caching —
|
||||
// so it's safe to register unconditionally as soon as the page boots.
|
||||
// Without registration, iOS PWAs cannot receive lock-screen notifications.
|
||||
if (
|
||||
typeof window !== "undefined" &&
|
||||
"serviceWorker" in navigator &&
|
||||
"PushManager" in window &&
|
||||
window.isSecureContext
|
||||
) {
|
||||
const base = import.meta.env.BASE_URL.endsWith("/")
|
||||
? import.meta.env.BASE_URL
|
||||
: `${import.meta.env.BASE_URL}/`;
|
||||
window.addEventListener("load", () => {
|
||||
navigator.serviceWorker
|
||||
.register(`${base}sw.js`, { scope: base })
|
||||
.catch(() => {
|
||||
/* SW registration is best-effort — push will just stay disabled */
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
createRoot(document.getElementById("root")!).render(<App />);
|
||||
|
||||
@@ -27,6 +27,8 @@ import {
|
||||
useUpdateAppSettings,
|
||||
useGetAdminStats,
|
||||
getGetAdminStatsQueryKey,
|
||||
useGetSystemVersion,
|
||||
getGetSystemVersionQueryKey,
|
||||
useGetAdminAppOpensByApp,
|
||||
getGetAdminAppOpensByAppQueryKey,
|
||||
getAdminAppOpensByApp,
|
||||
@@ -124,6 +126,7 @@ import type {
|
||||
ServiceDependentOrdersPage,
|
||||
UserDependentNotesPage,
|
||||
UserDependentOrdersPage,
|
||||
SystemVersionResponse,
|
||||
} from "@workspace/api-client-react";
|
||||
import { ListUsersStatus } from "@workspace/api-client-react";
|
||||
import { useQueryClient, useQuery } from "@tanstack/react-query";
|
||||
@@ -131,7 +134,7 @@ import { useAuth } from "@/contexts/AuthContext";
|
||||
import { useUpload, type UploadResponse } from "@workspace/object-storage-web";
|
||||
import { isBuiltinAppSlug } from "@workspace/db/built-in-apps";
|
||||
import { resolveServiceImageUrl } from "@/lib/image-url";
|
||||
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText, Download, X, ImageIcon, Power, HelpCircle, Palette, Link2, Hash, Lock, ExternalLink, AlertTriangle, type LucideIcon } from "lucide-react";
|
||||
import { ArrowRight, ArrowLeft, Settings, Plus, Pencil, Trash2, Shield, Upload, Loader2, LayoutDashboard, Grid2X2, Coffee, Users as UsersIcon, Menu as MenuIcon, TrendingUp, TrendingDown, UserPlus, Activity, KeyRound, Copy, ChevronDown, ScrollText, Download, X, ImageIcon, Power, HelpCircle, Palette, Link2, Hash, Lock, ExternalLink, AlertTriangle, Package, RefreshCw, CheckCircle2, AlertCircle, type LucideIcon } from "lucide-react";
|
||||
import * as LucideIcons from "lucide-react";
|
||||
|
||||
function resolveAppIcon(name: string): LucideIcon | null {
|
||||
@@ -184,7 +187,7 @@ async function fetchAdminApps(): Promise<App[]> {
|
||||
return res.json();
|
||||
}
|
||||
|
||||
type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "roles" | "audit-log" | "settings";
|
||||
type AdminSection = "dashboard" | "apps" | "services" | "users" | "groups" | "roles" | "audit-log" | "settings" | "system-updates";
|
||||
|
||||
function LeaderboardAvatar({
|
||||
src,
|
||||
@@ -1081,6 +1084,11 @@ export default function AdminPage() {
|
||||
],
|
||||
},
|
||||
{ key: "settings", label: t("admin.nav.settings"), Icon: Settings },
|
||||
// #575: Read-only "System Updates" page — checks the running build
|
||||
// against a remote latest.json (configured via LATEST_VERSION_URL on
|
||||
// the server) and shows whether a newer version is available. It
|
||||
// never performs an update — pure status surface.
|
||||
{ key: "system-updates", label: t("admin.nav.systemUpdates"), Icon: Package },
|
||||
];
|
||||
|
||||
// Sync section to URL hash so deep links work
|
||||
@@ -1090,7 +1098,7 @@ export default function AdminPage() {
|
||||
const sectionMatch = hash.match(/section=([a-z-]+)/);
|
||||
if (sectionMatch) {
|
||||
const s = sectionMatch[1] as AdminSection;
|
||||
if (["dashboard", "apps", "services", "users", "groups", "roles", "audit-log", "settings"].includes(s)) {
|
||||
if (["dashboard", "apps", "services", "users", "groups", "roles", "audit-log", "settings", "system-updates"].includes(s)) {
|
||||
setSection(s);
|
||||
}
|
||||
}
|
||||
@@ -2211,6 +2219,8 @@ export default function AdminPage() {
|
||||
<SiteSettingsPanel />
|
||||
</div>
|
||||
)}
|
||||
|
||||
{section === "system-updates" && <SystemUpdatesPanel />}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2331,6 +2341,188 @@ export default function AdminPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// #575: System Updates panel — read-only version checker. Reads the
|
||||
// local build (server returns version.json embedded at build time)
|
||||
// and, if the server has LATEST_VERSION_URL configured, compares it
|
||||
// against the remote latest.json via semver on the server side. We
|
||||
// never perform an update from this screen — pure status surface.
|
||||
// The bell icon and other admin features stay untouched.
|
||||
function SystemUpdatesPanel() {
|
||||
const { t, i18n } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
const queryClient = useQueryClient();
|
||||
const queryKey = getGetSystemVersionQueryKey();
|
||||
const { data, isLoading, isFetching, refetch } = useGetSystemVersion({
|
||||
query: { queryKey, refetchOnWindowFocus: false },
|
||||
});
|
||||
|
||||
const handleCheckNow = async () => {
|
||||
// Invalidate first so React Query treats the next fetch as fresh
|
||||
// (no stale cache reuse) — this is the user explicitly asking for
|
||||
// a re-check, so we want a real network hit.
|
||||
await queryClient.invalidateQueries({ queryKey });
|
||||
const result = await refetch();
|
||||
if (result.error) {
|
||||
toast({
|
||||
title: t("admin.systemUpdates.toastErrorTitle"),
|
||||
description: t("admin.systemUpdates.toastErrorDesc"),
|
||||
variant: "destructive",
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const formatChecked = (iso: string | null | undefined) => {
|
||||
if (!iso) return null;
|
||||
const d = new Date(iso);
|
||||
if (Number.isNaN(d.getTime())) return null;
|
||||
return new Intl.DateTimeFormat(i18n.language === "ar" ? "ar" : "en", {
|
||||
dateStyle: "medium",
|
||||
timeStyle: "short",
|
||||
}).format(d);
|
||||
};
|
||||
|
||||
const renderStatusBadge = (resp: SystemVersionResponse | undefined) => {
|
||||
if (!resp) return null;
|
||||
const map: Record<
|
||||
SystemVersionResponse["status"],
|
||||
{ label: string; cls: string; Icon: typeof CheckCircle2 }
|
||||
> = {
|
||||
"up-to-date": {
|
||||
label: t("admin.systemUpdates.statusUpToDate"),
|
||||
cls: "bg-emerald-50 text-emerald-700 border-emerald-200",
|
||||
Icon: CheckCircle2,
|
||||
},
|
||||
"update-available": {
|
||||
label: t("admin.systemUpdates.statusUpdateAvailable"),
|
||||
cls: "bg-amber-50 text-amber-800 border-amber-200",
|
||||
Icon: AlertCircle,
|
||||
},
|
||||
"not-configured": {
|
||||
label: t("admin.systemUpdates.statusNotConfigured"),
|
||||
cls: "bg-slate-50 text-slate-700 border-slate-200",
|
||||
Icon: HelpCircle,
|
||||
},
|
||||
error: {
|
||||
label: t("admin.systemUpdates.statusError"),
|
||||
cls: "bg-rose-50 text-rose-700 border-rose-200",
|
||||
Icon: AlertTriangle,
|
||||
},
|
||||
};
|
||||
const cfg = map[resp.status];
|
||||
const Icon = cfg.Icon;
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"inline-flex items-center gap-2 px-3 py-1.5 rounded-full text-sm font-medium border",
|
||||
cfg.cls,
|
||||
)}
|
||||
data-testid="system-updates-status-badge"
|
||||
>
|
||||
<Icon size={14} />
|
||||
{cfg.label}
|
||||
</span>
|
||||
);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="space-y-4" data-testid="system-updates-panel" dir={i18n.language === "ar" ? "rtl" : "ltr"}>
|
||||
<div className="bg-white rounded-2xl shadow-sm border border-slate-200 p-5">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold text-foreground">
|
||||
{t("admin.systemUpdates.title")}
|
||||
</h2>
|
||||
<p className="text-sm text-muted-foreground mt-1">
|
||||
{t("admin.systemUpdates.subtitle")}
|
||||
</p>
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={handleCheckNow}
|
||||
disabled={isFetching}
|
||||
data-testid="system-updates-check-now"
|
||||
>
|
||||
{isFetching ? (
|
||||
<Loader2 size={14} className="animate-spin me-2" />
|
||||
) : (
|
||||
<RefreshCw size={14} className="me-2" />
|
||||
)}
|
||||
{t("admin.systemUpdates.checkNow")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{isLoading ? (
|
||||
<div className="flex items-center gap-2 text-sm text-muted-foreground mt-6">
|
||||
<Loader2 size={14} className="animate-spin" />
|
||||
{t("admin.systemUpdates.loading")}
|
||||
</div>
|
||||
) : data ? (
|
||||
<div className="mt-6 grid gap-4 sm:grid-cols-2">
|
||||
<div className="rounded-xl border border-slate-200 p-4 bg-slate-50/60">
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{t("admin.systemUpdates.currentVersion")}
|
||||
</div>
|
||||
<div
|
||||
className="text-xl font-semibold mt-1 tabular-nums"
|
||||
data-testid="system-updates-current-version"
|
||||
>
|
||||
{data.current.version}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t("admin.systemUpdates.buildNumber")}:{" "}
|
||||
<span className="tabular-nums" data-testid="system-updates-current-build">
|
||||
{data.current.build}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-xl border border-slate-200 p-4 bg-slate-50/60">
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground">
|
||||
{t("admin.systemUpdates.latestVersion")}
|
||||
</div>
|
||||
<div
|
||||
className="text-xl font-semibold mt-1 tabular-nums"
|
||||
data-testid="system-updates-latest-version"
|
||||
>
|
||||
{data.latest?.version ?? "—"}
|
||||
</div>
|
||||
<div className="text-xs text-muted-foreground mt-1">
|
||||
{t("admin.systemUpdates.buildNumber")}:{" "}
|
||||
<span className="tabular-nums" data-testid="system-updates-latest-build">
|
||||
{data.latest?.build ?? "—"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="sm:col-span-2 rounded-xl border border-slate-200 p-4">
|
||||
<div className="text-xs uppercase tracking-wider text-muted-foreground mb-2">
|
||||
{t("admin.systemUpdates.updateStatus")}
|
||||
</div>
|
||||
{renderStatusBadge(data)}
|
||||
{data.status === "error" && data.errorMessage && (
|
||||
<div className="text-xs text-rose-700 mt-2" data-testid="system-updates-error-message">
|
||||
{data.errorMessage}
|
||||
</div>
|
||||
)}
|
||||
{data.status === "not-configured" && (
|
||||
<div className="text-xs text-muted-foreground mt-2">
|
||||
{t("admin.systemUpdates.notConfiguredHint")}
|
||||
</div>
|
||||
)}
|
||||
{data.checkedAt && (
|
||||
<div className="text-xs text-muted-foreground mt-3">
|
||||
{t("admin.systemUpdates.lastChecked")}: {formatChecked(data.checkedAt)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SiteSettingsPanel() {
|
||||
const { t } = useTranslation();
|
||||
const { toast } = useToast();
|
||||
|
||||
@@ -114,6 +114,8 @@ import { EditableCell } from "@/components/editable-cell";
|
||||
// #486: reuse the same dialog the upcoming-alert pops, so postponing
|
||||
// from the schedule row quick-actions menu shares one implementation.
|
||||
import { PostponeDialog } from "@/components/executive-meetings/upcoming-meeting-alert";
|
||||
import { AnalogClock } from "@/components/executive-meetings/analog-clock";
|
||||
import { formatDate, formatTime as formatTimeI18n, formatWeekday } from "@/lib/i18n-format";
|
||||
import { safeHtml, htmlToPlainText, wrapAsParagraph } from "@/lib/safe-html";
|
||||
import {
|
||||
ALERT_COLOR_PRESETS,
|
||||
@@ -779,6 +781,34 @@ export default function ExecutiveMeetingsPage() {
|
||||
);
|
||||
}
|
||||
|
||||
function PresidentClockBar({ date, lang }: { date: string; lang: "ar" | "en" }) {
|
||||
const [now, setNow] = useState<Date>(() => new Date());
|
||||
useEffect(() => {
|
||||
const id = window.setInterval(() => setNow(new Date()), 1000);
|
||||
return () => window.clearInterval(id);
|
||||
}, []);
|
||||
return (
|
||||
<div
|
||||
className="flex items-center gap-2 sm:gap-3 ps-2 sm:ps-3 ms-1 border-s border-gray-200"
|
||||
data-testid="em-president-clock-bar"
|
||||
>
|
||||
<AnalogClock size={40} />
|
||||
<div className="leading-tight">
|
||||
<div className="flex items-center gap-2 text-[#0B1E3F] font-semibold text-sm sm:text-base">
|
||||
<span data-testid="em-president-weekday">{formatWeekday(date, lang, "long")}</span>
|
||||
<span className="text-muted-foreground/60">·</span>
|
||||
<span className="tabular-nums" data-testid="em-president-digital-time">
|
||||
{formatTimeI18n(now, lang, { hour: "2-digit", minute: "2-digit", second: "2-digit", hour12: true })}
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs sm:text-sm text-muted-foreground tabular-nums" data-testid="em-president-date">
|
||||
{formatDate(date, lang)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function HeaderClock({ lang }: { lang: "ar" | "en" }) {
|
||||
const [now, setNow] = useState<Date>(() => new Date());
|
||||
useEffect(() => {
|
||||
@@ -850,6 +880,27 @@ function ExecutiveMeetingsPageInner() {
|
||||
queryFn: () => apiJson<MeRoles>("/api/executive-meetings/me"),
|
||||
});
|
||||
|
||||
// #572: "President view" — a focused, presentation-style layout for users
|
||||
// whose primary role is the CEO/president (`executive_ceo`) and who are
|
||||
// NOT also wearing an admin hat (admin / office_manager). They get the
|
||||
// schedule table only — no sub-nav tabs, no PDF shortcut, no audit log —
|
||||
// plus an analog clock + weekday/date label in the toolbar. Their
|
||||
// permissions on the backend are untouched; this is purely a UI gate.
|
||||
const isPresidentView = useMemo(() => {
|
||||
const roles = me?.roles ?? [];
|
||||
if (!roles.includes("executive_ceo")) return false;
|
||||
return !roles.some((r) => r === "admin" || r === "executive_office_manager");
|
||||
}, [me]);
|
||||
|
||||
// Force the schedule section when the president lands on the page or
|
||||
// their role flips on mid-session, so they never get stuck on a now-
|
||||
// hidden tab.
|
||||
useEffect(() => {
|
||||
if (isPresidentView && section !== "schedule") {
|
||||
setSection("schedule");
|
||||
}
|
||||
}, [isPresidentView, section]);
|
||||
|
||||
const { data: fontResp } = useQuery<FontSettingsResponse>({
|
||||
queryKey: ["/api/executive-meetings/font-settings"],
|
||||
queryFn: () => apiJson<FontSettingsResponse>("/api/executive-meetings/font-settings"),
|
||||
@@ -932,30 +983,34 @@ function ExecutiveMeetingsPageInner() {
|
||||
|
||||
<div className="flex-1" />
|
||||
|
||||
<div className="hidden sm:flex pointer-events-none absolute inset-x-0 top-0 bottom-0 items-center justify-center print:hidden">
|
||||
<div className="pointer-events-auto">
|
||||
<HeaderClock lang={lang} />
|
||||
{!isPresidentView && (
|
||||
<div className="hidden sm:flex pointer-events-none absolute inset-x-0 top-0 bottom-0 items-center justify-center print:hidden">
|
||||
<div className="pointer-events-auto">
|
||||
<HeaderClock lang={lang} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => setSection("pdf")}
|
||||
data-testid="em-export-shortcut"
|
||||
>
|
||||
<FileDown className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t("executiveMeetings.exportPdf")}</span>
|
||||
</Button>
|
||||
{!isPresidentView && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="gap-1.5"
|
||||
onClick={() => setSection("pdf")}
|
||||
data-testid="em-export-shortcut"
|
||||
>
|
||||
<FileDown className="w-4 h-4" />
|
||||
<span className="hidden sm:inline">{t("executiveMeetings.exportPdf")}</span>
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<nav className="border-t border-gray-100 bg-white overflow-x-auto">
|
||||
<div className="max-w-screen-2xl mx-auto px-2 sm:px-4 flex items-center gap-1 sm:gap-2">
|
||||
<div className="flex gap-1 sm:gap-2 min-w-max">
|
||||
{SECTIONS.filter(({ key }) => isSectionVisible(key, me)).map(
|
||||
{!isPresidentView && SECTIONS.filter(({ key }) => isSectionVisible(key, me)).map(
|
||||
({ key, icon: Icon }) => {
|
||||
const active = section === key;
|
||||
return (
|
||||
@@ -999,6 +1054,8 @@ function ExecutiveMeetingsPageInner() {
|
||||
columns={columns}
|
||||
onColumnsChange={setColumns}
|
||||
highlightPrefs={highlightPrefs}
|
||||
isPresidentView={isPresidentView}
|
||||
lang={lang}
|
||||
/>
|
||||
)}
|
||||
{section === "manage" && me && (
|
||||
@@ -1057,6 +1114,8 @@ function ScheduleSection({
|
||||
columns,
|
||||
onColumnsChange,
|
||||
highlightPrefs,
|
||||
isPresidentView = false,
|
||||
lang = "en",
|
||||
}: {
|
||||
meetings: Meeting[];
|
||||
date: string;
|
||||
@@ -1070,6 +1129,8 @@ function ScheduleSection({
|
||||
columns: ColumnSetting[];
|
||||
onColumnsChange: Dispatch<SetStateAction<ColumnSetting[]>>;
|
||||
highlightPrefs: HighlightPrefs;
|
||||
isPresidentView?: boolean;
|
||||
lang?: "ar" | "en";
|
||||
}) {
|
||||
const qc = useQueryClient();
|
||||
const { toast } = useToast();
|
||||
@@ -2246,6 +2307,15 @@ function ScheduleSection({
|
||||
const [postponeMeetingId, setPostponeMeetingId] = useState<number | null>(
|
||||
null,
|
||||
);
|
||||
// #570: editing state for the in-place Meeting edit dialog opened
|
||||
// from the row's quick-actions Edit button. We host the dialog in
|
||||
// ScheduleSection (rather than switching the user over to the
|
||||
// Manage tab) so the edit flow feels local to the row the user
|
||||
// just tapped. Mirrors ManageSection's `editing` shape so we can
|
||||
// reuse the same MeetingFormDialog + save body unchanged.
|
||||
const [editingMeeting, setEditingMeeting] =
|
||||
useState<MeetingFormState | null>(null);
|
||||
const [editingSaving, setEditingSaving] = useState(false);
|
||||
const postponeMeeting = useMemo(() => {
|
||||
if (postponeMeetingId == null) return null;
|
||||
return meetings.find((m) => m.id === postponeMeetingId) ?? null;
|
||||
@@ -2645,6 +2715,9 @@ function ScheduleSection({
|
||||
className="border border-gray-300 rounded px-2 py-1 text-sm bg-white"
|
||||
/>
|
||||
</label>
|
||||
{isPresidentView && (
|
||||
<PresidentClockBar date={date} lang={lang} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
|
||||
@@ -2947,6 +3020,33 @@ function ScheduleSection({
|
||||
onBulkSelectChange={(v) => toggleMeetingSelected(m.id, v)}
|
||||
quickActionsCanMutate={canMutate && !editMode}
|
||||
onQuickPostpone={() => setPostponeMeetingId(m.id)}
|
||||
onQuickEdit={
|
||||
canMutate && !editMode
|
||||
? () =>
|
||||
setEditingMeeting({
|
||||
id: m.id,
|
||||
titleAr: htmlToPlainText(m.titleAr),
|
||||
titleEn: htmlToPlainText(m.titleEn ?? ""),
|
||||
meetingDate: m.meetingDate,
|
||||
dailyNumber: String(m.dailyNumber),
|
||||
startTime: m.startTime
|
||||
? m.startTime.slice(0, 5)
|
||||
: "",
|
||||
endTime: m.endTime ? m.endTime.slice(0, 5) : "",
|
||||
location: m.location ?? "",
|
||||
meetingUrl: m.meetingUrl ?? "",
|
||||
platform: m.platform,
|
||||
status: m.status,
|
||||
isHighlighted: m.isHighlighted === 1,
|
||||
notes: m.notes ?? "",
|
||||
attendees: m.attendees.map((a) => ({
|
||||
...a,
|
||||
name: htmlToPlainText(a.name),
|
||||
_sid: genAttendeeSid(),
|
||||
})),
|
||||
})
|
||||
: undefined
|
||||
}
|
||||
/>
|
||||
))}
|
||||
</SortableContext>
|
||||
@@ -2987,6 +3087,77 @@ function ScheduleSection({
|
||||
same component the upcoming-meeting alert renders so the
|
||||
postpone-minutes / reschedule / cancel flows + the 409
|
||||
stale_meeting handling stay in one place. */}
|
||||
{/* #570: In-place Meeting edit dialog hosted by ScheduleSection,
|
||||
opened from the row's quick-actions Edit button. Save body
|
||||
mirrors ManageSection's `save()` exactly (PATCH with the
|
||||
same field projection) so an edit started here writes the
|
||||
same shape the Manage tab would. Invalidates the same
|
||||
react-query key the rest of the page uses so the row
|
||||
reflects new values instantly. */}
|
||||
{editingMeeting && (
|
||||
<MeetingFormDialog
|
||||
state={editingMeeting}
|
||||
onChange={setEditingMeeting}
|
||||
onSave={async (committedStart, committedEnd) => {
|
||||
if (!editingMeeting) return;
|
||||
if (!editingMeeting.titleAr.trim()) {
|
||||
toast({
|
||||
title: t("executiveMeetings.manage.errors.titleRequired"),
|
||||
variant: "destructive",
|
||||
});
|
||||
return;
|
||||
}
|
||||
const titleEnPlain =
|
||||
editingMeeting.titleEn.trim() || editingMeeting.titleAr.trim();
|
||||
setEditingSaving(true);
|
||||
const body: Record<string, unknown> = {
|
||||
titleAr: wrapAsParagraph(editingMeeting.titleAr),
|
||||
titleEn: wrapAsParagraph(titleEnPlain),
|
||||
meetingDate: editingMeeting.meetingDate,
|
||||
startTime: committedStart || null,
|
||||
endTime: committedEnd || null,
|
||||
location: editingMeeting.location.trim() || null,
|
||||
meetingUrl: editingMeeting.meetingUrl.trim() || null,
|
||||
platform: editingMeeting.platform,
|
||||
status: editingMeeting.status,
|
||||
isHighlighted: editingMeeting.isHighlighted ? 1 : 0,
|
||||
notes: editingMeeting.notes.trim() || null,
|
||||
attendees: editingMeeting.attendees.map((a, idx) => ({
|
||||
name: wrapAsParagraph(a.name),
|
||||
title: a.title,
|
||||
attendanceType: a.attendanceType,
|
||||
sortOrder: idx,
|
||||
kind: a.kind ?? "person",
|
||||
})),
|
||||
};
|
||||
if (editingMeeting.dailyNumber.trim()) {
|
||||
body.dailyNumber = Number(editingMeeting.dailyNumber);
|
||||
}
|
||||
try {
|
||||
await apiJson(
|
||||
`/api/executive-meetings/${editingMeeting.id}`,
|
||||
{ method: "PATCH", body },
|
||||
);
|
||||
toast({ title: t("executiveMeetings.common.saved") });
|
||||
setEditingMeeting(null);
|
||||
refreshDay();
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : String(err);
|
||||
toast({
|
||||
title: t("executiveMeetings.manage.errors.saveFailed"),
|
||||
description: translateSaveError(msg, t),
|
||||
variant: "destructive",
|
||||
});
|
||||
} finally {
|
||||
setEditingSaving(false);
|
||||
}
|
||||
}}
|
||||
onClose={() => setEditingMeeting(null)}
|
||||
saving={editingSaving}
|
||||
t={t}
|
||||
/>
|
||||
)}
|
||||
|
||||
{postponeMeeting && canMutate ? (
|
||||
<PostponeDialog
|
||||
open={postponeMeetingId !== null}
|
||||
@@ -3747,6 +3918,7 @@ function MeetingRow({
|
||||
dragEnabled,
|
||||
quickActionsCanMutate,
|
||||
onQuickPostpone,
|
||||
onQuickEdit,
|
||||
}: {
|
||||
meeting: Meeting;
|
||||
displayNumber?: number;
|
||||
@@ -3772,6 +3944,12 @@ function MeetingRow({
|
||||
// MUTATE_ROLES server-side gating as the postpone-minutes endpoint.
|
||||
quickActionsCanMutate?: boolean;
|
||||
onQuickPostpone?: () => void;
|
||||
// #570: opens the full Meeting edit dialog (same one used in
|
||||
// ManageSection) and closes the quick-actions surface. Wired by
|
||||
// ScheduleSection to its own `setEditingMeeting` lifted state so
|
||||
// the Schedule tab can host the edit dialog without switching the
|
||||
// user to the Manage tab.
|
||||
onQuickEdit?: () => void;
|
||||
onSaveTitle: (html: string) => Promise<void>;
|
||||
onSaveAttendeeName: (idx: number, html: string) => Promise<void>;
|
||||
onSaveTimes: (
|
||||
@@ -4263,7 +4441,16 @@ function MeetingRow({
|
||||
if (!quickActionsCanMutate && quickOpen) setQuickOpen(false);
|
||||
}, [quickActionsCanMutate, quickOpen]);
|
||||
|
||||
// #570: bulk-selected rows previously only got a 2px inset ring in
|
||||
// navy, which is easy to miss on iPad against the table grid. We now
|
||||
// *also* paint a visible navy tint behind the row so the selected
|
||||
// state reads at a glance. The tint composes after the per-row
|
||||
// `baseBg` so user-picked row colours still win when nothing is
|
||||
// selected, but loses to `quickOpenBg` (the open quick-actions
|
||||
// surface is a transient, action-scoped state) — see the
|
||||
// `backgroundColor` composition in `trStyle` below.
|
||||
const selectionShadow = bulkSelected ? "inset 0 0 0 2px #0B1E3F" : null;
|
||||
const selectionBg = bulkSelected ? "rgba(11, 30, 63, 0.18)" : null;
|
||||
const currentShadow = isCurrent
|
||||
? `inset 0 0 0 2px ${highlightColor}`
|
||||
: null;
|
||||
@@ -4275,8 +4462,17 @@ function MeetingRow({
|
||||
// #496: belt-and-suspenders — also gate on capability so a stale
|
||||
// `quickOpen=true` from a render before the effect runs cannot
|
||||
// paint the accented frame in edit mode.
|
||||
// #563: thicker inset ring + outer glow + tinted background so the
|
||||
// originating row is unmistakable on iPad where the default 3px
|
||||
// inset is easy to miss against the table grid. The outer
|
||||
// `0 0 0 2px` ring sits OUTSIDE the row box (no inset), so the row
|
||||
// appears framed on all four sides even when adjacent rows would
|
||||
// otherwise visually clip the inset ring.
|
||||
const quickOpenShadow = quickActionsCanMutate && quickOpen
|
||||
? `inset 0 0 0 3px ${highlightColor}`
|
||||
? `inset 0 0 0 4px ${highlightColor}, 0 0 0 2px ${highlightColor}`
|
||||
: null;
|
||||
const quickOpenBg = quickActionsCanMutate && quickOpen
|
||||
? hexToRgba(highlightColor, 0.08)
|
||||
: null;
|
||||
// #499: iPad long-press feedback. dnd-kit's TouchSensor has a 200ms
|
||||
// activation delay before a long-press becomes a drag — during that
|
||||
@@ -4329,7 +4525,7 @@ function MeetingRow({
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
const trStyle: CSSProperties = {
|
||||
backgroundColor: baseBg,
|
||||
backgroundColor: quickOpenBg ?? selectionBg ?? baseBg,
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
opacity: isDragging ? 0.5 : reordering ? 0.7 : 1,
|
||||
@@ -4340,38 +4536,37 @@ function MeetingRow({
|
||||
if (!quickActionsCanMutate) return;
|
||||
const target = e.target as HTMLElement | null;
|
||||
if (!target) return;
|
||||
// Skip clicks that landed on an interactive child — leave them to
|
||||
// their own handlers. Includes ARIA roles because TimeRangeCell
|
||||
// and a few other affordances render as `div role="button"`
|
||||
// (keyboard-activatable) rather than native `<button>`s, so a
|
||||
// pure tag-name filter would let those clicks fall through to
|
||||
// the row handler and pop the quick-actions menu underneath the
|
||||
// inline editor the user just opened.
|
||||
// #489: scope to descendants — the row itself now carries
|
||||
// role="button" + tabIndex from dnd-kit's `attributes` (because
|
||||
// dragEnabled is no longer gated by edit mode), so an unscoped
|
||||
// closest() would match the row itself and swallow every click.
|
||||
// #563: outside edit mode (which is the only state where
|
||||
// handleRowClick runs — `quickActionsCanMutate` is force-false
|
||||
// in edit mode, see line ~2948), the user expects ANY click
|
||||
// anywhere in the row to open the quick-actions surface, not
|
||||
// just the attendees cell. We only skip clicks that landed on
|
||||
// a *truly* interactive native control (buttons, inputs,
|
||||
// links, menu/checkbox/switch/combobox/dialog roles) — the
|
||||
// `[role='button']` rule is intentionally DROPPED because
|
||||
// TimeRangeCell and EditableInlineText render with role='button'
|
||||
// to mark themselves as keyboard-activatable, but their actual
|
||||
// edit affordance is gated on `editMode === true` — outside
|
||||
// edit mode they are display-only and clicks should bubble.
|
||||
// #489 still holds: scope to descendants so the row's own
|
||||
// dnd-kit `role="button"` doesn't swallow every click.
|
||||
const interactive = target.closest(
|
||||
"button, a, input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='menuitem'], [role='button'], [role='checkbox'], [role='switch'], [role='combobox'], [role='dialog']",
|
||||
"button, a, input, textarea, select, [contenteditable='true'], [contenteditable=''], [role='menuitem'], [role='checkbox'], [role='switch'], [role='combobox'], [role='dialog']",
|
||||
);
|
||||
if (interactive && interactive !== e.currentTarget) {
|
||||
return;
|
||||
}
|
||||
// Skip clicks on row affordances by data-testid prefix so we
|
||||
// don't fight with drag, row-actions, merge-edit, inline-edit,
|
||||
// bulk-select, or the time cell's edit-on-click surface
|
||||
// (em-time-*). The role='button' rule above catches the time
|
||||
// cell's display state too, but the explicit testid guard keeps
|
||||
// the intent obvious and survives any future refactor that
|
||||
// drops the role attribute.
|
||||
// Keep only the affordances that are visible OUTSIDE edit mode
|
||||
// and must not trigger quick-actions: the 3-dot row menu and
|
||||
// the bulk-select checkbox. The previous `em-edit-*`,
|
||||
// `em-merge-edit-*`, and `em-time-*` guards were dropped because
|
||||
// those affordances only do anything in edit mode (where this
|
||||
// handler never fires).
|
||||
if (
|
||||
target.closest(
|
||||
[
|
||||
`[data-testid="em-row-actions-${meeting.id}"]`,
|
||||
`[data-testid="em-row-select-${meeting.id}"]`,
|
||||
`[data-testid^="em-edit-"]`,
|
||||
`[data-testid^="em-merge-edit-"]`,
|
||||
`[data-testid^="em-time-"]`,
|
||||
].join(", "),
|
||||
)
|
||||
) {
|
||||
@@ -4455,8 +4650,31 @@ function MeetingRow({
|
||||
</DialogHeader>
|
||||
{/* #490: polished Postpone button — primary-styled, larger
|
||||
tap target for iPad, icon flips with locale via flex
|
||||
direction so it stays adjacent to the label in RTL. */}
|
||||
<div className="flex justify-center pt-2">
|
||||
direction so it stays adjacent to the label in RTL.
|
||||
#570: added an Edit (pencil) button alongside Postpone
|
||||
that opens the same Meeting edit dialog ManageSection
|
||||
uses. Wrapped in a flex row + `flex-wrap` so the two
|
||||
buttons sit side by side on iPad/desktop and stack on
|
||||
very narrow viewports. Edit button only renders when a
|
||||
handler is wired (defensive — quickActionsCanMutate
|
||||
already gates the surface itself). */}
|
||||
<div className="flex flex-wrap justify-center gap-2 pt-2">
|
||||
{onQuickEdit ? (
|
||||
<Button
|
||||
type="button"
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setQuickOpen(false);
|
||||
onQuickEdit();
|
||||
}}
|
||||
className={`min-w-[11rem] gap-2 px-3 py-2 h-10 text-sm font-medium shadow-sm ${isRtl ? "flex-row-reverse" : ""}`}
|
||||
data-testid={`em-row-quick-edit-${meeting.id}`}
|
||||
>
|
||||
<Pencil className="w-4 h-4" aria-hidden="true" />
|
||||
<span>{t("executiveMeetings.quickActions.edit")}</span>
|
||||
</Button>
|
||||
) : null}
|
||||
<Button
|
||||
type="button"
|
||||
variant="default"
|
||||
|
||||
@@ -135,8 +135,18 @@ function AppIconContent({
|
||||
)}
|
||||
</div>
|
||||
<span
|
||||
lang={lang === "ar" ? "ar" : "en"}
|
||||
dir={lang === "ar" ? "rtl" : "ltr"}
|
||||
className="text-[11px] text-foreground/85 font-medium text-center max-w-[78px] block"
|
||||
style={{
|
||||
// Pin an Arabic-capable face so iOS Safari doesn't fall back
|
||||
// to a Latin font (which drops the dots under final ي in
|
||||
// labels like "خدماتي"). Falls back to the Latin face for
|
||||
// English labels via the second family in the stack.
|
||||
fontFamily:
|
||||
lang === "ar"
|
||||
? '"DIN Next LT Arabic", "Helvetica Neue LT Arabic", "Tajawal", sans-serif'
|
||||
: '"Helvetica Neue", system-ui, sans-serif',
|
||||
lineHeight: 1.6,
|
||||
paddingTop: 2,
|
||||
overflow: "hidden",
|
||||
@@ -511,7 +521,12 @@ export default function HomePage() {
|
||||
});
|
||||
};
|
||||
|
||||
const dockApps = (orderedApps ?? apps)?.filter((a) => ["services", "notifications", "admin"].includes(a.slug)) ?? [];
|
||||
// #571: drop the Notifications tile from the bottom dock — the bell
|
||||
// icon in the top bar (with the same unread badge) is the canonical
|
||||
// entry point, so a second affordance for the same destination on
|
||||
// the home screen was redundant. The bell still routes to
|
||||
// `/notifications` (see the top-bar button below).
|
||||
const dockApps = (orderedApps ?? apps)?.filter((a) => ["services", "admin"].includes(a.slug)) ?? [];
|
||||
|
||||
const initials = (displayName || "?").trim().slice(0, 1).toUpperCase();
|
||||
|
||||
|
||||
@@ -66,9 +66,33 @@ export default function LoginPage() {
|
||||
{lang === "ar" ? "EN" : "عربي"}
|
||||
</button>
|
||||
|
||||
{/* Animated art panel — hidden on small screens, no logo, no text */}
|
||||
{/* Animated art panel — IT-P brand image layered over animated art.
|
||||
Hidden on small screens. The image sits above AnimatedArt with a
|
||||
soft gradient overlay so the lines/orbs still bleed through at the
|
||||
edges and the composition reads as one piece. */}
|
||||
<div className="hidden lg:block lg:w-1/2 relative overflow-hidden shrink-0">
|
||||
<AnimatedArt />
|
||||
<div
|
||||
className="absolute inset-0 bg-center bg-cover bg-no-repeat opacity-90 mix-blend-screen"
|
||||
style={{ backgroundImage: "url('/brand-itp.jpg?v=itp1')" }}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(180deg, rgba(11,30,63,0.35) 0%, rgba(11,30,63,0) 30%, rgba(11,30,63,0) 70%, rgba(11,30,63,0.6) 100%)",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-y-0 right-0 w-24"
|
||||
style={{
|
||||
background:
|
||||
"linear-gradient(90deg, rgba(11,30,63,0) 0%, rgba(11,30,63,0.5) 100%)",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Form panel */}
|
||||
|
||||
@@ -30,7 +30,6 @@ import {
|
||||
AlertDialogAction,
|
||||
AlertDialogCancel,
|
||||
AlertDialogContent,
|
||||
AlertDialogDescription,
|
||||
AlertDialogFooter,
|
||||
AlertDialogHeader,
|
||||
AlertDialogTitle,
|
||||
@@ -169,7 +168,6 @@ function OrderCard({
|
||||
const { toast } = useToast();
|
||||
const lang = i18n.language;
|
||||
const [confirmCancelOpen, setConfirmCancelOpen] = useState(false);
|
||||
const [confirmDeleteOpen, setConfirmDeleteOpen] = useState(false);
|
||||
const updateStatus = useUpdateServiceOrderStatus();
|
||||
|
||||
const name = lang === "ar" ? order.service.nameAr : order.service.nameEn;
|
||||
@@ -233,7 +231,6 @@ function OrderCard({
|
||||
};
|
||||
|
||||
const handleDelete = () => {
|
||||
setConfirmDeleteOpen(false);
|
||||
onDelete(order.id);
|
||||
};
|
||||
|
||||
@@ -293,7 +290,7 @@ function OrderCard({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-500 hover:text-rose-700 hover:bg-rose-50"
|
||||
onClick={() => setConfirmDeleteOpen(true)}
|
||||
onClick={handleDelete}
|
||||
aria-label={t("myOrders.delete")}
|
||||
title={t("myOrders.delete")}
|
||||
>
|
||||
@@ -309,9 +306,6 @@ function OrderCard({
|
||||
<AlertDialogTitle>
|
||||
{t("myOrders.cancelConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("myOrders.cancelConfirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel disabled={updateStatus.isPending}>
|
||||
@@ -331,32 +325,6 @@ function OrderCard({
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<AlertDialog open={confirmDeleteOpen} onOpenChange={setConfirmDeleteOpen}>
|
||||
<AlertDialogContent dir={lang === "ar" ? "rtl" : "ltr"}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("myOrders.deleteConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("myOrders.deleteConfirmBody")}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("myOrders.keep")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleDelete();
|
||||
}}
|
||||
className="bg-rose-600 hover:bg-rose-700 text-white"
|
||||
>
|
||||
{t("myOrders.deleteConfirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -369,7 +337,6 @@ export default function MyOrdersPage() {
|
||||
const lang = i18n.language;
|
||||
const isRtl = lang === "ar";
|
||||
const BackIcon = isRtl ? ArrowRight : ArrowLeft;
|
||||
const [confirmClearOpen, setConfirmClearOpen] = useState(false);
|
||||
const deleteOrder = useDeleteServiceOrder();
|
||||
const [pendingDeleteIds, setPendingDeleteIds] = useState<Set<number>>(
|
||||
() => new Set(),
|
||||
@@ -438,65 +405,22 @@ export default function MyOrdersPage() {
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
ids.forEach((id) => timersRef.current.delete(id));
|
||||
void performDelete(ids).then(({ okCount, failCount }) => {
|
||||
void performDelete(ids).then(({ failCount }) => {
|
||||
setPendingDeleteIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
ids.forEach((id) => next.delete(id));
|
||||
return next;
|
||||
});
|
||||
if (failCount > 0) {
|
||||
// Restore failed deletions on error so the user sees them again.
|
||||
setPendingDeleteIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
ids.forEach((id) => next.delete(id));
|
||||
return next;
|
||||
});
|
||||
// #224: surface ONE summary toast instead of N error toasts. Total
|
||||
// failure keeps the existing single-line copy; partial failure
|
||||
// uses the existing `clearedPartial` key with both counts.
|
||||
toast({
|
||||
title:
|
||||
okCount === 0
|
||||
? t("myOrders.deleteFailed")
|
||||
: t("myOrders.clearedPartial", { ok: okCount, fail: failCount }),
|
||||
title: t("myOrders.deleteFailed"),
|
||||
variant: "destructive",
|
||||
});
|
||||
} else {
|
||||
setPendingDeleteIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
ids.forEach((id) => next.delete(id));
|
||||
return next;
|
||||
});
|
||||
}
|
||||
});
|
||||
}, UNDO_WINDOW_MS);
|
||||
|
||||
ids.forEach((id) => timersRef.current.set(id, timer));
|
||||
|
||||
const undo = () => {
|
||||
ids.forEach((id) => {
|
||||
const existing = timersRef.current.get(id);
|
||||
if (existing) clearTimeout(existing);
|
||||
timersRef.current.delete(id);
|
||||
});
|
||||
setPendingDeleteIds((prev) => {
|
||||
const next = new Set(prev);
|
||||
ids.forEach((id) => next.delete(id));
|
||||
return next;
|
||||
});
|
||||
handle.dismiss();
|
||||
toast({ title: t("myOrders.undone") });
|
||||
};
|
||||
|
||||
// #223: lean on i18next pluralization (clearedCount_one / _other are
|
||||
// already in both locales) so N=1 reads as "1 order deleted" instead
|
||||
// of the generic "Order deleted" copy.
|
||||
const title = t("myOrders.clearedCount", { count: ids.length });
|
||||
|
||||
const handle = toast({
|
||||
title,
|
||||
duration: UNDO_WINDOW_MS,
|
||||
action: (
|
||||
<ToastAction altText={t("myOrders.undo")} onClick={undo}>
|
||||
{t("myOrders.undo")}
|
||||
</ToastAction>
|
||||
),
|
||||
});
|
||||
};
|
||||
|
||||
const sortedOrders = orders
|
||||
@@ -508,14 +432,6 @@ export default function MyOrdersPage() {
|
||||
)
|
||||
: [];
|
||||
|
||||
const finishedOrders = sortedOrders.filter((o) => isFinished(o.status));
|
||||
const finishedCount = finishedOrders.length;
|
||||
|
||||
const handleClearFinished = () => {
|
||||
setConfirmClearOpen(false);
|
||||
scheduleDelete(finishedOrders.map((o) => o.id));
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="min-h-screen os-bg flex flex-col">
|
||||
<div className="glass-panel border-b border-slate-200/70 px-4 py-4 flex items-center gap-3 sticky top-0 z-10">
|
||||
@@ -567,19 +483,6 @@ export default function MyOrdersPage() {
|
||||
</div>
|
||||
) : (
|
||||
<div className="flex flex-col gap-3">
|
||||
{finishedCount > 0 && (
|
||||
<div className="flex justify-end">
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="text-slate-600 hover:text-rose-700 hover:bg-rose-50 gap-1.5"
|
||||
onClick={() => setConfirmClearOpen(true)}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
{t("myOrders.clearFinished", { count: finishedCount })}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
{sortedOrders.map((o) => (
|
||||
<OrderCard
|
||||
key={o.id}
|
||||
@@ -602,33 +505,6 @@ export default function MyOrdersPage() {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AlertDialog open={confirmClearOpen} onOpenChange={setConfirmClearOpen}>
|
||||
<AlertDialogContent dir={lang === "ar" ? "rtl" : "ltr"}>
|
||||
<AlertDialogHeader>
|
||||
<AlertDialogTitle>
|
||||
{t("myOrders.clearConfirmTitle")}
|
||||
</AlertDialogTitle>
|
||||
<AlertDialogDescription>
|
||||
{t("myOrders.clearConfirmBody", { count: finishedCount })}
|
||||
</AlertDialogDescription>
|
||||
</AlertDialogHeader>
|
||||
<AlertDialogFooter>
|
||||
<AlertDialogCancel>
|
||||
{t("myOrders.keep")}
|
||||
</AlertDialogCancel>
|
||||
<AlertDialogAction
|
||||
onClick={(e) => {
|
||||
e.preventDefault();
|
||||
handleClearFinished();
|
||||
}}
|
||||
className="bg-rose-600 hover:bg-rose-700 text-white"
|
||||
>
|
||||
{t("myOrders.clearConfirm")}
|
||||
</AlertDialogAction>
|
||||
</AlertDialogFooter>
|
||||
</AlertDialogContent>
|
||||
</AlertDialog>
|
||||
|
||||
<OrderServiceModal
|
||||
open={reorderTarget !== null}
|
||||
onOpenChange={(o) => {
|
||||
|
||||
@@ -32,6 +32,7 @@ services:
|
||||
# the first-run Setup Wizard (/api/setup/complete).
|
||||
SEED_ADMIN_PASSWORD: ${SEED_ADMIN_PASSWORD:-}
|
||||
SEED_USER_PASSWORD: ${SEED_USER_PASSWORD:-}
|
||||
SEED_DEMO_MEETINGS: ${SEED_DEMO_MEETINGS:-false}
|
||||
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:${APP_PORT:-3000}}
|
||||
user: root
|
||||
entrypoint: ["/usr/bin/tini", "--"]
|
||||
@@ -62,6 +63,7 @@ services:
|
||||
DEFAULT_OBJECT_STORAGE_BUCKET_ID: ${DEFAULT_OBJECT_STORAGE_BUCKET_ID:-tx-local}
|
||||
ALLOWED_ORIGINS: ${ALLOWED_ORIGINS:-http://localhost:${APP_PORT:-3000}}
|
||||
PUBLIC_BASE_URL: ${PUBLIC_BASE_URL:-http://localhost:${APP_PORT:-3000}}
|
||||
TRUST_PROXY_HTTPS: ${TRUST_PROXY_HTTPS:-false}
|
||||
LOG_LEVEL: ${LOG_LEVEL:-info}
|
||||
HTTPS_MODE: ${HTTPS_MODE:-local}
|
||||
LOCAL_DOMAIN: ${LOCAL_DOMAIN:-}
|
||||
@@ -72,6 +74,9 @@ services:
|
||||
SMTP_PASS: ${SMTP_PASS:-}
|
||||
SMTP_FROM: ${SMTP_FROM:-}
|
||||
SMTP_SECURE: ${SMTP_SECURE:-}
|
||||
VAPID_PUBLIC_KEY: ${VAPID_PUBLIC_KEY:-}
|
||||
VAPID_PRIVATE_KEY: ${VAPID_PRIVATE_KEY:-}
|
||||
VAPID_SUBJECT: ${VAPID_SUBJECT:-}
|
||||
volumes:
|
||||
- app_storage:/app/storage
|
||||
|
||||
@@ -97,15 +102,29 @@ services:
|
||||
LOCAL_DOMAIN: ${LOCAL_DOMAIN:-tx.local}
|
||||
LOCAL_IP: ${LOCAL_IP:-127.0.0.1}
|
||||
HTTPS_MODE: ${HTTPS_MODE:-local}
|
||||
# Pick the Caddyfile at boot. We auto-fall-back to the cert-free
|
||||
# Caddyfile.skip when HTTPS_MODE=skip OR when /certs is missing
|
||||
# the keypair, so a fresh `./start.sh` works on hosts that don't
|
||||
# have mkcert without any extra wiring.
|
||||
PUBLIC_DOMAIN: ${PUBLIC_DOMAIN:-}
|
||||
ACME_EMAIL: ${ACME_EMAIL:-}
|
||||
# Pick the Caddyfile at boot:
|
||||
# * HTTPS_MODE=auto → Caddyfile.auto (Let's Encrypt for PUBLIC_DOMAIN)
|
||||
# * HTTPS_MODE=skip → Caddyfile.skip (plain HTTP, dev only)
|
||||
# * /certs missing → Caddyfile.skip (graceful fall-back)
|
||||
# * otherwise → Caddyfile (BYO cert under /certs)
|
||||
entrypoint:
|
||||
- /bin/sh
|
||||
- -c
|
||||
- |
|
||||
if [ "$${HTTPS_MODE}" = "skip" ] || [ ! -f /certs/local-cert.pem ] || [ ! -f /certs/local-key.pem ]; then
|
||||
if [ "$${HTTPS_MODE}" = "auto" ]; then
|
||||
if [ -z "$${PUBLIC_DOMAIN}" ]; then
|
||||
echo "HTTPS_MODE=auto requires PUBLIC_DOMAIN to be set (e.g. tx.example.com)" >&2
|
||||
exit 1
|
||||
fi
|
||||
if [ -n "$${ACME_EMAIL}" ]; then
|
||||
export ACME_EMAIL_DIRECTIVE="email $${ACME_EMAIL}"
|
||||
else
|
||||
export ACME_EMAIL_DIRECTIVE=""
|
||||
fi
|
||||
exec caddy run --config /etc/caddy/Caddyfile.auto --adapter caddyfile
|
||||
elif [ "$${HTTPS_MODE}" = "skip" ] || [ ! -f /certs/local-cert.pem ] || [ ! -f /certs/local-key.pem ]; then
|
||||
exec caddy run --config /etc/caddy/Caddyfile.skip --adapter caddyfile
|
||||
else
|
||||
exec caddy run --config /etc/caddy/Caddyfile --adapter caddyfile
|
||||
@@ -124,6 +143,7 @@ services:
|
||||
volumes:
|
||||
- ./docker/Caddyfile:/etc/caddy/Caddyfile:ro
|
||||
- ./docker/Caddyfile.skip:/etc/caddy/Caddyfile.skip:ro
|
||||
- ./docker/Caddyfile.auto:/etc/caddy/Caddyfile.auto:ro
|
||||
- ./certs:/certs:ro
|
||||
- caddy_data:/data
|
||||
- caddy_config:/config
|
||||
|
||||
@@ -0,0 +1,54 @@
|
||||
# Tx OS — Caddy reverse proxy in HTTPS_MODE=auto.
|
||||
#
|
||||
# Issues + renews a real Let's Encrypt certificate for $PUBLIC_DOMAIN.
|
||||
# Used when the operator points a stable custom domain (e.g.
|
||||
# `tx.example.com`) at the box and wants automatic TLS — this keeps
|
||||
# iPad Web Push subscriptions valid forever, because subscriptions
|
||||
# are bound to the origin (host) they were created under and a stable
|
||||
# hostname means they never get orphaned when Tailscale or the LAN IP
|
||||
# changes.
|
||||
#
|
||||
# Requirements:
|
||||
# - PUBLIC_DOMAIN env var must be set (e.g. tx.example.com).
|
||||
# - DNS A/AAAA record for PUBLIC_DOMAIN points at this host.
|
||||
# - Ports 80 + 443 are reachable from the public internet (ACME
|
||||
# HTTP-01 / TLS-ALPN challenge). Behind a NAT/firewall you must
|
||||
# forward both. If only 443 is reachable, switch ACME to DNS-01
|
||||
# by extending this file with a DNS provider plugin.
|
||||
# - ACME_EMAIL env var SHOULD be set for renewal notifications.
|
||||
#
|
||||
# The site MUST stay single-origin so the API session cookie
|
||||
# (sameSite=lax) keeps working across SPA + /api requests.
|
||||
{
|
||||
{$ACME_EMAIL_DIRECTIVE}
|
||||
admin off
|
||||
}
|
||||
|
||||
(api_proxy) {
|
||||
@websocket {
|
||||
header Connection *Upgrade*
|
||||
header Upgrade websocket
|
||||
}
|
||||
reverse_proxy /api/socket.io/* api:8080
|
||||
reverse_proxy /api/* api:8080
|
||||
}
|
||||
|
||||
(spa_proxy) {
|
||||
reverse_proxy web:80
|
||||
}
|
||||
|
||||
# ---------- HTTPS site for the public custom domain ----------
|
||||
# Caddy auto-issues + renews the cert via ACME when this host is
|
||||
# reached over HTTPS for the first time.
|
||||
{$PUBLIC_DOMAIN} {
|
||||
encode zstd gzip
|
||||
import api_proxy
|
||||
import spa_proxy
|
||||
}
|
||||
|
||||
# Plain HTTP → permanent redirect to HTTPS. Caddy still needs :80
|
||||
# bound so the ACME HTTP-01 challenge can be answered.
|
||||
:80 {
|
||||
redir https://{host}{uri} permanent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": "0.1.0-dev",
|
||||
"build": "20260517.b5efd9eb"
|
||||
}
|
||||
@@ -9,6 +9,32 @@ export interface HealthStatus {
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface SystemVersionInfo {
|
||||
version: string;
|
||||
build: string;
|
||||
}
|
||||
|
||||
export type SystemVersionResponseStatus =
|
||||
(typeof SystemVersionResponseStatus)[keyof typeof SystemVersionResponseStatus];
|
||||
|
||||
export const SystemVersionResponseStatus = {
|
||||
"up-to-date": "up-to-date",
|
||||
"update-available": "update-available",
|
||||
"not-configured": "not-configured",
|
||||
error: "error",
|
||||
} as const;
|
||||
|
||||
export interface SystemVersionResponse {
|
||||
current: SystemVersionInfo;
|
||||
latest: SystemVersionInfo | null;
|
||||
status: SystemVersionResponseStatus;
|
||||
/** @nullable */
|
||||
errorMessage: string | null;
|
||||
/** @nullable */
|
||||
checkedAt: string | null;
|
||||
configured: boolean;
|
||||
}
|
||||
|
||||
export interface ErrorResponse {
|
||||
error: string;
|
||||
}
|
||||
@@ -1303,6 +1329,31 @@ export type DeleteServiceParams = {
|
||||
force?: boolean;
|
||||
};
|
||||
|
||||
export type GetPushVapidPublicKey200 = {
|
||||
publicKey: string;
|
||||
};
|
||||
|
||||
export type SubscribePushBodyKeys = {
|
||||
p256dh: string;
|
||||
auth: string;
|
||||
};
|
||||
|
||||
export type SubscribePushBody = {
|
||||
endpoint: string;
|
||||
keys: SubscribePushBodyKeys;
|
||||
};
|
||||
|
||||
export type DeletePushSubscriptionParams = {
|
||||
/**
|
||||
* The push endpoint URL to unsubscribe
|
||||
*/
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
export type UnsubscribePushBody = {
|
||||
endpoint: string;
|
||||
};
|
||||
|
||||
export type ListUsersParams = {
|
||||
/**
|
||||
* Free-text search across username, email, and display names
|
||||
|
||||
@@ -46,6 +46,7 @@ import type {
|
||||
CreateUserBody,
|
||||
DeleteAppParams,
|
||||
DeleteGroupParams,
|
||||
DeletePushSubscriptionParams,
|
||||
DeleteServiceParams,
|
||||
DeleteUserParams,
|
||||
ErrorResponse,
|
||||
@@ -64,6 +65,7 @@ import type {
|
||||
GetAdminUserDependentOrdersParams,
|
||||
GetAppPermissionAuditParams,
|
||||
GetGroupPermissionAuditParams,
|
||||
GetPushVapidPublicKey200,
|
||||
GetRolePermissionAuditParams,
|
||||
GetUserPermissionAuditParams,
|
||||
Group,
|
||||
@@ -106,7 +108,10 @@ import type {
|
||||
ServiceDeletionConflict,
|
||||
ServiceDependentOrdersPage,
|
||||
ServiceOrder,
|
||||
SubscribePushBody,
|
||||
SuccessResponse,
|
||||
SystemVersionResponse,
|
||||
UnsubscribePushBody,
|
||||
UpdateAppBody,
|
||||
UpdateAppSettingsBody,
|
||||
UpdateClockHour12Body,
|
||||
@@ -213,6 +218,86 @@ export function useHealthCheck<
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns the local build version (read from version.json embedded at
|
||||
build time) and, if `LATEST_VERSION_URL` is configured on the
|
||||
server, the latest published version fetched from that URL. Admin
|
||||
only. Never performs an update — pure read.
|
||||
|
||||
* @summary Get current and latest system version
|
||||
*/
|
||||
export const getGetSystemVersionUrl = () => {
|
||||
return `/api/system/version`;
|
||||
};
|
||||
|
||||
export const getSystemVersion = async (
|
||||
options?: RequestInit,
|
||||
): Promise<SystemVersionResponse> => {
|
||||
return customFetch<SystemVersionResponse>(getGetSystemVersionUrl(), {
|
||||
...options,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetSystemVersionQueryKey = () => {
|
||||
return [`/api/system/version`] as const;
|
||||
};
|
||||
|
||||
export const getGetSystemVersionQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getSystemVersion>>,
|
||||
TError = ErrorType<unknown>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemVersion>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetSystemVersionQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getSystemVersion>>
|
||||
> = ({ signal }) => getSystemVersion({ signal, ...requestOptions });
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemVersion>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetSystemVersionQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getSystemVersion>>
|
||||
>;
|
||||
export type GetSystemVersionQueryError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary Get current and latest system version
|
||||
*/
|
||||
|
||||
export function useGetSystemVersion<
|
||||
TData = Awaited<ReturnType<typeof getSystemVersion>>,
|
||||
TError = ErrorType<unknown>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getSystemVersion>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetSystemVersionQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Register a new user
|
||||
*/
|
||||
@@ -3573,6 +3658,351 @@ export const useMarkAllNotificationsRead = <
|
||||
return useMutation(getMarkAllNotificationsReadMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Get the server's VAPID public key for Web Push subscriptions
|
||||
*/
|
||||
export const getGetPushVapidPublicKeyUrl = () => {
|
||||
return `/api/push/vapid-public-key`;
|
||||
};
|
||||
|
||||
export const getPushVapidPublicKey = async (
|
||||
options?: RequestInit,
|
||||
): Promise<GetPushVapidPublicKey200> => {
|
||||
return customFetch<GetPushVapidPublicKey200>(getGetPushVapidPublicKeyUrl(), {
|
||||
...options,
|
||||
method: "GET",
|
||||
});
|
||||
};
|
||||
|
||||
export const getGetPushVapidPublicKeyQueryKey = () => {
|
||||
return [`/api/push/vapid-public-key`] as const;
|
||||
};
|
||||
|
||||
export const getGetPushVapidPublicKeyQueryOptions = <
|
||||
TData = Awaited<ReturnType<typeof getPushVapidPublicKey>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPushVapidPublicKey>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}) => {
|
||||
const { query: queryOptions, request: requestOptions } = options ?? {};
|
||||
|
||||
const queryKey = queryOptions?.queryKey ?? getGetPushVapidPublicKeyQueryKey();
|
||||
|
||||
const queryFn: QueryFunction<
|
||||
Awaited<ReturnType<typeof getPushVapidPublicKey>>
|
||||
> = ({ signal }) => getPushVapidPublicKey({ signal, ...requestOptions });
|
||||
|
||||
return { queryKey, queryFn, ...queryOptions } as UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPushVapidPublicKey>>,
|
||||
TError,
|
||||
TData
|
||||
> & { queryKey: QueryKey };
|
||||
};
|
||||
|
||||
export type GetPushVapidPublicKeyQueryResult = NonNullable<
|
||||
Awaited<ReturnType<typeof getPushVapidPublicKey>>
|
||||
>;
|
||||
export type GetPushVapidPublicKeyQueryError = ErrorType<ErrorResponse>;
|
||||
|
||||
/**
|
||||
* @summary Get the server's VAPID public key for Web Push subscriptions
|
||||
*/
|
||||
|
||||
export function useGetPushVapidPublicKey<
|
||||
TData = Awaited<ReturnType<typeof getPushVapidPublicKey>>,
|
||||
TError = ErrorType<ErrorResponse>,
|
||||
>(options?: {
|
||||
query?: UseQueryOptions<
|
||||
Awaited<ReturnType<typeof getPushVapidPublicKey>>,
|
||||
TError,
|
||||
TData
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseQueryResult<TData, TError> & { queryKey: QueryKey } {
|
||||
const queryOptions = getGetPushVapidPublicKeyQueryOptions(options);
|
||||
|
||||
const query = useQuery(queryOptions) as UseQueryResult<TData, TError> & {
|
||||
queryKey: QueryKey;
|
||||
};
|
||||
|
||||
return { ...query, queryKey: queryOptions.queryKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* @summary Register a Web Push subscription for the current user
|
||||
*/
|
||||
export const getSubscribePushUrl = () => {
|
||||
return `/api/push/subscribe`;
|
||||
};
|
||||
|
||||
export const subscribePush = async (
|
||||
subscribePushBody: SubscribePushBody,
|
||||
options?: RequestInit,
|
||||
): Promise<SuccessResponse> => {
|
||||
return customFetch<SuccessResponse>(getSubscribePushUrl(), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(subscribePushBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getSubscribePushMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof subscribePush>>,
|
||||
TError,
|
||||
{ data: BodyType<SubscribePushBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof subscribePush>>,
|
||||
TError,
|
||||
{ data: BodyType<SubscribePushBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["subscribePush"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof subscribePush>>,
|
||||
{ data: BodyType<SubscribePushBody> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return subscribePush(data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type SubscribePushMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof subscribePush>>
|
||||
>;
|
||||
export type SubscribePushMutationBody = BodyType<SubscribePushBody>;
|
||||
export type SubscribePushMutationError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary Register a Web Push subscription for the current user
|
||||
*/
|
||||
export const useSubscribePush = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof subscribePush>>,
|
||||
TError,
|
||||
{ data: BodyType<SubscribePushBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof subscribePush>>,
|
||||
TError,
|
||||
{ data: BodyType<SubscribePushBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getSubscribePushMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Remove a Web Push subscription by endpoint (spec-aligned alias of POST /push/unsubscribe)
|
||||
*/
|
||||
export const getDeletePushSubscriptionUrl = (
|
||||
params: DeletePushSubscriptionParams,
|
||||
) => {
|
||||
const normalizedParams = new URLSearchParams();
|
||||
|
||||
Object.entries(params || {}).forEach(([key, value]) => {
|
||||
if (value !== undefined) {
|
||||
normalizedParams.append(key, value === null ? "null" : value.toString());
|
||||
}
|
||||
});
|
||||
|
||||
const stringifiedParams = normalizedParams.toString();
|
||||
|
||||
return stringifiedParams.length > 0
|
||||
? `/api/push/subscribe?${stringifiedParams}`
|
||||
: `/api/push/subscribe`;
|
||||
};
|
||||
|
||||
export const deletePushSubscription = async (
|
||||
params: DeletePushSubscriptionParams,
|
||||
options?: RequestInit,
|
||||
): Promise<SuccessResponse> => {
|
||||
return customFetch<SuccessResponse>(getDeletePushSubscriptionUrl(params), {
|
||||
...options,
|
||||
method: "DELETE",
|
||||
});
|
||||
};
|
||||
|
||||
export const getDeletePushSubscriptionMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deletePushSubscription>>,
|
||||
TError,
|
||||
{ params: DeletePushSubscriptionParams },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deletePushSubscription>>,
|
||||
TError,
|
||||
{ params: DeletePushSubscriptionParams },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["deletePushSubscription"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof deletePushSubscription>>,
|
||||
{ params: DeletePushSubscriptionParams }
|
||||
> = (props) => {
|
||||
const { params } = props ?? {};
|
||||
|
||||
return deletePushSubscription(params, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type DeletePushSubscriptionMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof deletePushSubscription>>
|
||||
>;
|
||||
|
||||
export type DeletePushSubscriptionMutationError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary Remove a Web Push subscription by endpoint (spec-aligned alias of POST /push/unsubscribe)
|
||||
*/
|
||||
export const useDeletePushSubscription = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof deletePushSubscription>>,
|
||||
TError,
|
||||
{ params: DeletePushSubscriptionParams },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof deletePushSubscription>>,
|
||||
TError,
|
||||
{ params: DeletePushSubscriptionParams },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getDeletePushSubscriptionMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary Remove a Web Push subscription for the current user
|
||||
*/
|
||||
export const getUnsubscribePushUrl = () => {
|
||||
return `/api/push/unsubscribe`;
|
||||
};
|
||||
|
||||
export const unsubscribePush = async (
|
||||
unsubscribePushBody: UnsubscribePushBody,
|
||||
options?: RequestInit,
|
||||
): Promise<SuccessResponse> => {
|
||||
return customFetch<SuccessResponse>(getUnsubscribePushUrl(), {
|
||||
...options,
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", ...options?.headers },
|
||||
body: JSON.stringify(unsubscribePushBody),
|
||||
});
|
||||
};
|
||||
|
||||
export const getUnsubscribePushMutationOptions = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unsubscribePush>>,
|
||||
TError,
|
||||
{ data: BodyType<UnsubscribePushBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unsubscribePush>>,
|
||||
TError,
|
||||
{ data: BodyType<UnsubscribePushBody> },
|
||||
TContext
|
||||
> => {
|
||||
const mutationKey = ["unsubscribePush"];
|
||||
const { mutation: mutationOptions, request: requestOptions } = options
|
||||
? options.mutation &&
|
||||
"mutationKey" in options.mutation &&
|
||||
options.mutation.mutationKey
|
||||
? options
|
||||
: { ...options, mutation: { ...options.mutation, mutationKey } }
|
||||
: { mutation: { mutationKey }, request: undefined };
|
||||
|
||||
const mutationFn: MutationFunction<
|
||||
Awaited<ReturnType<typeof unsubscribePush>>,
|
||||
{ data: BodyType<UnsubscribePushBody> }
|
||||
> = (props) => {
|
||||
const { data } = props ?? {};
|
||||
|
||||
return unsubscribePush(data, requestOptions);
|
||||
};
|
||||
|
||||
return { mutationFn, ...mutationOptions };
|
||||
};
|
||||
|
||||
export type UnsubscribePushMutationResult = NonNullable<
|
||||
Awaited<ReturnType<typeof unsubscribePush>>
|
||||
>;
|
||||
export type UnsubscribePushMutationBody = BodyType<UnsubscribePushBody>;
|
||||
export type UnsubscribePushMutationError = ErrorType<unknown>;
|
||||
|
||||
/**
|
||||
* @summary Remove a Web Push subscription for the current user
|
||||
*/
|
||||
export const useUnsubscribePush = <
|
||||
TError = ErrorType<unknown>,
|
||||
TContext = unknown,
|
||||
>(options?: {
|
||||
mutation?: UseMutationOptions<
|
||||
Awaited<ReturnType<typeof unsubscribePush>>,
|
||||
TError,
|
||||
{ data: BodyType<UnsubscribePushBody> },
|
||||
TContext
|
||||
>;
|
||||
request?: SecondParameter<typeof customFetch>;
|
||||
}): UseMutationResult<
|
||||
Awaited<ReturnType<typeof unsubscribePush>>,
|
||||
TError,
|
||||
{ data: BodyType<UnsubscribePushBody> },
|
||||
TContext
|
||||
> => {
|
||||
return useMutation(getUnsubscribePushMutationOptions(options));
|
||||
};
|
||||
|
||||
/**
|
||||
* @summary List all users (admin)
|
||||
*/
|
||||
|
||||
@@ -30,6 +30,8 @@ tags:
|
||||
description: Object storage upload and serving endpoints
|
||||
- name: audit
|
||||
description: Admin audit log
|
||||
- name: system
|
||||
description: System metadata (version, update availability)
|
||||
|
||||
paths:
|
||||
/healthz:
|
||||
@@ -45,6 +47,24 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/HealthStatus"
|
||||
|
||||
/system/version:
|
||||
get:
|
||||
operationId: getSystemVersion
|
||||
tags: [system]
|
||||
summary: Get current and latest system version
|
||||
description: |
|
||||
Returns the local build version (read from version.json embedded at
|
||||
build time) and, if `LATEST_VERSION_URL` is configured on the
|
||||
server, the latest published version fetched from that URL. Admin
|
||||
only. Never performs an update — pure read.
|
||||
responses:
|
||||
"200":
|
||||
description: System version info
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SystemVersionResponse"
|
||||
|
||||
# Auth
|
||||
/auth/register:
|
||||
post:
|
||||
@@ -972,6 +992,101 @@ paths:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SuccessResponse"
|
||||
|
||||
/push/vapid-public-key:
|
||||
get:
|
||||
operationId: getPushVapidPublicKey
|
||||
tags: [notifications]
|
||||
summary: Get the server's VAPID public key for Web Push subscriptions
|
||||
responses:
|
||||
"200":
|
||||
description: VAPID public key
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
publicKey:
|
||||
type: string
|
||||
required: [publicKey]
|
||||
"503":
|
||||
description: Web Push not configured on this server
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/ErrorResponse"
|
||||
|
||||
/push/subscribe:
|
||||
post:
|
||||
operationId: subscribePush
|
||||
tags: [notifications]
|
||||
summary: Register a Web Push subscription for the current user
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
endpoint:
|
||||
type: string
|
||||
keys:
|
||||
type: object
|
||||
properties:
|
||||
p256dh:
|
||||
type: string
|
||||
auth:
|
||||
type: string
|
||||
required: [p256dh, auth]
|
||||
required: [endpoint, keys]
|
||||
responses:
|
||||
"200":
|
||||
description: Subscribed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SuccessResponse"
|
||||
delete:
|
||||
operationId: deletePushSubscription
|
||||
tags: [notifications]
|
||||
summary: Remove a Web Push subscription by endpoint (spec-aligned alias of POST /push/unsubscribe)
|
||||
parameters:
|
||||
- in: query
|
||||
name: endpoint
|
||||
required: true
|
||||
schema:
|
||||
type: string
|
||||
description: The push endpoint URL to unsubscribe
|
||||
responses:
|
||||
"200":
|
||||
description: Unsubscribed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SuccessResponse"
|
||||
|
||||
/push/unsubscribe:
|
||||
post:
|
||||
operationId: unsubscribePush
|
||||
tags: [notifications]
|
||||
summary: Remove a Web Push subscription for the current user
|
||||
requestBody:
|
||||
required: true
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
type: object
|
||||
properties:
|
||||
endpoint:
|
||||
type: string
|
||||
required: [endpoint]
|
||||
responses:
|
||||
"200":
|
||||
description: Unsubscribed
|
||||
content:
|
||||
application/json:
|
||||
schema:
|
||||
$ref: "#/components/schemas/SuccessResponse"
|
||||
|
||||
# Users (admin)
|
||||
/users:
|
||||
get:
|
||||
@@ -2832,6 +2947,44 @@ components:
|
||||
required:
|
||||
- status
|
||||
|
||||
SystemVersionInfo:
|
||||
type: object
|
||||
properties:
|
||||
version:
|
||||
type: string
|
||||
build:
|
||||
type: string
|
||||
required:
|
||||
- version
|
||||
- build
|
||||
|
||||
SystemVersionResponse:
|
||||
type: object
|
||||
properties:
|
||||
current:
|
||||
$ref: "#/components/schemas/SystemVersionInfo"
|
||||
latest:
|
||||
oneOf:
|
||||
- $ref: "#/components/schemas/SystemVersionInfo"
|
||||
- type: "null"
|
||||
status:
|
||||
type: string
|
||||
enum: [up-to-date, update-available, not-configured, error]
|
||||
errorMessage:
|
||||
type: ["string", "null"]
|
||||
checkedAt:
|
||||
type: ["string", "null"]
|
||||
format: date-time
|
||||
configured:
|
||||
type: boolean
|
||||
required:
|
||||
- current
|
||||
- latest
|
||||
- status
|
||||
- errorMessage
|
||||
- checkedAt
|
||||
- configured
|
||||
|
||||
ErrorResponse:
|
||||
type: object
|
||||
properties:
|
||||
|
||||
@@ -14,6 +14,37 @@ export const HealthCheckResponse = zod.object({
|
||||
status: zod.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* Returns the local build version (read from version.json embedded at
|
||||
build time) and, if `LATEST_VERSION_URL` is configured on the
|
||||
server, the latest published version fetched from that URL. Admin
|
||||
only. Never performs an update — pure read.
|
||||
|
||||
* @summary Get current and latest system version
|
||||
*/
|
||||
export const GetSystemVersionResponse = zod.object({
|
||||
current: zod.object({
|
||||
version: zod.string(),
|
||||
build: zod.string(),
|
||||
}),
|
||||
latest: zod.union([
|
||||
zod.object({
|
||||
version: zod.string(),
|
||||
build: zod.string(),
|
||||
}),
|
||||
zod.null(),
|
||||
]),
|
||||
status: zod.enum([
|
||||
"up-to-date",
|
||||
"update-available",
|
||||
"not-configured",
|
||||
"error",
|
||||
]),
|
||||
errorMessage: zod.string().nullable(),
|
||||
checkedAt: zod.coerce.date().nullable(),
|
||||
configured: zod.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Register a new user
|
||||
*/
|
||||
@@ -1458,6 +1489,52 @@ export const MarkAllNotificationsReadResponse = zod.object({
|
||||
success: zod.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Get the server's VAPID public key for Web Push subscriptions
|
||||
*/
|
||||
export const GetPushVapidPublicKeyResponse = zod.object({
|
||||
publicKey: zod.string(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Register a Web Push subscription for the current user
|
||||
*/
|
||||
export const SubscribePushBody = zod.object({
|
||||
endpoint: zod.string(),
|
||||
keys: zod.object({
|
||||
p256dh: zod.string(),
|
||||
auth: zod.string(),
|
||||
}),
|
||||
});
|
||||
|
||||
export const SubscribePushResponse = zod.object({
|
||||
success: zod.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Remove a Web Push subscription by endpoint (spec-aligned alias of POST /push/unsubscribe)
|
||||
*/
|
||||
export const DeletePushSubscriptionQueryParams = zod.object({
|
||||
endpoint: zod.coerce
|
||||
.string()
|
||||
.describe("The push endpoint URL to unsubscribe"),
|
||||
});
|
||||
|
||||
export const DeletePushSubscriptionResponse = zod.object({
|
||||
success: zod.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary Remove a Web Push subscription for the current user
|
||||
*/
|
||||
export const UnsubscribePushBody = zod.object({
|
||||
endpoint: zod.string(),
|
||||
});
|
||||
|
||||
export const UnsubscribePushResponse = zod.object({
|
||||
success: zod.boolean(),
|
||||
});
|
||||
|
||||
/**
|
||||
* @summary List all users (admin)
|
||||
*/
|
||||
|
||||
@@ -252,6 +252,11 @@ export const executiveMeetingAlertStateTable = pgTable(
|
||||
.references(() => usersTable.id, { onDelete: "cascade" }),
|
||||
dismissed: boolean("dismissed").notNull().default(false),
|
||||
acknowledged: boolean("acknowledged").notNull().default(false),
|
||||
// #558: Set once by the server-side scheduler the moment a Web
|
||||
// Push reminder has been fired for this (meeting, user) pair, so a
|
||||
// single tick (or any later tick that re-runs the same query) does
|
||||
// not double-ring the iPad. NULL = no push has been sent yet.
|
||||
pushedAt: timestamp("pushed_at", { withTimezone: true }),
|
||||
updatedAt: timestamp("updated_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow()
|
||||
|
||||
@@ -14,3 +14,4 @@ export * from "./audit-logs";
|
||||
export * from "./role-audit";
|
||||
export * from "./permission-audit";
|
||||
export * from "./executive-meetings";
|
||||
export * from "./push-subscriptions";
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import {
|
||||
pgTable,
|
||||
serial,
|
||||
integer,
|
||||
text,
|
||||
varchar,
|
||||
timestamp,
|
||||
index,
|
||||
} from "drizzle-orm/pg-core";
|
||||
import { usersTable } from "./users";
|
||||
|
||||
export const pushSubscriptionsTable = pgTable(
|
||||
"push_subscriptions",
|
||||
{
|
||||
id: serial("id").primaryKey(),
|
||||
userId: integer("user_id")
|
||||
.notNull()
|
||||
.references(() => usersTable.id, { onDelete: "cascade" }),
|
||||
endpoint: text("endpoint").notNull().unique(),
|
||||
p256dh: text("p256dh").notNull(),
|
||||
auth: text("auth").notNull(),
|
||||
userAgent: varchar("user_agent", { length: 400 }),
|
||||
createdAt: timestamp("created_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
lastUsedAt: timestamp("last_used_at", { withTimezone: true })
|
||||
.notNull()
|
||||
.defaultNow(),
|
||||
},
|
||||
(t) => ({
|
||||
userIdx: index("push_subscriptions_user_idx").on(t.userId),
|
||||
}),
|
||||
);
|
||||
|
||||
export type PushSubscriptionRow = typeof pushSubscriptionsTable.$inferSelect;
|
||||
@@ -4,10 +4,12 @@
|
||||
"license": "MIT",
|
||||
"scripts": {
|
||||
"preinstall": "sh -c 'rm -f package-lock.json yarn.lock; case \"$npm_config_user_agent\" in pnpm/*) ;; *) echo \"Use pnpm instead\" >&2; exit 1 ;; esac'",
|
||||
"update-version": "node scripts/update-version.mjs",
|
||||
"prebuild": "pnpm run update-version",
|
||||
"build": "pnpm run typecheck && pnpm -r --if-present run build",
|
||||
"typecheck:libs": "tsc --build",
|
||||
"typecheck": "pnpm run typecheck:libs && pnpm -r --filter \"./artifacts/**\" --filter \"./scripts\" --if-present run typecheck",
|
||||
"test": "pnpm --filter @workspace/api-server test && pnpm --filter @workspace/scripts test && pnpm --filter @workspace/tx-os test:e2e"
|
||||
"test": "node artifacts/api-server/scripts/with-server.mjs -- sh -c 'pnpm --filter @workspace/api-server run test:run && pnpm --filter @workspace/scripts test && pnpm --filter @workspace/tx-os test:e2e'"
|
||||
},
|
||||
"private": true,
|
||||
"devDependencies": {
|
||||
|
||||
@@ -46,7 +46,7 @@ catalogs:
|
||||
specifier: ^4.1.14
|
||||
version: 4.2.1
|
||||
tsx:
|
||||
specifier: ^4.21.0
|
||||
specifier: 4.21.0
|
||||
version: 4.21.0
|
||||
vite:
|
||||
specifier: ^7.3.2
|
||||
@@ -135,9 +135,15 @@ importers:
|
||||
sanitize-html:
|
||||
specifier: ^2.17.3
|
||||
version: 2.17.3
|
||||
semver:
|
||||
specifier: ^7.6.3
|
||||
version: 7.8.0
|
||||
socket.io:
|
||||
specifier: ^4.8.3
|
||||
version: 4.8.3
|
||||
web-push:
|
||||
specifier: ^3.6.7
|
||||
version: 3.6.7
|
||||
zod:
|
||||
specifier: 'catalog:'
|
||||
version: 3.25.76
|
||||
@@ -172,6 +178,12 @@ importers:
|
||||
'@types/sanitize-html':
|
||||
specifier: ^2.16.1
|
||||
version: 2.16.1
|
||||
'@types/semver':
|
||||
specifier: ^7.5.8
|
||||
version: 7.7.1
|
||||
'@types/web-push':
|
||||
specifier: ^3.6.4
|
||||
version: 3.6.4
|
||||
esbuild:
|
||||
specifier: ^0.27.3
|
||||
version: 0.27.3
|
||||
@@ -190,6 +202,9 @@ importers:
|
||||
thread-stream:
|
||||
specifier: 3.1.0
|
||||
version: 3.1.0
|
||||
tsx:
|
||||
specifier: 'catalog:'
|
||||
version: 4.21.0
|
||||
|
||||
artifacts/mockup-sandbox:
|
||||
devDependencies:
|
||||
@@ -2987,6 +3002,9 @@ packages:
|
||||
'@types/sanitize-html@2.16.1':
|
||||
resolution: {integrity: sha512-n9wjs8bCOTyN/ynwD8s/nTcTreIHB1vf31vhLMGqUPNHaweKC4/fAl4Dj+hUlCTKYgm4P3k83fmiFfzkZ6sgMA==}
|
||||
|
||||
'@types/semver@7.7.1':
|
||||
resolution: {integrity: sha512-FmgJfu+MOcQ370SD0ev7EI8TlCAfKYU+B4m5T3yXc1CiRN94g/SZPtsCkk506aUDtlMnFZvasDwHHUcZUEaYuA==}
|
||||
|
||||
'@types/send@1.2.1':
|
||||
resolution: {integrity: sha512-arsCikDvlU99zl1g69TcAB3mzZPpxgw0UQnaHeC1Nwb015xp8bknZv5rIfri9xTOcMuaVgvabfIRA7PSZVuZIQ==}
|
||||
|
||||
@@ -3002,6 +3020,9 @@ packages:
|
||||
'@types/use-sync-external-store@0.0.6':
|
||||
resolution: {integrity: sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==}
|
||||
|
||||
'@types/web-push@3.6.4':
|
||||
resolution: {integrity: sha512-GnJmSr40H3RAnj0s34FNTcJi1hmWFV5KXugE0mYWnYhgTAHLJ/dJKAwDmvPJYMke0RplY2XE9LnM4hqSqKIjhQ==}
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
resolution: {integrity: sha512-ThVF6DCVhA8kUGy+aazFQ4kXQ7E1Ty7A3ypFOe0IcJV8O/M511G99AW24irKrW56Wt44yG9+ij8FaqoBGkuBXg==}
|
||||
|
||||
@@ -3093,6 +3114,10 @@ packages:
|
||||
engines: {node: '>=0.4.0'}
|
||||
hasBin: true
|
||||
|
||||
agent-base@7.1.4:
|
||||
resolution: {integrity: sha512-MnA+YT8fwfJPgBx3m60MNqakm30XOkyIoH1y6huTQvC0PwZG7ki8NacLBcrPbNoo8vEZy7Jpuk7+jMO+CUovTQ==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
ajv-draft-04@1.0.0:
|
||||
resolution: {integrity: sha512-mv00Te6nmYbRp5DCwclxtt7yV/joXJPGS7nM+97GdxvuttCOfgI3K4U25zboyeX0O+myI8ERluxQe5wljMmVIw==}
|
||||
peerDependencies:
|
||||
@@ -3125,6 +3150,9 @@ packages:
|
||||
resolution: {integrity: sha512-ik3ZgC9dY/lYVVM++OISsaYDeg1tb0VtP5uL3ouh1koGOaUMDPpbFIei4JkFimWUFPn90sbMNMXQAIVOlnYKJA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
asn1.js@5.4.1:
|
||||
resolution: {integrity: sha512-+I//4cYPccV8LdmBLiX8CYvf9Sp3vQsrqu2QNXRcrbiWvcx/UdlFiqUJJzxRQxgsZmvhXhn4cSKeSmoFjVdupA==}
|
||||
|
||||
atomic-sleep@1.0.0:
|
||||
resolution: {integrity: sha512-kNOjDqAh7px0XWNI+4QbzoiR/nTkHAWNud2uvnJquD1/x5a7EQZMJT0AczqK0Qn67oY/TTQ1LbUKajZpp3I9tQ==}
|
||||
engines: {node: '>=8.0.0'}
|
||||
@@ -3155,6 +3183,9 @@ packages:
|
||||
bidi-js@1.0.3:
|
||||
resolution: {integrity: sha512-RKshQI1R3YQ+n9YJz2QQ147P66ELpa1FQEg20Dk8oW9t2KgLbpDLLp9aGZ7y8WHSshDknG0bknqGw5/tyCs5tw==}
|
||||
|
||||
bn.js@4.12.3:
|
||||
resolution: {integrity: sha512-fGTi3gxV/23FTYdAoUtLYp6qySe2KE3teyZitipKNRuVYcBkoP/bB3guXN/XVKUe9mxCHXnc9C4ocyz8OmgN0g==}
|
||||
|
||||
body-parser@2.2.2:
|
||||
resolution: {integrity: sha512-oP5VkATKlNwcgvxi0vM0p/D3n2C3EReYVX+DNYs5TjZFn/oQt2j+4sVJtSMr18pdRr8wjTcBl6LoV+FUwzPmNA==}
|
||||
engines: {node: '>=18'}
|
||||
@@ -3180,6 +3211,9 @@ packages:
|
||||
engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7}
|
||||
hasBin: true
|
||||
|
||||
buffer-equal-constant-time@1.0.1:
|
||||
resolution: {integrity: sha512-zRpUiDwd/xk6ADqPMATG8vc9VPrkck7T07OIx0gnjmJAnHnTVXNQG3vfvWNuiZIkwu9KrKdA1iJKfsfTVxE6NA==}
|
||||
|
||||
buffer-from@1.1.2:
|
||||
resolution: {integrity: sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==}
|
||||
|
||||
@@ -3508,6 +3542,9 @@ packages:
|
||||
resolution: {integrity: sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
resolution: {integrity: sha512-nagl3RYrbNv6kQkeJIpt6NJZy8twLB/2vtz6yN9Z4vRKHN4/QZJIEbqohALSgwKdnksuY3k5Addp5lg8sVoVcQ==}
|
||||
|
||||
ee-first@1.1.1:
|
||||
resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==}
|
||||
|
||||
@@ -3812,6 +3849,14 @@ packages:
|
||||
resolution: {integrity: sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ==}
|
||||
engines: {node: '>= 0.8'}
|
||||
|
||||
http_ece@1.2.0:
|
||||
resolution: {integrity: sha512-JrF8SSLVmcvc5NducxgyOrKXe3EsyHMgBFgSaIUGmArKe+rwr0uphRkRXvwiom3I+fpIfoItveHrfudL8/rxuA==}
|
||||
engines: {node: '>=16'}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
resolution: {integrity: sha512-vK9P5/iUfdl95AI+JVyUuIcVtd4ofvtrOr3HNtM2yxC9bnMbEdp3x01OhQNnjb8IJYi38VlTE3mBXwcfvywuSw==}
|
||||
engines: {node: '>= 14'}
|
||||
|
||||
human-signals@8.0.1:
|
||||
resolution: {integrity: sha512-eKCa6bwnJhvxj14kZk5NCPc6Hb6BdsU9DZcOnmQKSnO1VKrfV0zCvtttPZUsBvjmNDn8rpcJfpwSYnHBjc95MQ==}
|
||||
engines: {node: '>=18.18.0'}
|
||||
@@ -3933,6 +3978,12 @@ packages:
|
||||
resolution: {integrity: sha512-p/nXbhSEcu3pZRdkW1OfJhpsVtW1gd4Wa1fnQc9YLiTfAjn0312eMKimbdIQzuZl9aa9xUGaRlP9T/CJE/ditQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
jwa@2.0.1:
|
||||
resolution: {integrity: sha512-hRF04fqJIP8Abbkq5NKGN0Bbr3JxlQ+qhZufXVr0DvujKy93ZCbXZMHDL4EOtodSbCWxOqR8MS1tXA5hwqCXDg==}
|
||||
|
||||
jws@4.0.1:
|
||||
resolution: {integrity: sha512-EKI/M/yqPncGUUh44xz0PxSidXFr/+r0pA70+gIYhjv+et7yxM+s29Y+VGDkovRofQem0fs7Uvf4+YmAdyRduA==}
|
||||
|
||||
leven@4.1.0:
|
||||
resolution: {integrity: sha512-KZ9W9nWDT7rF7Dazg8xyLHGLrmpgq2nVNFUckhqdW3szVP6YhCpp/RAnpmVExA9JvrMynjwSLVrEj3AepHR6ew==}
|
||||
engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0}
|
||||
@@ -4091,6 +4142,9 @@ packages:
|
||||
resolution: {integrity: sha512-Lbgzdk0h4juoQ9fCKXW4by0UJqj+nOOrI9MJ1sSj4nI8aI2eo1qmvQEie4VD1glsS250n15LsWsYtCugiStS5A==}
|
||||
engines: {node: '>=18'}
|
||||
|
||||
minimalistic-assert@1.0.1:
|
||||
resolution: {integrity: sha512-UtJcAD4yEaGtjPezWuO9wC4nwUnVH/8/Im3yEHQP4b67cXlD/Qr9hdITCU1xDbSEXg2XKNaP8jsReV7vQd00/A==}
|
||||
|
||||
minimatch@9.0.9:
|
||||
resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==}
|
||||
engines: {node: '>=16 || 14 >=14.17'}
|
||||
@@ -4623,6 +4677,11 @@ packages:
|
||||
resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==}
|
||||
hasBin: true
|
||||
|
||||
semver@7.8.0:
|
||||
resolution: {integrity: sha512-AcM7dV/5ul4EekoQ29Agm5vri8JNqRyj39o0qpX6vDF2GZrtutZl5RwgD1XnZjiTAfncsJhMI48QQH3sN87YNA==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
send@1.2.1:
|
||||
resolution: {integrity: sha512-1gnZf7DFcoIcajTjTwjwuDjzuz4PPcY2StKPlsGAQ1+YH20IRVrBaXSWmdjowTJ6u8Rc01PoYOGHXfP1mYcZNQ==}
|
||||
engines: {node: '>= 18'}
|
||||
@@ -4942,6 +5001,11 @@ packages:
|
||||
w3c-keyname@2.2.8:
|
||||
resolution: {integrity: sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==}
|
||||
|
||||
web-push@3.6.7:
|
||||
resolution: {integrity: sha512-OpiIUe8cuGjrj3mMBFWY+e4MMIkW3SVT+7vEIjvD9kejGUypv8GPDf84JdPWskK8zMRIJ6xYGm+Kxr8YkPyA0A==}
|
||||
engines: {node: '>= 16'}
|
||||
hasBin: true
|
||||
|
||||
which@2.0.2:
|
||||
resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -7490,6 +7554,8 @@ snapshots:
|
||||
dependencies:
|
||||
htmlparser2: 10.1.0
|
||||
|
||||
'@types/semver@7.7.1': {}
|
||||
|
||||
'@types/send@1.2.1':
|
||||
dependencies:
|
||||
'@types/node': 25.3.5
|
||||
@@ -7506,6 +7572,10 @@ snapshots:
|
||||
|
||||
'@types/use-sync-external-store@0.0.6': {}
|
||||
|
||||
'@types/web-push@3.6.4':
|
||||
dependencies:
|
||||
'@types/node': 25.3.5
|
||||
|
||||
'@types/ws@8.18.1':
|
||||
dependencies:
|
||||
'@types/node': 25.3.5
|
||||
@@ -7615,6 +7685,8 @@ snapshots:
|
||||
|
||||
acorn@8.16.0: {}
|
||||
|
||||
agent-base@7.1.4: {}
|
||||
|
||||
ajv-draft-04@1.0.0(ajv@8.18.0):
|
||||
optionalDependencies:
|
||||
ajv: 8.18.0
|
||||
@@ -7642,6 +7714,13 @@ snapshots:
|
||||
dependencies:
|
||||
tslib: 2.8.1
|
||||
|
||||
asn1.js@5.4.1:
|
||||
dependencies:
|
||||
bn.js: 4.12.3
|
||||
inherits: 2.0.4
|
||||
minimalistic-assert: 1.0.1
|
||||
safer-buffer: 2.1.2
|
||||
|
||||
atomic-sleep@1.0.0: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
@@ -7660,6 +7739,8 @@ snapshots:
|
||||
dependencies:
|
||||
require-from-string: 2.0.2
|
||||
|
||||
bn.js@4.12.3: {}
|
||||
|
||||
body-parser@2.2.2:
|
||||
dependencies:
|
||||
bytes: 3.1.2
|
||||
@@ -7700,6 +7781,8 @@ snapshots:
|
||||
node-releases: 2.0.36
|
||||
update-browserslist-db: 1.2.3(browserslist@4.28.1)
|
||||
|
||||
buffer-equal-constant-time@1.0.1: {}
|
||||
|
||||
buffer-from@1.1.2: {}
|
||||
|
||||
bytes@3.1.2: {}
|
||||
@@ -7912,6 +7995,10 @@ snapshots:
|
||||
es-errors: 1.3.0
|
||||
gopd: 1.2.0
|
||||
|
||||
ecdsa-sig-formatter@1.0.11:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
ee-first@1.1.1: {}
|
||||
|
||||
electron-to-chromium@1.5.307: {}
|
||||
@@ -8344,6 +8431,15 @@ snapshots:
|
||||
statuses: 2.0.2
|
||||
toidentifier: 1.0.1
|
||||
|
||||
http_ece@1.2.0: {}
|
||||
|
||||
https-proxy-agent@7.0.6:
|
||||
dependencies:
|
||||
agent-base: 7.1.4
|
||||
debug: 4.4.3
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
human-signals@8.0.1: {}
|
||||
|
||||
i18next@26.0.6(typescript@5.9.3):
|
||||
@@ -8421,6 +8517,17 @@ snapshots:
|
||||
|
||||
jsonpointer@5.0.1: {}
|
||||
|
||||
jwa@2.0.1:
|
||||
dependencies:
|
||||
buffer-equal-constant-time: 1.0.1
|
||||
ecdsa-sig-formatter: 1.0.11
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
jws@4.0.1:
|
||||
dependencies:
|
||||
jwa: 2.0.1
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
leven@4.1.0: {}
|
||||
|
||||
lightningcss-android-arm64@1.31.1:
|
||||
@@ -8547,6 +8654,8 @@ snapshots:
|
||||
dependencies:
|
||||
mime-db: 1.54.0
|
||||
|
||||
minimalistic-assert@1.0.1: {}
|
||||
|
||||
minimatch@9.0.9:
|
||||
dependencies:
|
||||
brace-expansion: 2.0.2
|
||||
@@ -9128,6 +9237,8 @@ snapshots:
|
||||
|
||||
semver@6.3.1: {}
|
||||
|
||||
semver@7.8.0: {}
|
||||
|
||||
send@1.2.1:
|
||||
dependencies:
|
||||
debug: 4.4.3
|
||||
@@ -9440,6 +9551,16 @@ snapshots:
|
||||
|
||||
w3c-keyname@2.2.8: {}
|
||||
|
||||
web-push@3.6.7:
|
||||
dependencies:
|
||||
asn1.js: 5.4.1
|
||||
http_ece: 1.2.0
|
||||
https-proxy-agent: 7.0.6
|
||||
jws: 4.0.1
|
||||
minimist: 1.2.8
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
which@2.0.2:
|
||||
dependencies:
|
||||
isexe: 2.0.0
|
||||
|
||||
@@ -22,7 +22,7 @@ catalog:
|
||||
react-dom: 19.1.0
|
||||
tailwind-merge: ^3.3.1
|
||||
tailwindcss: ^4.1.14
|
||||
tsx: ^4.21.0
|
||||
tsx: 4.21.0
|
||||
vite: ^7.3.2
|
||||
zod: 3.25.76
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@
|
||||
"hello": "tsx ./src/hello.ts",
|
||||
"seed": "tsx ./src/seed.ts",
|
||||
"remove-chat": "tsx ./src/remove-chat.ts",
|
||||
"enable-default-apps": "tsx ./src/enable-default-apps.ts",
|
||||
"typecheck": "tsc -p tsconfig.json --noEmit",
|
||||
"test": "node --test 'tests/**/*.test.mjs'"
|
||||
},
|
||||
|
||||
@@ -0,0 +1,95 @@
|
||||
#!/usr/bin/env bash
|
||||
# Tx OS — redeploy after `git pull` on the Mac (or any Docker host).
|
||||
#
|
||||
# Why this exists:
|
||||
# The SPA is baked into the `web` (nginx) image at build time, and the
|
||||
# API binary is baked into the `api` image. `docker compose up -d` on
|
||||
# its own re-uses the existing images, so a plain `git pull` will NOT
|
||||
# pick up new front-end assets (e.g. service tile images), code
|
||||
# changes, or DB-seed updates.
|
||||
#
|
||||
# What it does:
|
||||
# 1. git pull — fast-forward the working tree (skipped with --no-pull)
|
||||
# 2. docker compose build api web
|
||||
# — rebuild both images so source + static assets are fresh
|
||||
# 3. docker compose run --rm migrate
|
||||
# — apply Drizzle schema (migrate.sh also runs the seed,
|
||||
# but tolerates seed failures — see step 4)
|
||||
# 4. docker compose run --rm --entrypoint "" --no-deps migrate \
|
||||
# pnpm --filter @workspace/scripts run seed
|
||||
# — explicit seed pass that fails hard if the seed
|
||||
# errors. Catches new-app / renamed-app regressions
|
||||
# that migrate.sh would otherwise swallow.
|
||||
# 5. docker compose up -d
|
||||
# — restart api + web with the new images
|
||||
#
|
||||
# Safe to re-run. Each step is idempotent.
|
||||
|
||||
set -euo pipefail
|
||||
|
||||
PULL=1
|
||||
for arg in "$@"; do
|
||||
case "$arg" in
|
||||
--no-pull) PULL=0 ;;
|
||||
-h|--help)
|
||||
sed -n '2,27p' "$0"
|
||||
exit 0
|
||||
;;
|
||||
*)
|
||||
echo "Unknown argument: $arg" >&2
|
||||
echo "Usage: $0 [--no-pull]" >&2
|
||||
exit 2
|
||||
;;
|
||||
esac
|
||||
done
|
||||
|
||||
# Resolve the repo root so the script works no matter where it's invoked from.
|
||||
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
|
||||
REPO_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
|
||||
cd "$REPO_ROOT"
|
||||
|
||||
if [ ! -f docker-compose.yml ]; then
|
||||
echo "[redeploy] error: docker-compose.yml not found in $REPO_ROOT" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if ! command -v docker >/dev/null 2>&1; then
|
||||
echo "[redeploy] error: docker CLI not found in PATH" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# Pick `docker compose` (v2) and fall back to `docker-compose` (v1) so this
|
||||
# works on older hosts.
|
||||
if docker compose version >/dev/null 2>&1; then
|
||||
DC=(docker compose)
|
||||
elif command -v docker-compose >/dev/null 2>&1; then
|
||||
DC=(docker-compose)
|
||||
else
|
||||
echo "[redeploy] error: neither 'docker compose' nor 'docker-compose' is available" >&2
|
||||
exit 1
|
||||
fi
|
||||
|
||||
step() { echo; echo "==> $*"; }
|
||||
|
||||
if [ "$PULL" -eq 1 ]; then
|
||||
step "1/4 git pull"
|
||||
git pull --ff-only
|
||||
else
|
||||
step "1/4 git pull (skipped via --no-pull)"
|
||||
fi
|
||||
|
||||
step "2/5 docker compose build api web (rebuilds SPA + API images)"
|
||||
"${DC[@]}" build api web
|
||||
|
||||
step "3/5 docker compose run --rm migrate (schema push + tolerant seed)"
|
||||
"${DC[@]}" run --rm migrate
|
||||
|
||||
step "4/5 explicit seed pass (fails hard on seed regressions)"
|
||||
"${DC[@]}" run --rm --no-deps --entrypoint "" migrate \
|
||||
pnpm --filter @workspace/scripts run seed
|
||||
|
||||
step "5/5 docker compose up -d (restart with the new images)"
|
||||
"${DC[@]}" up -d
|
||||
|
||||
echo
|
||||
echo "[redeploy] done. Verify in a browser; hard-refresh once to bust the SPA cache."
|
||||
@@ -0,0 +1,29 @@
|
||||
import { db, appsTable } from "@workspace/db";
|
||||
import { inArray } from "drizzle-orm";
|
||||
|
||||
const SLUGS = ["calendar", "notifications"];
|
||||
|
||||
async function main() {
|
||||
console.log(`Enabling apps: ${SLUGS.join(", ")}...`);
|
||||
const updated = await db
|
||||
.update(appsTable)
|
||||
.set({ isActive: true })
|
||||
.where(inArray(appsTable.slug, SLUGS))
|
||||
.returning({ slug: appsTable.slug, isActive: appsTable.isActive });
|
||||
|
||||
if (updated.length === 0) {
|
||||
console.warn("No matching apps found — did seed run yet?");
|
||||
} else {
|
||||
for (const row of updated) {
|
||||
console.log(` ✓ ${row.slug} (isActive=${row.isActive})`);
|
||||
}
|
||||
}
|
||||
console.log("Done.");
|
||||
}
|
||||
|
||||
main()
|
||||
.catch((err) => {
|
||||
console.error(err);
|
||||
process.exit(1);
|
||||
})
|
||||
.finally(() => process.exit(0));
|
||||
@@ -231,7 +231,14 @@ async function main() {
|
||||
iconName: "Bell",
|
||||
route: "/notifications",
|
||||
color: "#8b5cf6",
|
||||
isActive: true,
|
||||
// #571: ship the Notifications app DISABLED by default so fresh
|
||||
// installs don't surface a redundant tile alongside the bell icon
|
||||
// already in the top bar. The bell stays the canonical entry
|
||||
// point; admins can flip this on from app settings if they want
|
||||
// the launcher tile too. Insert here uses onConflictDoNothing,
|
||||
// so existing environments that already have the app enabled
|
||||
// are NOT affected — only new inserts pick up the default.
|
||||
isActive: false,
|
||||
isSystem: true,
|
||||
sortOrder: 3,
|
||||
},
|
||||
@@ -248,6 +255,19 @@ async function main() {
|
||||
isSystem: true,
|
||||
sortOrder: 4,
|
||||
},
|
||||
{
|
||||
slug: "notes",
|
||||
nameAr: "الملاحظات",
|
||||
nameEn: "Notes",
|
||||
descriptionAr: "ملاحظاتك الشخصية ورسائلك بين الزملاء",
|
||||
descriptionEn: "Personal notes and messages with colleagues",
|
||||
iconName: "StickyNote",
|
||||
route: "/notes",
|
||||
color: "#eab308",
|
||||
isActive: true,
|
||||
isSystem: false,
|
||||
sortOrder: 8,
|
||||
},
|
||||
{
|
||||
slug: "calendar",
|
||||
nameAr: "التقويم",
|
||||
@@ -257,7 +277,11 @@ async function main() {
|
||||
iconName: "Calendar",
|
||||
route: "/calendar",
|
||||
color: "#3b82f6",
|
||||
isActive: true,
|
||||
// #571: ship the Calendar app DISABLED by default. Admins can
|
||||
// enable it from app settings when they actually want a
|
||||
// calendar surface. Insert uses onConflictDoNothing so existing
|
||||
// environments where calendar is already enabled stay enabled.
|
||||
isActive: false,
|
||||
isSystem: false,
|
||||
sortOrder: 5,
|
||||
},
|
||||
@@ -276,10 +300,10 @@ async function main() {
|
||||
},
|
||||
{
|
||||
slug: "executive-meetings",
|
||||
nameAr: "إدارة الاجتماعات التنفيذية",
|
||||
nameEn: "Executive Meetings",
|
||||
descriptionAr: "جدولة اجتماعات المكتب التنفيذي ومتابعتها",
|
||||
descriptionEn: "Schedule and track executive office meetings",
|
||||
nameAr: "الاجتماعات",
|
||||
nameEn: "Meetings",
|
||||
descriptionAr: "جدولة الاجتماعات ومتابعتها",
|
||||
descriptionEn: "Schedule and track meetings",
|
||||
iconName: "CalendarClock",
|
||||
route: "/meetings",
|
||||
color: "#0B1E3F",
|
||||
@@ -321,17 +345,26 @@ async function main() {
|
||||
const insertedApps = await db.insert(appsTable).values(apps).onConflictDoNothing().returning();
|
||||
console.log("Apps created");
|
||||
|
||||
// Force the executive-meetings row onto the canonical /meetings route.
|
||||
// The slug is in BUILTIN_APP_SLUGS, so the SPA owns its route — any
|
||||
// drift in the DB (including legacy /executive-meetings or other
|
||||
// experimental values) would launch the home tile at a path that
|
||||
// has no React route and silently break the app. `onConflictDoNothing`
|
||||
// above skips updating existing rows, so this UPDATE is what carries
|
||||
// the route migration into already-deployed environments. The WHERE
|
||||
// by slug keeps it idempotent across re-seeds.
|
||||
// Force the executive-meetings row onto the canonical /meetings route
|
||||
// AND onto the current display name. The slug is in BUILTIN_APP_SLUGS,
|
||||
// so the SPA owns its route — any drift in the DB (including legacy
|
||||
// /executive-meetings or other experimental values) would launch the
|
||||
// home tile at a path that has no React route and silently break the
|
||||
// app. `onConflictDoNothing` above skips updating existing rows, so
|
||||
// this UPDATE is what carries the route + name migration into
|
||||
// already-deployed environments. The WHERE by slug keeps it
|
||||
// idempotent across re-seeds. Names are kept in sync with the
|
||||
// hardcoded values in the `apps` array above so admins on existing
|
||||
// deployments don't have to rename the tile by hand.
|
||||
await db
|
||||
.update(appsTable)
|
||||
.set({ route: "/meetings" })
|
||||
.set({
|
||||
route: "/meetings",
|
||||
nameAr: "الاجتماعات",
|
||||
nameEn: "Meetings",
|
||||
descriptionAr: "جدولة الاجتماعات ومتابعتها",
|
||||
descriptionEn: "Schedule and track meetings",
|
||||
})
|
||||
.where(eq(appsTable.slug, "executive-meetings"));
|
||||
|
||||
// Restrict admin app to users with apps:manage permission
|
||||
@@ -404,6 +437,7 @@ async function main() {
|
||||
categoryId: beveragesCat?.id ?? null,
|
||||
nameAr: "شاي",
|
||||
nameEn: "Tea",
|
||||
imageUrl: "service-images/tea.png",
|
||||
price: "0.00",
|
||||
isAvailable: true,
|
||||
sortOrder: 1,
|
||||
@@ -421,6 +455,7 @@ async function main() {
|
||||
categoryId: beveragesCat?.id ?? null,
|
||||
nameAr: "بلاك كوفي",
|
||||
nameEn: "Black Coffee",
|
||||
imageUrl: "service-images/black-coffee.png",
|
||||
price: "0.00",
|
||||
isAvailable: true,
|
||||
sortOrder: 3,
|
||||
@@ -429,6 +464,7 @@ async function main() {
|
||||
categoryId: beveragesCat?.id ?? null,
|
||||
nameAr: "عصير طازج",
|
||||
nameEn: "Fresh Juice",
|
||||
imageUrl: "service-images/juice.png",
|
||||
price: "5.00",
|
||||
isAvailable: true,
|
||||
sortOrder: 5,
|
||||
@@ -438,6 +474,23 @@ async function main() {
|
||||
await db.insert(servicesTable).values(services).onConflictDoNothing({ target: servicesTable.nameEn });
|
||||
console.log("Services created");
|
||||
|
||||
// Backfill default imageUrl on existing rows that have a NULL imageUrl
|
||||
// (the insert above is onConflictDoNothing, so existing rows aren't
|
||||
// touched). Only fills NULL — admin-customized URLs are never
|
||||
// overwritten. Re-reads the table so newly-inserted rows are included.
|
||||
const allServicesAfterInsert = await db.select().from(servicesTable);
|
||||
for (const seed of services) {
|
||||
if (!seed.imageUrl) continue;
|
||||
const existing = allServicesAfterInsert.find((s) => s.nameEn === seed.nameEn);
|
||||
if (existing && !existing.imageUrl) {
|
||||
await db
|
||||
.update(servicesTable)
|
||||
.set({ imageUrl: seed.imageUrl })
|
||||
.where(eq(servicesTable.id, existing.id));
|
||||
}
|
||||
}
|
||||
console.log("Service images backfilled for NULL rows");
|
||||
|
||||
// ----- Groups: idempotent migration -----
|
||||
await db
|
||||
.insert(groupsTable)
|
||||
@@ -646,13 +699,74 @@ async function main() {
|
||||
}
|
||||
}
|
||||
|
||||
// Executive meetings: sample data for today (idempotent per-date)
|
||||
// Notes: gate the home-screen icon behind a dedicated permission so a
|
||||
// freshly seeded deployment does NOT auto-show Notes to every regular
|
||||
// user. By default only the `admin` role gets the permission; admins
|
||||
// then grant `notes.access` to whichever roles/users they want from
|
||||
// the admin panel. Same three-step pattern as executive_meetings.access
|
||||
// above — all inserts are idempotent.
|
||||
await db
|
||||
.insert(permissionsTable)
|
||||
.values({
|
||||
name: "notes.access",
|
||||
descriptionAr: "الوصول إلى تطبيق الملاحظات",
|
||||
descriptionEn: "Access the Notes app",
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
|
||||
const [notesAccessPerm] = await db
|
||||
.select()
|
||||
.from(permissionsTable)
|
||||
.where(eq(permissionsTable.name, "notes.access"));
|
||||
|
||||
if (notesAccessPerm) {
|
||||
const adminRoleForNotes = (await db.select().from(rolesTable))
|
||||
.find((r) => r.name === "admin");
|
||||
if (adminRoleForNotes) {
|
||||
await db
|
||||
.insert(rolePermissionsTable)
|
||||
.values({
|
||||
roleId: adminRoleForNotes.id,
|
||||
permissionId: notesAccessPerm.id,
|
||||
})
|
||||
.onConflictDoNothing();
|
||||
}
|
||||
|
||||
const [notesApp] = await db
|
||||
.select()
|
||||
.from(appsTable)
|
||||
.where(eq(appsTable.slug, "notes"));
|
||||
if (notesApp) {
|
||||
await db
|
||||
.insert(appPermissionsTable)
|
||||
.values({ appId: notesApp.id, permissionId: notesAccessPerm.id })
|
||||
.onConflictDoNothing();
|
||||
console.log(
|
||||
"App permissions set: notes app restricted to notes.access permission",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
// Executive meetings: sample data for today (idempotent per-date).
|
||||
//
|
||||
// Opt-in only — a vanilla self-hosted install should start with an
|
||||
// empty Executive Meetings module. Set `SEED_DEMO_MEETINGS=true` in
|
||||
// your environment when bootstrapping a demo / staging deploy that
|
||||
// benefits from realistic-looking sample data.
|
||||
const seedDemoMeetings =
|
||||
(process.env.SEED_DEMO_MEETINGS ?? "").toLowerCase() === "true";
|
||||
const today = new Date().toISOString().slice(0, 10);
|
||||
const existingForToday = await db
|
||||
.select({ id: executiveMeetingsTable.id })
|
||||
.from(executiveMeetingsTable)
|
||||
.where(eq(executiveMeetingsTable.meetingDate, today));
|
||||
if (existingForToday.length === 0) {
|
||||
const existingForToday = seedDemoMeetings
|
||||
? await db
|
||||
.select({ id: executiveMeetingsTable.id })
|
||||
.from(executiveMeetingsTable)
|
||||
.where(eq(executiveMeetingsTable.meetingDate, today))
|
||||
: [];
|
||||
if (!seedDemoMeetings) {
|
||||
console.log(
|
||||
"Executive Meetings demo data skipped (set SEED_DEMO_MEETINGS=true to enable)",
|
||||
);
|
||||
} else if (existingForToday.length === 0) {
|
||||
{
|
||||
const sampleMeetings: Array<{
|
||||
meeting: {
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
#!/usr/bin/env node
|
||||
import { execSync } from "node:child_process";
|
||||
import { readFileSync, writeFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { dirname, resolve } from "node:path";
|
||||
|
||||
const __dirname = dirname(fileURLToPath(import.meta.url));
|
||||
const versionPath = resolve(__dirname, "..", "version.json");
|
||||
|
||||
function git(args) {
|
||||
return execSync(`git ${args}`, { encoding: "utf8" }).trim();
|
||||
}
|
||||
|
||||
let commit = "unknown";
|
||||
let date = new Date().toISOString().slice(0, 10).replace(/-/g, "");
|
||||
try {
|
||||
commit = git("log -1 --format=%h");
|
||||
date = git("log -1 --format=%cd --date=format:%Y%m%d");
|
||||
} catch (err) {
|
||||
console.warn(`[update-version] git unavailable, using fallback: ${err.message}`);
|
||||
}
|
||||
|
||||
const build = `${date}.${commit}`;
|
||||
const current = JSON.parse(readFileSync(versionPath, "utf8"));
|
||||
const next = { ...current, build };
|
||||
|
||||
if (current.build === build) {
|
||||
console.log(`[update-version] build unchanged: ${build}`);
|
||||
} else {
|
||||
writeFileSync(versionPath, JSON.stringify(next, null, 2) + "\n");
|
||||
console.log(`[update-version] ${current.build} -> ${build}`);
|
||||
}
|
||||
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"version": "0.1.0-dev",
|
||||
"build": "20260517.b5efd9eb"
|
||||
}
|
||||