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",
|
marker_type as "markerType", marker_config as "markerConfig",
|
||||||
path::text as path, depth, sort_order as "order",
|
path::text as path, depth, sort_order as "order",
|
||||||
is_active as "isActive", is_selectable as "isSelectable",
|
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"
|
created_at as "createdAt", updated_at as "updatedAt"
|
||||||
from public.content_nodes
|
from public.content_nodes
|
||||||
where ${filters.join(' and ')}
|
where ${filters.join(' and ')}
|
||||||
@@ -132,7 +132,7 @@ export async function questionCollectionsRoute(ctx: RequestContext) {
|
|||||||
legacy_id as "legacyId", name, collection_type as "collectionType",
|
legacy_id as "legacyId", name, collection_type as "collectionType",
|
||||||
source_type as "sourceType", filters, question_count as "questionCount",
|
source_type as "sourceType", filters, question_count as "questionCount",
|
||||||
total_score as "totalScore", duration_minutes as "durationMinutes",
|
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"
|
created_at as "createdAt", updated_at as "updatedAt"
|
||||||
from public.question_collections
|
from public.question_collections
|
||||||
where ${filters.join(' and ')}
|
where ${filters.join(' and ')}
|
||||||
@@ -180,7 +180,7 @@ export async function practiceBlueprintsRoute(ctx: RequestContext) {
|
|||||||
legacy_id as "legacyId", name, mode,
|
legacy_id as "legacyId", name, mode,
|
||||||
assembly_type as "assemblyType", question_limit as "questionLimit",
|
assembly_type as "assemblyType", question_limit as "questionLimit",
|
||||||
duration_minutes as "durationMinutes", total_score as "totalScore",
|
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",
|
sort_order as "order", created_at as "createdAt",
|
||||||
updated_at as "updatedAt"
|
updated_at as "updatedAt"
|
||||||
from public.practice_blueprints
|
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,
|
userIdFrom,
|
||||||
} from '../../core/request.js';
|
} from '../../core/request.js';
|
||||||
import { query, queryOne, transaction } from '../../core/db.js';
|
import { query, queryOne, transaction } from '../../core/db.js';
|
||||||
|
import { assertAnswerSessionAccess, authorizePracticeSession, recordPracticeAccessEvent } from './access.js';
|
||||||
|
|
||||||
interface QuestionAnswerRow {
|
interface QuestionAnswerRow {
|
||||||
question_id: string;
|
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');
|
throw new HttpError(409, 'No published questions are available for this practice target', 'NO_PRACTICE_QUESTIONS');
|
||||||
}
|
}
|
||||||
|
|
||||||
const item = await queryOne(
|
const item = await transaction(async client => {
|
||||||
`
|
const access = await authorizePracticeSession(client, {
|
||||||
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
|
|
||||||
`,
|
|
||||||
[
|
|
||||||
tenantId,
|
tenantId,
|
||||||
userId,
|
userId,
|
||||||
assembly.mode,
|
questionIds,
|
||||||
assembly.targetType,
|
assembly,
|
||||||
assembly.targetId,
|
});
|
||||||
assembly.blueprintId,
|
const sessionResult = await client.query(
|
||||||
assembly.collectionId,
|
`
|
||||||
assembly.entryId,
|
insert into public.practice_sessions (
|
||||||
assembly.contentNodeId,
|
tenant_id, user_id, mode, target_type, target_id,
|
||||||
JSON.stringify(questionIds),
|
blueprint_id, collection_id, entry_id, content_node_id,
|
||||||
questionIds.length,
|
question_ids, question_count, duration_minutes, total_score,
|
||||||
assembly.durationMinutes,
|
access_mode, access_entitlement_id, consumed_free_quota, access_snapshot,
|
||||||
assembly.totalScore,
|
expires_at, metadata
|
||||||
JSON.stringify({
|
)
|
||||||
...(body.metadata && typeof body.metadata === 'object' && !Array.isArray(body.metadata) ? body.metadata : {}),
|
values (
|
||||||
assembly: {
|
$1, $2, $3, $4, $5::uuid,
|
||||||
sections: assembly.sections,
|
$6::uuid, $7::uuid, $8::uuid, $9::uuid,
|
||||||
rules: assembly.rules,
|
$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 };
|
return { item };
|
||||||
}
|
}
|
||||||
@@ -430,6 +464,8 @@ export async function submitAnswerRoute(ctx: RequestContext) {
|
|||||||
const judged = judgeAnswer(question, selectedOptions, answerText);
|
const judged = judgeAnswer(question, selectedOptions, answerText);
|
||||||
|
|
||||||
const result = await transaction(async client => {
|
const result = await transaction(async client => {
|
||||||
|
await assertAnswerSessionAccess(client, { tenantId, userId, practiceSessionId, questionId });
|
||||||
|
|
||||||
const answerResult = await client.query(
|
const answerResult = await client.query(
|
||||||
`
|
`
|
||||||
insert into public.answer_records (
|
insert into public.answer_records (
|
||||||
|
|||||||
@@ -244,7 +244,7 @@ export async function contentNodesAdminRoute(ctx: RequestContext) {
|
|||||||
marker_type as "markerType", marker_config as "markerConfig",
|
marker_type as "markerType", marker_config as "markerConfig",
|
||||||
path::text as path, depth, sort_order as "order",
|
path::text as path, depth, sort_order as "order",
|
||||||
is_active as "isActive", is_selectable as "isSelectable",
|
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_by as "updatedBy", created_at as "createdAt",
|
||||||
updated_at as "updatedAt"
|
updated_at as "updatedAt"
|
||||||
from public.content_nodes
|
from public.content_nodes
|
||||||
@@ -299,12 +299,12 @@ export async function upsertContentNodeRoute(ctx: RequestContext) {
|
|||||||
insert into public.content_nodes (
|
insert into public.content_nodes (
|
||||||
id, tenant_id, entry_id, region_id, parent_id, legacy_id, node_key,
|
id, tenant_id, entry_id, region_id, parent_id, legacy_id, node_key,
|
||||||
name, node_type, marker_type, marker_config, path, depth, sort_order,
|
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 (
|
values (
|
||||||
$1, $2, $3, $4::uuid, $5::uuid, $6, $7,
|
$1, $2, $3, $4::uuid, $5::uuid, $6, $7,
|
||||||
$8, $9, $10, $11::jsonb, $12::ltree, $13, $14,
|
$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)
|
on conflict (id)
|
||||||
do update set entry_id = excluded.entry_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_active = excluded.is_active,
|
||||||
is_selectable = excluded.is_selectable,
|
is_selectable = excluded.is_selectable,
|
||||||
is_leaf = excluded.is_leaf,
|
is_leaf = excluded.is_leaf,
|
||||||
|
access_rules = excluded.access_rules,
|
||||||
metadata = excluded.metadata,
|
metadata = excluded.metadata,
|
||||||
updated_by = excluded.updated_by,
|
updated_by = excluded.updated_by,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
@@ -332,7 +333,7 @@ export async function upsertContentNodeRoute(ctx: RequestContext) {
|
|||||||
marker_type as "markerType", marker_config as "markerConfig",
|
marker_type as "markerType", marker_config as "markerConfig",
|
||||||
path::text as path, depth, sort_order as "order",
|
path::text as path, depth, sort_order as "order",
|
||||||
is_active as "isActive", is_selectable as "isSelectable",
|
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"
|
updated_at as "updatedAt"
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
@@ -353,6 +354,7 @@ export async function upsertContentNodeRoute(ctx: RequestContext) {
|
|||||||
boolValue(body.isActive, true),
|
boolValue(body.isActive, true),
|
||||||
boolValue(body.isSelectable, true),
|
boolValue(body.isSelectable, true),
|
||||||
boolValue(body.isLeaf, false),
|
boolValue(body.isLeaf, false),
|
||||||
|
jsonObjectValue(body.accessRules),
|
||||||
jsonObjectValue(body.metadata),
|
jsonObjectValue(body.metadata),
|
||||||
auth.userId,
|
auth.userId,
|
||||||
],
|
],
|
||||||
@@ -418,7 +420,7 @@ export async function questionCollectionsAdminRoute(ctx: RequestContext) {
|
|||||||
legacy_id as "legacyId", name, collection_type as "collectionType",
|
legacy_id as "legacyId", name, collection_type as "collectionType",
|
||||||
source_type as "sourceType", filters, question_count as "questionCount",
|
source_type as "sourceType", filters, question_count as "questionCount",
|
||||||
total_score as "totalScore", duration_minutes as "durationMinutes",
|
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_by as "createdBy", updated_by as "updatedBy",
|
||||||
created_at as "createdAt", updated_at as "updatedAt"
|
created_at as "createdAt", updated_at as "updatedAt"
|
||||||
from public.question_collections
|
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,
|
id, tenant_id, region_id, entry_id, node_id, subject_id,
|
||||||
category_id, question_bank_id, legacy_id, name, collection_type,
|
category_id, question_bank_id, legacy_id, name, collection_type,
|
||||||
source_type, filters, question_count, total_score, duration_minutes,
|
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 (
|
values (
|
||||||
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6::uuid,
|
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6::uuid,
|
||||||
$7::uuid, $8::uuid, $9, $10, $11,
|
$7::uuid, $8::uuid, $9, $10, $11,
|
||||||
$12, $13::jsonb, $14, $15, $16,
|
$12, $13::jsonb, $14, $15, $16,
|
||||||
$17, $18, $19::jsonb, $20, $20
|
$17, $18, $19::jsonb, $20::jsonb, $21, $21
|
||||||
)
|
)
|
||||||
on conflict (id)
|
on conflict (id)
|
||||||
do update set region_id = excluded.region_id,
|
do update set region_id = excluded.region_id,
|
||||||
@@ -551,6 +553,7 @@ export async function upsertQuestionCollectionRoute(ctx: RequestContext) {
|
|||||||
duration_minutes = excluded.duration_minutes,
|
duration_minutes = excluded.duration_minutes,
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
sort_order = excluded.sort_order,
|
sort_order = excluded.sort_order,
|
||||||
|
access_rules = excluded.access_rules,
|
||||||
metadata = excluded.metadata,
|
metadata = excluded.metadata,
|
||||||
updated_by = excluded.updated_by,
|
updated_by = excluded.updated_by,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
@@ -561,7 +564,7 @@ export async function upsertQuestionCollectionRoute(ctx: RequestContext) {
|
|||||||
legacy_id as "legacyId", name, collection_type as "collectionType",
|
legacy_id as "legacyId", name, collection_type as "collectionType",
|
||||||
source_type as "sourceType", filters, question_count as "questionCount",
|
source_type as "sourceType", filters, question_count as "questionCount",
|
||||||
total_score as "totalScore", duration_minutes as "durationMinutes",
|
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"
|
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),
|
body.durationMinutes === undefined ? null : intValue(body.durationMinutes, 0),
|
||||||
status,
|
status,
|
||||||
intValue(body.order, 0),
|
intValue(body.order, 0),
|
||||||
|
jsonObjectValue(body.accessRules),
|
||||||
jsonObjectValue(body.metadata),
|
jsonObjectValue(body.metadata),
|
||||||
auth.userId,
|
auth.userId,
|
||||||
],
|
],
|
||||||
@@ -647,7 +651,7 @@ export async function practiceBlueprintsAdminRoute(ctx: RequestContext) {
|
|||||||
legacy_id as "legacyId", name, mode,
|
legacy_id as "legacyId", name, mode,
|
||||||
assembly_type as "assemblyType", question_limit as "questionLimit",
|
assembly_type as "assemblyType", question_limit as "questionLimit",
|
||||||
duration_minutes as "durationMinutes", total_score as "totalScore",
|
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",
|
sort_order as "order", created_by as "createdBy",
|
||||||
updated_by as "updatedBy", created_at as "createdAt",
|
updated_by as "updatedBy", created_at as "createdAt",
|
||||||
updated_at as "updatedAt"
|
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,
|
id, tenant_id, region_id, entry_id, node_id, collection_id,
|
||||||
legacy_id, name, mode, assembly_type, question_limit,
|
legacy_id, name, mode, assembly_type, question_limit,
|
||||||
duration_minutes, total_score, pass_score, sections, rules,
|
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 (
|
values (
|
||||||
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6::uuid,
|
coalesce($1::uuid, gen_random_uuid()), $2, $3::uuid, $4::uuid, $5::uuid, $6::uuid,
|
||||||
$7, $8, $9, $10, $11,
|
$7, $8, $9, $10, $11,
|
||||||
$12, $13, $14, $15::jsonb, $16::jsonb,
|
$12, $13, $14, $15::jsonb, $16::jsonb,
|
||||||
$17, $18, $19, $19
|
$17::jsonb, $18, $19, $20, $20
|
||||||
)
|
)
|
||||||
on conflict (id)
|
on conflict (id)
|
||||||
do update set region_id = excluded.region_id,
|
do update set region_id = excluded.region_id,
|
||||||
@@ -709,6 +713,7 @@ export async function upsertPracticeBlueprintRoute(ctx: RequestContext) {
|
|||||||
pass_score = excluded.pass_score,
|
pass_score = excluded.pass_score,
|
||||||
sections = excluded.sections,
|
sections = excluded.sections,
|
||||||
rules = excluded.rules,
|
rules = excluded.rules,
|
||||||
|
access_rules = excluded.access_rules,
|
||||||
status = excluded.status,
|
status = excluded.status,
|
||||||
sort_order = excluded.sort_order,
|
sort_order = excluded.sort_order,
|
||||||
updated_by = excluded.updated_by,
|
updated_by = excluded.updated_by,
|
||||||
@@ -719,7 +724,7 @@ export async function upsertPracticeBlueprintRoute(ctx: RequestContext) {
|
|||||||
legacy_id as "legacyId", name, mode,
|
legacy_id as "legacyId", name, mode,
|
||||||
assembly_type as "assemblyType", question_limit as "questionLimit",
|
assembly_type as "assemblyType", question_limit as "questionLimit",
|
||||||
duration_minutes as "durationMinutes", total_score as "totalScore",
|
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",
|
sort_order as "order", created_at as "createdAt",
|
||||||
updated_at as "updatedAt"
|
updated_at as "updatedAt"
|
||||||
`,
|
`,
|
||||||
@@ -740,6 +745,7 @@ export async function upsertPracticeBlueprintRoute(ctx: RequestContext) {
|
|||||||
body.passScore === undefined || body.passScore === null ? null : Number(body.passScore),
|
body.passScore === undefined || body.passScore === null ? null : Number(body.passScore),
|
||||||
jsonArrayValue(body.sections),
|
jsonArrayValue(body.sections),
|
||||||
jsonObjectValue(body.rules),
|
jsonObjectValue(body.rules),
|
||||||
|
jsonObjectValue(body.accessRules),
|
||||||
status,
|
status,
|
||||||
intValue(body.order, 0),
|
intValue(body.order, 0),
|
||||||
auth.userId,
|
auth.userId,
|
||||||
|
|||||||
@@ -52,11 +52,11 @@
|
|||||||
| 任意深度分类树 | 可联调 | `/api/catalog/content-nodes` |
|
| 任意深度分类树 | 可联调 | `/api/catalog/content-nodes` |
|
||||||
| 题目列表/集合 | 可联调 | `/api/catalog/question-collections`、`question-collections/questions` |
|
| 题目列表/集合 | 可联调 | `/api/catalog/question-collections`、`question-collections/questions` |
|
||||||
| 顺序/随机/全真模拟规则 | 可联调 | `/api/catalog/practice-blueprints` |
|
| 顺序/随机/全真模拟规则 | 可联调 | `/api/catalog/practice-blueprints` |
|
||||||
| 创建练习 session | 可联调 | `POST /api/learning/practice-sessions` |
|
| 创建练习 session | 可联调 | `POST /api/learning/practice-sessions`;后端强制校验免费额度、SVIP 范围和内容访问规则 |
|
||||||
| 答题记录 | 可联调 | `POST /api/learning/answers` |
|
| 答题记录 | 可联调 | `POST /api/learning/answers`;题目必须属于本人有效 session 快照 |
|
||||||
| 错题本 | 可联调 | `/api/learning/wrong-questions` |
|
| 错题本 | 可联调 | `/api/learning/wrong-questions` |
|
||||||
| 收藏夹 | 可联调 | `/api/learning/favorites/questions` |
|
| 收藏夹 | 可联调 | `/api/learning/favorites/questions` |
|
||||||
| 免费用户题量限制 | 待补齐 | 旧项目有保护逻辑,新后端需按租户/套餐/内容范围实现 |
|
| 免费用户题量限制 | 可联调 | `practice_daily_usage` + `practice_access_events`;支持内容 accessRules、每日额度、session 截断、SVIP-only 拦截 |
|
||||||
| 模考交卷报告 | 待补齐 | 已有 session/answer 基础,缺完整交卷、评分报告、错题解析汇总 |
|
| 模考交卷报告 | 待补齐 | 已有 session/answer 基础,缺完整交卷、评分报告、错题解析汇总 |
|
||||||
|
|
||||||
## 背单词、知识手册、分数线、视频
|
## 背单词、知识手册、分数线、视频
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、激活码批次、优惠券、成员管理、权限矩阵、审计查询。
|
- `tenant-admin`:租户资料、品牌、公开设置、域名、支付账户、登录 provider、私密密钥掩码、活动内容、激活码批次、优惠券、成员管理、权限矩阵、审计查询。
|
||||||
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册 JSON 导入维护。
|
- `tenant-content`:租户后台内容入口、任意深度分类树、考试意向标记、题目集合、练习蓝图、题目、视频、分数线、单词、知识手册、资料资源、题目/单词/知识手册 JSON 导入维护。
|
||||||
- `tenant`:域名/租户解析。
|
- `tenant`:域名/租户解析。
|
||||||
|
- `learning` 已接入商用访问控制:免费用户每日题量、SVIP 范围、SVIP-only 内容、答题 session 快照保护由后端强制执行。
|
||||||
- `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。
|
- `src/services/supabaseApi.ts` 已加入新 API 客户端方法,供旧 Web 逐步替换和后续 Taro 复用。
|
||||||
- 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。
|
- 已新增 `npm run db:smoke-seed`,用于 `supabase:reset` 后恢复最小烟测数据。
|
||||||
- 已新增 `npm run smoke:core-api`,用于验证个人中心、分数线、题目视频、背单词进度/收藏等学生端核心 API。
|
- 已新增 `npm run smoke:core-api`,用于验证个人中心、分数线、题目视频、背单词进度/收藏等学生端核心 API。
|
||||||
@@ -178,6 +179,7 @@ GET /api/tenant-admin/audit-logs
|
|||||||
- CRM 当前完成配置、密钥入私密表、客资入队和队列查询;真实 webhook 发送、重试、签名在后续 `apps/worker` 中实现。
|
- CRM 当前完成配置、密钥入私密表、客资入队和队列查询;真实 webhook 发送、重试、签名在后续 `apps/worker` 中实现。
|
||||||
- 内容资源当前完成台账、租户后台维护、学生端 SVIP 下载权限,以及 `local_dev`、阿里云 OSS、腾讯 COS、Supabase Storage 的上传/下载签名 provider。真实对象存在性校验、PDF 预览渲染、防盗链、水印和大文件上传后 worker 校验仍需继续补。
|
- 内容资源当前完成台账、租户后台维护、学生端 SVIP 下载权限,以及 `local_dev`、阿里云 OSS、腾讯 COS、Supabase Storage 的上传/下载签名 provider。真实对象存在性校验、PDF 预览渲染、防盗链、水印和大文件上传后 worker 校验仍需继续补。
|
||||||
- 题库内容导航当前以 `content_entries/content_nodes` 为主模型,可表达“入口 -> 多级分类 -> 院校/专业/学科/销售意向标记”;题目集合和练习方式由 `question_collections/practice_blueprints` 管理,练习 session 会保存当次题目 ID 快照。
|
- 题库内容导航当前以 `content_entries/content_nodes` 为主模型,可表达“入口 -> 多级分类 -> 院校/专业/学科/销售意向标记”;题目集合和练习方式由 `question_collections/practice_blueprints` 管理,练习 session 会保存当次题目 ID 快照。
|
||||||
|
- 练习访问控制由 `content_entries/content_nodes/question_collections/practice_blueprints` 的 `accessRules` 合并决定;普通用户消耗 `practice_daily_usage`,事件写入 `practice_access_events`,SVIP/staff 不消耗免费额度。
|
||||||
- 批量导入当前支持题目、单词、知识手册 JSON 预览、逐行 issue、job/item 台账、执行导入、幂等跳过,并可落到新内容入口和分类节点。旧单词模板的 `vocabulary_units_示例数据` / `vocabulary_示例数据`、知识手册的书籍/章节/小节/知识点嵌套结构都由后端规范化。Excel/CSV、分数线/视频导入会继续复用同一套 `content_import_jobs` 管线。
|
- 批量导入当前支持题目、单词、知识手册 JSON 预览、逐行 issue、job/item 台账、执行导入、幂等跳过,并可落到新内容入口和分类节点。旧单词模板的 `vocabulary_units_示例数据` / `vocabulary_示例数据`、知识手册的书籍/章节/小节/知识点嵌套结构都由后端规范化。Excel/CSV、分数线/视频导入会继续复用同一套 `content_import_jobs` 管线。
|
||||||
|
|
||||||
## 下一步
|
## 下一步
|
||||||
|
|||||||
@@ -20,8 +20,8 @@
|
|||||||
| 首页/学生看板 | `pages/StudentDashboardNew.tsx` | 部分覆盖 | 品牌、Banner、公告、入口、个人统计有基础;缺完整运营动态/学习任务聚合 |
|
| 首页/学生看板 | `pages/StudentDashboardNew.tsx` | 部分覆盖 | 品牌、Banner、公告、入口、个人统计有基础;缺完整运营动态/学习任务聚合 |
|
||||||
| 题库入口 | `pages/SubjectSelector.tsx`、`RegionArchitectureEditor.tsx` | 已覆盖 | 前端应改接 `content_entries/content_nodes` |
|
| 题库入口 | `pages/SubjectSelector.tsx`、`RegionArchitectureEditor.tsx` | 已覆盖 | 前端应改接 `content_entries/content_nodes` |
|
||||||
| 多级分类树 | 旧 module/subject/category 树 | 已覆盖 | 新后端支持任意深度和 `marker_type`;前端不要写死层级 |
|
| 多级分类树 | 旧 module/subject/category 树 | 已覆盖 | 新后端支持任意深度和 `marker_type`;前端不要写死层级 |
|
||||||
| 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 继续补题量限制、断点续练、更多题型渲染 |
|
| 顺序刷题 | `pages/Quiz.tsx` | 已覆盖 | 免费额度/SVIP 校验已由后端强制;继续补断点续练、更多题型渲染 |
|
||||||
| 随机刷题 | `pages/Quiz.tsx` | 已覆盖 | 已有 blueprint/session 快照,前端需按 mode 调用 |
|
| 随机刷题 | `pages/Quiz.tsx` | 已覆盖 | 已有 blueprint/session 快照和访问控制,前端需按 mode 调用 |
|
||||||
| 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | 后端有 blueprint 基础;缺完整交卷报告、排名、复盘 |
|
| 全真模拟 | `components/AdminMockexam`、`MockExamConfigModal.tsx` | 部分覆盖 | 后端有 blueprint 基础;缺完整交卷报告、排名、复盘 |
|
||||||
| 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 后续补错题复习计划 |
|
| 错题本 | 用户 stats/错题逻辑 | 已覆盖 | 后续补错题复习计划 |
|
||||||
| 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 |
|
| 收藏夹 | `WordFavoritesPage.tsx`、题目收藏 | 已覆盖 | 题目和单词收藏已有 |
|
||||||
@@ -49,7 +49,7 @@
|
|||||||
| 商户收款配置 | 部分覆盖 | 配置 API 有;缺真实支付 provider 和验签 |
|
| 商户收款配置 | 部分覆盖 | 配置 API 有;缺真实支付 provider 和验签 |
|
||||||
| 登录配置 | 部分覆盖 | 配置 API 有;缺真实短信/OAuth provider 实现 |
|
| 登录配置 | 部分覆盖 | 配置 API 有;缺真实短信/OAuth provider 实现 |
|
||||||
| Banner/公告/FAQ/活动 | 已覆盖 | 前端运营后台可以接 |
|
| Banner/公告/FAQ/活动 | 已覆盖 | 前端运营后台可以接 |
|
||||||
| SVIP 套餐 | 已覆盖 | 后续补地区/分类/专业增项限制规则 |
|
| SVIP 套餐 | 部分覆盖 | 地区/科目/题库范围校验已接入练习/资料/视频;后续补分类/专业增项购买和套餐规则 UI |
|
||||||
| 优惠券 | 部分覆盖 | 后台配置有;前台兑换、下单抵扣待补 |
|
| 优惠券 | 部分覆盖 | 后台配置有;前台兑换、下单抵扣待补 |
|
||||||
| 激活码 | 已覆盖 | 批次、生成、兑换主链路已有 |
|
| 激活码 | 已覆盖 | 批次、生成、兑换主链路已有 |
|
||||||
| 勋章管理 | 部分覆盖 | 表结构有 badges/user_badges;缺后台和学生端 API |
|
| 勋章管理 | 部分覆盖 | 表结构有 badges/user_badges;缺后台和学生端 API |
|
||||||
|
|||||||
@@ -14,6 +14,7 @@
|
|||||||
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。
|
- 销售/代理/CRM 增长链路:邀请码、扫码事件、首绑保护、团队、统计、CRM 队列。
|
||||||
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
- 内容导航:`content_entries/content_nodes` 支持任意深度入口和分类。
|
||||||
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
|
- 练习组卷:`question_collections/practice_blueprints` 支持顺序、随机、全真模拟快照。
|
||||||
|
- 练习访问控制:`practice_daily_usage/practice_access_events` 支持免费每日额度、SVIP 范围校验、SVIP-only 内容拦截和答题 session 快照保护。
|
||||||
- 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。
|
- 内容导入:题目、单词、知识手册 JSON 预览、校验、导入、幂等、审计。
|
||||||
- 本地验证:`npm run check:refactor` 已通过。
|
- 本地验证:`npm run check:refactor` 已通过。
|
||||||
|
|
||||||
@@ -76,7 +77,8 @@
|
|||||||
- 单题视频和通用知识视频混合推荐。
|
- 单题视频和通用知识视频混合推荐。
|
||||||
|
|
||||||
6. 学习统计
|
6. 学习统计
|
||||||
- 练习历史、正确率趋势、题型分布、错题复习计划。
|
- 已完成免费额度和练习访问事件基础。
|
||||||
|
- 继续补练习历史、正确率趋势、题型分布、错题复习计划。
|
||||||
- 单词复习算法、每日计划、排行榜。
|
- 单词复习算法、每日计划、排行榜。
|
||||||
- 模考交卷、评分报告、错题解析汇总。
|
- 模考交卷、评分报告、错题解析汇总。
|
||||||
|
|
||||||
|
|||||||
@@ -152,6 +152,51 @@ tenant:<tenantId>:theme
|
|||||||
| 个人中心 | `GET/PATCH /api/profile/me` |
|
| 个人中心 | `GET/PATCH /api/profile/me` |
|
||||||
| 销售分享 | `/api/referral/resolve`、`track-event`、`bind` |
|
| 销售分享 | `/api/referral/resolve`、`track-event`、`bind` |
|
||||||
|
|
||||||
|
## 练习访问控制契约
|
||||||
|
|
||||||
|
前端不要先拉完整题目列表再自行判断免费额度。用户点击顺序刷题、随机刷题、全真模拟时,统一调用 `POST /api/learning/practice-sessions`,后端会根据 `content_entries.accessRules`、`content_nodes.accessRules`、`question_collections.accessRules`、`practice_blueprints.accessRules` 和当前用户权益决定最终题目快照。
|
||||||
|
|
||||||
|
请求示例:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mode": "sequential",
|
||||||
|
"collectionId": "00000000-0000-0000-0000-000000000615",
|
||||||
|
"questionLimit": 50
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
响应关键字段:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"item": {
|
||||||
|
"id": "...",
|
||||||
|
"mode": "sequential",
|
||||||
|
"questionIds": ["..."],
|
||||||
|
"questionCount": 25,
|
||||||
|
"accessMode": "free",
|
||||||
|
"consumedFreeQuota": 25,
|
||||||
|
"accessSnapshot": {
|
||||||
|
"grantedBy": "free_quota",
|
||||||
|
"requestedCount": 50,
|
||||||
|
"grantedCount": 25,
|
||||||
|
"truncated": true,
|
||||||
|
"dailyLimit": 25
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端处理规则:
|
||||||
|
|
||||||
|
- 以返回的 `questionIds` 为准渲染本次练习,不要自行追加题目。
|
||||||
|
- `accessSnapshot.truncated=true` 时,可提示“今日免费额度有限,已为你开放 N 题”并引导开通 SVIP。
|
||||||
|
- `PRACTICE_FREE_LIMIT_REACHED`:弹出会员购买/激活码兑换入口。
|
||||||
|
- `PRACTICE_SVIP_REQUIRED`:提示该内容需要对应地区/科目/题库 SVIP。
|
||||||
|
- `PRACTICE_SESSION_QUESTION_FORBIDDEN`:说明提交答案的题目不在本次 session 快照内,应清理本地异常进度并重新开始。
|
||||||
|
- 提交答案必须传 `practiceSessionId`;后端会拒绝不属于本人有效 session 的题目。
|
||||||
|
|
||||||
## 视频播放契约
|
## 视频播放契约
|
||||||
|
|
||||||
题目视频分为 `free`、`svip`、`video_quota` 三种访问模式。列表接口只用于展示标题、封面、时长、访问模式和试看秒数;除免费公开视频外,列表和搜索接口不会返回可播放 URL。
|
题目视频分为 `free`、`svip`、`video_quota` 三种访问模式。列表接口只用于展示标题、封面、时长、访问模式和试看秒数;除免费公开视频外,列表和搜索接口不会返回可播放 URL。
|
||||||
|
|||||||
@@ -28,6 +28,8 @@ const ids = {
|
|||||||
practiceBlueprintRandom: '00000000-0000-0000-0000-000000000617',
|
practiceBlueprintRandom: '00000000-0000-0000-0000-000000000617',
|
||||||
practiceBlueprintMock: '00000000-0000-0000-0000-000000000618',
|
practiceBlueprintMock: '00000000-0000-0000-0000-000000000618',
|
||||||
question: '00000000-0000-0000-0000-000000000401',
|
question: '00000000-0000-0000-0000-000000000401',
|
||||||
|
questionTwo: '00000000-0000-0000-0000-000000000403',
|
||||||
|
questionThree: '00000000-0000-0000-0000-000000000405',
|
||||||
vocabularyUnit: '00000000-0000-0000-0000-000000000811',
|
vocabularyUnit: '00000000-0000-0000-0000-000000000811',
|
||||||
vocabularyWord: '00000000-0000-0000-0000-000000000812',
|
vocabularyWord: '00000000-0000-0000-0000-000000000812',
|
||||||
video: '00000000-0000-0000-0000-000000000821',
|
video: '00000000-0000-0000-0000-000000000821',
|
||||||
@@ -565,28 +567,80 @@ async function testCatalogAndLearning() {
|
|||||||
});
|
});
|
||||||
assert.ok(questionsByNode.items?.some(item => item.id === ids.question), 'catalog questions should filter by contentNodeId');
|
assert.ok(questionsByNode.items?.some(item => item.id === ids.question), 'catalog questions should filter by contentNodeId');
|
||||||
|
|
||||||
const session = await request('/api/learning/practice-sessions', {
|
const freeLogin = await loginBySms('13800000008');
|
||||||
method: 'POST',
|
const freeSession = await request('/api/learning/practice-sessions', {
|
||||||
body: { userId: USER_ID, mode: 'chapter', targetType: 'category', targetId: ids.question },
|
userId: false,
|
||||||
});
|
headers: { authorization: `Bearer ${freeLogin.session.token}` },
|
||||||
assert.ok(session.item?.id, 'practice session should be created');
|
|
||||||
|
|
||||||
const sequentialSession = await request('/api/learning/practice-sessions', {
|
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
userId: USER_ID,
|
|
||||||
mode: 'sequential',
|
mode: 'sequential',
|
||||||
collectionId: ids.questionCollection,
|
collectionId: ids.questionCollection,
|
||||||
questionLimit: 5,
|
questionLimit: 5,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
assert.equal(sequentialSession.item?.collectionId, ids.questionCollection, 'collection session should bind collection');
|
assert.equal(freeSession.item?.collectionId, ids.questionCollection, 'collection session should bind collection');
|
||||||
assert.ok(sequentialSession.item?.questionIds?.includes(ids.question), 'collection session should snapshot question ids');
|
assert.equal(freeSession.item?.accessMode, 'free', 'non-SVIP user should use free quota');
|
||||||
|
assert.equal(freeSession.item?.consumedFreeQuota, 2, 'free practice should consume configured daily quota');
|
||||||
|
assert.equal(freeSession.item?.questionCount, 2, 'free practice should be truncated to configured free limit');
|
||||||
|
assert.equal(freeSession.item?.accessSnapshot?.truncated, true, 'free practice access snapshot should mark truncation');
|
||||||
|
assert.ok(freeSession.item?.questionIds?.includes(ids.question), 'free session should snapshot first question');
|
||||||
|
|
||||||
const nodeSession = await request('/api/learning/practice-sessions', {
|
const freeLimitReached = await request('/api/learning/practice-sessions', {
|
||||||
|
userId: false,
|
||||||
|
headers: { authorization: `Bearer ${freeLogin.session.token}` },
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
userId: USER_ID,
|
mode: 'sequential',
|
||||||
|
collectionId: ids.questionCollection,
|
||||||
|
questionLimit: 5,
|
||||||
|
},
|
||||||
|
expectStatus: 403,
|
||||||
|
});
|
||||||
|
assert.equal(freeLimitReached.code, 'PRACTICE_FREE_LIMIT_REACHED', 'second free session should be blocked after quota is exhausted');
|
||||||
|
|
||||||
|
const forbiddenAnswer = await request('/api/learning/answers', {
|
||||||
|
userId: false,
|
||||||
|
headers: { authorization: `Bearer ${freeLogin.session.token}` },
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
questionId: ids.questionThree,
|
||||||
|
selectedOptions: ['1'],
|
||||||
|
practiceSessionId: freeSession.item.id,
|
||||||
|
},
|
||||||
|
expectStatus: 403,
|
||||||
|
});
|
||||||
|
assert.equal(forbiddenAnswer.code, 'PRACTICE_SESSION_QUESTION_FORBIDDEN', 'answers outside the session snapshot should be rejected');
|
||||||
|
|
||||||
|
const answer = await request('/api/learning/answers', {
|
||||||
|
userId: false,
|
||||||
|
headers: { authorization: `Bearer ${freeLogin.session.token}` },
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
questionId: ids.question,
|
||||||
|
selectedOptions: ['0'],
|
||||||
|
practiceSessionId: freeSession.item.id,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(answer.item?.isCorrect, false, 'wrong answer should be judged false');
|
||||||
|
|
||||||
|
const staffSequentialSession = await request('/api/learning/practice-sessions', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
mode: 'sequential',
|
||||||
|
collectionId: ids.questionCollection,
|
||||||
|
questionLimit: 5,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
assert.equal(staffSequentialSession.item?.accessMode, 'staff', 'tenant staff should bypass free practice quota');
|
||||||
|
assert.ok(staffSequentialSession.item?.questionIds?.includes(ids.questionThree), 'staff session should receive the full collection');
|
||||||
|
|
||||||
|
const nodeSession = await request('/api/learning/practice-sessions', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
|
method: 'POST',
|
||||||
|
body: {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
mode: 'random',
|
mode: 'random',
|
||||||
contentNodeId: ids.contentNodeProfessional,
|
contentNodeId: ids.contentNodeProfessional,
|
||||||
questionLimit: 5,
|
questionLimit: 5,
|
||||||
@@ -596,9 +650,10 @@ async function testCatalogAndLearning() {
|
|||||||
assert.ok(nodeSession.item?.questionIds?.includes(ids.question), 'node session should include descendant questions');
|
assert.ok(nodeSession.item?.questionIds?.includes(ids.question), 'node session should include descendant questions');
|
||||||
|
|
||||||
const mockSession = await request('/api/learning/practice-sessions', {
|
const mockSession = await request('/api/learning/practice-sessions', {
|
||||||
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: {
|
body: {
|
||||||
userId: USER_ID,
|
userId: TENANT_ADMIN_USER_ID,
|
||||||
blueprintId: ids.practiceBlueprintMock,
|
blueprintId: ids.practiceBlueprintMock,
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
@@ -608,18 +663,11 @@ async function testCatalogAndLearning() {
|
|||||||
assert.equal(Number(mockSession.item?.totalScore), 100, 'mock session should inherit total score');
|
assert.equal(Number(mockSession.item?.totalScore), 100, 'mock session should inherit total score');
|
||||||
assert.ok(mockSession.item?.questionIds?.includes(ids.question), 'mock session should snapshot assembled questions');
|
assert.ok(mockSession.item?.questionIds?.includes(ids.question), 'mock session should snapshot assembled questions');
|
||||||
|
|
||||||
const answer = await request('/api/learning/answers', {
|
const wrong = await request('/api/learning/wrong-questions', {
|
||||||
method: 'POST',
|
userId: false,
|
||||||
body: {
|
headers: { authorization: `Bearer ${freeLogin.session.token}` },
|
||||||
userId: USER_ID,
|
query: { status: 'all' },
|
||||||
questionId: ids.question,
|
|
||||||
selectedOptions: ['0'],
|
|
||||||
practiceSessionId: session.item.id,
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
assert.equal(answer.item?.isCorrect, false, 'wrong answer should be judged false');
|
|
||||||
|
|
||||||
const wrong = await request('/api/learning/wrong-questions', { query: { status: 'all' } });
|
|
||||||
assert.ok(wrong.items?.some(item => item.questionId === ids.question), 'wrong book should include smoke question');
|
assert.ok(wrong.items?.some(item => item.questionId === ids.question), 'wrong book should include smoke question');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ const ids = {
|
|||||||
questionBank: '00000000-0000-0000-0000-000000000400',
|
questionBank: '00000000-0000-0000-0000-000000000400',
|
||||||
question: '00000000-0000-0000-0000-000000000401',
|
question: '00000000-0000-0000-0000-000000000401',
|
||||||
questionVersion: '00000000-0000-0000-0000-000000000402',
|
questionVersion: '00000000-0000-0000-0000-000000000402',
|
||||||
|
questionTwo: '00000000-0000-0000-0000-000000000403',
|
||||||
|
questionTwoVersion: '00000000-0000-0000-0000-000000000404',
|
||||||
|
questionThree: '00000000-0000-0000-0000-000000000405',
|
||||||
|
questionThreeVersion: '00000000-0000-0000-0000-000000000406',
|
||||||
plan: '00000000-0000-0000-0000-000000000201',
|
plan: '00000000-0000-0000-0000-000000000201',
|
||||||
order: '00000000-0000-0000-0000-000000000701',
|
order: '00000000-0000-0000-0000-000000000701',
|
||||||
payment: '00000000-0000-0000-0000-000000000702',
|
payment: '00000000-0000-0000-0000-000000000702',
|
||||||
@@ -73,6 +77,97 @@ async function main() {
|
|||||||
[tenantId, ids.user, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
|
[tenantId, ids.user, ids.tenantOperatorUser, ids.tenantSalesUser, ids.tenantAgentUser],
|
||||||
);
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
with transient_users as (
|
||||||
|
select u.id
|
||||||
|
from public.platform_users u
|
||||||
|
where u.phone in ('13800000006', '13800000007', '13800000008')
|
||||||
|
)
|
||||||
|
delete from public.practice_access_events
|
||||||
|
where tenant_id = $1
|
||||||
|
and user_id in (select id from transient_users)
|
||||||
|
`,
|
||||||
|
[tenantId],
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
with transient_users as (
|
||||||
|
select u.id
|
||||||
|
from public.platform_users u
|
||||||
|
where u.phone in ('13800000006', '13800000007', '13800000008')
|
||||||
|
)
|
||||||
|
delete from public.practice_daily_usage
|
||||||
|
where tenant_id = $1
|
||||||
|
and user_id in (select id from transient_users)
|
||||||
|
`,
|
||||||
|
[tenantId],
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
with transient_users as (
|
||||||
|
select u.id
|
||||||
|
from public.platform_users u
|
||||||
|
where u.phone in ('13800000006', '13800000007', '13800000008')
|
||||||
|
)
|
||||||
|
delete from public.answer_records
|
||||||
|
where tenant_id = $1
|
||||||
|
and user_id in (select id from transient_users)
|
||||||
|
`,
|
||||||
|
[tenantId],
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
with transient_users as (
|
||||||
|
select u.id
|
||||||
|
from public.platform_users u
|
||||||
|
where u.phone in ('13800000006', '13800000007', '13800000008')
|
||||||
|
)
|
||||||
|
delete from public.practice_sessions
|
||||||
|
where tenant_id = $1
|
||||||
|
and user_id in (select id from transient_users)
|
||||||
|
`,
|
||||||
|
[tenantId],
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
with transient_users as (
|
||||||
|
select u.id
|
||||||
|
from public.platform_users u
|
||||||
|
where u.phone in ('13800000006', '13800000007', '13800000008')
|
||||||
|
)
|
||||||
|
delete from app_private.auth_sessions
|
||||||
|
where tenant_id = $1
|
||||||
|
and user_id in (select id from transient_users)
|
||||||
|
`,
|
||||||
|
[tenantId],
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
with transient_users as (
|
||||||
|
select u.id
|
||||||
|
from public.platform_users u
|
||||||
|
where u.phone in ('13800000006', '13800000007', '13800000008')
|
||||||
|
)
|
||||||
|
delete from public.user_identities
|
||||||
|
where user_id in (select id from transient_users)
|
||||||
|
`,
|
||||||
|
);
|
||||||
|
|
||||||
|
await client.query(
|
||||||
|
`
|
||||||
|
delete from public.sms_verification_codes
|
||||||
|
where tenant_id = $1
|
||||||
|
and phone in ('13800000006', '13800000007', '13800000008')
|
||||||
|
`,
|
||||||
|
[tenantId],
|
||||||
|
);
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
delete from public.referral_qrcodes
|
delete from public.referral_qrcodes
|
||||||
@@ -328,13 +423,14 @@ async function main() {
|
|||||||
id, tenant_id, region_id, entry_id, node_id, subject_id, category_id,
|
id, tenant_id, region_id, entry_id, node_id, subject_id, category_id,
|
||||||
question_bank_id, legacy_id, name, collection_type, source_type,
|
question_bank_id, legacy_id, name, collection_type, source_type,
|
||||||
filters, question_count, total_score, duration_minutes, status,
|
filters, question_count, total_score, duration_minutes, status,
|
||||||
sort_order, metadata, created_by, updated_by
|
sort_order, access_rules, metadata, created_by, updated_by
|
||||||
)
|
)
|
||||||
values (
|
values (
|
||||||
$1, $2, $3, $4, $5, $6, $7,
|
$1, $2, $3, $4, $5, $6, $7,
|
||||||
$8, 'smoke-collection', '烟测学院专业课题目列表', 'manual', 'manual_questions',
|
$8, 'smoke-collection', '烟测学院专业课题目列表', 'manual', 'manual_questions',
|
||||||
'{"tabs":["all","paper","chapter","type"]}'::jsonb, 0, 100, 120, 'active',
|
'{"tabs":["all","paper","chapter","type"]}'::jsonb, 0, 100, 120, 'active',
|
||||||
1, '{"business":"supports sequential random mock exam"}'::jsonb, $9, $9
|
1, '{"freeDailyLimit":2,"freeSessionLimit":2,"freeQuotaScopeType":"tenant"}'::jsonb,
|
||||||
|
'{"business":"supports sequential random mock exam"}'::jsonb, $9, $9
|
||||||
)
|
)
|
||||||
on conflict (id)
|
on conflict (id)
|
||||||
do update set node_id = excluded.node_id,
|
do update set node_id = excluded.node_id,
|
||||||
@@ -348,6 +444,7 @@ async function main() {
|
|||||||
total_score = excluded.total_score,
|
total_score = excluded.total_score,
|
||||||
duration_minutes = excluded.duration_minutes,
|
duration_minutes = excluded.duration_minutes,
|
||||||
status = 'active',
|
status = 'active',
|
||||||
|
access_rules = excluded.access_rules,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
`,
|
`,
|
||||||
[
|
[
|
||||||
@@ -370,11 +467,22 @@ async function main() {
|
|||||||
entry_id, content_node_id, primary_collection_id,
|
entry_id, content_node_id, primary_collection_id,
|
||||||
legacy_id, type, type_label, difficulty, status, exam_markers
|
legacy_id, type, type_label, difficulty, status, exam_markers
|
||||||
)
|
)
|
||||||
values (
|
values
|
||||||
$1, $2, $3, $4, $5, $6, $7, $8,
|
(
|
||||||
'smoke-question', 'choice', '单选题', 1, 'published',
|
$1, $2, $5, $6, $7, $8, $9, $10,
|
||||||
'{"examTrack":"professional","school":"烟测学院"}'::jsonb
|
'smoke-question', 'choice', '单选题', 1, 'published',
|
||||||
)
|
'{"examTrack":"professional","school":"烟测学院"}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
$3, $2, $5, $6, $7, $8, $9, $10,
|
||||||
|
'smoke-question-2', 'choice', '单选题', 1, 'published',
|
||||||
|
'{"examTrack":"professional","school":"烟测学院"}'::jsonb
|
||||||
|
),
|
||||||
|
(
|
||||||
|
$4, $2, $5, $6, $7, $8, $9, $10,
|
||||||
|
'smoke-question-3', 'choice', '单选题', 1, 'published',
|
||||||
|
'{"examTrack":"professional","school":"烟测学院"}'::jsonb
|
||||||
|
)
|
||||||
on conflict (id)
|
on conflict (id)
|
||||||
do update set question_bank_id = excluded.question_bank_id,
|
do update set question_bank_id = excluded.question_bank_id,
|
||||||
subject_id = excluded.subject_id,
|
subject_id = excluded.subject_id,
|
||||||
@@ -388,6 +496,8 @@ async function main() {
|
|||||||
[
|
[
|
||||||
ids.question,
|
ids.question,
|
||||||
tenantId,
|
tenantId,
|
||||||
|
ids.questionTwo,
|
||||||
|
ids.questionThree,
|
||||||
ids.questionBank,
|
ids.questionBank,
|
||||||
ids.subject,
|
ids.subject,
|
||||||
ids.category,
|
ids.category,
|
||||||
@@ -402,14 +512,17 @@ async function main() {
|
|||||||
insert into public.question_collection_items (
|
insert into public.question_collection_items (
|
||||||
tenant_id, collection_id, question_id, section_key, sort_order, score, required, metadata
|
tenant_id, collection_id, question_id, section_key, sort_order, score, required, metadata
|
||||||
)
|
)
|
||||||
values ($1, $2, $3, 'choice', 1, 2, true, '{"source":"smoke-seed"}'::jsonb)
|
values
|
||||||
|
($1, $2, $3, 'choice', 1, 2, true, '{"source":"smoke-seed"}'::jsonb),
|
||||||
|
($1, $2, $4, 'choice', 2, 2, true, '{"source":"smoke-seed"}'::jsonb),
|
||||||
|
($1, $2, $5, 'choice', 3, 2, true, '{"source":"smoke-seed"}'::jsonb)
|
||||||
on conflict (tenant_id, collection_id, question_id)
|
on conflict (tenant_id, collection_id, question_id)
|
||||||
do update set section_key = excluded.section_key,
|
do update set section_key = excluded.section_key,
|
||||||
sort_order = excluded.sort_order,
|
sort_order = excluded.sort_order,
|
||||||
score = excluded.score,
|
score = excluded.score,
|
||||||
updated_at = now()
|
updated_at = now()
|
||||||
`,
|
`,
|
||||||
[tenantId, ids.questionCollection, ids.question],
|
[tenantId, ids.questionCollection, ids.question, ids.questionTwo, ids.questionThree],
|
||||||
);
|
);
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
@@ -487,11 +600,22 @@ async function main() {
|
|||||||
id, tenant_id, question_id, version_no, content, options,
|
id, tenant_id, question_id, version_no, content, options,
|
||||||
correct_option_index, correct_option_indices, answer_text, explanation
|
correct_option_index, correct_option_indices, answer_text, explanation
|
||||||
)
|
)
|
||||||
values (
|
values
|
||||||
$1, $2, $3, 1, '1 + 1 = ?',
|
(
|
||||||
'[{"label":"A","text":"1"},{"label":"B","text":"2"},{"label":"C","text":"3"}]'::jsonb,
|
$1, $2, $4, 1, '1 + 1 = ?',
|
||||||
1, '[1]'::jsonb, '2', '基础加法。'
|
'[{"label":"A","text":"1"},{"label":"B","text":"2"},{"label":"C","text":"3"}]'::jsonb,
|
||||||
)
|
1, '[1]'::jsonb, '2', '基础加法。'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
$3, $2, $5, 1, '2 + 2 = ?',
|
||||||
|
'[{"label":"A","text":"3"},{"label":"B","text":"4"},{"label":"C","text":"5"}]'::jsonb,
|
||||||
|
1, '[1]'::jsonb, '4', '基础加法。'
|
||||||
|
),
|
||||||
|
(
|
||||||
|
$6, $2, $7, 1, '3 + 3 = ?',
|
||||||
|
'[{"label":"A","text":"5"},{"label":"B","text":"6"},{"label":"C","text":"7"}]'::jsonb,
|
||||||
|
1, '[1]'::jsonb, '6', '基础加法。'
|
||||||
|
)
|
||||||
on conflict (question_id, version_no)
|
on conflict (question_id, version_no)
|
||||||
do update set content = excluded.content,
|
do update set content = excluded.content,
|
||||||
options = excluded.options,
|
options = excluded.options,
|
||||||
@@ -500,16 +624,31 @@ async function main() {
|
|||||||
answer_text = excluded.answer_text,
|
answer_text = excluded.answer_text,
|
||||||
explanation = excluded.explanation
|
explanation = excluded.explanation
|
||||||
`,
|
`,
|
||||||
[ids.questionVersion, tenantId, ids.question],
|
[ids.questionVersion, tenantId, ids.questionTwoVersion, ids.question, ids.questionTwo, ids.questionThreeVersion, ids.questionThree],
|
||||||
);
|
);
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
`
|
`
|
||||||
update public.questions
|
update public.questions q
|
||||||
set current_version_id = $3, has_video_explanation = true, updated_at = now()
|
set current_version_id = v.version_id,
|
||||||
where tenant_id = $1 and id = $2
|
has_video_explanation = case when q.id = $2::uuid then true else q.has_video_explanation end,
|
||||||
|
updated_at = now()
|
||||||
|
from (values
|
||||||
|
($2::uuid, $3::uuid),
|
||||||
|
($4::uuid, $5::uuid),
|
||||||
|
($6::uuid, $7::uuid)
|
||||||
|
) as v(question_id, version_id)
|
||||||
|
where q.tenant_id = $1 and q.id = v.question_id
|
||||||
`,
|
`,
|
||||||
[tenantId, ids.question, ids.questionVersion],
|
[
|
||||||
|
tenantId,
|
||||||
|
ids.question,
|
||||||
|
ids.questionVersion,
|
||||||
|
ids.questionTwo,
|
||||||
|
ids.questionTwoVersion,
|
||||||
|
ids.questionThree,
|
||||||
|
ids.questionThreeVersion,
|
||||||
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
await client.query(
|
await client.query(
|
||||||
|
|||||||
95
supabase/migrations/202606210011_practice_access_control.sql
Normal file
95
supabase/migrations/202606210011_practice_access_control.sql
Normal file
@@ -0,0 +1,95 @@
|
|||||||
|
alter table public.content_nodes
|
||||||
|
add column if not exists access_rules jsonb not null default '{}'::jsonb;
|
||||||
|
|
||||||
|
alter table public.question_collections
|
||||||
|
add column if not exists access_rules jsonb not null default '{}'::jsonb;
|
||||||
|
|
||||||
|
alter table public.practice_blueprints
|
||||||
|
add column if not exists access_rules jsonb not null default '{}'::jsonb;
|
||||||
|
|
||||||
|
alter table public.practice_sessions
|
||||||
|
add column if not exists access_mode text not null default 'free',
|
||||||
|
add column if not exists access_entitlement_id uuid references public.entitlements(id) on delete set null,
|
||||||
|
add column if not exists consumed_free_quota integer not null default 0,
|
||||||
|
add column if not exists access_snapshot jsonb not null default '{}'::jsonb;
|
||||||
|
|
||||||
|
do $$
|
||||||
|
begin
|
||||||
|
if not exists (select 1 from pg_constraint where conname = 'practice_sessions_access_mode_check') then
|
||||||
|
alter table public.practice_sessions
|
||||||
|
add constraint practice_sessions_access_mode_check
|
||||||
|
check (access_mode in ('free', 'svip', 'staff'));
|
||||||
|
end if;
|
||||||
|
|
||||||
|
if not exists (select 1 from pg_constraint where conname = 'practice_sessions_consumed_free_quota_check') then
|
||||||
|
alter table public.practice_sessions
|
||||||
|
add constraint practice_sessions_consumed_free_quota_check
|
||||||
|
check (consumed_free_quota >= 0);
|
||||||
|
end if;
|
||||||
|
end $$;
|
||||||
|
|
||||||
|
create table if not exists public.practice_daily_usage (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||||
|
user_id uuid not null references public.platform_users(id) on delete cascade,
|
||||||
|
usage_date date not null default current_date,
|
||||||
|
scope_type text not null default 'tenant'
|
||||||
|
check (scope_type in ('tenant', 'region', 'subject', 'question_bank', 'content_entry', 'content_node', 'collection', 'blueprint')),
|
||||||
|
scope_id uuid,
|
||||||
|
free_limit integer not null default 25 check (free_limit >= 0),
|
||||||
|
used_count integer not null default 0 check (used_count >= 0),
|
||||||
|
metadata jsonb not null default '{}'::jsonb,
|
||||||
|
created_at timestamptz not null default now(),
|
||||||
|
updated_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create table if not exists public.practice_access_events (
|
||||||
|
id uuid primary key default gen_random_uuid(),
|
||||||
|
tenant_id uuid not null references public.tenants(id) on delete cascade,
|
||||||
|
user_id uuid references public.platform_users(id) on delete set null,
|
||||||
|
practice_session_id uuid references public.practice_sessions(id) on delete set null,
|
||||||
|
event_type text not null
|
||||||
|
check (event_type in ('session_created', 'session_denied', 'quota_consumed')),
|
||||||
|
access_mode text not null default 'free'
|
||||||
|
check (access_mode in ('free', 'svip', 'staff', 'denied')),
|
||||||
|
requested_count integer not null default 0 check (requested_count >= 0),
|
||||||
|
granted_count integer not null default 0 check (granted_count >= 0),
|
||||||
|
consumed_free_quota integer not null default 0 check (consumed_free_quota >= 0),
|
||||||
|
reason text,
|
||||||
|
scope_type text,
|
||||||
|
scope_id uuid,
|
||||||
|
entitlement_id uuid references public.entitlements(id) on delete set null,
|
||||||
|
metadata jsonb not null default '{}'::jsonb,
|
||||||
|
created_at timestamptz not null default now()
|
||||||
|
);
|
||||||
|
|
||||||
|
create index if not exists idx_practice_daily_usage_user
|
||||||
|
on public.practice_daily_usage(tenant_id, user_id, usage_date desc);
|
||||||
|
|
||||||
|
create unique index if not exists idx_practice_daily_usage_unique_scope
|
||||||
|
on public.practice_daily_usage(tenant_id, user_id, usage_date, scope_type, scope_id)
|
||||||
|
nulls not distinct;
|
||||||
|
|
||||||
|
create index if not exists idx_practice_access_events_user
|
||||||
|
on public.practice_access_events(tenant_id, user_id, created_at desc);
|
||||||
|
|
||||||
|
create index if not exists idx_practice_access_events_session
|
||||||
|
on public.practice_access_events(tenant_id, practice_session_id);
|
||||||
|
|
||||||
|
do $$
|
||||||
|
declare
|
||||||
|
table_name text;
|
||||||
|
begin
|
||||||
|
foreach table_name in array array['practice_daily_usage', 'practice_access_events']
|
||||||
|
loop
|
||||||
|
execute format('alter table public.%I enable row level security', table_name);
|
||||||
|
execute format('drop policy if exists tenant_isolation on public.%I', table_name);
|
||||||
|
execute format(
|
||||||
|
'create policy tenant_isolation on public.%I for all using (tenant_id = app.current_tenant_id() or app.is_platform_admin()) with check (tenant_id = app.current_tenant_id() or app.is_platform_admin())',
|
||||||
|
table_name
|
||||||
|
);
|
||||||
|
execute format('drop trigger if exists set_updated_at on public.%I', table_name);
|
||||||
|
end loop;
|
||||||
|
|
||||||
|
execute 'create trigger set_updated_at before update on public.practice_daily_usage for each row execute function app.touch_updated_at()';
|
||||||
|
end $$;
|
||||||
Reference in New Issue
Block a user