forked from wangziqi/gongxue-base
feat: enforce practice access controls
This commit is contained in:
@@ -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
|
||||
|
||||
564
apps/api/src/features/learning/access.ts
Normal file
564
apps/api/src/features/learning/access.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
@@ -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 (
|
||||
|
||||
@@ -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,
|
||||
|
||||
Reference in New Issue
Block a user