Compare commits
12
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
98bffc4117 | ||
|
|
c7a0d921ea | ||
|
|
a788ea2642 | ||
|
|
2fb15443ef | ||
|
|
e3d8f41f02 | ||
|
|
83c9dbb9fa | ||
|
|
3c7e419ffa | ||
|
|
94d7b76c52 | ||
|
|
afbf67187f | ||
|
|
5004e86bee | ||
|
|
6a7e705f1f | ||
|
|
0f6728da40 |
@@ -58,3 +58,11 @@
|
||||
|
||||
### Next Steps
|
||||
- Add verification with camera in core script
|
||||
|
||||
## Week 5
|
||||
### Completed
|
||||
- camera scripts
|
||||
- adminjs configuration
|
||||
|
||||
### In Progress
|
||||
- connect camera scripts to core script and adminjs
|
||||
|
||||
@@ -2,4 +2,6 @@ node_modules
|
||||
# Keep environment variables out of version control
|
||||
.env
|
||||
|
||||
**/generated/prisma
|
||||
/generated/prisma
|
||||
|
||||
/generated/prisma
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"semi": true,
|
||||
"singleQuote": false,
|
||||
"tabWidth": 4,
|
||||
"trailingComma": "es5",
|
||||
"printWidth": 100
|
||||
}
|
||||
Generated
+6646
-1334
File diff suppressed because it is too large
Load Diff
@@ -9,22 +9,27 @@
|
||||
"scripts": {
|
||||
"dev": "tsx src/index.ts",
|
||||
"build": "tsc",
|
||||
"start": "node dist/index.js"
|
||||
"start": "node dist/index.js",
|
||||
"format": "prettier --write \"src/**/*.{ts,tsx}\"",
|
||||
"format:check": "prettier --check \"src/**/*.{ts,tsx}\""
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/better-sqlite3": "^7.6.13",
|
||||
"@types/express": "^5.0.6",
|
||||
"@types/express-session": "^1.19.0",
|
||||
"@types/node": "^25.7.0",
|
||||
"prisma": "^7.8.0",
|
||||
"prettier": "^3.8.3",
|
||||
"tsx": "^4.21.0",
|
||||
"typescript": "^6.0.3"
|
||||
},
|
||||
"dependencies": {
|
||||
"@prisma/adapter-better-sqlite3": "^7.8.0",
|
||||
"@prisma/client": "^7.8.0",
|
||||
"dotenv": "^17.4.2",
|
||||
"@adminjs/express": "^6.1.1",
|
||||
"@adminjs/prisma": "^5.0.4",
|
||||
"@prisma/client": "^6.19.3",
|
||||
"adminjs": "^7.8.17",
|
||||
"express": "^5.2.1",
|
||||
"express-session": "^1.19.0",
|
||||
"mqtt": "^5.15.1",
|
||||
"prisma": "^6.19.3",
|
||||
"redis": "^5.12.1"
|
||||
}
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
// This file was generated by Prisma, and assumes you have installed the following:
|
||||
// npm install --save-dev prisma dotenv
|
||||
import "dotenv/config";
|
||||
import { defineConfig } from "prisma/config";
|
||||
|
||||
export default defineConfig({
|
||||
schema: "prisma/schema.prisma",
|
||||
migrations: {
|
||||
path: "prisma/migrations",
|
||||
},
|
||||
datasource: {
|
||||
url: process.env["DATABASE_URL"],
|
||||
},
|
||||
});
|
||||
@@ -0,0 +1,14 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_User" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"firstName" TEXT NOT NULL,
|
||||
"lastName" TEXT NOT NULL,
|
||||
"faceEmbedding" TEXT
|
||||
);
|
||||
INSERT INTO "new_User" ("faceEmbedding", "firstName", "id", "lastName") SELECT "faceEmbedding", "firstName", "id", "lastName" FROM "User";
|
||||
DROP TABLE "User";
|
||||
ALTER TABLE "new_User" RENAME TO "User";
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -0,0 +1,14 @@
|
||||
-- RedefineTables
|
||||
PRAGMA defer_foreign_keys=ON;
|
||||
PRAGMA foreign_keys=OFF;
|
||||
CREATE TABLE "new_Tag" (
|
||||
"id" TEXT NOT NULL PRIMARY KEY,
|
||||
"userId" TEXT NOT NULL,
|
||||
CONSTRAINT "Tag_userId_fkey" FOREIGN KEY ("userId") REFERENCES "User" ("id") ON DELETE CASCADE ON UPDATE CASCADE
|
||||
);
|
||||
INSERT INTO "new_Tag" ("id", "userId") SELECT "id", "userId" FROM "Tag";
|
||||
DROP TABLE "Tag";
|
||||
ALTER TABLE "new_Tag" RENAME TO "Tag";
|
||||
CREATE UNIQUE INDEX "Tag_userId_key" ON "Tag"("userId");
|
||||
PRAGMA foreign_keys=ON;
|
||||
PRAGMA defer_foreign_keys=OFF;
|
||||
@@ -1,24 +1,24 @@
|
||||
generator client {
|
||||
provider = "prisma-client"
|
||||
output = "../src/generated/prisma"
|
||||
provider = "prisma-client-js"
|
||||
}
|
||||
|
||||
datasource db {
|
||||
provider = "sqlite"
|
||||
url = env("DATABASE_URL")
|
||||
}
|
||||
|
||||
model User {
|
||||
id String @id @default(uuid())
|
||||
firstName String
|
||||
lastName String
|
||||
faceEmbedding Bytes?
|
||||
faceEmbedding String?
|
||||
tag Tag?
|
||||
}
|
||||
|
||||
model Tag {
|
||||
id String @id @default(uuid())
|
||||
userId String @unique
|
||||
user User @relation(fields: [userId], references: [id])
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade, onUpdate: Cascade)
|
||||
}
|
||||
|
||||
model Room {
|
||||
|
||||
@@ -3,8 +3,7 @@ import { getEntities, getEntitiesOfType, getEntity } from "./livedata.controller
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/entities", getEntities);
|
||||
router.get("/entities", getEntitiesOfType);
|
||||
router.get("/entities", getEntities, getEntitiesOfType);
|
||||
router.get("/entities/:urn", getEntity);
|
||||
|
||||
export default router;
|
||||
|
||||
@@ -0,0 +1,59 @@
|
||||
import AdminJS from "adminjs";
|
||||
import AdminJSExpress from "@adminjs/express";
|
||||
import * as AdminJSPrisma from "@adminjs/prisma";
|
||||
import session from "express-session";
|
||||
import { componentLoader, Components } from './components.js'
|
||||
|
||||
import userResource from "./resources/adminjs.user.resource.js";
|
||||
import tagResource from "./resources/adminjs.tag.resource.js";
|
||||
import roomResource from "./resources/adminjs.room.resource.js";
|
||||
|
||||
AdminJS.registerAdapter({
|
||||
Database: AdminJSPrisma.Database,
|
||||
Resource: AdminJSPrisma.Resource,
|
||||
})
|
||||
|
||||
async function createAdmin() {
|
||||
const admin = new AdminJS({
|
||||
rootPath: "/admin",
|
||||
resources: [userResource, tagResource, roomResource],
|
||||
branding: { companyName: "OfficeSense", logo: false },
|
||||
dashboard: { component: Components.Dashboard },
|
||||
componentLoader,
|
||||
});
|
||||
|
||||
await admin.watch();
|
||||
|
||||
const router = AdminJSExpress.buildAuthenticatedRouter(
|
||||
admin,
|
||||
{
|
||||
authenticate: async (email, password) => {
|
||||
if (
|
||||
email === process.env.ADMIN_EMAIL &&
|
||||
password === process.env.ADMIN_PASSWORD
|
||||
) {
|
||||
return { email }
|
||||
}
|
||||
return null
|
||||
},
|
||||
cookieName: 'adminjs',
|
||||
cookiePassword: process.env.ADMIN_COOKIE_SECRET ?? 'change-me',
|
||||
},
|
||||
null,
|
||||
{
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
secret: process.env.ADMIN_COOKIE_SECRET ?? 'change-me',
|
||||
}
|
||||
)
|
||||
|
||||
return { admin, router }
|
||||
}
|
||||
|
||||
const sessionMiddleware = session({
|
||||
secret: process.env.ADMIN_COOKIE_SECRET ?? "change-me",
|
||||
resave: false,
|
||||
saveUninitialized: false,
|
||||
});
|
||||
|
||||
export { createAdmin, sessionMiddleware }
|
||||
@@ -0,0 +1,13 @@
|
||||
import { ComponentLoader } from 'adminjs'
|
||||
import { fileURLToPath } from 'url'
|
||||
import path from 'path'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
|
||||
export const componentLoader = new ComponentLoader()
|
||||
|
||||
export const Components = {
|
||||
Dashboard: componentLoader.add('Dashboard', path.join(__dirname, './components/Dashboard.tsx'), 'components'),
|
||||
UploadFace: componentLoader.add('UploadFace', path.join(__dirname, './components/UploadFace.tsx'), 'components'),
|
||||
FaceEmbeddingField: componentLoader.add('FaceEmbeddingField', path.join(__dirname, './components/FaceEmbeddingField.tsx'), 'components'),
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import React, { useEffect, useState } from 'react'
|
||||
import { Box, H2, Text, Loader } from '@adminjs/design-system'
|
||||
import { ApiClient } from 'adminjs'
|
||||
|
||||
type Session = {
|
||||
realUserId: string
|
||||
firstName: string
|
||||
lastName: string
|
||||
pseudoId: string
|
||||
pseudoName: string
|
||||
}
|
||||
|
||||
const Dashboard = () => {
|
||||
const [sessions, setSessions] = useState<Session[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [updated, setUpdated] = useState<string>('')
|
||||
|
||||
const load = async () => {
|
||||
try {
|
||||
const res = await fetch('/admin/map', { credentials: 'include' })
|
||||
const data = await res.json()
|
||||
setSessions(data)
|
||||
setUpdated(new Date().toLocaleTimeString())
|
||||
} catch (e) {
|
||||
console.error(e)
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
const interval = setInterval(load, 10000)
|
||||
return () => clearInterval(interval)
|
||||
}, [])
|
||||
|
||||
const initials = (first: string, last: string) =>
|
||||
((first?.[0] ?? '') + (last?.[0] ?? '')).toUpperCase() || '?'
|
||||
|
||||
return (
|
||||
<Box padding="xl">
|
||||
<Box display="flex" alignItems="center" justifyContent="space-between" marginBottom="xl">
|
||||
<Box>
|
||||
<H2>OfficeSense Admin Dashboard</H2>
|
||||
<Text color="grey60">Live Redis sessions mapped to registered users</Text>
|
||||
</Box>
|
||||
<Box display="flex" alignItems="center">
|
||||
<Text color="grey60" fontSize="sm" marginRight="md">Updated {updated}</Text>
|
||||
<Box
|
||||
as="button"
|
||||
onClick={load}
|
||||
style={{
|
||||
padding: '6px 14px',
|
||||
borderRadius: '6px',
|
||||
border: '1px solid #e5e7eb',
|
||||
background: '#fff',
|
||||
cursor: 'pointer',
|
||||
fontSize: '13px',
|
||||
}}
|
||||
>
|
||||
Refresh
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
|
||||
{loading ? (
|
||||
<Loader />
|
||||
) : sessions.length === 0 ? (
|
||||
<Text color="grey60">No active sessions.</Text>
|
||||
) : (
|
||||
<Box
|
||||
style={{
|
||||
background: '#fff',
|
||||
border: '1px solid #e5e7eb',
|
||||
borderRadius: '12px',
|
||||
overflow: 'hidden',
|
||||
}}
|
||||
>
|
||||
<table style={{ width: '100%', borderCollapse: 'collapse', fontSize: '13px' }}>
|
||||
<thead>
|
||||
<tr style={{ background: '#fafafa' }}>
|
||||
{['User', 'Real UUID', 'Pseudo UUID', 'Pseudo name'].map(h => (
|
||||
<th key={h} style={{
|
||||
textAlign: 'left', padding: '10px 16px',
|
||||
fontSize: '11px', color: '#9ca3af',
|
||||
textTransform: 'uppercase', letterSpacing: '0.05em',
|
||||
borderBottom: '1px solid #f3f4f6', fontWeight: 500,
|
||||
}}>{h}</th>
|
||||
))}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sessions.map(s => (
|
||||
<tr key={s.realUserId} style={{ borderBottom: '1px solid #f9fafb' }}>
|
||||
<td style={{ padding: '12px 16px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<div style={{
|
||||
width: 28, height: 28, borderRadius: '50%',
|
||||
background: '#eff6ff', color: '#2563eb',
|
||||
display: 'flex', alignItems: 'center', justifyContent: 'center',
|
||||
fontSize: '11px', fontWeight: 600, flexShrink: 0,
|
||||
}}>
|
||||
{initials(s.firstName, s.lastName)}
|
||||
</div>
|
||||
{s.firstName} {s.lastName}
|
||||
</div>
|
||||
</td>
|
||||
<td style={{ padding: '12px 16px', fontFamily: 'monospace', fontSize: '12px', color: '#6b7280' }}>{s.realUserId}</td>
|
||||
<td style={{ padding: '12px 16px', fontFamily: 'monospace', fontSize: '12px', color: '#6b7280' }}>{s.pseudoId}</td>
|
||||
<td style={{ padding: '12px 16px' }}>{s.pseudoName ?? '—'}</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default Dashboard
|
||||
@@ -0,0 +1,26 @@
|
||||
import React from 'react'
|
||||
import { BasePropertyProps } from 'adminjs'
|
||||
import { Label } from '@adminjs/design-system'
|
||||
|
||||
const FaceEmbeddingField: React.FC<BasePropertyProps> = ({ record, property }) => {
|
||||
const has = !!record?.params?.faceEmbedding
|
||||
return (
|
||||
<div style={{ marginBottom: '16px' }}>
|
||||
<Label>{property.label}</Label>
|
||||
<div style={{ marginTop: 6 }}>
|
||||
<span style={{
|
||||
fontSize: '12px',
|
||||
fontWeight: 600,
|
||||
padding: '3px 10px',
|
||||
borderRadius: '4px',
|
||||
background: has ? '#dcfce7' : '#fee2e2',
|
||||
color: has ? '#16a34a' : '#dc2626',
|
||||
}}>
|
||||
{has ? 'SET' : 'UNSET'}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default FaceEmbeddingField
|
||||
@@ -0,0 +1,96 @@
|
||||
import React, { useState, useRef } from 'react'
|
||||
import { Box, H3, Text, Button, MessageBox } from '@adminjs/design-system'
|
||||
import { ActionProps, useRecord } from 'adminjs'
|
||||
|
||||
const UploadFace: React.FC<ActionProps> = ({ record, action }) => {
|
||||
const [preview, setPreview] = useState<string | null>(null)
|
||||
const [base64, setBase64] = useState<string | null>(null)
|
||||
const [loading, setLoading] = useState(false)
|
||||
const [notice, setNotice] = useState<{ message: string, type: 'success' | 'error' } | null>(null)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const handleFile = (e: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const file = e.target.files?.[0]
|
||||
if (!file) return
|
||||
const reader = new FileReader()
|
||||
reader.onload = () => {
|
||||
const result = reader.result as string
|
||||
setPreview(result)
|
||||
setBase64(result.split(',')[1]) // strip data:image/jpeg;base64,
|
||||
}
|
||||
reader.readAsDataURL(file)
|
||||
}
|
||||
|
||||
const handleSubmit = async () => {
|
||||
if (!base64) return
|
||||
setLoading(true)
|
||||
setNotice(null)
|
||||
try {
|
||||
const res = await fetch(
|
||||
`/admin/api/resources/User/records/${record?.params.id}/uploadFace`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ base64 }),
|
||||
}
|
||||
)
|
||||
const data = await res.json()
|
||||
setNotice(data.notice)
|
||||
} catch (e: any) {
|
||||
setNotice({ message: e.message, type: 'error' })
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Box padding="xl">
|
||||
<H3 marginBottom="md">Upload face image</H3>
|
||||
<Text color="grey60" marginBottom="lg">
|
||||
Upload a JPEG photo of the user to generate and store their face embedding.
|
||||
</Text>
|
||||
|
||||
{notice && (
|
||||
<MessageBox
|
||||
message={notice.message}
|
||||
variant={notice.type === 'success' ? 'success' : 'danger'}
|
||||
marginBottom="lg"
|
||||
/>
|
||||
)}
|
||||
|
||||
<Box marginBottom="lg">
|
||||
<input
|
||||
ref={inputRef}
|
||||
type="file"
|
||||
accept="image/jpeg"
|
||||
onChange={handleFile}
|
||||
style={{ display: 'none' }}
|
||||
/>
|
||||
<Button onClick={() => inputRef.current?.click()} variant="outlined">
|
||||
Choose JPEG
|
||||
</Button>
|
||||
</Box>
|
||||
|
||||
{preview && (
|
||||
<Box marginBottom="lg">
|
||||
<img
|
||||
src={preview}
|
||||
alt="Preview"
|
||||
style={{ width: 160, height: 160, objectFit: 'cover', borderRadius: 8, border: '1px solid #e5e7eb' }}
|
||||
/>
|
||||
</Box>
|
||||
)}
|
||||
|
||||
<Button
|
||||
onClick={handleSubmit}
|
||||
disabled={!base64 || loading}
|
||||
variant="contained"
|
||||
>
|
||||
{loading ? 'Processing…' : 'Save embedding'}
|
||||
</Button>
|
||||
</Box>
|
||||
)
|
||||
}
|
||||
|
||||
export default UploadFace
|
||||
@@ -0,0 +1,16 @@
|
||||
import { prisma } from "../../../lib/prisma.js";
|
||||
import { getModelByName } from "@adminjs/prisma";
|
||||
|
||||
export default {
|
||||
resource: {
|
||||
model: getModelByName("Room"),
|
||||
client: prisma,
|
||||
},
|
||||
options: {
|
||||
navigation: { icon: "Map" },
|
||||
listProperties: ["id", "name"],
|
||||
showProperties: ["id", "name"],
|
||||
editProperties: ["name"],
|
||||
filterProperties: ["name"],
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
import { prisma } from "../../../lib/prisma.js";
|
||||
import { getModelByName } from "@adminjs/prisma";
|
||||
|
||||
export default {
|
||||
resource: {
|
||||
model: getModelByName("Tag"),
|
||||
client: prisma,
|
||||
},
|
||||
options: {
|
||||
navigation: { icon: "Tag" },
|
||||
listProperties: ["id", "user"],
|
||||
showProperties: ["id", "user"],
|
||||
editProperties: ["user"],
|
||||
filterProperties: ["user"],
|
||||
properties: {
|
||||
user: {
|
||||
reference: "User",
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { prisma } from "../../../lib/prisma.js";
|
||||
import { getModelByName } from "@adminjs/prisma";
|
||||
import { spawn } from "child_process";
|
||||
import { Components } from '../components.js'
|
||||
import { fileURLToPath } from 'url'
|
||||
import path from 'path'
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const execPath = '../../../../bin';
|
||||
const binaryPath = path.join(__dirname, execPath, 'extractEmbeddings')
|
||||
|
||||
async function extractEmbedding(base64: string): Promise<string> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(binaryPath, [], {
|
||||
cwd: path.join(__dirname, execPath)
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
proc.stdout.on('data', (d) => stdout += d)
|
||||
proc.stderr.on('data', (d) => stderr += d)
|
||||
|
||||
proc.stdin.on('error', (err) => { });
|
||||
|
||||
proc.on('error', (err) => {
|
||||
reject(new Error(`Failed to start binary: ${err.message}`))
|
||||
})
|
||||
|
||||
proc.on('close', (code) => {
|
||||
console.log('extractEmbeddings exited with code', code)
|
||||
console.log('stdout:', stdout)
|
||||
console.log('stderr:', stderr)
|
||||
if (code !== 0) return reject(new Error(stderr || `exited with code ${code}`))
|
||||
try {
|
||||
const result = JSON.parse(stdout)
|
||||
if (!result.success) return reject(new Error(result.error))
|
||||
resolve(JSON.stringify(result.descriptor))
|
||||
} catch (e) {
|
||||
reject(new Error(`Failed to parse output: ${stdout}`))
|
||||
}
|
||||
})
|
||||
|
||||
proc.stdin.write(base64)
|
||||
proc.stdin.end()
|
||||
})
|
||||
}
|
||||
|
||||
export default {
|
||||
resource: {
|
||||
model: getModelByName("User"),
|
||||
client: prisma,
|
||||
},
|
||||
options: {
|
||||
titleProperty: "id",
|
||||
navigation: { icon: "User" },
|
||||
listProperties: ["id", "firstName", "lastName"],
|
||||
showProperties: ["id", "firstName", "lastName", "faceEmbedding"],
|
||||
editProperties: ["firstName", "lastName"],
|
||||
filterProperties: ["firstName", "lastName"],
|
||||
properties: {
|
||||
faceEmbedding: {
|
||||
isVisible: { list: false, show: true, edit: false, filter: false },
|
||||
type: "string",
|
||||
components: {
|
||||
list: Components.FaceEmbeddingField,
|
||||
show: Components.FaceEmbeddingField,
|
||||
},
|
||||
},
|
||||
},
|
||||
actions: {
|
||||
uploadFace: {
|
||||
actionType: 'record',
|
||||
icon: 'Camera',
|
||||
label: 'Upload face',
|
||||
showInDrawer: true,
|
||||
handler: async (request: any, response: any, context: any) => {
|
||||
const { record, currentAdmin } = context
|
||||
if (request.method === 'post') {
|
||||
const { base64 } = request.payload
|
||||
if (!base64) {
|
||||
return {
|
||||
record: record.toJSON(currentAdmin),
|
||||
notice: { message: 'No image provided', type: 'error' },
|
||||
}
|
||||
}
|
||||
try {
|
||||
const embedding = await extractEmbedding(base64)
|
||||
await prisma.user.update({
|
||||
where: { id: record.params.id },
|
||||
data: { faceEmbedding: embedding },
|
||||
})
|
||||
return {
|
||||
record: record.toJSON(currentAdmin),
|
||||
notice: { message: 'Face embedding saved!', type: 'success' },
|
||||
}
|
||||
} catch (e: any) {
|
||||
return {
|
||||
record: record.toJSON(currentAdmin),
|
||||
notice: { message: `Failed: ${e.message}`, type: 'error' },
|
||||
}
|
||||
}
|
||||
}
|
||||
return { record: record.toJSON(currentAdmin) }
|
||||
},
|
||||
component: Components.UploadFace,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import type { NextFunction, Request, Response } from "express";
|
||||
import { generateMap } from "./mapper.repository.js";
|
||||
|
||||
export function checkSession(req: Request, res: Response, next: NextFunction) {
|
||||
if (!(req.session as any)?.adminUser) {
|
||||
return res.status(401).json({ error: 'Unauthorized' })
|
||||
}
|
||||
next();
|
||||
}
|
||||
|
||||
export async function getRedisMapping(req: Request, res: Response) {
|
||||
try {
|
||||
res.status(200).send(await generateMap());
|
||||
} catch (err) {
|
||||
res.status(500).json({ error: 'Failed to fetch sessions' })
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
import { getRedis } from "../../redis/redis.js";
|
||||
import { prisma } from "../../lib/prisma.js";
|
||||
|
||||
export async function generateMap() {
|
||||
const redis = getRedis();
|
||||
|
||||
const keys = await redis.keys('user:*')
|
||||
|
||||
const sessions = await Promise.all(keys.map(async (key) => {
|
||||
const raw = await redis.get(key)
|
||||
const session = JSON.parse(raw!)
|
||||
const realUserId = key.replace('user:', '')
|
||||
|
||||
const user = await prisma.user.findUnique({
|
||||
where: { id: realUserId },
|
||||
select: { id: true, firstName: true, lastName: true }
|
||||
})
|
||||
|
||||
return {
|
||||
realUserId,
|
||||
firstName: user?.firstName ?? 'Unknown',
|
||||
lastName: user?.lastName ?? 'Unknown',
|
||||
pseudoId: session.userID,
|
||||
pseudoName: session.name
|
||||
}
|
||||
}))
|
||||
|
||||
return sessions;
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import express from "express";
|
||||
import { getRedisMapping, checkSession } from "./mapper.controller.js";
|
||||
|
||||
const router = express.Router();
|
||||
|
||||
router.get("/map", checkSession, getRedisMapping);
|
||||
|
||||
export default router;
|
||||
@@ -23,6 +23,7 @@ interface Config {
|
||||
transitionCleanupInterval: number;
|
||||
lossThreshold: number;
|
||||
userTTL: number;
|
||||
verifyTimeout: number;
|
||||
};
|
||||
}
|
||||
|
||||
@@ -50,7 +51,8 @@ const config: Config = {
|
||||
transitionTTL: 5 * 60 * 1000,
|
||||
transitionCleanupInterval: 60 * 1000,
|
||||
lossThreshold: 5000,
|
||||
userTTL: 3 * 60 * 1000
|
||||
userTTL: 3 * 60 * 1000,
|
||||
verifyTimeout: 30 * 1000
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
@@ -38,7 +38,7 @@ export async function updateRoomOccupancyListener(message: string, channel: stri
|
||||
const redis = getRedis();
|
||||
|
||||
if (!lastRoomID) {
|
||||
console.log("[!] Key not found in local mapι")
|
||||
console.log("[!] Key not found in local map")
|
||||
} else {
|
||||
await redis.decr(`room:${lastRoomID}`);
|
||||
}
|
||||
@@ -103,8 +103,6 @@ export async function analyzeData(roomID: string, metrics: {
|
||||
const user = newUser();
|
||||
console.log(`[Redis] Created USER: ${user.psuedoName} USER_ID: ${user.pseudoID}`);
|
||||
|
||||
// trigger camera
|
||||
|
||||
await redis.set(`user:${userID}`, JSON.stringify({
|
||||
userID: user.pseudoID,
|
||||
name: user.psuedoName,
|
||||
@@ -141,8 +139,6 @@ export async function analyzeData(roomID: string, metrics: {
|
||||
|
||||
let transition = transitions.get(userID);
|
||||
|
||||
// update room occupancy
|
||||
|
||||
if (!transition) {
|
||||
console.log("[!] Starting new transition");
|
||||
transition = new RoomTransition();
|
||||
@@ -150,8 +146,6 @@ export async function analyzeData(roomID: string, metrics: {
|
||||
}
|
||||
|
||||
if (transition.shouldTransitionTo(roomID, metrics.rssi, resObj["rssi"], resObj["timestamp"])) {
|
||||
// trigger camera
|
||||
|
||||
console.log(`[!] Transition done ${resObj["room"]} -> ${roomID}`);
|
||||
|
||||
await redis.decr(`room:${resObj["room"]}`);
|
||||
@@ -162,7 +156,7 @@ export async function analyzeData(roomID: string, metrics: {
|
||||
resObj["rssi"] = metrics.rssi;
|
||||
resObj["room"] = roomID;
|
||||
resObj["timestamp"] = Date.now()
|
||||
|
||||
resObj["verified"] = false;
|
||||
|
||||
await redis.set(`user:${userID}`, JSON.stringify(resObj), { PX: config.core.userTTL });
|
||||
} else
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { getRoomIDs } from "../api/livedata/livedata.repository.js";
|
||||
import { initRedis, initSubRedis } from "../redis/redis.js";
|
||||
import { initCamera } from "./camera.js";
|
||||
import * as mqtt from "./mqtt.js";
|
||||
import { cleanupWorker } from "./transition.js";
|
||||
|
||||
@@ -19,6 +20,8 @@ export async function bootstrap() {
|
||||
|
||||
cleanupWorker();
|
||||
|
||||
await initCamera();
|
||||
|
||||
console.log("[!] Core started.");
|
||||
} catch (err: any) {
|
||||
console.log("[!] Bootstrap failed:", err.message);
|
||||
|
||||
@@ -0,0 +1,132 @@
|
||||
import { fileURLToPath } from 'url'
|
||||
import path from 'path'
|
||||
import { spawn } from "child_process";
|
||||
import { getRedis } from '../redis/redis.js';
|
||||
import { prisma } from '../lib/prisma.js';
|
||||
import config from '../config/config.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url))
|
||||
const execPath = '../../bin';
|
||||
const binaryPath = path.join(__dirname, execPath, 'cameraSession')
|
||||
|
||||
type InputPacket = {
|
||||
uuid: string
|
||||
descriptor: number[]
|
||||
}
|
||||
|
||||
type ResultPacket = {
|
||||
uuid: string
|
||||
verified: boolean
|
||||
}
|
||||
|
||||
export async function getUnverifiedUserKeys(): Promise<string[]> {
|
||||
const redis = getRedis()
|
||||
|
||||
const keys = await redis.keys('user:*')
|
||||
|
||||
const unverified: string[] = []
|
||||
|
||||
await Promise.all(keys.map(async (key) => {
|
||||
const raw = await redis.get(key)
|
||||
if (!raw) return
|
||||
const session = JSON.parse(raw)
|
||||
if (!session.verified) unverified.push(key.replace('user:', ''))
|
||||
}))
|
||||
|
||||
return unverified
|
||||
}
|
||||
|
||||
export async function getUnverifiedUsersWithEmbeddings(): Promise<{ uuid: string, descriptor: number[] }[]> {
|
||||
const userIds = await getUnverifiedUserKeys()
|
||||
|
||||
const users = await prisma.user.findMany({
|
||||
where: {
|
||||
id: { in: userIds },
|
||||
faceEmbedding: { not: null },
|
||||
},
|
||||
select: { id: true, faceEmbedding: true },
|
||||
})
|
||||
|
||||
return users.map((user) => ({
|
||||
uuid: user.id,
|
||||
descriptor: JSON.parse(user.faceEmbedding!),
|
||||
}))
|
||||
}
|
||||
|
||||
async function cameraSession(packets: InputPacket[]): Promise<ResultPacket[] | null> {
|
||||
return new Promise((resolve, reject) => {
|
||||
const proc = spawn(binaryPath, [], {
|
||||
cwd: path.join(__dirname, execPath)
|
||||
})
|
||||
let stdout = ''
|
||||
let stderr = ''
|
||||
|
||||
proc.stdout.on('data', (d) => stdout += d)
|
||||
proc.stderr.on('data', (d) => stderr += d)
|
||||
proc.stdin.on('error', () => { })
|
||||
|
||||
proc.on('error', (err) => {
|
||||
reject(new Error(`Failed to start binary: ${err.message}`))
|
||||
})
|
||||
|
||||
proc.on('close', (code) => {
|
||||
console.log('cameraSession exited with code', code)
|
||||
const trimmed = stdout.trim()
|
||||
if (trimmed === 'no face found.') return resolve(null)
|
||||
if (code !== 0) return reject(new Error(stderr || `exited with code ${code}`))
|
||||
try {
|
||||
const results: ResultPacket[] = JSON.parse(trimmed)
|
||||
resolve(results)
|
||||
} catch (e) {
|
||||
reject(new Error(`Failed to parse output: ${trimmed}`))
|
||||
}
|
||||
})
|
||||
|
||||
for (const packet of packets) {
|
||||
proc.stdin.write(JSON.stringify(packet) + '\n')
|
||||
}
|
||||
proc.stdin.end()
|
||||
})
|
||||
}
|
||||
|
||||
const setUnverified = async (uuid: string) => {
|
||||
const redis = getRedis()
|
||||
const key = `user:${uuid}`
|
||||
const raw = await redis.get(key)
|
||||
if (!raw) return
|
||||
const session = JSON.parse(raw)
|
||||
session.verified = false
|
||||
await redis.set(key, JSON.stringify(session))
|
||||
console.log(`[Camera] Unverified user ${uuid} due to timeout`)
|
||||
}
|
||||
|
||||
const run = async () => {
|
||||
const redis = getRedis();
|
||||
|
||||
try {
|
||||
const packets = await getUnverifiedUsersWithEmbeddings()
|
||||
if (packets.length > 0) {
|
||||
const results = await cameraSession(packets)
|
||||
if (results) {
|
||||
await Promise.all(results.map(async (r) => {
|
||||
if (!r.verified) return
|
||||
const key = `user:${r.uuid}`
|
||||
const raw = await redis.get(key)
|
||||
if (!raw) return
|
||||
const session = JSON.parse(raw)
|
||||
session.verified = true
|
||||
await redis.set(key, JSON.stringify(session))
|
||||
|
||||
setTimeout(() => setUnverified(r.uuid), config.core.verifyTimeout)
|
||||
|
||||
console.log(`[Camera] Verified user ${r.uuid}`)
|
||||
}))
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('camera run error:', e)
|
||||
}
|
||||
setTimeout(run, 1000)
|
||||
}
|
||||
|
||||
export { run as initCamera };
|
||||
@@ -1,14 +1,22 @@
|
||||
import express from "express";
|
||||
import lookupRoutes from "./api/lookup/lookup.routes.js";
|
||||
import livedataRoutes from "./api/livedata/livadata.routes.js"
|
||||
import lookupRouter from "./api/lookup/lookup.routes.js";
|
||||
import livedataRouter from "./api/livedata/livadata.routes.js"
|
||||
import mapperRouter from "./api/mapper/mapper.routes.js"
|
||||
import { createAdmin, sessionMiddleware } from './api/management/adminjs.routes.js'
|
||||
import config from "./config/config.js";
|
||||
import { bootstrap } from "./core/bootstrap.js";
|
||||
|
||||
(async () => {
|
||||
const app = express();
|
||||
|
||||
app.use("/lookup", lookupRoutes);
|
||||
app.use("/livedata", livedataRoutes);
|
||||
const { admin, router: adminRouter } = await createAdmin();
|
||||
|
||||
app.use(sessionMiddleware);
|
||||
|
||||
app.use("/lookup", lookupRouter);
|
||||
app.use("/livedata", livedataRouter);
|
||||
app.use("/admin", mapperRouter);
|
||||
app.use("/admin", adminRouter);
|
||||
|
||||
app.listen(config.api.port, config.api.address, () => {
|
||||
console.log(`Server listening on ${config.api.address}:${config.api.port}`);
|
||||
|
||||
@@ -1,10 +1,5 @@
|
||||
import "dotenv/config";
|
||||
import { PrismaBetterSqlite3 } from "@prisma/adapter-better-sqlite3";
|
||||
import { PrismaClient } from "../generated/prisma/client.js";
|
||||
import { PrismaClient } from '@prisma/client'
|
||||
|
||||
const connectionString = `${process.env.DATABASE_URL}`;
|
||||
|
||||
const adapter = new PrismaBetterSqlite3({ url: connectionString });
|
||||
const prisma = new PrismaClient({ adapter });
|
||||
const prisma = new PrismaClient()
|
||||
|
||||
export { prisma };
|
||||
Reference in New Issue
Block a user