feat: enforce practice access controls

This commit is contained in:
Codex
2026-06-28 23:23:02 +08:00
parent 3647c2bc2d
commit 6a7294224d
12 changed files with 1047 additions and 110 deletions

View File

@@ -84,7 +84,7 @@ export async function contentNodesRoute(ctx: RequestContext) {
marker_type as "markerType", marker_config as "markerConfig",
path::text as path, depth, sort_order as "order",
is_active as "isActive", is_selectable as "isSelectable",
is_leaf as "isLeaf", metadata,
is_leaf as "isLeaf", access_rules as "accessRules", metadata,
created_at as "createdAt", updated_at as "updatedAt"
from public.content_nodes
where ${filters.join(' and ')}
@@ -132,7 +132,7 @@ export async function questionCollectionsRoute(ctx: RequestContext) {
legacy_id as "legacyId", name, collection_type as "collectionType",
source_type as "sourceType", filters, question_count as "questionCount",
total_score as "totalScore", duration_minutes as "durationMinutes",
status, sort_order as "order", metadata,
status, sort_order as "order", access_rules as "accessRules", metadata,
created_at as "createdAt", updated_at as "updatedAt"
from public.question_collections
where ${filters.join(' and ')}
@@ -180,7 +180,7 @@ export async function practiceBlueprintsRoute(ctx: RequestContext) {
legacy_id as "legacyId", name, mode,
assembly_type as "assemblyType", question_limit as "questionLimit",
duration_minutes as "durationMinutes", total_score as "totalScore",
pass_score as "passScore", sections, rules, status,
pass_score as "passScore", sections, rules, access_rules as "accessRules", status,
sort_order as "order", created_at as "createdAt",
updated_at as "updatedAt"
from public.practice_blueprints

View File

@@ -0,0 +1,564 @@
import type pg from 'pg';
import { HttpError } from '../../core/http.js';
type JsonObject = Record<string, unknown>;
export interface PracticeAssemblyAccessInput {
mode: string;
blueprintId: string | null;
collectionId: string | null;
entryId: string | null;
contentNodeId: string | null;
rules: JsonObject;
}
export interface PracticeAccessDecision {
accessMode: 'free' | 'svip' | 'staff';
entitlementId: string | null;
questionIds: string[];
consumedFreeQuota: number;
accessSnapshot: JsonObject;
}
interface PracticeAccessScopeRow {
entryId: string | null;
entryRegionId: string | null;
entryVisibility: string | null;
entryAccessRules: JsonObject | null;
nodeId: string | null;
nodeRegionId: string | null;
nodeAccessRules: JsonObject | null;
nodeMetadata: JsonObject | null;
collectionId: string | null;
collectionRegionId: string | null;
collectionSubjectId: string | null;
collectionQuestionBankId: string | null;
collectionAccessRules: JsonObject | null;
collectionMetadata: JsonObject | null;
blueprintId: string | null;
blueprintRegionId: string | null;
blueprintAccessRules: JsonObject | null;
blueprintRules: JsonObject | null;
}
interface QuestionScopeRow {
id: string;
regionId: string | null;
subjectId: string | null;
questionBankId: string | null;
contentNodeId: string | null;
primaryCollectionId: string | null;
}
interface EntitlementRow {
id: string;
scopeType: string;
scopeId: string | null;
expiresAt: string | null;
}
interface StaffRow {
id: string;
role: string;
}
function isObject(value: unknown): value is JsonObject {
return !!value && typeof value === 'object' && !Array.isArray(value);
}
function objectValue(value: unknown): JsonObject {
return isObject(value) ? value : {};
}
function positiveIntValue(value: unknown, fallback: number, max = 1000) {
const parsed = Number(value ?? fallback);
if (!Number.isFinite(parsed) || parsed < 0) return fallback;
return Math.min(Math.trunc(parsed), max);
}
function stringValue(value: unknown) {
return typeof value === 'string' && value.trim() ? value.trim() : '';
}
function boolValue(value: unknown, fallback = false) {
return typeof value === 'boolean' ? value : fallback;
}
function mergeRules(...rules: unknown[]): JsonObject {
return rules.reduce<JsonObject>((acc, rule) => ({ ...acc, ...objectValue(rule) }), {});
}
function resolveAccessMode(scope: PracticeAccessScopeRow, assembly: PracticeAssemblyAccessInput, mergedRules: JsonObject) {
const explicitMode = stringValue(mergedRules.accessMode || mergedRules.mode || mergedRules.requiredAccess);
if (['public', 'free'].includes(explicitMode)) return 'free';
if (['members', 'svip', 'paid'].includes(explicitMode)) return 'svip';
if (scope.entryVisibility === 'svip') return 'svip';
if (scope.entryVisibility === 'members') return 'free';
if (assembly.mode === 'mock_exam' && boolValue(mergedRules.mockExamRequiresSvip, false)) return 'svip';
return 'free';
}
function scopeCandidates(input: {
tenantId: string;
mergedRules: JsonObject;
scope: PracticeAccessScopeRow;
questions: QuestionScopeRow[];
}) {
const regionIds = new Set<string>();
const subjectIds = new Set<string>();
const questionBankIds = new Set<string>();
for (const id of [input.scope.blueprintRegionId, input.scope.collectionRegionId, input.scope.nodeRegionId, input.scope.entryRegionId]) {
if (id) regionIds.add(id);
}
for (const question of input.questions) {
if (question.regionId) regionIds.add(question.regionId);
if (question.subjectId) subjectIds.add(question.subjectId);
if (question.questionBankId) questionBankIds.add(question.questionBankId);
}
const requiredRegionId = stringValue(input.mergedRules.regionId || input.mergedRules.requiredRegionId);
const requiredSubjectId = stringValue(input.mergedRules.subjectId || input.mergedRules.requiredSubjectId);
const requiredQuestionBankId = stringValue(input.mergedRules.questionBankId || input.mergedRules.requiredQuestionBankId);
if (requiredRegionId) regionIds.add(requiredRegionId);
if (requiredSubjectId) subjectIds.add(requiredSubjectId);
if (requiredQuestionBankId) questionBankIds.add(requiredQuestionBankId);
if (input.scope.collectionSubjectId) subjectIds.add(input.scope.collectionSubjectId);
if (input.scope.collectionQuestionBankId) questionBankIds.add(input.scope.collectionQuestionBankId);
return {
tenantIds: [input.tenantId],
regionIds: [...regionIds],
subjectIds: [...subjectIds],
questionBankIds: [...questionBankIds],
};
}
function hasScopeAccess(entitlement: EntitlementRow, candidates: ReturnType<typeof scopeCandidates>) {
if (entitlement.scopeType === 'tenant') return true;
if (!entitlement.scopeId) return false;
if (entitlement.scopeType === 'region') return candidates.regionIds.includes(entitlement.scopeId);
if (entitlement.scopeType === 'subject') return candidates.subjectIds.includes(entitlement.scopeId);
if (entitlement.scopeType === 'question_bank') return candidates.questionBankIds.includes(entitlement.scopeId);
return false;
}
async function loadScope(client: pg.PoolClient, tenantId: string, assembly: PracticeAssemblyAccessInput) {
const result = await client.query<PracticeAccessScopeRow>(
`
select
ce.id as "entryId",
ce.region_id as "entryRegionId",
ce.visibility as "entryVisibility",
ce.access_rules as "entryAccessRules",
cn.id as "nodeId",
cn.region_id as "nodeRegionId",
cn.access_rules as "nodeAccessRules",
cn.metadata as "nodeMetadata",
qc.id as "collectionId",
qc.region_id as "collectionRegionId",
qc.subject_id as "collectionSubjectId",
qc.question_bank_id as "collectionQuestionBankId",
qc.access_rules as "collectionAccessRules",
qc.metadata as "collectionMetadata",
pb.id as "blueprintId",
pb.region_id as "blueprintRegionId",
pb.access_rules as "blueprintAccessRules",
pb.rules as "blueprintRules"
from (select $1::uuid as tenant_id) t
left join public.content_entries ce on ce.tenant_id = t.tenant_id and ce.id = $2::uuid
left join public.content_nodes cn on cn.tenant_id = t.tenant_id and cn.id = $3::uuid
left join public.question_collections qc on qc.tenant_id = t.tenant_id and qc.id = $4::uuid
left join public.practice_blueprints pb on pb.tenant_id = t.tenant_id and pb.id = $5::uuid
limit 1
`,
[tenantId, assembly.entryId, assembly.contentNodeId, assembly.collectionId, assembly.blueprintId],
);
return result.rows[0] || {
entryId: null,
entryRegionId: null,
entryVisibility: null,
entryAccessRules: null,
nodeId: null,
nodeRegionId: null,
nodeAccessRules: null,
nodeMetadata: null,
collectionId: null,
collectionRegionId: null,
collectionSubjectId: null,
collectionQuestionBankId: null,
collectionAccessRules: null,
collectionMetadata: null,
blueprintId: null,
blueprintRegionId: null,
blueprintAccessRules: null,
blueprintRules: null,
};
}
async function loadQuestionScopes(client: pg.PoolClient, tenantId: string, questionIds: string[]) {
if (!questionIds.length) return [];
const result = await client.query<QuestionScopeRow>(
`
select q.id,
coalesce(qb.region_id, s.region_id) as "regionId",
q.subject_id as "subjectId",
q.question_bank_id as "questionBankId",
q.content_node_id as "contentNodeId",
q.primary_collection_id as "primaryCollectionId"
from public.questions q
left join public.subjects s on s.tenant_id = q.tenant_id and s.id = q.subject_id
left join public.question_banks qb on qb.tenant_id = q.tenant_id and qb.id = q.question_bank_id
where q.tenant_id = $1 and q.id = any($2::uuid[])
`,
[tenantId, questionIds],
);
const order = new Map(questionIds.map((id, index) => [id, index]));
return result.rows.sort((left, right) => (order.get(left.id) ?? 0) - (order.get(right.id) ?? 0));
}
async function loadActiveEntitlements(client: pg.PoolClient, tenantId: string, userId: string) {
const result = await client.query<EntitlementRow>(
`
select id, scope_type as "scopeType", scope_id as "scopeId", expires_at as "expiresAt"
from public.entitlements
where tenant_id = $1
and user_id = $2
and entitlement_type = 'svip'
and status = 'active'
and starts_at <= now()
and (expires_at is null or expires_at > now())
order by case scope_type
when 'subject' then 0
when 'question_bank' then 1
when 'region' then 2
else 3
end, expires_at desc nulls first
`,
[tenantId, userId],
);
return result.rows;
}
async function loadStaffMembership(client: pg.PoolClient, tenantId: string, userId: string) {
const result = await client.query<StaffRow>(
`
select id, role
from public.tenant_memberships
where tenant_id = $1
and user_id = $2
and status = 'active'
and role in ('tenant_owner', 'tenant_admin', 'tenant_operator', 'teacher')
order by case role
when 'tenant_owner' then 0
when 'tenant_admin' then 1
when 'tenant_operator' then 2
else 3
end
limit 1
`,
[tenantId, userId],
);
return result.rows[0] || null;
}
async function consumeDailyFreeQuota(client: pg.PoolClient, input: {
tenantId: string;
userId: string;
requestedCount: number;
dailyLimit: number;
scopeType: string;
scopeId: string | null;
metadata: JsonObject;
}) {
if (input.dailyLimit <= 0) return { grantedCount: 0, consumedFreeQuota: 0, remainingBefore: 0, remainingAfter: 0 };
const usage = await client.query<{ used_count: number }>(
`
insert into public.practice_daily_usage (
tenant_id, user_id, usage_date, scope_type, scope_id, free_limit, used_count, metadata
)
values ($1, $2, current_date, $3, $4::uuid, $5, 0, $6::jsonb)
on conflict (tenant_id, user_id, usage_date, scope_type, scope_id)
do update set free_limit = excluded.free_limit,
metadata = public.practice_daily_usage.metadata || excluded.metadata,
updated_at = now()
returning used_count
`,
[
input.tenantId,
input.userId,
input.scopeType,
input.scopeId,
input.dailyLimit,
JSON.stringify(input.metadata),
],
);
const usedCount = Number(usage.rows[0]?.used_count || 0);
const remainingBefore = Math.max(0, input.dailyLimit - usedCount);
const grantedCount = Math.min(input.requestedCount, remainingBefore);
if (grantedCount > 0) {
await client.query(
`
update public.practice_daily_usage
set used_count = used_count + $5,
updated_at = now()
where tenant_id = $1
and user_id = $2
and usage_date = current_date
and scope_type = $3
and (($4::uuid is null and scope_id is null) or scope_id = $4::uuid)
and used_count + $5 <= free_limit
`,
[input.tenantId, input.userId, input.scopeType, input.scopeId, grantedCount],
);
}
return {
grantedCount,
consumedFreeQuota: grantedCount,
remainingBefore,
remainingAfter: Math.max(0, remainingBefore - grantedCount),
};
}
export async function authorizePracticeSession(client: pg.PoolClient, input: {
tenantId: string;
userId: string;
questionIds: string[];
assembly: PracticeAssemblyAccessInput;
}): Promise<PracticeAccessDecision> {
const scope = await loadScope(client, input.tenantId, input.assembly);
const questionScopes = await loadQuestionScopes(client, input.tenantId, input.questionIds);
const mergedRules = mergeRules(
scope.entryAccessRules,
scope.nodeMetadata?.accessRules,
scope.nodeAccessRules,
scope.collectionMetadata?.accessRules,
scope.collectionAccessRules,
scope.blueprintRules?.accessRules,
scope.blueprintAccessRules,
input.assembly.rules?.accessRules,
);
const requiredAccess = resolveAccessMode(scope, input.assembly, mergedRules);
const dailyLimit = positiveIntValue(
mergedRules.freeDailyLimit ?? mergedRules.dailyFreeLimit ?? mergedRules.freeQuestionLimit,
25,
500,
);
const maxFreePerSession = positiveIntValue(
mergedRules.freeSessionLimit ?? mergedRules.maxFreePerSession,
dailyLimit,
dailyLimit,
);
const candidates = scopeCandidates({
tenantId: input.tenantId,
mergedRules,
scope,
questions: questionScopes,
});
const staff = await loadStaffMembership(client, input.tenantId, input.userId);
if (staff && boolValue(mergedRules.staffBypass, true)) {
return {
accessMode: 'staff',
entitlementId: null,
questionIds: input.questionIds,
consumedFreeQuota: 0,
accessSnapshot: {
requiredAccess,
grantedBy: 'staff',
staffRole: staff.role,
requestedCount: input.questionIds.length,
grantedCount: input.questionIds.length,
scope: candidates,
rules: mergedRules,
},
};
}
const entitlement = (await loadActiveEntitlements(client, input.tenantId, input.userId))
.find(row => hasScopeAccess(row, candidates));
if (entitlement) {
return {
accessMode: 'svip',
entitlementId: entitlement.id,
questionIds: input.questionIds,
consumedFreeQuota: 0,
accessSnapshot: {
requiredAccess,
grantedBy: 'svip',
requestedCount: input.questionIds.length,
grantedCount: input.questionIds.length,
entitlement: {
id: entitlement.id,
scopeType: entitlement.scopeType,
scopeId: entitlement.scopeId,
expiresAt: entitlement.expiresAt,
},
scope: candidates,
rules: mergedRules,
},
};
}
if (requiredAccess === 'svip') {
await recordPracticeAccessEvent(client, {
tenantId: input.tenantId,
userId: input.userId,
eventType: 'session_denied',
accessMode: 'denied',
requestedCount: input.questionIds.length,
grantedCount: 0,
consumedFreeQuota: 0,
reason: 'SVIP_REQUIRED',
scopeType: 'tenant',
scopeId: null,
entitlementId: null,
metadata: { requiredAccess, scope: candidates, rules: mergedRules },
});
throw new HttpError(403, 'SVIP entitlement is required for this practice target', 'PRACTICE_SVIP_REQUIRED');
}
const quotaScopeType = stringValue(mergedRules.freeQuotaScopeType) || 'tenant';
const quotaScopeId =
quotaScopeType === 'region'
? candidates.regionIds[0] || null
: quotaScopeType === 'content_entry'
? input.assembly.entryId
: quotaScopeType === 'content_node'
? input.assembly.contentNodeId
: quotaScopeType === 'collection'
? input.assembly.collectionId
: quotaScopeType === 'blueprint'
? input.assembly.blueprintId
: null;
const cappedRequestCount = Math.min(input.questionIds.length, maxFreePerSession);
const quota = await consumeDailyFreeQuota(client, {
tenantId: input.tenantId,
userId: input.userId,
requestedCount: cappedRequestCount,
dailyLimit,
scopeType: quotaScopeType,
scopeId: quotaScopeId,
metadata: {
source: 'practice_session',
mode: input.assembly.mode,
blueprintId: input.assembly.blueprintId,
collectionId: input.assembly.collectionId,
entryId: input.assembly.entryId,
contentNodeId: input.assembly.contentNodeId,
},
});
if (quota.grantedCount <= 0) {
await recordPracticeAccessEvent(client, {
tenantId: input.tenantId,
userId: input.userId,
eventType: 'session_denied',
accessMode: 'denied',
requestedCount: input.questionIds.length,
grantedCount: 0,
consumedFreeQuota: 0,
reason: 'FREE_DAILY_LIMIT_REACHED',
scopeType: quotaScopeType,
scopeId: quotaScopeId,
entitlementId: null,
metadata: { requiredAccess, dailyLimit, scope: candidates, rules: mergedRules, quota },
});
throw new HttpError(403, 'Daily free practice quota is used up', 'PRACTICE_FREE_LIMIT_REACHED');
}
const grantedQuestionIds = input.questionIds.slice(0, quota.grantedCount);
return {
accessMode: 'free',
entitlementId: null,
questionIds: grantedQuestionIds,
consumedFreeQuota: quota.consumedFreeQuota,
accessSnapshot: {
requiredAccess,
grantedBy: 'free_quota',
requestedCount: input.questionIds.length,
grantedCount: grantedQuestionIds.length,
truncated: grantedQuestionIds.length < input.questionIds.length,
dailyLimit,
maxFreePerSession,
quotaScopeType,
quotaScopeId,
quota,
scope: candidates,
rules: mergedRules,
},
};
}
export async function recordPracticeAccessEvent(client: pg.PoolClient, input: {
tenantId: string;
userId: string | null;
practiceSessionId?: string | null;
eventType: 'session_created' | 'session_denied' | 'quota_consumed';
accessMode: 'free' | 'svip' | 'staff' | 'denied';
requestedCount: number;
grantedCount: number;
consumedFreeQuota: number;
reason?: string | null;
scopeType?: string | null;
scopeId?: string | null;
entitlementId?: string | null;
metadata?: JsonObject;
}) {
await client.query(
`
insert into public.practice_access_events (
tenant_id, user_id, practice_session_id, event_type, access_mode,
requested_count, granted_count, consumed_free_quota, reason,
scope_type, scope_id, entitlement_id, metadata
)
values ($1, $2::uuid, $3::uuid, $4, $5, $6, $7, $8, $9, $10, $11::uuid, $12::uuid, $13::jsonb)
`,
[
input.tenantId,
input.userId,
input.practiceSessionId || null,
input.eventType,
input.accessMode,
input.requestedCount,
input.grantedCount,
input.consumedFreeQuota,
input.reason || null,
input.scopeType || null,
input.scopeId || null,
input.entitlementId || null,
JSON.stringify(input.metadata || {}),
],
);
}
export async function assertAnswerSessionAccess(client: pg.PoolClient, input: {
tenantId: string;
userId: string;
practiceSessionId: string | null;
questionId: string;
}) {
if (!input.practiceSessionId) {
throw new HttpError(400, 'practiceSessionId is required for answer submission', 'PRACTICE_SESSION_REQUIRED');
}
const result = await client.query<{ id: string }>(
`
select id
from public.practice_sessions
where tenant_id = $1
and user_id = $2
and id = $3
and finished_at is null
and (expires_at is null or expires_at > now())
and question_ids ? $4
limit 1
`,
[input.tenantId, input.userId, input.practiceSessionId, input.questionId],
);
if (!result.rows[0]) {
throw new HttpError(403, 'Question is not available in the active practice session', 'PRACTICE_SESSION_QUESTION_FORBIDDEN');
}
}

View File

@@ -11,6 +11,7 @@ import {
userIdFrom,
} from '../../core/request.js';
import { query, queryOne, transaction } from '../../core/db.js';
import { assertAnswerSessionAccess, authorizePracticeSession, recordPracticeAccessEvent } from './access.js';
interface QuestionAnswerRow {
question_id: string;
@@ -351,53 +352,86 @@ export async function createPracticeSessionRoute(ctx: RequestContext) {
throw new HttpError(409, 'No published questions are available for this practice target', 'NO_PRACTICE_QUESTIONS');
}
const item = await queryOne(
`
insert into public.practice_sessions (
tenant_id, user_id, mode, target_type, target_id,
blueprint_id, collection_id, entry_id, content_node_id,
question_ids, question_count, duration_minutes, total_score,
expires_at, metadata
)
values (
$1, $2, $3, $4, $5,
$6::uuid, $7::uuid, $8::uuid, $9::uuid,
$10::jsonb, $11, $12, $13,
case when $12::integer is null then null else now() + make_interval(mins => $12::integer) end,
$14::jsonb
)
returning id, tenant_id as "tenantId", user_id as "userId", mode,
target_type as "targetType", target_id as "targetId",
blueprint_id as "blueprintId", collection_id as "collectionId",
entry_id as "entryId", content_node_id as "contentNodeId",
question_ids as "questionIds", question_count as "questionCount",
duration_minutes as "durationMinutes", total_score as "totalScore",
expires_at as "expiresAt", started_at as "startedAt",
finished_at as "finishedAt", metadata
`,
[
const item = await transaction(async client => {
const access = await authorizePracticeSession(client, {
tenantId,
userId,
assembly.mode,
assembly.targetType,
assembly.targetId,
assembly.blueprintId,
assembly.collectionId,
assembly.entryId,
assembly.contentNodeId,
JSON.stringify(questionIds),
questionIds.length,
assembly.durationMinutes,
assembly.totalScore,
JSON.stringify({
...(body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {}),
assembly: {
sections: assembly.sections,
rules: assembly.rules,
},
}),
],
);
questionIds,
assembly,
});
const sessionResult = await client.query(
`
insert into public.practice_sessions (
tenant_id, user_id, mode, target_type, target_id,
blueprint_id, collection_id, entry_id, content_node_id,
question_ids, question_count, duration_minutes, total_score,
access_mode, access_entitlement_id, consumed_free_quota, access_snapshot,
expires_at, metadata
)
values (
$1, $2, $3, $4, $5::uuid,
$6::uuid, $7::uuid, $8::uuid, $9::uuid,
$10::jsonb, $11, $12, $13,
$14, $15::uuid, $16, $17::jsonb,
case when $12::integer is null then null else now() + make_interval(mins => $12::integer) end,
$18::jsonb
)
returning id, tenant_id as "tenantId", user_id as "userId", mode,
target_type as "targetType", target_id as "targetId",
blueprint_id as "blueprintId", collection_id as "collectionId",
entry_id as "entryId", content_node_id as "contentNodeId",
question_ids as "questionIds", question_count as "questionCount",
duration_minutes as "durationMinutes", total_score as "totalScore",
access_mode as "accessMode", access_entitlement_id as "accessEntitlementId",
consumed_free_quota as "consumedFreeQuota", access_snapshot as "accessSnapshot",
expires_at as "expiresAt", started_at as "startedAt",
finished_at as "finishedAt", metadata
`,
[
tenantId,
userId,
assembly.mode,
assembly.targetType,
assembly.targetId,
assembly.blueprintId,
assembly.collectionId,
assembly.entryId,
assembly.contentNodeId,
JSON.stringify(access.questionIds),
access.questionIds.length,
assembly.durationMinutes,
assembly.totalScore,
access.accessMode,
access.entitlementId,
access.consumedFreeQuota,
JSON.stringify(access.accessSnapshot),
JSON.stringify({
...(body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {}),
assembly: {
sections: assembly.sections,
rules: assembly.rules,
},
}),
],
);
await recordPracticeAccessEvent(client, {
tenantId,
userId,
practiceSessionId: sessionResult.rows[0].id,
eventType: 'session_created',
accessMode: access.accessMode,
requestedCount: questionIds.length,
grantedCount: access.questionIds.length,
consumedFreeQuota: access.consumedFreeQuota,
scopeType: access.accessSnapshot.quotaScopeType as string | undefined,
scopeId: access.accessSnapshot.quotaScopeId as string | undefined,
entitlementId: access.entitlementId,
metadata: access.accessSnapshot,
});
return sessionResult.rows[0];
});
return { item };
}
@@ -430,6 +464,8 @@ export async function submitAnswerRoute(ctx: RequestContext) {
const judged = judgeAnswer(question, selectedOptions, answerText);
const result = await transaction(async client => {
await assertAnswerSessionAccess(client, { tenantId, userId, practiceSessionId, questionId });
const answerResult = await client.query(
`
insert into public.answer_records (

View File

@@ -244,7 +244,7 @@ export async function contentNodesAdminRoute(ctx: RequestContext) {
marker_type as "markerType", marker_config as "markerConfig",
path::text as path, depth, sort_order as "order",
is_active as "isActive", is_selectable as "isSelectable",
is_leaf as "isLeaf", metadata, created_by as "createdBy",
is_leaf as "isLeaf", access_rules as "accessRules", metadata, created_by as "createdBy",
updated_by as "updatedBy", created_at as "createdAt",
updated_at as "updatedAt"
from public.content_nodes
@@ -299,12 +299,12 @@ export async function upsertContentNodeRoute(ctx: RequestContext) {
insert into public.content_nodes (
id, tenant_id, entry_id, region_id, parent_id, legacy_id, node_key,
name, node_type, marker_type, marker_config, path, depth, sort_order,
is_active, is_selectable, is_leaf, metadata, created_by, updated_by
is_active, is_selectable, is_leaf, access_rules, metadata, created_by, updated_by
)
values (
$1, $2, $3, $4::uuid, $5::uuid, $6, $7,
$8, $9, $10, $11::jsonb, $12::ltree, $13, $14,
$15, $16, $17, $18::jsonb, $19, $19
$15, $16, $17, $18::jsonb, $19::jsonb, $20, $20
)
on conflict (id)
do update set entry_id = excluded.entry_id,
@@ -322,6 +322,7 @@ export async function upsertContentNodeRoute(ctx: RequestContext) {
is_active = excluded.is_active,
is_selectable = excluded.is_selectable,
is_leaf = excluded.is_leaf,
access_rules = excluded.access_rules,
metadata = excluded.metadata,
updated_by = excluded.updated_by,
updated_at = now()
@@ -332,7 +333,7 @@ export async function upsertContentNodeRoute(ctx: RequestContext) {
marker_type as "markerType", marker_config as "markerConfig",
path::text as path, depth, sort_order as "order",
is_active as "isActive", is_selectable as "isSelectable",
is_leaf as "isLeaf", metadata, created_at as "createdAt",
is_leaf as "isLeaf", access_rules as "accessRules", metadata, created_at as "createdAt",
updated_at as "updatedAt"
`,
[
@@ -353,6 +354,7 @@ export async function upsertContentNodeRoute(ctx: RequestContext) {
boolValue(body.isActive, true),
boolValue(body.isSelectable, true),
boolValue(body.isLeaf, false),
jsonObjectValue(body.accessRules),
jsonObjectValue(body.metadata),
auth.userId,
],
@@ -418,7 +420,7 @@ export async function questionCollectionsAdminRoute(ctx: RequestContext) {
legacy_id as "legacyId", name, collection_type as "collectionType",
source_type as "sourceType", filters, question_count as "questionCount",
total_score as "totalScore", duration_minutes as "durationMinutes",
status, sort_order as "order", metadata,
status, sort_order as "order", access_rules as "accessRules", metadata,
created_by as "createdBy", updated_by as "updatedBy",
created_at as "createdAt", updated_at as "updatedAt"
from public.question_collections
@@ -526,13 +528,13 @@ export async function upsertQuestionCollectionRoute(ctx: RequestContext) {
id, tenant_id, region_id, entry_id, node_id, subject_id,
category_id, question_bank_id, legacy_id, name, collection_type,
source_type, filters, question_count, total_score, duration_minutes,
status, sort_order, metadata, created_by, updated_by
status, sort_order, access_rules, metadata, created_by, updated_by
)
values (
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6::uuid,
$7::uuid, $8::uuid, $9, $10, $11,
$12, $13::jsonb, $14, $15, $16,
$17, $18, $19::jsonb, $20, $20
$17, $18, $19::jsonb, $20::jsonb, $21, $21
)
on conflict (id)
do update set region_id = excluded.region_id,
@@ -551,6 +553,7 @@ export async function upsertQuestionCollectionRoute(ctx: RequestContext) {
duration_minutes = excluded.duration_minutes,
status = excluded.status,
sort_order = excluded.sort_order,
access_rules = excluded.access_rules,
metadata = excluded.metadata,
updated_by = excluded.updated_by,
updated_at = now()
@@ -561,7 +564,7 @@ export async function upsertQuestionCollectionRoute(ctx: RequestContext) {
legacy_id as "legacyId", name, collection_type as "collectionType",
source_type as "sourceType", filters, question_count as "questionCount",
total_score as "totalScore", duration_minutes as "durationMinutes",
status, sort_order as "order", metadata,
status, sort_order as "order", access_rules as "accessRules", metadata,
created_at as "createdAt", updated_at as "updatedAt"
`,
[
@@ -583,6 +586,7 @@ export async function upsertQuestionCollectionRoute(ctx: RequestContext) {
body.durationMinutes === undefined ? null : intValue(body.durationMinutes, 0),
status,
intValue(body.order, 0),
jsonObjectValue(body.accessRules),
jsonObjectValue(body.metadata),
auth.userId,
],
@@ -647,7 +651,7 @@ export async function practiceBlueprintsAdminRoute(ctx: RequestContext) {
legacy_id as "legacyId", name, mode,
assembly_type as "assemblyType", question_limit as "questionLimit",
duration_minutes as "durationMinutes", total_score as "totalScore",
pass_score as "passScore", sections, rules, status,
pass_score as "passScore", sections, rules, access_rules as "accessRules", status,
sort_order as "order", created_by as "createdBy",
updated_by as "updatedBy", created_at as "createdAt",
updated_at as "updatedAt"
@@ -686,13 +690,13 @@ export async function upsertPracticeBlueprintRoute(ctx: RequestContext) {
id, tenant_id, region_id, entry_id, node_id, collection_id,
legacy_id, name, mode, assembly_type, question_limit,
duration_minutes, total_score, pass_score, sections, rules,
status, sort_order, created_by, updated_by
access_rules, status, sort_order, created_by, updated_by
)
values (
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6::uuid,
$7, $8, $9, $10, $11,
$12, $13, $14, $15::jsonb, $16::jsonb,
$17, $18, $19, $19
$17::jsonb, $18, $19, $20, $20
)
on conflict (id)
do update set region_id = excluded.region_id,
@@ -709,6 +713,7 @@ export async function upsertPracticeBlueprintRoute(ctx: RequestContext) {
pass_score = excluded.pass_score,
sections = excluded.sections,
rules = excluded.rules,
access_rules = excluded.access_rules,
status = excluded.status,
sort_order = excluded.sort_order,
updated_by = excluded.updated_by,
@@ -719,7 +724,7 @@ export async function upsertPracticeBlueprintRoute(ctx: RequestContext) {
legacy_id as "legacyId", name, mode,
assembly_type as "assemblyType", question_limit as "questionLimit",
duration_minutes as "durationMinutes", total_score as "totalScore",
pass_score as "passScore", sections, rules, status,
pass_score as "passScore", sections, rules, access_rules as "accessRules", status,
sort_order as "order", created_at as "createdAt",
updated_at as "updatedAt"
`,
@@ -740,6 +745,7 @@ export async function upsertPracticeBlueprintRoute(ctx: RequestContext) {
body.passScore === undefined || body.passScore === null ? null : Number(body.passScore),
jsonArrayValue(body.sections),
jsonObjectValue(body.rules),
jsonObjectValue(body.accessRules),
status,
intValue(body.order, 0),
auth.userId,