From 00580a98fc44bd988ed1d28b17673291bad0044e Mon Sep 17 00:00:00 2001 From: Wira Basalamah Date: Sat, 6 Jun 2026 20:58:04 +0700 Subject: [PATCH] Prepare Soundbox Ops deployment --- CODEX_HANDOFF.md | 62 +- ...R_SETUP.md => DEBIAN13_APP_SERVER_SETUP.md | 78 +- README.md | 2 +- migrations/004_soundbox_catalog.sql | 66 ++ migrations/005_soundbox_vendor_contacts.sql | 8 + migrations/006_soundbox_model_thumbnail.sql | 5 + src/app.ts | 23 +- src/routes/admin.ts | 273 +++++ src/routes/speaker.ts | 9 +- src/shared/db/pool.ts | 75 ++ src/shared/store/soundboxCatalogStore.ts | 376 ++++++ ui/device-registry-monitoring/index.html | 1025 ++++++++++++++--- ui/device-technical-detail/index.html | 524 ++++++++- ui/shared/admin-api.js | 75 +- ui/soundbox-catalog/index.html | 723 ++++++++++++ ui/soundbox-ops/index.html | 184 ++- 16 files changed, 3238 insertions(+), 270 deletions(-) rename DEBIAN12_APP_SERVER_SETUP.md => DEBIAN13_APP_SERVER_SETUP.md (79%) create mode 100644 migrations/004_soundbox_catalog.sql create mode 100644 migrations/005_soundbox_vendor_contacts.sql create mode 100644 migrations/006_soundbox_model_thumbnail.sql create mode 100644 src/shared/store/soundboxCatalogStore.ts create mode 100644 ui/soundbox-catalog/index.html diff --git a/CODEX_HANDOFF.md b/CODEX_HANDOFF.md index d9bf5d7..0298612 100644 --- a/CODEX_HANDOFF.md +++ b/CODEX_HANDOFF.md @@ -1,12 +1,70 @@ # Codex Handoff - QRIS Soundbox Platform -Tanggal update: 2026-06-03, Asia/Jakarta. +Tanggal update: 2026-06-06, Asia/Jakarta. Dokumen ini adalah snapshot kerja terakhir untuk melanjutkan project tanpa perlu membaca ulang seluruh chat. +## Update Terbaru - 2026-06-06 + +- Fokus produk sekarang: `sms.bizone.id` menjadi portal utama Soundbox Ops / Monitoring, bukan katalog UI atau dashboard admin campuran. +- Default route sudah disesuaikan: + - `GET /` redirect ke `/ui/soundbox-ops`; + - `GET /ui` redirect ke `/ui/soundbox-ops`; + - halaman Soundbox Ops tetap berada di `/ui/soundbox-ops`. +- Server target deploy diganti menjadi Debian 13, memakai user khusus `qrisapp`. +- Dokumen deploy: + - `DEBIAN12_APP_SERVER_SETUP.md` diganti menjadi `DEBIAN13_APP_SERVER_SETUP.md`; + - README sudah menunjuk ke dokumen Debian 13; + - install dari server kosong mencakup package dasar, Node.js 22, PostgreSQL, Nginx, Certbot, UFW, env production, migration, systemd, TLS, dan verification; + - semua proses app/repo/build/migration/service dijalankan sebagai `qrisapp`; + - root/sudo hanya untuk install package, direktori, env, Nginx, systemd. +- Env deploy sekarang mencantumkan `DATABASE_URL` selain `PGHOST/PGPORT/PGUSER/PGPASSWORD/PGDATABASE`, supaya `npm run deploy:check-env` tidak gagal dengan error `DATABASE_URL is required`. +- Endpoint config device sudah men-support: + - `GET /speaker/dev-config`; + - `POST /speaker/dev-config`; + - lookup device berdasarkan `dev-sn` ke `devices.serial_number`; + - device tidak dikunci model tertentu, sehingga tipe device asli bisa diinput lewat master data/model dan matching tetap berdasarkan SN. +- Response config device tetap vendor-compatible: + - `error-code`; + - `mqtt.broker-ip`; + - `mqtt.broker-port`; + - `mqtt.client-id`; + - `mqtt.user-name`; + - `mqtt.password`; + - `mqtt.subscribe-topic`; + - `mqtt.keep-alive`. +- Production target: + - HTTP config URL device: `https://sms.bizone.id/speaker/dev-config`; + - MQTT broker: `broker.bizone.id`; + - MQTT TLS default: `mqtts://broker.bizone.id:8883`; + - jika firmware belum support TLS, siapkan listener non-TLS terbatas atau patch firmware. +- Dashboard Soundbox Ops sudah dirapikan untuk fokus operasional: + - sidebar hanya menu ops: Monitoring, Registry, MQTT Trace, Config & Commands, Catalog; + - menu Register Device dihapus dari sidebar, action register ada di Device Registry; + - Technical Detail dihapus dari sidebar, akses detail lewat row action di Device Registry; + - footer/dev nav UI katalog dihapus dari halaman ops yang relevan. +- Master data `Catalog` sudah ditambahkan: + - vendor create/edit/delete; + - model create/edit/delete; + - model code generated backend; + - model thumbnail upload; + - country dropdown searchable dengan Palestine/Palestina masuk list; + - structured vendor contact: PIC name, phone/WhatsApp, email, notes. +- Device Registry sudah dibuat live dari API, bukan dummy: + - KPI dari data device; + - search berfungsi; + - pagination nyata; + - filter reset page; + - row action 3-dot untuk View Device Detail / Quick Inspect. +- Device Detail sudah dibuat lebih operasional: + - tombol refresh, retry push, test QR, copy payload, clear console, reboot, firmware update, unbind, rotate MQTT credential, decommission punya action; + - Dynamic QR preview dibuka sebagai modal inline, bukan pindah ke dashboard lain. +- Verifikasi terakhir lokal: + - `npm run typecheck`: pass. + ## Status Terakhir -- Fokus hari ini bergeser dari production readiness umum ke integrasi device soundbox QF100. +- Fokus sebelumnya bergeser dari production readiness umum ke integrasi device soundbox QF100. - Folder SDK lokal `QF100-60s-l511-SecondApp-260107/` ditemukan dan dianalisis. Folder ini sengaja tidak dimasukkan git. - Backend sekarang sudah punya adapter awal untuk firmware QF100 sample: - config server vendor-compatible; diff --git a/DEBIAN12_APP_SERVER_SETUP.md b/DEBIAN13_APP_SERVER_SETUP.md similarity index 79% rename from DEBIAN12_APP_SERVER_SETUP.md rename to DEBIAN13_APP_SERVER_SETUP.md index 2ce4d01..4ffb7e6 100644 --- a/DEBIAN12_APP_SERVER_SETUP.md +++ b/DEBIAN13_APP_SERVER_SETUP.md @@ -1,6 +1,6 @@ -# Debian 12 App Server Setup +# Debian 13 App Server Setup -Panduan ini untuk menyiapkan server kosong Debian 12 sebagai app server QRIS Soundbox Platform. +Panduan ini untuk menyiapkan server kosong Debian 13 sebagai app server QRIS Soundbox Platform. - App domain: `sms.bizone.id` - App user: `qrisapp` @@ -32,6 +32,7 @@ sudo apt install -y \ curl \ gnupg \ git \ + openssl \ build-essential \ nginx \ certbot \ @@ -65,7 +66,7 @@ Jangan buka port `3000` ke internet. Traffic publik masuk lewat Nginx. ## 5. App User -Buat user khusus untuk menjalankan service. +Buat user khusus untuk menjalankan service. Semua proses aplikasi, install dependency, build, migration, dan systemd runtime dijalankan sebagai `qrisapp`; user `root`/sudo hanya dipakai untuk install paket, membuat direktori, menulis env, Nginx, dan systemd. ```bash sudo adduser --system --group --home /opt/qris-soundbox qrisapp @@ -74,23 +75,28 @@ sudo install -d -o root -g qrisapp -m 750 /etc/qris-soundbox sudo install -d -o qrisapp -g qrisapp -m 750 /var/lib/qris-soundbox/exports ``` -Untuk deploy via git/rsync, operator boleh masuk dengan user sudo biasa, lalu copy release ke `/opt/qris-soundbox` dan set ownership ke `qrisapp:qrisapp`. +Untuk deploy via git/rsync, operator boleh login dengan user sudo biasa, tetapi command yang menyentuh repo/app dijalankan via `sudo -u qrisapp ...`. ## 6. PostgreSQL Buat database dan user production. ```bash -sudo -u postgres psql -``` +DB_PASSWORD="$(openssl rand -hex 32)" +echo "Simpan DB password ini untuk env: ${DB_PASSWORD}" -Di prompt `psql`: - -```sql -CREATE USER qris_app WITH PASSWORD '5174e2c2fb3f8424806d1e5a4ca873a3dd33aace06a7b99c49f1bed9d1f42c4a'; +sudo -u postgres psql -v app_password="$DB_PASSWORD" <<'SQL' +CREATE USER qris_app WITH PASSWORD :'app_password'; CREATE DATABASE qris_soundbox_platform OWNER qris_app; GRANT ALL PRIVILEGES ON DATABASE qris_soundbox_platform TO qris_app; \q +SQL +``` + +Kalau user/database sudah pernah dibuat, gunakan reset password: + +```sql +ALTER USER qris_app WITH PASSWORD ''; ``` ## 7. Deploy Code @@ -193,6 +199,13 @@ PGPORT=5432 PGUSER=qris_app PGPASSWORD=CHANGE_ME_STRONG_DB_PASSWORD PGDATABASE=qris_soundbox_platform +DATABASE_URL=postgresql://qris_app:CHANGE_ME_STRONG_DB_PASSWORD@127.0.0.1:5432/qris_soundbox_platform +``` + +Ganti semua `CHANGE_ME_*` dengan secret production. Untuk generate secret cepat: + +```bash +openssl rand -hex 32 ``` Lock permission: @@ -317,7 +330,52 @@ sudo -u qrisapp bash -lc 'set -a; source /etc/qris-soundbox/qris-soundbox.env; s ```bash curl -fsS https://sms.bizone.id/health curl -fsS https://sms.bizone.id/health/deep +curl -I https://sms.bizone.id/ curl -I https://sms.bizone.id/ui +curl -I https://sms.bizone.id/ui/soundbox-ops +``` + +Expected: + +- `https://sms.bizone.id/` redirects to `/ui/soundbox-ops`. +- `https://sms.bizone.id/ui` redirects to `/ui/soundbox-ops`. +- `https://sms.bizone.id/ui/soundbox-ops` returns the Soundbox Ops portal HTML. + +Test config endpoint shape. Replace `9011001900006` with a serial number already registered and active in Device Registry. + +```bash +curl -fsS https://sms.bizone.id/speaker/dev-config \ + -H 'Content-Type: application/json' \ + -d '{ + "dev-model": "QF100", + "item-number": "00", + "dev-sn": "9011001900006", + "hardware-config": "0x0F", + "fw-version": "1.1.2", + "fw-build": 13, + "app-config-version": 21, + "imei": "12345678123456789", + "imsi": "310150123456789", + "iccid": "898600MFSSYYGXXXXXXP" + }' +``` + +Expected successful device config response: + +```json +{ + "error-code": 0, + "mqtt": { + "broker-ip": "broker.bizone.id", + "broker-port": 8883, + "client-id": "", + "user-name": "qris-backend", + "password": "", + "subscribe-topic": "devices//downlink/qf100", + "publish-topic": "devices//uplink/qf100", + "keep-alive": 60 + } +} ``` Run production preflight from server: diff --git a/README.md b/README.md index d53e45e..a22549e 100644 --- a/README.md +++ b/README.md @@ -14,7 +14,7 @@ Paket ini berisi blueprint final v1 untuk platform merchant aggregator QRIS + so - 09-screen-inventory.md - 10-design-blueprint.md - 11-low-fi-wireframes.md -- DEBIAN12_APP_SERVER_SETUP.md +- DEBIAN13_APP_SERVER_SETUP.md - 18-mqtt-broker-mosquitto-debian13.md ## Tujuan diff --git a/migrations/004_soundbox_catalog.sql b/migrations/004_soundbox_catalog.sql new file mode 100644 index 0000000..79893eb --- /dev/null +++ b/migrations/004_soundbox_catalog.sql @@ -0,0 +1,66 @@ +BEGIN; + +CREATE TABLE IF NOT EXISTS soundbox_vendors ( + id TEXT PRIMARY KEY, + vendor_code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + country TEXT, + support_contact TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); + +CREATE TABLE IF NOT EXISTS soundbox_models ( + id TEXT PRIMARY KEY, + vendor_id TEXT NOT NULL REFERENCES soundbox_vendors (id) ON DELETE CASCADE, + model_code TEXT NOT NULL, + name TEXT NOT NULL, + communication_mode TEXT NOT NULL DEFAULT 'mqtt' CHECK (communication_mode IN ('static', 'mqtt', 'api')), + screen_flag BOOLEAN NOT NULL DEFAULT false, + qr_mode TEXT NOT NULL DEFAULT 'static' CHECK (qr_mode IN ('static', 'dynamic_mqtt', 'dynamic_api')), + mqtt_payload_profile TEXT, + capability_template_json JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE (vendor_id, model_code) +); + +CREATE INDEX IF NOT EXISTS idx_soundbox_models_vendor ON soundbox_models (vendor_id, status); + +INSERT INTO soundbox_vendors (id, vendor_code, name, country, support_contact, status, created_at, updated_at) +VALUES ('vendor_qf', 'QF', 'QF', NULL, NULL, 'active', NOW(), NOW()) +ON CONFLICT (vendor_code) DO NOTHING; + +INSERT INTO soundbox_models ( + id, + vendor_id, + model_code, + name, + communication_mode, + screen_flag, + qr_mode, + mqtt_payload_profile, + capability_template_json, + status, + created_at, + updated_at +) +VALUES ( + 'model_qf100', + 'vendor_qf', + 'QF100', + 'QF100', + 'mqtt', + false, + 'static', + 'qf100', + '{"device_type":"static_soundbox","screen":false,"qr_mode":"static","mqtt_payload_profile":"qf100","flows":["static_payment_notification"],"features":{"payment_sound":true,"dynamic_qr":false}}'::jsonb, + 'active', + NOW(), + NOW() +) +ON CONFLICT (vendor_id, model_code) DO NOTHING; + +COMMIT; diff --git a/migrations/005_soundbox_vendor_contacts.sql b/migrations/005_soundbox_vendor_contacts.sql new file mode 100644 index 0000000..f234e61 --- /dev/null +++ b/migrations/005_soundbox_vendor_contacts.sql @@ -0,0 +1,8 @@ +BEGIN; + +ALTER TABLE soundbox_vendors ADD COLUMN IF NOT EXISTS support_pic_name TEXT; +ALTER TABLE soundbox_vendors ADD COLUMN IF NOT EXISTS support_email TEXT; +ALTER TABLE soundbox_vendors ADD COLUMN IF NOT EXISTS support_phone TEXT; +ALTER TABLE soundbox_vendors ADD COLUMN IF NOT EXISTS support_notes TEXT; + +COMMIT; diff --git a/migrations/006_soundbox_model_thumbnail.sql b/migrations/006_soundbox_model_thumbnail.sql new file mode 100644 index 0000000..f6d1891 --- /dev/null +++ b/migrations/006_soundbox_model_thumbnail.sql @@ -0,0 +1,5 @@ +BEGIN; + +ALTER TABLE soundbox_models ADD COLUMN IF NOT EXISTS thumbnail_url TEXT; + +COMMIT; diff --git a/src/app.ts b/src/app.ts index e635a03..6c37461 100644 --- a/src/app.ts +++ b/src/app.ts @@ -23,6 +23,26 @@ const app = express(); if (env.TRUST_PROXY === "true") { app.set("trust proxy", 1); } + +const allowedPreviewOrigins = new Set([ + "http://127.0.0.1:4173", + "http://localhost:4173" +]); + +app.use((req, res, next) => { + const origin = req.header("origin"); + if (origin && allowedPreviewOrigins.has(origin)) { + res.header("Access-Control-Allow-Origin", origin); + res.header("Vary", "Origin"); + res.header("Access-Control-Allow-Headers", "authorization, content-type, x-request-id, idempotency-key"); + res.header("Access-Control-Allow-Methods", "GET,POST,PATCH,PUT,DELETE,OPTIONS"); + } + if (req.method === "OPTIONS") { + return res.sendStatus(204); + } + return next(); +}); + startNotificationOrchestrator(); startDynamicQrExpiryScheduler(); startExportJobWorker(); @@ -105,8 +125,7 @@ function resolveUiPageFile(slug: string) { } app.get("/ui", (_req, res) => { - const filePath = path.resolve(process.cwd(), "ui/index.html"); - res.sendFile(filePath); + res.redirect(302, "/ui/soundbox-ops"); }); app.get("/ui/hub", (_req, res) => { diff --git a/src/routes/admin.ts b/src/routes/admin.ts index 1398eef..d614669 100644 --- a/src/routes/admin.ts +++ b/src/routes/admin.ts @@ -112,6 +112,20 @@ import { buildDeviceConfigStatus } from "../shared/services/deviceConfigStatus"; import { expireDueDynamicQrTransactions } from "../shared/services/dynamicQrExpiry"; import { getDynamicQrExpirySchedulerStatus } from "../shared/services/dynamicQrExpiryScheduler"; import { createSettlementAdjustmentExportJob, getExportJobWorkerStatus } from "../shared/services/exportJobWorker"; +import { + createSoundboxModel, + createSoundboxVendor, + deleteSoundboxModel, + deleteSoundboxVendor, + getSoundboxModelById, + getSoundboxVendorById, + listSoundboxModels, + listSoundboxVendors, + patchSoundboxModel, + patchSoundboxVendor, + toSoundboxModelPayload, + toSoundboxVendorPayload +} from "../shared/store/soundboxCatalogStore"; const router = Router(); @@ -142,6 +156,31 @@ type TerminalCreateInput = { status?: "active" | "inactive"; }; +type SoundboxVendorInput = { + vendor_code?: string; + name?: string; + country?: string; + support_contact?: string; + support_pic_name?: string; + support_email?: string; + support_phone?: string; + support_notes?: string; + status?: "active" | "inactive"; +}; + +type SoundboxModelInput = { + vendor_id?: string; + model_code?: string; + name?: string; + communication_mode?: "static" | "mqtt" | "api"; + screen_flag?: boolean; + qr_mode?: "static" | "dynamic_mqtt" | "dynamic_api"; + mqtt_payload_profile?: string; + thumbnail_url?: string; + capability_template_json?: Record; + status?: "active" | "inactive"; +}; + type DeviceCreateInput = { device_code?: string; serial_number?: string; @@ -1231,6 +1270,240 @@ router.patch("/terminals/:terminalId", requireAdminToken, async (req: Request, r } }); +router.get("/soundbox-vendors", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const statusRaw = (req.query.status as string | undefined)?.trim(); + const status = parseDeviceStatusValue(statusRaw); + if (statusRaw && !status) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + res.json(successResponse(req, (await listSoundboxVendors({ status })).map(toSoundboxVendorPayload))); +}); + +router.post("/soundbox-vendors", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const payload = req.body as SoundboxVendorInput; + const vendorCode = payload?.vendor_code?.trim(); + const name = payload?.name?.trim(); + if (!vendorCode || !name) { + return next(new ApiError("BAD_REQUEST", "vendor_code and name are required", 400)); + } + if (payload.status && !parseDeviceStatusValue(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + + try { + const created = await createSoundboxVendor({ + vendor_code: vendorCode, + name, + country: payload.country?.trim() || undefined, + support_contact: payload.support_contact?.trim() || undefined, + support_pic_name: payload.support_pic_name?.trim() || undefined, + support_email: payload.support_email?.trim() || undefined, + support_phone: payload.support_phone?.trim() || undefined, + support_notes: payload.support_notes?.trim() || undefined, + status: payload.status + }); + await auditAdminAction(req, { + action: "soundbox_vendor.create", + entity_type: "soundbox_vendor", + entity_id: created.id, + after_json: toSoundboxVendorPayload(created) + }); + res.status(201).json(successResponse(req, toSoundboxVendorPayload(created))); + } catch (err) { + return next(err as Error); + } +}); + +router.patch("/soundbox-vendors/:vendorId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const payload = req.body as SoundboxVendorInput; + if (!payload || Object.keys(payload).length === 0) { + return next(new ApiError("BAD_REQUEST", "patch payload required", 400)); + } + if (payload.status && !parseDeviceStatusValue(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + + try { + const existing = await getSoundboxVendorById(req.params.vendorId); + const updated = await patchSoundboxVendor(req.params.vendorId, { + ...(payload.vendor_code !== undefined ? { vendor_code: payload.vendor_code.trim() } : {}), + ...(payload.name !== undefined ? { name: payload.name.trim() } : {}), + ...(payload.country !== undefined ? { country: payload.country.trim() } : {}), + ...(payload.support_contact !== undefined ? { support_contact: payload.support_contact.trim() } : {}), + ...(payload.support_pic_name !== undefined ? { support_pic_name: payload.support_pic_name.trim() } : {}), + ...(payload.support_email !== undefined ? { support_email: payload.support_email.trim() } : {}), + ...(payload.support_phone !== undefined ? { support_phone: payload.support_phone.trim() } : {}), + ...(payload.support_notes !== undefined ? { support_notes: payload.support_notes.trim() } : {}), + ...(payload.status !== undefined ? { status: payload.status } : {}) + }); + await auditAdminAction(req, { + action: "soundbox_vendor.update", + entity_type: "soundbox_vendor", + entity_id: updated.id, + before_json: existing ? toSoundboxVendorPayload(existing) : null, + after_json: toSoundboxVendorPayload(updated) + }); + res.json(successResponse(req, toSoundboxVendorPayload(updated))); + } catch (err) { + if (err instanceof Error && err.message === "SOUNDBOX_VENDOR_NOT_FOUND") { + return next(new ApiError("NOT_FOUND", "soundbox vendor not found", 404)); + } + return next(err as Error); + } +}); + +router.delete("/soundbox-vendors/:vendorId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + try { + const deleted = await deleteSoundboxVendor(req.params.vendorId); + await auditAdminAction(req, { + action: "soundbox_vendor.delete", + entity_type: "soundbox_vendor", + entity_id: deleted.id, + before_json: toSoundboxVendorPayload(deleted) + }); + res.json(successResponse(req, toSoundboxVendorPayload(deleted))); + } catch (err) { + if (err instanceof Error && err.message === "SOUNDBOX_VENDOR_NOT_FOUND") { + return next(new ApiError("NOT_FOUND", "soundbox vendor not found", 404)); + } + if (err instanceof Error && err.message === "SOUNDBOX_VENDOR_HAS_MODELS") { + return next(new ApiError("CONFLICT", "delete models for this vendor first", 409)); + } + return next(err as Error); + } +}); + +router.get("/soundbox-models", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const statusRaw = (req.query.status as string | undefined)?.trim(); + const status = parseDeviceStatusValue(statusRaw); + if (statusRaw && !status) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + const vendorId = (req.query.vendor_id as string | undefined)?.trim(); + const vendorCode = (req.query.vendor_code as string | undefined)?.trim(); + res.json( + successResponse( + req, + (await listSoundboxModels({ vendor_id: vendorId, vendor_code: vendorCode, status })).map(toSoundboxModelPayload) + ) + ); +}); + +router.post("/soundbox-models", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const payload = req.body as SoundboxModelInput; + const vendorId = payload?.vendor_id?.trim(); + const name = payload?.name?.trim(); + if (!vendorId || !name) { + return next(new ApiError("BAD_REQUEST", "vendor_id and name are required", 400)); + } + if (payload.communication_mode && !parseDeviceCommunicationMode(payload.communication_mode)) { + return next(new ApiError("BAD_REQUEST", "communication_mode must be static|mqtt|api", 400)); + } + if (payload.qr_mode && !parseTerminalModeFilter(payload.qr_mode)) { + return next(new ApiError("BAD_REQUEST", "qr_mode must be static|dynamic_mqtt|dynamic_api", 400)); + } + if (payload.status && !parseDeviceStatusValue(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + const vendor = await getSoundboxVendorById(vendorId); + if (!vendor) { + return next(new ApiError("BAD_REQUEST", "vendor_id is invalid", 400)); + } + + try { + const created = await createSoundboxModel({ + vendor_id: vendorId, + model_code: payload.model_code?.trim() || undefined, + name, + communication_mode: payload.communication_mode, + screen_flag: Boolean(payload.screen_flag), + qr_mode: payload.qr_mode, + mqtt_payload_profile: payload.mqtt_payload_profile?.trim() || undefined, + thumbnail_url: payload.thumbnail_url?.trim() || undefined, + capability_template_json: payload.capability_template_json, + status: payload.status + }); + await auditAdminAction(req, { + action: "soundbox_model.create", + entity_type: "soundbox_model", + entity_id: created.id, + after_json: toSoundboxModelPayload(created) + }); + res.status(201).json(successResponse(req, toSoundboxModelPayload(created))); + } catch (err) { + return next(err as Error); + } +}); + +router.patch("/soundbox-models/:modelId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + const payload = req.body as SoundboxModelInput; + if (!payload || Object.keys(payload).length === 0) { + return next(new ApiError("BAD_REQUEST", "patch payload required", 400)); + } + if (payload.communication_mode && !parseDeviceCommunicationMode(payload.communication_mode)) { + return next(new ApiError("BAD_REQUEST", "communication_mode must be static|mqtt|api", 400)); + } + if (payload.qr_mode && !parseTerminalModeFilter(payload.qr_mode)) { + return next(new ApiError("BAD_REQUEST", "qr_mode must be static|dynamic_mqtt|dynamic_api", 400)); + } + if (payload.status && !parseDeviceStatusValue(payload.status)) { + return next(new ApiError("BAD_REQUEST", "status must be active|inactive", 400)); + } + if (payload.vendor_id) { + const vendor = await getSoundboxVendorById(payload.vendor_id); + if (!vendor) { + return next(new ApiError("BAD_REQUEST", "vendor_id is invalid", 400)); + } + } + + try { + const existing = await getSoundboxModelById(req.params.modelId); + const updated = await patchSoundboxModel(req.params.modelId, { + ...(payload.vendor_id !== undefined ? { vendor_id: payload.vendor_id.trim() } : {}), + ...(payload.model_code !== undefined ? { model_code: payload.model_code.trim() } : {}), + ...(payload.name !== undefined ? { name: payload.name.trim() } : {}), + ...(payload.communication_mode !== undefined ? { communication_mode: payload.communication_mode } : {}), + ...(payload.screen_flag !== undefined ? { screen_flag: Boolean(payload.screen_flag) } : {}), + ...(payload.qr_mode !== undefined ? { qr_mode: payload.qr_mode } : {}), + ...(payload.mqtt_payload_profile !== undefined ? { mqtt_payload_profile: payload.mqtt_payload_profile.trim() } : {}), + ...(payload.thumbnail_url !== undefined ? { thumbnail_url: payload.thumbnail_url.trim() } : {}), + ...(payload.capability_template_json !== undefined ? { capability_template_json: payload.capability_template_json } : {}), + ...(payload.status !== undefined ? { status: payload.status } : {}) + }); + await auditAdminAction(req, { + action: "soundbox_model.update", + entity_type: "soundbox_model", + entity_id: updated.id, + before_json: existing ? toSoundboxModelPayload(existing) : null, + after_json: toSoundboxModelPayload(updated) + }); + res.json(successResponse(req, toSoundboxModelPayload(updated))); + } catch (err) { + if (err instanceof Error && err.message === "SOUNDBOX_MODEL_NOT_FOUND") { + return next(new ApiError("NOT_FOUND", "soundbox model not found", 404)); + } + return next(err as Error); + } +}); + +router.delete("/soundbox-models/:modelId", requireAdminToken, async (req: Request, res: Response, next: NextFunction) => { + try { + const deleted = await deleteSoundboxModel(req.params.modelId); + await auditAdminAction(req, { + action: "soundbox_model.delete", + entity_type: "soundbox_model", + entity_id: deleted.id, + before_json: toSoundboxModelPayload(deleted) + }); + res.json(successResponse(req, toSoundboxModelPayload(deleted))); + } catch (err) { + if (err instanceof Error && err.message === "SOUNDBOX_MODEL_NOT_FOUND") { + return next(new ApiError("NOT_FOUND", "soundbox model not found", 404)); + } + return next(err as Error); + } +}); + router.post("/devices", requireAdminToken, idempotency({ scope: "device.create", required: false }), async (req: Request, res: Response, next: NextFunction) => { if (parseIdempotentReplay(req)) { return res.status(201).json(getReplayResponse(req)); diff --git a/src/routes/speaker.ts b/src/routes/speaker.ts index 055acbc..b4236ae 100644 --- a/src/routes/speaker.ts +++ b/src/routes/speaker.ts @@ -9,6 +9,7 @@ type Qf100ConfigRequest = { "dev-model"?: string; "item-number"?: string; "dev-sn"?: string; + "hardware-config"?: string; "fw-version"?: string; "fw-build"?: number; "app-config-version"?: number; @@ -47,7 +48,7 @@ function vendorError(res: Response, code: number, message: string) { }); } -router.get("/dev-config", async (req: Request, res: Response, next: NextFunction) => { +async function handleDevConfig(req: Request, res: Response, next: NextFunction) { try { const payload = getRequestPayload(req); const serialNumber = String(payload["dev-sn"] || "").trim(); @@ -81,6 +82,7 @@ router.get("/dev-config", async (req: Request, res: Response, next: NextFunction dev_model: payload["dev-model"], item_number: payload["item-number"], dev_sn: serialNumber, + hardware_config: payload["hardware-config"], fw_build: payload["fw-build"], app_config_version: payload["app-config-version"], imei: payload.imei, @@ -120,6 +122,9 @@ router.get("/dev-config", async (req: Request, res: Response, next: NextFunction } catch (error) { next(error); } -}); +} + +router.get("/dev-config", handleDevConfig); +router.post("/dev-config", handleDevConfig); export default router; diff --git a/src/shared/db/pool.ts b/src/shared/db/pool.ts index 2651696..ae90f17 100644 --- a/src/shared/db/pool.ts +++ b/src/shared/db/pool.ts @@ -137,6 +137,45 @@ CREATE TABLE IF NOT EXISTS terminals ( updated_at TIMESTAMPTZ NOT NULL ); +CREATE TABLE IF NOT EXISTS soundbox_vendors ( + id TEXT PRIMARY KEY, + vendor_code TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + country TEXT, + support_contact TEXT, + support_pic_name TEXT, + support_email TEXT, + support_phone TEXT, + support_notes TEXT, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL +); +ALTER TABLE soundbox_vendors ADD COLUMN IF NOT EXISTS support_pic_name TEXT; +ALTER TABLE soundbox_vendors ADD COLUMN IF NOT EXISTS support_email TEXT; +ALTER TABLE soundbox_vendors ADD COLUMN IF NOT EXISTS support_phone TEXT; +ALTER TABLE soundbox_vendors ADD COLUMN IF NOT EXISTS support_notes TEXT; + +CREATE TABLE IF NOT EXISTS soundbox_models ( + id TEXT PRIMARY KEY, + vendor_id TEXT NOT NULL REFERENCES soundbox_vendors (id) ON DELETE CASCADE, + model_code TEXT NOT NULL, + name TEXT NOT NULL, + communication_mode TEXT NOT NULL DEFAULT 'mqtt' CHECK (communication_mode IN ('static', 'mqtt', 'api')), + screen_flag BOOLEAN NOT NULL DEFAULT false, + qr_mode TEXT NOT NULL DEFAULT 'static' CHECK (qr_mode IN ('static', 'dynamic_mqtt', 'dynamic_api')), + mqtt_payload_profile TEXT, + thumbnail_url TEXT, + capability_template_json JSONB NOT NULL DEFAULT '{}'::jsonb, + status TEXT NOT NULL DEFAULT 'active' CHECK (status IN ('active', 'inactive')), + created_at TIMESTAMPTZ NOT NULL, + updated_at TIMESTAMPTZ NOT NULL, + UNIQUE (vendor_id, model_code) +); + +CREATE INDEX IF NOT EXISTS idx_soundbox_models_vendor ON soundbox_models (vendor_id, status); +ALTER TABLE soundbox_models ADD COLUMN IF NOT EXISTS thumbnail_url TEXT; + CREATE TABLE IF NOT EXISTS devices ( id TEXT PRIMARY KEY, device_code TEXT NOT NULL UNIQUE, @@ -168,6 +207,42 @@ ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_rotated_at TIMESTAMPTZ; ALTER TABLE devices ADD COLUMN IF NOT EXISTS credential_revoked_at TIMESTAMPTZ; CREATE INDEX IF NOT EXISTS idx_devices_credential_status ON devices (credential_status); +INSERT INTO soundbox_vendors (id, vendor_code, name, country, support_contact, status, created_at, updated_at) +VALUES ('vendor_qf', 'QF', 'QF', NULL, NULL, 'active', NOW(), NOW()) +ON CONFLICT (vendor_code) DO NOTHING; + +INSERT INTO soundbox_models ( + id, + vendor_id, + model_code, + name, + communication_mode, + screen_flag, + qr_mode, + mqtt_payload_profile, + thumbnail_url, + capability_template_json, + status, + created_at, + updated_at +) +VALUES ( + 'model_qf100', + 'vendor_qf', + 'QF100', + 'QF100', + 'mqtt', + false, + 'static', + 'qf100', + NULL, + '{"device_type":"static_soundbox","screen":false,"qr_mode":"static","mqtt_payload_profile":"qf100","flows":["static_payment_notification"],"features":{"payment_sound":true,"dynamic_qr":false}}'::jsonb, + 'active', + NOW(), + NOW() +) +ON CONFLICT (vendor_id, model_code) DO NOTHING; + CREATE TABLE IF NOT EXISTS device_bindings ( id TEXT PRIMARY KEY, device_id TEXT NOT NULL REFERENCES devices (id) ON DELETE CASCADE, diff --git a/src/shared/store/soundboxCatalogStore.ts b/src/shared/store/soundboxCatalogStore.ts new file mode 100644 index 0000000..93cb7e8 --- /dev/null +++ b/src/shared/store/soundboxCatalogStore.ts @@ -0,0 +1,376 @@ +import { randomUUID } from "node:crypto"; +import { getPool } from "../db/pool"; + +export type CatalogStatus = "active" | "inactive"; +export type CatalogCommunicationMode = "static" | "mqtt" | "api"; +export type CatalogQrMode = "static" | "dynamic_mqtt" | "dynamic_api"; + +export interface SoundboxVendorEntity { + id: string; + vendor_code: string; + name: string; + country?: string; + support_contact?: string; + support_pic_name?: string; + support_email?: string; + support_phone?: string; + support_notes?: string; + status: CatalogStatus; + created_at: string; + updated_at: string; +} + +export interface SoundboxModelEntity { + id: string; + vendor_id: string; + vendor_code?: string; + vendor_name?: string; + model_code: string; + name: string; + communication_mode: CatalogCommunicationMode; + screen_flag: boolean; + qr_mode: CatalogQrMode; + mqtt_payload_profile?: string; + thumbnail_url?: string; + capability_template_json: Record; + status: CatalogStatus; + created_at: string; + updated_at: string; +} + +function nowIso() { + return new Date().toISOString(); +} + +function slugCode(value: string) { + const slug = value + .trim() + .toUpperCase() + .replace(/[^A-Z0-9]+/g, "_") + .replace(/^_+|_+$/g, ""); + return slug || "MODEL"; +} + +async function generateModelCode(vendorId: string, modelName: string) { + const vendor = await getSoundboxVendorById(vendorId); + const vendorCode = slugCode(vendor?.vendor_code || "VENDOR"); + const baseCode = `${vendorCode}_${slugCode(modelName)}`.slice(0, 80); + let candidate = baseCode; + let suffix = 2; + + while (true) { + const { rows } = await getPool().query( + "SELECT 1 FROM soundbox_models WHERE vendor_id = $1 AND model_code = $2 LIMIT 1", + [vendorId, candidate] + ); + if (!rows.length) { + return candidate; + } + candidate = `${baseCode}_${suffix++}`.slice(0, 96); + } +} + +function mapVendor(row: any): SoundboxVendorEntity { + return { + id: row.id, + vendor_code: row.vendor_code, + name: row.name, + country: row.country || undefined, + support_contact: row.support_contact || undefined, + support_pic_name: row.support_pic_name || undefined, + support_email: row.support_email || undefined, + support_phone: row.support_phone || undefined, + support_notes: row.support_notes || undefined, + status: row.status, + created_at: row.created_at, + updated_at: row.updated_at + }; +} + +function mapModel(row: any): SoundboxModelEntity { + return { + id: row.id, + vendor_id: row.vendor_id, + vendor_code: row.vendor_code || undefined, + vendor_name: row.vendor_name || undefined, + model_code: row.model_code, + name: row.name, + communication_mode: row.communication_mode, + screen_flag: Boolean(row.screen_flag), + qr_mode: row.qr_mode, + mqtt_payload_profile: row.mqtt_payload_profile || undefined, + thumbnail_url: row.thumbnail_url || undefined, + capability_template_json: row.capability_template_json || {}, + status: row.status, + created_at: row.created_at, + updated_at: row.updated_at + }; +} + +export async function listSoundboxVendors(filter?: { status?: CatalogStatus }): Promise { + const clauses: string[] = []; + const params: unknown[] = []; + if (filter?.status) { + clauses.push("status = $1"); + params.push(filter.status); + } + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const { rows } = await getPool().query(`SELECT * FROM soundbox_vendors ${where} ORDER BY name ASC`, params); + return rows.map(mapVendor); +} + +export async function getSoundboxVendorById(id: string): Promise { + const { rows } = await getPool().query("SELECT * FROM soundbox_vendors WHERE id = $1", [id]); + return rows.length ? mapVendor(rows[0]) : null; +} + +export async function getSoundboxVendorByCode(vendorCode: string): Promise { + const { rows } = await getPool().query("SELECT * FROM soundbox_vendors WHERE vendor_code = $1", [vendorCode]); + return rows.length ? mapVendor(rows[0]) : null; +} + +export async function createSoundboxVendor(payload: { + vendor_code: string; + name: string; + country?: string; + support_contact?: string; + support_pic_name?: string; + support_email?: string; + support_phone?: string; + support_notes?: string; + status?: CatalogStatus; +}): Promise { + const id = randomUUID(); + const now = nowIso(); + const { rows } = await getPool().query( + `INSERT INTO soundbox_vendors ( + id, + vendor_code, + name, + country, + support_contact, + support_pic_name, + support_email, + support_phone, + support_notes, + status, + created_at, + updated_at + ) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12) + RETURNING *`, + [ + id, + payload.vendor_code, + payload.name, + payload.country || null, + payload.support_contact || null, + payload.support_pic_name || null, + payload.support_email || null, + payload.support_phone || null, + payload.support_notes || null, + payload.status || "active", + now, + now + ] + ); + return mapVendor(rows[0]); +} + +export async function patchSoundboxVendor( + id: string, + patch: Partial> +): Promise { + const existing = await getSoundboxVendorById(id); + if (!existing) { + throw new Error("SOUNDBOX_VENDOR_NOT_FOUND"); + } + const merged = { ...existing, ...patch, updated_at: nowIso() }; + const { rows } = await getPool().query( + `UPDATE soundbox_vendors + SET vendor_code = $2, + name = $3, + country = $4, + support_contact = $5, + support_pic_name = $6, + support_email = $7, + support_phone = $8, + support_notes = $9, + status = $10, + updated_at = $11 + WHERE id = $1 + RETURNING *`, + [ + id, + merged.vendor_code, + merged.name, + merged.country || null, + merged.support_contact || null, + merged.support_pic_name || null, + merged.support_email || null, + merged.support_phone || null, + merged.support_notes || null, + merged.status, + merged.updated_at + ] + ); + return mapVendor(rows[0]); +} + +export async function deleteSoundboxVendor(id: string): Promise { + const existing = await getSoundboxVendorById(id); + if (!existing) { + throw new Error("SOUNDBOX_VENDOR_NOT_FOUND"); + } + const { rows: modelRows } = await getPool().query("SELECT COUNT(*)::int AS count FROM soundbox_models WHERE vendor_id = $1", [id]); + if ((modelRows[0]?.count || 0) > 0) { + throw new Error("SOUNDBOX_VENDOR_HAS_MODELS"); + } + await getPool().query("DELETE FROM soundbox_vendors WHERE id = $1", [id]); + return existing; +} + +export async function listSoundboxModels(filter?: { + vendor_id?: string; + vendor_code?: string; + status?: CatalogStatus; +}): Promise { + const clauses: string[] = []; + const params: unknown[] = []; + let i = 1; + if (filter?.vendor_id) { + clauses.push(`m.vendor_id = $${i++}`); + params.push(filter.vendor_id); + } + if (filter?.vendor_code) { + clauses.push(`v.vendor_code = $${i++}`); + params.push(filter.vendor_code); + } + if (filter?.status) { + clauses.push(`m.status = $${i++}`); + params.push(filter.status); + } + const where = clauses.length ? `WHERE ${clauses.join(" AND ")}` : ""; + const { rows } = await getPool().query( + `SELECT m.*, v.vendor_code, v.name AS vendor_name + FROM soundbox_models m + JOIN soundbox_vendors v ON v.id = m.vendor_id + ${where} + ORDER BY v.name ASC, m.model_code ASC`, + params + ); + return rows.map(mapModel); +} + +export async function getSoundboxModelById(id: string): Promise { + const { rows } = await getPool().query( + `SELECT m.*, v.vendor_code, v.name AS vendor_name + FROM soundbox_models m + JOIN soundbox_vendors v ON v.id = m.vendor_id + WHERE m.id = $1`, + [id] + ); + return rows.length ? mapModel(rows[0]) : null; +} + +export async function createSoundboxModel(payload: { + vendor_id: string; + model_code?: string; + name: string; + communication_mode?: CatalogCommunicationMode; + screen_flag?: boolean; + qr_mode?: CatalogQrMode; + mqtt_payload_profile?: string; + thumbnail_url?: string; + capability_template_json?: Record; + status?: CatalogStatus; +}): Promise { + const id = randomUUID(); + const now = nowIso(); + const modelCode = payload.model_code?.trim() || await generateModelCode(payload.vendor_id, payload.name); + const { rows } = await getPool().query( + `INSERT INTO soundbox_models ( + id, vendor_id, model_code, name, communication_mode, screen_flag, qr_mode, + mqtt_payload_profile, thumbnail_url, capability_template_json, status, created_at, updated_at + ) + VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13) + RETURNING *`, + [ + id, + payload.vendor_id, + modelCode, + payload.name, + payload.communication_mode || "mqtt", + payload.screen_flag ?? false, + payload.qr_mode || "static", + payload.mqtt_payload_profile || null, + payload.thumbnail_url || null, + payload.capability_template_json || {}, + payload.status || "active", + now, + now + ] + ); + const created = await getSoundboxModelById(rows[0].id); + return created || mapModel(rows[0]); +} + +export async function patchSoundboxModel( + id: string, + patch: Partial> +): Promise { + const existing = await getSoundboxModelById(id); + if (!existing) { + throw new Error("SOUNDBOX_MODEL_NOT_FOUND"); + } + const merged = { ...existing, ...patch, updated_at: nowIso() }; + const { rows } = await getPool().query( + `UPDATE soundbox_models + SET vendor_id = $2, + model_code = $3, + name = $4, + communication_mode = $5, + screen_flag = $6, + qr_mode = $7, + mqtt_payload_profile = $8, + thumbnail_url = $9, + capability_template_json = $10, + status = $11, + updated_at = $12 + WHERE id = $1 + RETURNING *`, + [ + id, + merged.vendor_id, + merged.model_code, + merged.name, + merged.communication_mode, + merged.screen_flag, + merged.qr_mode, + merged.mqtt_payload_profile || null, + merged.thumbnail_url || null, + merged.capability_template_json || {}, + merged.status, + merged.updated_at + ] + ); + const updated = await getSoundboxModelById(rows[0].id); + return updated || mapModel(rows[0]); +} + +export async function deleteSoundboxModel(id: string): Promise { + const existing = await getSoundboxModelById(id); + if (!existing) { + throw new Error("SOUNDBOX_MODEL_NOT_FOUND"); + } + await getPool().query("DELETE FROM soundbox_models WHERE id = $1", [id]); + return existing; +} + +export function toSoundboxVendorPayload(vendor: SoundboxVendorEntity) { + return { ...vendor }; +} + +export function toSoundboxModelPayload(model: SoundboxModelEntity) { + return { ...model }; +} diff --git a/ui/device-registry-monitoring/index.html b/ui/device-registry-monitoring/index.html index 2169c86..556f047 100644 --- a/ui/device-registry-monitoring/index.html +++ b/ui/device-registry-monitoring/index.html @@ -134,46 +134,37 @@ -