Commit c4bb0a94 by luoqi

fix(plan): 详情按 ID 直取补 clinic 隔离,修切诊所后越权看别诊所患者

召回池列表按 orgScope(clinicIds)过滤诊所,但 detail / aggregate(/plans/:id/full)按 ID 直取只
校验 host/tenant/sourceUnit,漏了 clinicIds → 切诊所后残留的旧 planId 仍能拉到别诊所患者详情。
现补齐:无 PLAN_VIEW_ALL 时目标诊所须在 clinicIds 内(null 集团池放行),否则 aggregate 抛
PLAN_NOT_FOUND(前端已有该码处理 → 自动跳回当前 scope 首个患者),detail/recompute 抛 404。

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
parent d946dfc8
Pipeline #3378 failed in 0 seconds
import { Injectable, NotFoundException } from '@nestjs/common';
import type { Prisma } from '@prisma/client';
import { calcAge, maskName, maskPhone } from '@pac/utils';
import { applyLiveDays, ApiCode } from '@pac/types';
import { applyLiveDays, ApiCode, Permission } from '@pac/types';
import { BizError } from '../../common/errors/biz-error';
import { PrismaService } from '../../prisma/prisma.service';
import { ChainComposerService } from '../plan/engine/chain-composer.service';
......@@ -27,7 +27,11 @@ export class PlanAggregateService {
* 按 planId 查 → 反查 patient → 同 assemble。
* (app)/plans/[planId] 路由 + PlansAggregateController 的入口。
*/
async getPlanDetailByPlanId(scope: TenantScopeContext, planId: string) {
async getPlanDetailByPlanId(
scope: TenantScopeContext,
planId: string,
permissions: readonly string[],
) {
const plan = await this.prisma.followupPlan.findFirst({
// 集团模型:by-id 也按品牌圈(plan→patient.sourceUnit)
where: {
......@@ -40,6 +44,17 @@ export class PlanAggregateService {
if (plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
throw new NotFoundException(`Plan ${planId} not found`);
}
// clinic 隔离:与召回池列表一致 —— 无 PLAN_VIEW_ALL 时,目标诊所须在 orgScope(clinicIds)内,
// 或为 null(集团池)。否则按 ID 直取也不可见 —— 修"切诊所后残留旧 URL 越权看到别诊所患者"。
// 抛 PLAN_NOT_FOUND(非普通 404):前端据此 router.push('/plans') → landing 选回当前 scope 首个患者。
if (
!permissions.includes(Permission.PLAN_VIEW_ALL) &&
scope.clinicIds.length > 0 &&
plan.targetClinicId &&
!scope.clinicIds.includes(plan.targetClinicId)
) {
throw new BizError(ApiCode.PLAN_NOT_FOUND, `Plan ${planId} 不在当前诊所范围`);
}
const patient = await this.prisma.patient.findUnique({
where: { id: plan.patientId },
include: {
......
......@@ -71,8 +71,12 @@ export class PlansAggregateController {
summary: 'Plan 详情聚合(plan+patient+profile+persona+chains+facts)',
description: '前端 (app)/plans/[planId] 用本端点;PlanController.detail 走 strict Zod 给外部。',
})
getFull(@TenantScope() scope: TenantScopeContext, @Param('id') planId: string) {
return this.demo.getPlanDetailByPlanId(scope, planId);
getFull(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') planId: string,
) {
return this.demo.getPlanDetailByPlanId(scope, planId, user.permissions);
}
// 回访历史「一句话摘要」get-or-generate(详情页回访历史卡进卡片即调:
......
......@@ -76,8 +76,12 @@ export class PlanController {
@Get(':id')
@ZodResponse({ status: 200, type: PlanDetailResponseDto })
@RequirePermission(Permission.PLAN_VIEW_OWN)
detail(@TenantScope() scope: TenantScopeContext, @Param('id') id: string) {
return this.plans.detail(scope, id);
detail(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') id: string,
) {
return this.plans.detail(scope, id, user.permissions);
}
@Post(':id/assign')
......@@ -138,8 +142,12 @@ export class PlanController {
@ApiOperation({
summary: 'Force a plan recompute for the patient owning this plan (ops/dev tool)',
})
async recompute(@TenantScope() scope: TenantScopeContext, @Param('id') id: string) {
const plan = await this.plans.detail(scope, id);
async recompute(
@TenantScope() scope: TenantScopeContext,
@CurrentUser() user: AuthenticatedUser,
@Param('id') id: string,
) {
const plan = await this.plans.detail(scope, id, user.permissions);
const result = await this.engine.recomputeForPatient({
hostId: scope.hostId,
tenantId: scope.tenantId,
......
......@@ -366,7 +366,11 @@ export class PlanService {
// detail
// ─────────────────────────────────────────────
async detail(scope: TenantScopeContext, planId: string): Promise<PlanDetailResponse> {
async detail(
scope: TenantScopeContext,
planId: string,
permissions: readonly string[],
): Promise<PlanDetailResponse> {
const plan = await this.prisma.followupPlan.findFirst({
// 集团模型:by-id 取 plan 也按品牌圈(plan→patient.sourceUnit);命中不到→下方 NotFound
where: {
......@@ -378,6 +382,16 @@ export class PlanService {
if (!plan || plan.hostId !== scope.hostId || plan.tenantId !== scope.tenantId) {
throw new NotFoundException(`Plan ${planId} not found`);
}
// clinic 隔离:与 list 一致 —— 无 PLAN_VIEW_ALL 时,目标诊所须在 orgScope(clinicIds)内,
// 或为 null(集团池)。否则按 ID 直取也不可见 —— 避免切诊所后残留的旧 URL 越权看别诊所患者。
if (
!permissions.includes(Permission.PLAN_VIEW_ALL) &&
scope.clinicIds.length > 0 &&
plan.targetClinicId &&
!scope.clinicIds.includes(plan.targetClinicId)
) {
throw new NotFoundException(`Plan ${planId} not found`);
}
const [patient, persona, scriptRow, summariesRows, executions] = await Promise.all([
this.prisma.patient.findUnique({
......
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment