Skip to content

Commit d489429

Browse files
committed
Merge branch 'develop' into feat/new-invite-cooperators
2 parents bf4cbcb + 7606bf2 commit d489429

37 files changed

Lines changed: 7047 additions & 2418 deletions

.env.example

Lines changed: 6 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -29,4 +29,9 @@ VUE_APP_TRANSCRIPTION_API_BASE_URL=BASE_URL_FOR_TRANSCRIPTION_API # e.g. http://
2929
VUE_APP_TRANSCRIPTION_SENTIMENT_API_BASE_URL=BASE_URL_FOR_SENTIMENT_API # e.g. http://127.0.0.1:8001/audio-transcript-sentiment
3030

3131
######## Facial Sentiment Analysis ##########
32-
VUE_APP_FACIAL_SENTIMENT_API_BASE_URL=BASE_URL_FOR_SENTIMENT_API # e.g http://localhost:8001/process_video
32+
VUE_APP_FACIAL_SENTIMENT_API_BASE_URL=BASE_URL_FOR_SENTIMENT_API # e.g http://localhost:8001/process_video
33+
34+
######### LiveKit (dev) #########
35+
VUE_APP_LIVEKIT_ENABLED=true
36+
VUE_APP_LIVEKIT_TOKEN_SERVER_URL=http://localhost:3001
37+
VUE_APP_LIVEKIT_URL=ws://localhost:7880

README.md

Lines changed: 26 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -26,7 +26,7 @@
2626
</div>
2727

2828
## About
29-
as
29+
3030
UX Remote LAB is an open-source platform designed to collect usability feedback from users. It allows you to gather user reviews, analyze them, and create comprehensive reports to better understand your application's usability. Additionally, it offers heuristic tests, enabling experts to evaluate your application's compliance with usability principles.
3131

3232
### Community & Experience
@@ -221,6 +221,31 @@ npm run deploy:prod
221221

222222
These scripts load `functions/.env.development` or `functions/.env.production` depending on the target and use the `develop` and `prod` aliases from `.firebaserc`.
223223

224+
## LiveKit Setup (Moderated Video Calls)
225+
226+
Moderated user tests with video calls require a separate **LiveKit** stack. RUXAILAB does not bundle the server or token issuer — clone the companion repository and run it alongside this project.
227+
228+
### Clone the repository
229+
230+
```bash
231+
git clone https://github.com/ruxailab/video-call-server.git
232+
cd video-call-server
233+
```
234+
235+
Follow the setup and run instructions in that repository's README (LiveKit server + token server).
236+
237+
### Configure RUXAILAB
238+
239+
Copy the LiveKit variables from `.env.example` into your `.env` file:
240+
241+
```bash
242+
VUE_APP_LIVEKIT_ENABLED=true
243+
VUE_APP_LIVEKIT_TOKEN_SERVER_URL=http://localhost:3001
244+
VUE_APP_LIVEKIT_URL=ws://localhost:7880
245+
```
246+
247+
Adjust the URLs if your local setup uses different ports.
248+
224249
## Docker Setup
225250

226251
### Building the Docker Image

functions/.env.example

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,5 @@ SMTP_PASS=
55
SMTP_SECURE=
66
SITE_URL=
77
RUXAILAB_FUNCTIONS_REGION=
8-
EYE_LAB_CORS_ORIGINS=
8+
EYE_LAB_CORS_ORIGINS=
9+
LOG_ACTOR_HASH_SALT=

functions/src/https/index.js

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
11
export * from './users.js'
22
export * from './eyeTracking.js'
33
export * from './email.js'
4+
export * from './logEvents.js'

