forked from wangziqi/gongxue-base
feat: enforce trusted session identity
This commit is contained in:
@@ -4,3 +4,10 @@ DEFAULT_TENANT_SLUG=master
|
||||
CORS_ORIGIN=http://127.0.0.1:5173,http://localhost:5173
|
||||
MAX_JSON_BODY_BYTES=1048576
|
||||
MAX_IMPORT_JSON_BODY_BYTES=10485760
|
||||
AUTH_SMS_PROVIDER=mock
|
||||
AUTH_CODE_PEPPER=replace_with_a_long_random_secret
|
||||
AUTH_SESSION_SECRET=replace_with_another_long_random_secret
|
||||
# 安全目标默认关闭迁移期身份头。仅本地兼容旧测试/旧前端时可临时设为 true。
|
||||
ALLOW_LEGACY_AUTH_HEADERS=false
|
||||
ALLOW_PLATFORM_ADMIN_KEY=false
|
||||
PLATFORM_ADMIN_API_KEY=replace_with_platform_admin_key
|
||||
|
||||
73
apps/api/src/core/auth-context.ts
Normal file
73
apps/api/src/core/auth-context.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { config } from './config.js';
|
||||
import { queryOne } from './db.js';
|
||||
import { getHeader, type RequestContext } from './http.js';
|
||||
|
||||
export interface SessionIdentity {
|
||||
id: string;
|
||||
username: string | null;
|
||||
phone: string | null;
|
||||
name: string | null;
|
||||
avatarUrl: string | null;
|
||||
primaryRole: string;
|
||||
createdAt: string;
|
||||
tenantId: string;
|
||||
sessionId: string;
|
||||
sessionExpiresAt: string;
|
||||
}
|
||||
|
||||
interface RequestAuthState {
|
||||
bearerToken: string;
|
||||
session: SessionIdentity | null;
|
||||
sessionResolved: boolean;
|
||||
}
|
||||
|
||||
const requestAuthState = new WeakMap<RequestContext, RequestAuthState>();
|
||||
|
||||
export function bearerTokenFrom(ctx: RequestContext) {
|
||||
const authorization = getHeader(ctx.req, 'authorization');
|
||||
const match = authorization.match(/^Bearer\s+(.+)$/i);
|
||||
return match?.[1]?.trim() || '';
|
||||
}
|
||||
|
||||
export function hashSessionToken(token: string) {
|
||||
return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex');
|
||||
}
|
||||
|
||||
export async function findUserBySessionToken(token: string) {
|
||||
const tokenHash = hashSessionToken(token);
|
||||
return queryOne<SessionIdentity>(
|
||||
`
|
||||
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt",
|
||||
s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt"
|
||||
from app_private.auth_sessions s
|
||||
join public.platform_users u on u.id = s.user_id
|
||||
where s.token_hash = $1
|
||||
and s.revoked_at is null
|
||||
and s.expires_at > now()
|
||||
limit 1
|
||||
`,
|
||||
[tokenHash],
|
||||
);
|
||||
}
|
||||
|
||||
export async function hydrateRequestAuth(ctx: RequestContext) {
|
||||
const cached = requestAuthState.get(ctx);
|
||||
if (cached?.sessionResolved) return cached;
|
||||
|
||||
const bearerToken = bearerTokenFrom(ctx);
|
||||
const session = bearerToken ? await findUserBySessionToken(bearerToken) : null;
|
||||
const state = { bearerToken, session, sessionResolved: true };
|
||||
requestAuthState.set(ctx, state);
|
||||
return state;
|
||||
}
|
||||
|
||||
export function currentSessionFromContext(ctx: RequestContext) {
|
||||
return requestAuthState.get(ctx)?.session || null;
|
||||
}
|
||||
|
||||
export function hasInvalidBearerSession(ctx: RequestContext) {
|
||||
const state = requestAuthState.get(ctx);
|
||||
return Boolean(state?.bearerToken && !state.session);
|
||||
}
|
||||
@@ -14,6 +14,8 @@ export interface ApiConfig {
|
||||
authCodeTtlSeconds: number;
|
||||
authSmsCooldownSeconds: number;
|
||||
authSessionTtlSeconds: number;
|
||||
allowLegacyAuthHeaders: boolean;
|
||||
allowPlatformAdminKey: boolean;
|
||||
platformAdminApiKey: string;
|
||||
storageDefaultProvider: string;
|
||||
storageDefaultBucket: string;
|
||||
@@ -79,6 +81,12 @@ function validateProductionConfig(nextConfig: ApiConfig) {
|
||||
if (isUnsafeSecret(nextConfig.platformAdminApiKey, DEFAULT_PLATFORM_ADMIN_API_KEY)) {
|
||||
failures.push('PLATFORM_ADMIN_API_KEY must be a strong production secret until platform JWT is implemented');
|
||||
}
|
||||
if (nextConfig.allowLegacyAuthHeaders) {
|
||||
failures.push('ALLOW_LEGACY_AUTH_HEADERS=true is not allowed in production');
|
||||
}
|
||||
if (nextConfig.allowPlatformAdminKey) {
|
||||
failures.push('ALLOW_PLATFORM_ADMIN_KEY=true is not allowed in production');
|
||||
}
|
||||
|
||||
if (failures.length > 0) {
|
||||
throw new Error(`Invalid production API configuration: ${failures.join('; ')}`);
|
||||
@@ -102,6 +110,8 @@ const loadedConfig: ApiConfig = {
|
||||
authCodeTtlSeconds: envNumber('AUTH_CODE_TTL_SECONDS', 300),
|
||||
authSmsCooldownSeconds: envNumber('AUTH_SMS_COOLDOWN_SECONDS', 60),
|
||||
authSessionTtlSeconds: envNumber('AUTH_SESSION_TTL_SECONDS', 60 * 60 * 24 * 7),
|
||||
allowLegacyAuthHeaders: envBoolean('ALLOW_LEGACY_AUTH_HEADERS', !isProduction),
|
||||
allowPlatformAdminKey: envBoolean('ALLOW_PLATFORM_ADMIN_KEY', !isProduction),
|
||||
platformAdminApiKey: envString('PLATFORM_ADMIN_API_KEY', DEFAULT_PLATFORM_ADMIN_API_KEY),
|
||||
storageDefaultProvider: envString('STORAGE_DEFAULT_PROVIDER', 'local_dev'),
|
||||
storageDefaultBucket: envString('STORAGE_DEFAULT_BUCKET', 'tenant-assets'),
|
||||
|
||||
@@ -1,28 +1,76 @@
|
||||
import { config } from './config.js';
|
||||
import { hydrateRequestAuth } from './auth-context.js';
|
||||
import { getHeader, HttpError, type RequestContext } from './http.js';
|
||||
|
||||
export type JsonObject = Record<string, unknown>;
|
||||
|
||||
export function tenantIdFrom(ctx: RequestContext) {
|
||||
const tenantId = getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId');
|
||||
if (!tenantId) {
|
||||
throw new HttpError(400, 'x-tenant-id header or tenantId query is required', 'TENANT_ID_REQUIRED');
|
||||
}
|
||||
return tenantId;
|
||||
function legacyTenantIdFrom(ctx: RequestContext) {
|
||||
return getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId');
|
||||
}
|
||||
|
||||
export function userIdFrom(ctx: RequestContext, body?: JsonObject) {
|
||||
const userId =
|
||||
function legacyUserIdFrom(ctx: RequestContext, body?: JsonObject) {
|
||||
return (
|
||||
getHeader(ctx.req, 'x-user-id') ||
|
||||
ctx.url.searchParams.get('userId') ||
|
||||
(typeof body?.userId === 'string' ? body.userId : '');
|
||||
(typeof body?.userId === 'string' ? body.userId : '')
|
||||
);
|
||||
}
|
||||
|
||||
export async function tenantIdFrom(ctx: RequestContext) {
|
||||
const auth = await hydrateRequestAuth(ctx);
|
||||
const legacyTenantId = legacyTenantIdFrom(ctx);
|
||||
if (auth.session) {
|
||||
if (legacyTenantId && legacyTenantId !== auth.session.tenantId) {
|
||||
throw new HttpError(403, 'Request tenant does not match the authenticated session', 'AUTH_TENANT_MISMATCH');
|
||||
}
|
||||
return auth.session.tenantId;
|
||||
}
|
||||
|
||||
if (!legacyTenantId) {
|
||||
throw new HttpError(400, 'x-tenant-id header or tenantId query is required', 'TENANT_ID_REQUIRED');
|
||||
}
|
||||
return legacyTenantId;
|
||||
}
|
||||
|
||||
export async function userIdFrom(ctx: RequestContext, body?: JsonObject) {
|
||||
const auth = await hydrateRequestAuth(ctx);
|
||||
if (auth.session) {
|
||||
const legacyUserId = legacyUserIdFrom(ctx, body);
|
||||
if (legacyUserId && legacyUserId !== auth.session.id) {
|
||||
throw new HttpError(403, 'Request user identity does not match the authenticated session', 'AUTH_USER_MISMATCH');
|
||||
}
|
||||
return auth.session.id;
|
||||
}
|
||||
if (auth.bearerToken) {
|
||||
throw new HttpError(401, 'Invalid or expired session', 'AUTH_SESSION_INVALID');
|
||||
}
|
||||
|
||||
const userId = legacyUserIdFrom(ctx, body);
|
||||
if (!userId) {
|
||||
throw new HttpError(400, 'x-user-id header, userId query, or userId body is required', 'USER_ID_REQUIRED');
|
||||
}
|
||||
if (!config.allowLegacyAuthHeaders) {
|
||||
throw new HttpError(401, 'User identity must be resolved from a trusted session in this environment', 'TRUSTED_USER_REQUIRED');
|
||||
}
|
||||
return userId;
|
||||
}
|
||||
|
||||
export async function optionalUserIdFrom(ctx: RequestContext, body?: JsonObject) {
|
||||
const auth = await hydrateRequestAuth(ctx);
|
||||
if (auth.session) {
|
||||
const legacyUserId = legacyUserIdFrom(ctx, body);
|
||||
if (legacyUserId && legacyUserId !== auth.session.id) {
|
||||
throw new HttpError(403, 'Request user identity does not match the authenticated session', 'AUTH_USER_MISMATCH');
|
||||
}
|
||||
return auth.session.id;
|
||||
}
|
||||
if (auth.bearerToken) {
|
||||
throw new HttpError(401, 'Invalid or expired session', 'AUTH_SESSION_INVALID');
|
||||
}
|
||||
if (!config.allowLegacyAuthHeaders) return '';
|
||||
return legacyUserIdFrom(ctx, body);
|
||||
}
|
||||
|
||||
export function intParam(ctx: RequestContext, name: string, fallback: number, max = 500) {
|
||||
const value = Number(ctx.url.searchParams.get(name) || fallback);
|
||||
if (!Number.isFinite(value) || value <= 0) return fallback;
|
||||
@@ -96,8 +144,17 @@ export function optionalStringArray(body: JsonObject, key: string): string[] {
|
||||
return value.map(item => String(item)).filter(Boolean);
|
||||
}
|
||||
|
||||
export function requirePlatformAdmin(ctx: RequestContext) {
|
||||
export async function requirePlatformAdmin(ctx: RequestContext) {
|
||||
const auth = await hydrateRequestAuth(ctx);
|
||||
if (auth.session?.primaryRole === 'platform_admin') return;
|
||||
if (auth.bearerToken) {
|
||||
throw new HttpError(401, 'Invalid or expired platform admin session', 'AUTH_SESSION_INVALID');
|
||||
}
|
||||
|
||||
const provided = getHeader(ctx.req, 'x-platform-admin-key');
|
||||
if (provided && !config.allowPlatformAdminKey) {
|
||||
throw new HttpError(401, 'Platform admin API key is disabled in this environment', 'PLATFORM_ADMIN_KEY_DISABLED');
|
||||
}
|
||||
if (!provided || provided !== config.platformAdminApiKey) {
|
||||
throw new HttpError(403, 'Platform admin access is required', 'PLATFORM_ADMIN_REQUIRED');
|
||||
}
|
||||
|
||||
@@ -1,15 +1,14 @@
|
||||
import crypto from 'node:crypto';
|
||||
import { config } from '../../core/config.js';
|
||||
import { bearerTokenFrom, findUserBySessionToken } from '../../core/auth-context.js';
|
||||
import { query, transaction } from '../../core/db.js';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { optionalString, readJsonBody, requiredString, tenantIdFrom } from '../../core/request.js';
|
||||
import { createSmsProvider } from './providers.js';
|
||||
import {
|
||||
assertChinaPhone,
|
||||
bearerTokenFrom,
|
||||
clientIpFrom,
|
||||
createLoginSession,
|
||||
findUserBySessionToken,
|
||||
generateSmsCode,
|
||||
hashSmsCode,
|
||||
normalizePurpose,
|
||||
@@ -77,7 +76,7 @@ async function activeSmsProviderName(tenantId: string) {
|
||||
}
|
||||
|
||||
export async function sendSmsCodeRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const phone = assertChinaPhone(requiredString(body, 'phone'));
|
||||
const purpose = normalizePurpose(optionalString(body, 'purpose') || 'login');
|
||||
@@ -168,7 +167,7 @@ export async function sendSmsCodeRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function verifySmsCodeRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const phone = assertChinaPhone(requiredString(body, 'phone'));
|
||||
const code = requiredString(body, 'code');
|
||||
@@ -316,7 +315,7 @@ export async function verifySmsCodeRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function meRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const token = bearerTokenFrom(ctx);
|
||||
if (!token) {
|
||||
throw new HttpError(401, 'Bearer token is required', 'AUTH_TOKEN_REQUIRED');
|
||||
@@ -348,7 +347,7 @@ export async function meRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function logoutRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const token = bearerTokenFrom(ctx);
|
||||
if (!token) return { ok: true };
|
||||
|
||||
|
||||
@@ -1,8 +1,8 @@
|
||||
import crypto from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import { hashSessionToken } from '../../core/auth-context.js';
|
||||
import { config } from '../../core/config.js';
|
||||
import { HttpError, getHeader, type RequestContext } from '../../core/http.js';
|
||||
import { queryOne } from '../../core/db.js';
|
||||
|
||||
export interface PlatformUserSummary {
|
||||
id: string;
|
||||
@@ -60,40 +60,6 @@ export function createSessionToken() {
|
||||
return `tk_${crypto.randomBytes(32).toString('base64url')}`;
|
||||
}
|
||||
|
||||
export function hashSessionToken(token: string) {
|
||||
return crypto.createHmac('sha256', config.authSessionSecret).update(token).digest('hex');
|
||||
}
|
||||
|
||||
export async function findUserBySessionToken(token: string) {
|
||||
const tokenHash = hashSessionToken(token);
|
||||
return queryOne<
|
||||
PlatformUserSummary & {
|
||||
tenantId: string;
|
||||
sessionId: string;
|
||||
sessionExpiresAt: string;
|
||||
}
|
||||
>(
|
||||
`
|
||||
select u.id, u.username, u.phone, u.name, u.avatar_url as "avatarUrl",
|
||||
u.primary_role as "primaryRole", u.created_at as "createdAt",
|
||||
s.tenant_id as "tenantId", s.id as "sessionId", s.expires_at as "sessionExpiresAt"
|
||||
from app_private.auth_sessions s
|
||||
join public.platform_users u on u.id = s.user_id
|
||||
where s.token_hash = $1
|
||||
and s.revoked_at is null
|
||||
and s.expires_at > now()
|
||||
limit 1
|
||||
`,
|
||||
[tokenHash],
|
||||
);
|
||||
}
|
||||
|
||||
export function bearerTokenFrom(ctx: RequestContext) {
|
||||
const authorization = getHeader(ctx.req, 'authorization');
|
||||
const match = authorization.match(/^Bearer\s+(.+)$/i);
|
||||
return match?.[1]?.trim() || '';
|
||||
}
|
||||
|
||||
export async function createLoginSession(
|
||||
client: pg.PoolClient,
|
||||
input: {
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, optionalUserIdFrom, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
import { signStorageDownload, type StorageProviderName } from '../storage/service.js';
|
||||
|
||||
@@ -18,14 +18,6 @@ interface CatalogAssetRow {
|
||||
subjectId: string | null;
|
||||
}
|
||||
|
||||
function optionalUserId(ctx: RequestContext) {
|
||||
return getHeader(ctx.req, 'x-user-id') || ctx.url.searchParams.get('userId') || '';
|
||||
}
|
||||
|
||||
function hasUserContext(ctx: RequestContext) {
|
||||
return !!optionalUserId(ctx);
|
||||
}
|
||||
|
||||
async function hasActiveMembership(tenantId: string, userId: string) {
|
||||
const row = await queryOne<{ id: string }>(
|
||||
`
|
||||
@@ -64,13 +56,15 @@ async function hasSvipAccess(tenantId: string, userId: string, asset: CatalogAss
|
||||
}
|
||||
|
||||
async function assertAssetAccess(ctx: RequestContext, asset: CatalogAssetRow) {
|
||||
if (asset.visibility === 'public' || asset.visibility === 'tenant') return { userId: optionalUserId(ctx), svip: false };
|
||||
if (asset.visibility === 'public' || asset.visibility === 'tenant') {
|
||||
return { userId: await optionalUserIdFrom(ctx), svip: false };
|
||||
}
|
||||
if (asset.visibility === 'private') {
|
||||
throw new HttpError(403, 'Asset is private', 'ASSET_PRIVATE');
|
||||
}
|
||||
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const member = await hasActiveMembership(tenantId, userId);
|
||||
if (!member) {
|
||||
throw new HttpError(403, 'Tenant membership is required for this asset', 'ASSET_MEMBERSHIP_REQUIRED');
|
||||
@@ -86,7 +80,7 @@ async function assertAssetAccess(ctx: RequestContext, asset: CatalogAssetRow) {
|
||||
}
|
||||
|
||||
export async function assetsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
const assetType = stringParam(ctx, 'assetType');
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
@@ -95,7 +89,7 @@ export async function assetsRoute(ctx: RequestContext) {
|
||||
const entryId = stringParam(ctx, 'entryId');
|
||||
const contentNodeId = stringParam(ctx, 'contentNodeId');
|
||||
const includeLocked = stringParam(ctx, 'includeLocked') === 'true';
|
||||
const userPresent = hasUserContext(ctx);
|
||||
const userPresent = Boolean(await optionalUserIdFrom(ctx));
|
||||
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = [`tenant_id = $1`, `status = 'active'`, `visibility <> 'private'`];
|
||||
@@ -151,7 +145,7 @@ export async function assetsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function assetDownloadRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const assetId = stringParam(ctx, 'assetId') || ctx.url.searchParams.get('id') || '';
|
||||
if (!assetId) {
|
||||
throw new HttpError(400, 'assetId is required', 'REQUIRED_FIELD');
|
||||
|
||||
@@ -8,7 +8,7 @@ function optionalUuidParam(ctx: RequestContext, name: string) {
|
||||
}
|
||||
|
||||
export async function contentEntriesRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = optionalUuidParam(ctx, 'regionId');
|
||||
const entryType = stringParam(ctx, 'entryType');
|
||||
const includeHidden = stringParam(ctx, 'includeHidden') === 'true';
|
||||
@@ -44,7 +44,7 @@ export async function contentEntriesRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function contentNodesRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const entryId = stringParam(ctx, 'entryId');
|
||||
if (!entryId) {
|
||||
throw new HttpError(400, 'entryId is required', 'REQUIRED_FIELD');
|
||||
@@ -97,7 +97,7 @@ export async function contentNodesRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function questionCollectionsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = optionalUuidParam(ctx, 'regionId');
|
||||
const entryId = optionalUuidParam(ctx, 'entryId');
|
||||
const nodeId = optionalUuidParam(ctx, 'nodeId');
|
||||
@@ -146,7 +146,7 @@ export async function questionCollectionsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function practiceBlueprintsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const entryId = optionalUuidParam(ctx, 'entryId');
|
||||
const nodeId = optionalUuidParam(ctx, 'nodeId');
|
||||
const collectionId = optionalUuidParam(ctx, 'collectionId');
|
||||
@@ -195,7 +195,7 @@ export async function practiceBlueprintsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function collectionQuestionsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const collectionId = stringParam(ctx, 'collectionId');
|
||||
if (!collectionId) {
|
||||
throw new HttpError(400, 'collectionId is required', 'REQUIRED_FIELD');
|
||||
|
||||
@@ -1,22 +1,9 @@
|
||||
import { getHeader, HttpError, type RequestContext } from '../../core/http.js';
|
||||
import type { RequestContext } from '../../core/http.js';
|
||||
import { query } from '../../core/db.js';
|
||||
|
||||
function tenantIdFrom(ctx: RequestContext) {
|
||||
const tenantId = getHeader(ctx.req, 'x-tenant-id') || ctx.url.searchParams.get('tenantId');
|
||||
if (!tenantId) {
|
||||
throw new HttpError(400, 'x-tenant-id header or tenantId query is required', 'TENANT_ID_REQUIRED');
|
||||
}
|
||||
return tenantId;
|
||||
}
|
||||
|
||||
function intParam(ctx: RequestContext, name: string, fallback: number, max = 500) {
|
||||
const value = Number(ctx.url.searchParams.get(name) || fallback);
|
||||
if (!Number.isFinite(value) || value <= 0) return fallback;
|
||||
return Math.min(Math.trunc(value), max);
|
||||
}
|
||||
import { intParam, tenantIdFrom } from '../../core/request.js';
|
||||
|
||||
export async function regionsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
|
||||
const items = await query(
|
||||
`
|
||||
@@ -34,7 +21,7 @@ export async function regionsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function regionModulesRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
|
||||
const params: unknown[] = [tenantId];
|
||||
@@ -61,7 +48,7 @@ export async function regionModulesRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function moduleNodesRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const moduleId = ctx.url.searchParams.get('moduleId');
|
||||
const parentId = ctx.url.searchParams.get('parentId');
|
||||
@@ -98,7 +85,7 @@ export async function moduleNodesRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function schoolsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1'];
|
||||
@@ -124,7 +111,7 @@ export async function schoolsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function majorsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const schoolId = ctx.url.searchParams.get('schoolId');
|
||||
const params: unknown[] = [tenantId];
|
||||
@@ -155,7 +142,7 @@ export async function majorsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function subjectsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const schoolId = ctx.url.searchParams.get('schoolId');
|
||||
const majorId = ctx.url.searchParams.get('majorId');
|
||||
@@ -203,7 +190,7 @@ export async function subjectsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function categoriesRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const subjectId = ctx.url.searchParams.get('subjectId');
|
||||
const nodeId = ctx.url.searchParams.get('nodeId');
|
||||
const params: unknown[] = [tenantId];
|
||||
@@ -235,7 +222,7 @@ export async function categoriesRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function questionsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const subjectId = ctx.url.searchParams.get('subjectId');
|
||||
const categoryId = ctx.url.searchParams.get('categoryId');
|
||||
const nodeId = ctx.url.searchParams.get('nodeId');
|
||||
@@ -309,7 +296,7 @@ export async function questionsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function vocabularyUnitsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
@@ -336,7 +323,7 @@ export async function vocabularyUnitsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function vocabularyWordsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const unitId = ctx.url.searchParams.get('unitId');
|
||||
const limit = intParam(ctx, 'limit', 1000, 2000);
|
||||
const params: unknown[] = [tenantId];
|
||||
@@ -368,7 +355,7 @@ export async function vocabularyWordsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function handbookSubjectsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
@@ -395,7 +382,7 @@ export async function handbookSubjectsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function handbookChaptersRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const subjectId = ctx.url.searchParams.get('subjectId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
@@ -422,7 +409,7 @@ export async function handbookChaptersRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function handbookEntriesRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const chapterId = ctx.url.searchParams.get('chapterId');
|
||||
const includeContent = ctx.url.searchParams.get('includeContent') === 'true';
|
||||
const params: unknown[] = [tenantId];
|
||||
@@ -450,7 +437,7 @@ export async function handbookEntriesRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function bannersRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
@@ -478,7 +465,7 @@ export async function bannersRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function faqsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
@@ -503,7 +490,7 @@ export async function faqsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function announcementsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const items = await query(
|
||||
`
|
||||
select id, legacy_id as "legacyId", content, link,
|
||||
@@ -521,7 +508,7 @@ export async function announcementsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function productsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', `status = 'active'`];
|
||||
@@ -548,7 +535,7 @@ export async function productsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function timelinesRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const schoolId = ctx.url.searchParams.get('schoolId');
|
||||
const params: unknown[] = [tenantId];
|
||||
@@ -580,7 +567,7 @@ export async function timelinesRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function svipPlansRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId');
|
||||
const params: unknown[] = [tenantId];
|
||||
const filters = ['tenant_id = $1', 'is_active = true'];
|
||||
|
||||
@@ -45,8 +45,8 @@ interface EntitlementRow {
|
||||
|
||||
export async function createOrderRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx, body);
|
||||
const planId = requiredString(body, 'planId');
|
||||
const quantity = Math.max(1, optionalInteger(body, 'quantity', 1));
|
||||
const payMethod = optionalString(body, 'payMethod') || 'manual';
|
||||
@@ -133,8 +133,8 @@ export async function createOrderRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function ordersRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const limit = intParam(ctx, 'limit', 50, 200);
|
||||
|
||||
const items = await query<EntitlementRow>(
|
||||
@@ -157,8 +157,8 @@ export async function ordersRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function entitlementsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const now = new Date().toISOString();
|
||||
|
||||
const items: EntitlementRow[] = await query<EntitlementRow>(
|
||||
@@ -217,8 +217,8 @@ export async function entitlementsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function entitlementCheckRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const regionId = ctx.url.searchParams.get('regionId') || '';
|
||||
const now = new Date().toISOString();
|
||||
|
||||
@@ -255,7 +255,7 @@ export async function entitlementCheckRoute(ctx: RequestContext) {
|
||||
|
||||
export async function confirmManualPaymentRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const orderNo = requiredString(body, 'orderNo');
|
||||
const providerTradeNo = optionalString(body, 'providerTradeNo') || `manual-${orderNo}`;
|
||||
const amountCents = optionalInteger(body, 'amountCents', -1);
|
||||
@@ -324,8 +324,8 @@ export async function confirmManualPaymentRoute(ctx: RequestContext) {
|
||||
|
||||
export async function redeemActivationCodeRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx, body);
|
||||
const code = requiredString(body, 'code');
|
||||
const regionId = optionalString(body, 'regionId') || null;
|
||||
|
||||
|
||||
@@ -342,8 +342,8 @@ async function assembleQuestionIds(tenantId: string, assembly: PracticeAssembly)
|
||||
|
||||
export async function createPracticeSessionRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx, body);
|
||||
const assembly = await buildPracticeAssembly(tenantId, body);
|
||||
const questionIds = await assembleQuestionIds(tenantId, assembly);
|
||||
|
||||
@@ -404,8 +404,8 @@ export async function createPracticeSessionRoute(ctx: RequestContext) {
|
||||
|
||||
export async function submitAnswerRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx, body);
|
||||
const questionId = requiredString(body, 'questionId');
|
||||
const selectedOptions = optionalStringArray(body, 'selectedOptions');
|
||||
const answerText = optionalString(body, 'answerText');
|
||||
@@ -489,8 +489,8 @@ export async function submitAnswerRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function favoriteQuestionsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
|
||||
const items = await query(
|
||||
@@ -513,8 +513,8 @@ export async function favoriteQuestionsRoute(ctx: RequestContext) {
|
||||
|
||||
export async function toggleFavoriteQuestionRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx, body);
|
||||
const questionId = requiredString(body, 'questionId');
|
||||
const favorite = body.favorite !== false;
|
||||
|
||||
@@ -541,8 +541,8 @@ export async function toggleFavoriteQuestionRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function wrongQuestionsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const unresolvedOnly = stringParam(ctx, 'status') !== 'all';
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
|
||||
@@ -568,8 +568,8 @@ export async function wrongQuestionsRoute(ctx: RequestContext) {
|
||||
|
||||
export async function resolveWrongQuestionRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx, body);
|
||||
const questionId = requiredString(body, 'questionId');
|
||||
|
||||
await query(
|
||||
@@ -590,8 +590,8 @@ function normalizeWordStatus(value: string) {
|
||||
}
|
||||
|
||||
export async function wordProgressRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const unitId = stringParam(ctx, 'unitId');
|
||||
const status = stringParam(ctx, 'status');
|
||||
const limit = intParam(ctx, 'limit', 500, 2000);
|
||||
@@ -619,8 +619,8 @@ export async function wordProgressRoute(ctx: RequestContext) {
|
||||
|
||||
export async function updateWordProgressRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx, body);
|
||||
const wordId = requiredString(body, 'wordId');
|
||||
const status = normalizeWordStatus(optionalString(body, 'status') || 'learning');
|
||||
const correctDelta = Math.max(0, optionalInteger(body, 'correctDelta', status === 'mastered' ? 1 : 0));
|
||||
@@ -680,8 +680,8 @@ export async function updateWordProgressRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function favoriteWordsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const unitId = stringParam(ctx, 'unitId');
|
||||
const limit = intParam(ctx, 'limit', 500, 2000);
|
||||
|
||||
@@ -707,8 +707,8 @@ export async function favoriteWordsRoute(ctx: RequestContext) {
|
||||
|
||||
export async function toggleFavoriteWordRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx, body);
|
||||
const wordId = requiredString(body, 'wordId');
|
||||
const favorite = body.favorite !== false;
|
||||
const note = optionalString(body, 'note') || null;
|
||||
@@ -739,8 +739,8 @@ export async function toggleFavoriteWordRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function wordStatsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const unitId = stringParam(ctx, 'unitId');
|
||||
|
||||
const item = await queryOne(
|
||||
|
||||
@@ -33,7 +33,7 @@ function toDateText(value: unknown) {
|
||||
}
|
||||
|
||||
export async function platformOverviewRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const [tenantStats, invoiceStats, subscriptionStats, usageStats] = await Promise.all([
|
||||
queryOne<{
|
||||
@@ -110,7 +110,7 @@ export async function platformOverviewRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function platformPlansRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const includeArchived = listQuery(ctx, 'includeArchived') === 'true';
|
||||
const items = await query(
|
||||
@@ -130,7 +130,7 @@ export async function platformPlansRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function tenantsRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const status = listQuery(ctx, 'status');
|
||||
const billingStatus = listQuery(ctx, 'billingStatus');
|
||||
@@ -178,7 +178,7 @@ export async function tenantsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function tenantDetailRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
||||
if (!tenantId) throw new HttpError(400, 'tenantId is required', 'TENANT_ID_REQUIRED');
|
||||
@@ -259,7 +259,7 @@ export async function tenantDetailRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function createTenantRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const slug = normalizeSlug(requiredString(body, 'slug'));
|
||||
@@ -388,7 +388,7 @@ export async function createTenantRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function updateTenantStatusRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
@@ -415,7 +415,7 @@ export async function updateTenantStatusRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function upsertBillingProfileRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
@@ -467,7 +467,7 @@ export async function upsertBillingProfileRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function createSubscriptionRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
@@ -530,7 +530,7 @@ export async function createSubscriptionRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function tenantInvoicesRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
||||
const status = listQuery(ctx, 'status');
|
||||
@@ -650,7 +650,7 @@ async function createInvoiceRecord(input: CreateInvoiceInput) {
|
||||
}
|
||||
|
||||
export async function createInvoiceRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
@@ -676,7 +676,7 @@ export async function createInvoiceRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
@@ -761,7 +761,7 @@ export async function confirmInvoicePaymentRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function recordUsageRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
@@ -788,7 +788,7 @@ export async function recordUsageRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function tenantUsageRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const tenantId = ctx.url.searchParams.get('tenantId') || '';
|
||||
const limit = intParam(ctx, 'limit', 100, 500);
|
||||
@@ -812,7 +812,7 @@ export async function tenantUsageRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function createTenantInvoiceFromSubscriptionRoute(ctx: RequestContext) {
|
||||
requirePlatformAdmin(ctx);
|
||||
await requirePlatformAdmin(ctx);
|
||||
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = requiredString(body, 'tenantId');
|
||||
|
||||
@@ -40,8 +40,8 @@ function jsonArrayBodyValue(value: unknown) {
|
||||
}
|
||||
|
||||
export async function profileMeRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const limit = intParam(ctx, 'recentLimit', 8, 50);
|
||||
|
||||
const profile = await queryOne<ProfileRow>(
|
||||
@@ -198,8 +198,8 @@ export async function profileMeRoute(ctx: RequestContext) {
|
||||
|
||||
export async function updateProfileMeRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx, body);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx, body);
|
||||
|
||||
const name = optionalString(body, 'name') || null;
|
||||
const avatarUrl = optionalString(body, 'avatarUrl') || null;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { randomBytes } from 'node:crypto';
|
||||
import type pg from 'pg';
|
||||
import { HttpError, getHeader, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, optionalString, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { HttpError, type RequestContext } from '../../core/http.js';
|
||||
import { intParam, optionalString, optionalUserIdFrom, readJsonBody, requiredString, stringParam, tenantIdFrom, userIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne, transaction } from '../../core/db.js';
|
||||
import { clientIpFrom, userAgentFrom } from '../auth/service.js';
|
||||
import { hasTenantPermission, requireTenantAdmin, requireTenantPermission, type TenantAdminAuth } from '../tenant-admin/auth.js';
|
||||
@@ -334,8 +334,8 @@ function crmPermission(auth: TenantAdminAuth) {
|
||||
}
|
||||
|
||||
export async function referralInviteCodeRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
|
||||
const item = await transaction(async client => {
|
||||
const activeMember = await userHasTenantMembership(client, tenantId, userId);
|
||||
@@ -350,7 +350,7 @@ export async function referralInviteCodeRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function referralResolveRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const code = optionalString(body, 'code');
|
||||
if (!code) return { valid: false };
|
||||
@@ -368,12 +368,14 @@ export async function referralResolveRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function referralTrackEventRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const eventType = optionalChoice(body.eventType, EVENT_TYPES, 'enter');
|
||||
const source = optionalChoice(body.source, TRACK_SOURCES, 'unknown');
|
||||
const refCode = normalizeCode(requiredString(body, 'refCode'));
|
||||
const targetUserId = nullableString(body.targetUserId) || nullableString(body.userId) || getHeader(ctx.req, 'x-user-id') || null;
|
||||
const targetUserId = await optionalUserIdFrom(ctx, {
|
||||
userId: nullableString(body.targetUserId) || nullableString(body.userId) || undefined,
|
||||
}) || null;
|
||||
const referrer = await resolveReferralCode(tenantId, refCode);
|
||||
|
||||
const result = await transaction(async client => {
|
||||
@@ -431,9 +433,9 @@ export async function referralTrackEventRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function referralBindRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const studentUserId = userIdFrom(ctx, body);
|
||||
const studentUserId = await userIdFrom(ctx, body);
|
||||
const refCode = normalizeCode(requiredString(body, 'refCode'));
|
||||
const referrer = await resolveReferralCode(tenantId, refCode);
|
||||
if (!referrer) throw new HttpError(404, 'Referral code not found', 'REFERRAL_CODE_NOT_FOUND');
|
||||
@@ -772,8 +774,8 @@ export async function upsertReferralTeamRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function referralQrcodeRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
const body = await readJsonBody(ctx);
|
||||
const page = optionalString(body, 'page') || 'pages/index/index';
|
||||
const provider = optionalString(body, 'provider') || 'wechat-miniapp';
|
||||
|
||||
@@ -3,7 +3,7 @@ import { intParam, stringParam, tenantIdFrom } from '../../core/request.js';
|
||||
import { query, queryOne } from '../../core/db.js';
|
||||
|
||||
export async function scorelineFieldsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
|
||||
const items = await query(
|
||||
@@ -26,7 +26,7 @@ export async function scorelineFieldsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function scorelineSchoolsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const q = stringParam(ctx, 'q');
|
||||
const limit = intParam(ctx, 'limit', 200, 1000);
|
||||
@@ -51,7 +51,7 @@ export async function scorelineSchoolsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function scorelineMajorsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const schoolId = stringParam(ctx, 'schoolId');
|
||||
const limit = intParam(ctx, 'limit', 500, 2000);
|
||||
@@ -76,7 +76,7 @@ export async function scorelineMajorsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function scorelineRecordsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const schoolId = stringParam(ctx, 'schoolId');
|
||||
const majorId = stringParam(ctx, 'majorId');
|
||||
@@ -121,7 +121,7 @@ export async function scorelineRecordsRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function scorelineTrendRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
const schoolId = stringParam(ctx, 'schoolId');
|
||||
const majorId = stringParam(ctx, 'majorId');
|
||||
@@ -147,7 +147,7 @@ export async function scorelineTrendRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function scorelineYearsRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const regionId = stringParam(ctx, 'regionId');
|
||||
|
||||
const items = await query<{ year: number }>(
|
||||
|
||||
@@ -101,8 +101,8 @@ export function tenantPermissionCatalog() {
|
||||
}
|
||||
|
||||
export async function requireTenantAdmin(ctx: RequestContext): Promise<TenantAdminAuth> {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
|
||||
const membership = await queryOne<{ role: string; permissions: Record<string, unknown> }>(
|
||||
`
|
||||
|
||||
@@ -12,8 +12,8 @@ export interface TenantContentAuth {
|
||||
}
|
||||
|
||||
export async function requireTenantContentEditor(ctx: RequestContext): Promise<TenantContentAuth> {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const userId = userIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const userId = await userIdFrom(ctx);
|
||||
|
||||
const membership = await queryOne<{ role: string; permissions: Record<string, unknown> }>(
|
||||
`
|
||||
|
||||
@@ -34,7 +34,7 @@ function videoSelectSql() {
|
||||
}
|
||||
|
||||
export async function questionVideosRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const questionId = stringParam(ctx, 'questionId');
|
||||
if (!questionId) {
|
||||
throw new HttpError(400, 'questionId is required', 'QUESTION_ID_REQUIRED');
|
||||
@@ -54,7 +54,7 @@ export async function questionVideosRoute(ctx: RequestContext) {
|
||||
|
||||
export async function questionVideosBatchRoute(ctx: RequestContext) {
|
||||
const body = await readJsonBody(ctx);
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const questionIds = optionalStringArray(body, 'questionIds').slice(0, 50);
|
||||
|
||||
if (!questionIds.length) {
|
||||
@@ -80,7 +80,7 @@ export async function questionVideosBatchRoute(ctx: RequestContext) {
|
||||
}
|
||||
|
||||
export async function videoSearchRoute(ctx: RequestContext) {
|
||||
const tenantId = tenantIdFrom(ctx);
|
||||
const tenantId = await tenantIdFrom(ctx);
|
||||
const subjectId = stringParam(ctx, 'subjectId');
|
||||
const tags = stringParam(ctx, 'tags')
|
||||
.split(',')
|
||||
|
||||
Reference in New Issue
Block a user