store-scope.js 7.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778
  1. // Store requests use an explicit capability list; new headquarters endpoints
  2. // stay closed until their ownership rules have been reviewed.
  3. const STORE_OPERATIONS = new Set([
  4. 'ops/store/home','ops/live/versions','ops/users/list','ops/users/detail','ops/users/create',
  5. 'ops/users/update-profile','ops/users/reset-password','ops/users/save-note','ops/users/notes',
  6. 'ops/users/audit','ops/users/export','ops/users/reveal-mobile','ops/users/change-status',
  7. 'ops/users/credit-accounts','ops/users/credit-preview','ops/users/adjust-balance','ops/users/credit-ledger',
  8. 'ops/stores/credit-accounts','ops/stores/credit-ledger',
  9. 'ops/course-bindings/list','ops/course-bindings/options','ops/course-bindings/save',
  10. 'ops/course-bindings/recycle','ops/course-bindings/restore','ops/appointments/list',
  11. 'ops/schedule/calendar','ops/appointments/preflight','ops/appointments/create',
  12. 'ops/appointments/update','ops/appointments/cancel','ops/appointments/restore'
  13. ]);
  14. function storeOwns(context,row) {
  15. return !context.storeId || (pointerId(row.company)||row.company)===pointerId(context.company) &&
  16. number(legacyOf(row).ParentUserID)===context.storeId && row.identityType==='member' && row.isDeleted!==true;
  17. }
  18. async function storeAssertMember(context,input,id,objectId=false) {
  19. if(!context.storeId)return;
  20. const row=await Psql.oneOrNone('SELECT "objectId",company,"identityType","isDeleted","legacyUserData" FROM "_User" WHERE company=$1 AND '+(objectId?'"objectId"=$2':'"legacyUserId"::text=$2'),[pointerId(context.company),text(id)]);
  21. if(!row||!storeOwns(context,row))fail(404,'学生不存在或不属于本店');
  22. }
  23. async function storeAssertObject(context,input,className,target) {
  24. if(!context.storeId)return;
  25. if(className==='_User')return storeAssertMember(context,input,target.id,true);
  26. if(className!=='CommonModel')fail(403,'门店无权访问此类记录');
  27. const map={54:['CourseAppointment','szyh'],58:['CourseBinding','yhid']},entry=map[number(target.get('modelId'))];
  28. if(!entry)fail(403,'门店无权操作此类记录');
  29. const row=await Psql.oneOrNone('SELECT "'+entry[1]+'" AS uid FROM "'+entry[0]+'" WHERE company=$1 AND id::text=$2::text',[pointerId(context.company),target.get('itemId')]);
  30. if(!row)fail(404,'记录不存在');await storeAssertMember(context,input,row.uid);
  31. }
  32. async function prepareStoreRequest(context,input,payload) {
  33. if(!STORE_OPERATIONS.has(text(input.operation)))fail(403,'门店账号无权访问此功能');
  34. if(input.companyId&&text(input.companyId)!==pointerId(context.company))fail(403,'不能切换其他帐套');
  35. // Private scope values are overwritten, never trusted from browser filters.
  36. input.companyId=pointerId(context.company);input._storeId=context.storeId;
  37. input.filters={...(input.filters||{}),parentUserId:context.storeId};input.source='database';
  38. if(['ops/stores/credit-accounts','ops/stores/credit-ledger'].includes(input.operation)){
  39. if(text(input.objectId||input.targetId)!==context.current.id)fail(404,'门店不存在');
  40. }
  41. if(input.operation==='ops/users/create'||input.operation==='ops/users/update-profile'){
  42. if(number(payload.parentUserId)&&number(payload.parentUserId)!==context.storeId)fail(403,'不能将学生归属其他门店');
  43. payload.parentUserId=context.storeId;
  44. }
  45. if(input.operation==='ops/users/credit-preview'||input.operation==='ops/users/adjust-balance'){
  46. if(!['add','deduct'].includes(text(payload.direction)||'add'))fail(403,'门店只能划拨或按原单退回额度');
  47. if(payload.sourceTransactionId){const row=await Psql.oneOrNone('SELECT "storeId" FROM "CreditTransaction" WHERE company=$1 AND "objectId"=$2',[pointerId(context.company),text(payload.sourceTransactionId)]);if(!row||number(row.storeId)!==context.storeId)fail(404,'原单不属于本店');}
  48. }
  49. for(const id of [input.studentId,payload.studentId])if(id!==undefined&&id!==null&&id!=='')await storeAssertMember(context,input,id);
  50. // Check target before idempotent replay as well as before any mutation.
  51. if(input.operation.startsWith('ops/users/')&&text(input.objectId||input.targetId))await storeAssertMember(context,input,input.objectId||input.targetId,true);
  52. if(['ops/appointments/preflight','ops/appointments/update','ops/appointments/cancel','ops/appointments/restore','ops/course-bindings/recycle','ops/course-bindings/restore'].includes(input.operation)&&input.targetId)await scopedObject('CommonModel',text(input.targetId),context,input);
  53. if(number(payload.coachId)&&!await userByLegacyId(context,input,payload.coachId,3))fail(404,'陪练老师不存在或不属于本店');
  54. }
  55. function storePublicSchedule(data) {
  56. return {...data,appointments:data.appointments.map(({teacherPaySnapshot,...row})=>row),courses:data.courses||[],workflowIssues:[]};
  57. }
  58. async function storeSchedule(context,input) {
  59. const from=text(input.dateFrom)||businessDate(),to=text(input.dateTo)||from;
  60. const [members,appointments,coachRows,summary]=await Promise.all([
  61. queryUsers(context,input),appointmentRowsInRange(context,input,from,to),
  62. Psql.query('SELECT "objectId","legacyUserId","realName",nickname,username FROM "_User" WHERE company=$1 AND "identityType"=\'coach\' AND "legacyUserData"->>\'ParentUserID\'=$2 AND COALESCE("isDisabled",false)=false AND COALESCE("isDeleted",false)=false ORDER BY "legacyUserId"',[pointerId(context.company),String(context.storeId)]),
  63. Psql.one('SELECT count(*)::int total,min(left(a.yysj,10)) first,max(left(a.yysj,10)) last FROM "CourseAppointment" a JOIN "_User" s ON s.company=a.company AND s."legacyUserId"::text=a.szyh::text WHERE a.company=$1 AND s."identityType"=\'member\' AND s."legacyUserData"->>\'ParentUserID\'=$2 AND COALESCE(s."isDeleted",false)=false',[pointerId(context.company),String(context.storeId)])
  64. ]);
  65. const selected=appointments.filter(row=>!number(input.filters?.coachId)||row.coachId===number(input.filters.coachId));
  66. const coaches=coachRows.map(row=>({objectId:row.objectId,userId:number(row.legacyUserId),displayName:displayName(row),mobile:'',mobileMasked:'',status:'active',statusLabel:'正常',todayAppointments:selected.filter(a=>a.coachId===number(row.legacyUserId)&&!a.cancelled).length,availabilityConfigured:false}));
  67. return storePublicSchedule(await enrollmentSchedule(context,input,{dateFrom:from,dateTo:to,members,coaches,appointments:selected,unassigned:selected.filter(a=>!a.coachId&&!a.cancelled),totalAppointments:summary.total,dataDateFrom:text(summary.first),dataDateTo:text(summary.last),refreshedAt:new Date().toISOString(),source:'database',liveSourceAvailable:true,liveSourceMessage:'本店排课',sync:{state:'synced',label:'本店数据'}}));
  68. }
  69. async function storeHome(context,input) {
  70. const members=await queryUsers(context,input),store=context.current;
  71. return {storeId:context.storeId,storeObjectId:store.id,storeName:storeNameOf(store.toJSON()),summary:memberSummary(members),refreshedAt:new Date().toISOString()};
  72. }
  73. function storeSafeRules(context,value) {
  74. if(!context.storeId)return value;
  75. // Retain teaching costs required for enrollment, omit headquarters pay data.
  76. const clean=item=>{if(Array.isArray(item))return item.map(clean);if(!item||typeof item!=='object')return item;return Object.fromEntries(Object.entries(item).filter(([key])=>!['teacherPay','teacherPaySnapshot','operatorId'].includes(key)).map(([key,v])=>[key,clean(v)]));};
  77. const result=clean(value);if(result.conflicts)result.conflicts=result.conflicts.map(({targetId,...row})=>row);return result;
  78. }