functions/src/https/logEvents.js

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
import crypto from 'crypto'
2+
import { admin, functions } from '../f.firebase.js'
3+
import logger from '../utils/logger.js'
4+
5+
const MAX_EVENTS_PER_BATCH = 25
6+
const MAX_STRING_LENGTH = 240
7+
const MAX_TRACE_ID_LENGTH = 120
8+
const MAX_DETAILS_DEPTH = 4
9+
const MAX_ARRAY_ITEMS = 20
10+
const MAX_OBJECT_KEYS = 40
11+
12+
const ALLOWED_LAYERS = new Set(['technical', 'methodological', 'ai'])
13+
const ALLOWED_LEVELS = new Set(['info', 'warn', 'error'])
14+
15+
// Mirrors src/shared/utils/accessLevel.js — must stay in sync
16+
const COOPERATOR_ROLE_MAP = new Map([
17+
[0, 'admin'],
18+
[1, 'evaluator'],
19+
[2, 'guest'],
20+
[3, 'observator'],
21+
])
22+
const TYPE_PATTERN = /^[A-Z][A-Z0-9_]{1,63}$/
23+
const SAFE_ID_PATTERN = /^[A-Za-z0-9:_-]{3,160}$/
24+
const SENSITIVE_KEY_PATTERN = /email|fullName|displayName|phone|token|secret/i
25+
const EMAIL_PATTERN = /\b[a-zA-Z0-9._%+-]+@(?:[a-zA-Z0-9-]+\.)+[a-zA-Z]{2,63}\b/g
26+
27+
function httpsError(code, message) {
28+
return new functions.https.HttpsError(code, message)
29+
}
30+
31+
function getRequestData(request) {
32+
return request?.data || request || {}
33+
}
34+
35+
function getAuthUid(request) {
36+
return request?.auth?.uid || null
37+
}
38+
39+
function assertSafeId(value, fieldName) {
40+
if (typeof value !== 'string' || !SAFE_ID_PATTERN.test(value)) {
41+
throw httpsError('invalid-argument', `${fieldName} is invalid`)
42+
}
43+
return value
44+
}
45+
46+
function sanitizeString(value, maxLength = MAX_STRING_LENGTH) {
47+
if (value === undefined || value === null) return ''
48+
return String(value).replace(EMAIL_PATTERN, '[redacted-email]').slice(0, maxLength)
49+
}
50+
51+
function sanitizeDetails(value, depth = 0) {
52+
if (value === null || value === undefined) return null
53+
54+
if (depth >= MAX_DETAILS_DEPTH) {
55+
return '[truncated]'
56+
}
57+
58+
if (Array.isArray(value)) {
59+
return value
60+
.slice(0, MAX_ARRAY_ITEMS)
61+
.map((item) => sanitizeDetails(item, depth + 1))
62+
}
63+
64+
if (typeof value === 'object') {
65+
return Object.fromEntries(
66+
Object.entries(value)
67+
.filter(([key]) => !SENSITIVE_KEY_PATTERN.test(key))
68+
.slice(0, MAX_OBJECT_KEYS)
69+
.map(([key, item]) => [
70+
sanitizeString(key, 80),
71+
sanitizeDetails(item, depth + 1),
72+
]),
73+
)
74+
}
75+
76+
if (typeof value === 'number') return Number.isFinite(value) ? value : null
77+
if (typeof value === 'boolean') return value
78+
return sanitizeString(value)
79+
}
80+
81+
function validateEvent(event) {
82+
if (!event || typeof event !== 'object' || Array.isArray(event)) {
83+
throw httpsError('invalid-argument', 'Each log event must be an object')
84+
}
85+
86+
if (typeof event.type !== 'string' || !TYPE_PATTERN.test(event.type)) {
87+
throw httpsError('invalid-argument', 'Log event type is invalid')
88+
}
89+
90+
if (!ALLOWED_LAYERS.has(event.layer)) {
91+
throw httpsError('invalid-argument', 'Log event layer is invalid')
92+
}
93+
94+
if (!ALLOWED_LEVELS.has(event.level)) {
95+
throw httpsError('invalid-argument', 'Log event level is invalid')
96+
}
97+
98+
return {
99+
type: event.type,
100+
layer: event.layer,
101+
level: event.level,
102+
source: sanitizeString(event.source ?? 'client', 80),
103+
traceId: sanitizeString(event.traceId ?? '', MAX_TRACE_ID_LENGTH),
104+
message: sanitizeString(event.message ?? event.type),
105+
details: sanitizeDetails(event.details ?? {}),
106+
}
107+
}
108+
109+
function validatePayload(payload) {
110+
const testId = assertSafeId(payload.testId, 'testId')
111+
const batchId = assertSafeId(payload.batchId, 'batchId')
112+
const answersDocId =
113+
payload.answersDocId === undefined || payload.answersDocId === null
114+
? ''
115+
: assertSafeId(payload.answersDocId, 'answersDocId')
116+
117+
if (!Array.isArray(payload.events) || payload.events.length === 0) {
118+
throw httpsError('invalid-argument', 'events must contain at least one log event')
119+
}
120+
121+
if (payload.events.length > MAX_EVENTS_PER_BATCH) {
122+
throw httpsError(
123+
'invalid-argument',
124+
`events cannot contain more than ${MAX_EVENTS_PER_BATCH} items`,
125+
)
126+
}
127+
128+
return {
129+
testId,
130+
batchId,
131+
answersDocId,
132+
clientTimestamp: sanitizeString(payload.clientTimestamp ?? '', 80),
133+
sessionId: sanitizeString(payload.sessionId ?? '', 120),
134+
events: payload.events.map(validateEvent),
135+
}
136+
}
137+
138+
function createActorHash(uid, testId) {
139+
const salt = process.env.LOG_ACTOR_HASH_SALT
140+
if (!salt) {
141+
logger.error('LOG_ACTOR_HASH_SALT environment variable is not configured')
142+
throw httpsError('internal', 'Logging service is misconfigured')
143+
}
144+
return crypto.createHash('sha256').update(`${salt}:${testId}:${uid}`).digest('hex')
145+
}
146+
147+
function resolveActorRole(testData, uid) {
148+
if (testData?.testAdmin?.userDocId === uid) return 'admin'
149+
150+
const cooperator = (testData?.cooperators || []).find(
151+
(item) => item?.userDocId === uid,
152+
)
153+
154+
if (cooperator) {
155+
// accessLevel is stored as a number (0=admin, 1=evaluator, 2=guest, 3=observer).
156+
// Using ?? so accessLevel=0 is not incorrectly treated as falsy.
157+
const level = cooperator.accessLevel ?? null
158+
return COOPERATOR_ROLE_MAP.get(level) ?? 'cooperator'
159+
}
160+
161+
return null
162+
}
163+
164+
function isDuplicateBatchError(error) {
165+
return (
166+
error?.code === 6 ||
167+
error?.code === 'already-exists' ||
168+
String(error?.message || '').toLowerCase().includes('already exists')
169+
)
170+
}
171+
172+
export const logEvents = functions.onCall({
173+
handler: async (request) => {
174+
const uid = getAuthUid(request)
175+
if (!uid) {
176+
throw httpsError('unauthenticated', 'Authentication is required')
177+
}
178+
179+
const payload = validatePayload(getRequestData(request))
180+
const db = admin.firestore()
181+
const testRef = db.collection('tests').doc(payload.testId)
182+
const testSnap = await testRef.get()
183+
184+
if (!testSnap.exists) {
185+
throw httpsError('not-found', 'Study not found')
186+
}
187+
188+
const testData = testSnap.data()
189+
const actorRole = resolveActorRole(testData, uid)
190+
191+
if (!actorRole) {
192+
throw httpsError('permission-denied', 'User cannot write logs for this study')
193+
}
194+
195+
const timestamp = admin.firestore.FieldValue.serverTimestamp()
196+
const actorHash = createActorHash(uid, payload.testId)
197+
const batch = db.batch()
198+
const batchRef = testRef.collection('logBatches').doc(payload.batchId)
199+
const answersDocId = payload.answersDocId || testData.answersDocId || ''
200+
201+
for (const event of payload.events) {
202+
const logRef = testRef.collection('logs').doc()
203+
batch.set(logRef, {
204+
...event,
205+
testId: payload.testId,
206+
answersDocId,
207+
studyType: testData.testType || '',
208+
subType: testData.subType || '',
209+
actorHash,
210+
actorType: actorRole === 'admin' ? 'researcher' : 'cooperator',
211+
actorRole,
212+
sessionId: payload.sessionId,
213+
batchId: payload.batchId,
214+
clientTimestamp: payload.clientTimestamp,
215+
timestamp,
216+
schemaVersion: 1,
217+
})
218+
}
219+
220+
batch.create(batchRef, {
221+
batchId: payload.batchId,
222+
testId: payload.testId,
223+
answersDocId,
224+
actorHash,
225+
eventCount: payload.events.length,
226+
createdAt: timestamp,
227+
schemaVersion: 1,
228+
})
229+
230+
try {
231+
await batch.commit()
232+
} catch (error) {
233+
if (isDuplicateBatchError(error)) {
234+
logger.warn('Duplicate log batch skipped', {
235+
testId: payload.testId,
236+
batchId: payload.batchId,
237+
})
238+
return {
239+
status: 'duplicate',
240+
written: 0,
241+
batchId: payload.batchId,
242+
}
243+
}
244+
245+
logger.error('Failed to write log batch', {
246+
errorCode: error?.code,
247+
errorMessage: error?.message,
248+
testId: payload.testId,
249+
batchId: payload.batchId,
250+
})
251+
throw httpsError('internal', 'Failed to write log batch')
252+
}
253+
254+
return {
255+
status: 'ok',
256+
written: payload.events.length,
257+
batchId: payload.batchId,
258+
}
259+
},
260+
})

0 commit comments

Comments
 (0)