feat(org): enforce tenant isolation in ws/kc with leave and stats APIs

Add org_id schema migrations and service-level filtering for teacher-student and class workflows, then cover org-context behavior with focused unit/e2e tests for leave and hour statistics.

Made-with: Cursor
This commit is contained in:
2026-04-27 20:12:02 +08:00
parent a15b5dbf58
commit c5134c9356
19 changed files with 1226 additions and 87 deletions
@@ -0,0 +1,20 @@
drop index if exists idx_clazz_repeat_org_id;
drop index if exists idx_hour_transaction_org_id;
drop index if exists idx_course_hour_package_org_id;
drop index if exists idx_clazz_attendance_org_id;
drop index if exists idx_clazz_org_id;
alter table clazz_repeat
drop column if exists org_id;
alter table hour_transaction
drop column if exists org_id;
alter table course_hour_package
drop column if exists org_id;
alter table clazz_attendance
drop column if exists org_id;
alter table clazz
drop column if exists org_id;
@@ -0,0 +1,29 @@
alter table clazz
add column org_id varchar;
alter table clazz_attendance
add column org_id varchar;
alter table course_hour_package
add column org_id varchar;
alter table hour_transaction
add column org_id varchar;
alter table clazz_repeat
add column org_id varchar;
create index idx_clazz_org_id
on clazz (org_id);
create index idx_clazz_attendance_org_id
on clazz_attendance (org_id);
create index idx_course_hour_package_org_id
on course_hour_package (org_id);
create index idx_hour_transaction_org_id
on hour_transaction (org_id);
create index idx_clazz_repeat_org_id
on clazz_repeat (org_id);
@@ -0,0 +1 @@
drop table if exists clazz_leave_request;
@@ -0,0 +1,34 @@
create table clazz_leave_request
(
id varchar not null,
org_id varchar not null,
clazz_id varchar not null
constraint clazz_leave_request_clazz_id_fk
references clazz (id),
student_id varchar not null,
teacher_id varchar,
leave_type varchar not null,
reason varchar,
request_status varchar not null default 'PENDING',
created_at timestamp not null default now(),
created_by varchar,
reviewed_at timestamp,
reviewed_by varchar,
is_delete boolean not null default false
);
create unique index clazz_leave_request_id_uindex
on clazz_leave_request (id);
alter table clazz_leave_request
add constraint clazz_leave_request_pk
primary key (id);
create index idx_clazz_leave_request_org_id
on clazz_leave_request (org_id);
create index idx_clazz_leave_request_clazz_id
on clazz_leave_request (clazz_id);
create index idx_clazz_leave_request_student_id
on clazz_leave_request (student_id);
+180 -2
View File
@@ -51,6 +51,7 @@ pub struct Clazz {
pub course_sections: Option<MultiVals<CourseSectionJsonData>>,
pub is_notified: Option<bool>,
pub completed_at: Option<NaiveDateTime>,
pub org_id: Option<String>,
}
// https://weinan.io/2024/02/23/rust-diesel.html
@@ -97,6 +98,8 @@ pub struct ReqClazzWithRepeat {
pub clz_course_sections: Option<MultiVals<CourseSectionJsonData>>,
#[diesel(sql_type = diesel::sql_types::Nullable < diesel::sql_types::Timestamp >)]
pub clz_completed_at: Option<NaiveDateTime>,
#[diesel(sql_type = diesel::sql_types::Nullable < diesel::sql_types::Varchar >)]
pub clz_org_id: Option<String>,
// // kc_repeat
#[diesel(sql_type = diesel::sql_types::Varchar)]
pub re_id: String,
@@ -148,6 +151,7 @@ impl ReqClazzWithRepeat {
course_sections: None,
is_notified: None,
completed_at: None,
org_id: None,
}
};
@@ -164,6 +168,7 @@ impl ReqClazzWithRepeat {
repeat_end: None,
repeat_status: None,
latest_clazz_created_at: None,
org_id: None,
}
};
@@ -188,6 +193,7 @@ impl ReqClazzWithRepeat {
clz_is_repeat: the_kecheng.is_repeat.clone(),
clz_course_sections: the_kecheng.course_sections.clone(),
clz_completed_at: the_kecheng.completed_at.clone(),
clz_org_id: the_kecheng.org_id.clone(),
re_id: the_kecheng_repeat.id.clone(),
re_clazz_id: the_kecheng_repeat.clazz_id.clone(),
re_start_from: the_kecheng_repeat.start_from.clone(),
@@ -225,6 +231,7 @@ impl Clazz {
course_sections: self.course_sections.clone(),
is_notified: self.is_notified.clone(),
completed_at: self.completed_at.clone(),
org_id: self.org_id.clone(),
};
req_res
}
@@ -337,7 +344,7 @@ impl Clazz {
end_by: &NaiveDateTime,
conn: &mut PgConnection,
) -> anyhow::Result<Option<Vec<ReqClazzWithRepeat>>> {
let q = format!("SELECT clazz.id AS clz_id, clazz.clazz_name AS clz_clazz_name, clazz.clazz_status AS clz_clazz_status, clazz.clazz_desc AS clz_clazz_desc, clazz.start_from AS clz_start_from, clazz.end_by AS clz_end_by, clazz.duration AS clz_duration, clazz.root_id AS clz_root_id, clazz.clazz_type AS clz_clazz_type, clazz.parent_id AS clz_parent_id, clazz.created_by AS clz_created_by, clazz.created_at AS clz_created_at, clazz.students AS clz_students, clazz.teachers AS clz_teachers, clazz.jihuas AS clz_jihuas, clazz.dakas AS clz_dakas, clazz.is_delete AS clz_is_delete, clazz.is_repeat AS clz_is_repeat, clazz.course_sections AS clz_course_sections, clazz.completed_at AS clz_completed_at, clazz_repeat.id AS re_id, clazz_repeat.clazz_id AS re_clazz_id, clazz_repeat.start_from AS re_start_from, clazz_repeat.end_by AS re_end_by, clazz_repeat.repeat_start AS re_repeat_start, clazz_repeat.repeat_cycle_days AS re_repeat_cycle_days, clazz_repeat.repeat_end AS re_repeat_end, clazz_repeat.repeat_status AS re_repeat_status, clazz_repeat.latest_clazz_created_at AS re_latest_clazz_created_at FROM clazz JOIN clazz_repeat ON clazz.id = clazz_repeat.clazz_id WHERE (clazz.is_repeat IS NOT NULL AND clazz.is_repeat IS TRUE) AND ((clazz_repeat.repeat_start <= '{}' AND clazz_repeat.repeat_end >= '{}') OR (clazz.start_from <= '{}' AND clazz.end_by >= '{}'));", date_to_string(end_by), date_to_string(start_from), date_to_string(end_by), date_to_string(start_from)).to_string();
let q = format!("SELECT clazz.id AS clz_id, clazz.clazz_name AS clz_clazz_name, clazz.clazz_status AS clz_clazz_status, clazz.clazz_desc AS clz_clazz_desc, clazz.start_from AS clz_start_from, clazz.end_by AS clz_end_by, clazz.duration AS clz_duration, clazz.root_id AS clz_root_id, clazz.clazz_type AS clz_clazz_type, clazz.parent_id AS clz_parent_id, clazz.created_by AS clz_created_by, clazz.created_at AS clz_created_at, clazz.students AS clz_students, clazz.teachers AS clz_teachers, clazz.jihuas AS clz_jihuas, clazz.dakas AS clz_dakas, clazz.is_delete AS clz_is_delete, clazz.is_repeat AS clz_is_repeat, clazz.course_sections AS clz_course_sections, clazz.completed_at AS clz_completed_at, clazz.org_id AS clz_org_id, clazz_repeat.id AS re_id, clazz_repeat.clazz_id AS re_clazz_id, clazz_repeat.start_from AS re_start_from, clazz_repeat.end_by AS re_end_by, clazz_repeat.repeat_start AS re_repeat_start, clazz_repeat.repeat_cycle_days AS re_repeat_cycle_days, clazz_repeat.repeat_end AS re_repeat_end, clazz_repeat.repeat_status AS re_repeat_status, clazz_repeat.latest_clazz_created_at AS re_latest_clazz_created_at FROM clazz JOIN clazz_repeat ON clazz.id = clazz_repeat.clazz_id WHERE (clazz.is_repeat IS NOT NULL AND clazz.is_repeat IS TRUE) AND ((clazz_repeat.repeat_start <= '{}' AND clazz_repeat.repeat_end >= '{}') OR (clazz.start_from <= '{}' AND clazz.end_by >= '{}'));", date_to_string(end_by), date_to_string(start_from), date_to_string(end_by), date_to_string(start_from)).to_string();
debug!("find_all_repeatable_by_date_range -> q: {:?}", q);
@@ -358,7 +365,7 @@ impl Clazz {
end_by: &NaiveDateTime,
conn: &mut PgConnection,
) -> anyhow::Result<Option<Vec<ReqClazzWithRepeat>>> {
let q = format!("SELECT clazz.id AS clz_id, clazz.clazz_name AS clz_clazz_name, clazz.clazz_status AS clz_clazz_status, clazz.clazz_desc AS clz_clazz_desc, clazz.start_from AS clz_start_from, clazz.end_by AS clz_end_by, clazz.duration AS clz_duration, clazz.root_id AS clz_root_id, clazz.clazz_type AS clz_clazz_type, clazz.parent_id AS clz_parent_id, clazz.created_by AS clz_created_by, clazz.created_at AS clz_created_at, clazz.students AS clz_students, clazz.teachers AS clz_teachers, clazz.jihuas AS clz_jihuas, clazz.dakas AS clz_dakas, clazz.is_delete AS clz_is_delete, clazz.is_repeat AS clz_is_repeat, clazz.course_sections AS clz_course_sections, clazz.completed_at AS clz_completed_at, clazz_repeat.id AS re_id, clazz_repeat.clazz_id AS re_clazz_id, clazz_repeat.start_from AS re_start_from, clazz_repeat.end_by AS re_end_by, clazz_repeat.repeat_start AS re_repeat_start, clazz_repeat.repeat_cycle_days AS re_repeat_cycle_days, clazz_repeat.repeat_end AS re_repeat_end, clazz_repeat.repeat_status AS re_repeat_status, clazz_repeat.latest_clazz_created_at AS re_latest_clazz_created_at FROM clazz JOIN clazz_repeat ON clazz.id = clazz_repeat.clazz_id WHERE (jsonb_path_exists(clazz.students, '$.val.users.vals[*].user_id ? (@ == \"{}\")') OR jsonb_path_exists(clazz.teachers, '$.val.users.vals[*].user_id ? (@ == \"{}\")')) AND (clazz.is_repeat IS NOT NULL AND clazz.is_repeat IS TRUE) AND ((clazz_repeat.repeat_start <= '{}' AND clazz_repeat.repeat_end >= '{}') OR (clazz.start_from <= '{}' AND clazz.end_by >= '{}'));", id_user.clone(), id_user.clone(), date_to_string(end_by), date_to_string(start_from), date_to_string(end_by), date_to_string(start_from)).to_string();
let q = format!("SELECT clazz.id AS clz_id, clazz.clazz_name AS clz_clazz_name, clazz.clazz_status AS clz_clazz_status, clazz.clazz_desc AS clz_clazz_desc, clazz.start_from AS clz_start_from, clazz.end_by AS clz_end_by, clazz.duration AS clz_duration, clazz.root_id AS clz_root_id, clazz.clazz_type AS clz_clazz_type, clazz.parent_id AS clz_parent_id, clazz.created_by AS clz_created_by, clazz.created_at AS clz_created_at, clazz.students AS clz_students, clazz.teachers AS clz_teachers, clazz.jihuas AS clz_jihuas, clazz.dakas AS clz_dakas, clazz.is_delete AS clz_is_delete, clazz.is_repeat AS clz_is_repeat, clazz.course_sections AS clz_course_sections, clazz.completed_at AS clz_completed_at, clazz.org_id AS clz_org_id, clazz_repeat.id AS re_id, clazz_repeat.clazz_id AS re_clazz_id, clazz_repeat.start_from AS re_start_from, clazz_repeat.end_by AS re_end_by, clazz_repeat.repeat_start AS re_repeat_start, clazz_repeat.repeat_cycle_days AS re_repeat_cycle_days, clazz_repeat.repeat_end AS re_repeat_end, clazz_repeat.repeat_status AS re_repeat_status, clazz_repeat.latest_clazz_created_at AS re_latest_clazz_created_at FROM clazz JOIN clazz_repeat ON clazz.id = clazz_repeat.clazz_id WHERE (jsonb_path_exists(clazz.students, '$.val.users.vals[*].user_id ? (@ == \"{}\")') OR jsonb_path_exists(clazz.teachers, '$.val.users.vals[*].user_id ? (@ == \"{}\")')) AND (clazz.is_repeat IS NOT NULL AND clazz.is_repeat IS TRUE) AND ((clazz_repeat.repeat_start <= '{}' AND clazz_repeat.repeat_end >= '{}') OR (clazz.start_from <= '{}' AND clazz.end_by >= '{}'));", id_user.clone(), id_user.clone(), date_to_string(end_by), date_to_string(start_from), date_to_string(end_by), date_to_string(start_from)).to_string();
debug!(
"find_all_repeatable_by_date_range_and_user_id -> q: {:?}",
@@ -445,6 +452,7 @@ pub struct ClazzRepeat {
pub repeat_end: Option<NaiveDateTime>,
pub repeat_status: Option<String>,
pub latest_clazz_created_at: Option<NaiveDateTime>,
pub org_id: Option<String>,
}
impl ClazzRepeat {
@@ -476,6 +484,7 @@ impl ClazzRepeat {
repeat_end: self.repeat_end.clone(),
repeat_status: self.repeat_status.clone(),
latest_clazz_created_at: self.latest_clazz_created_at.clone(),
org_id: self.org_id.clone(),
};
req_repeat
}
@@ -609,6 +618,7 @@ pub struct ReqClazz {
pub course_sections: Option<MultiVals<CourseSectionJsonData>>,
pub is_notified: Option<bool>,
pub completed_at: Option<NaiveDateTime>,
pub org_id: Option<String>,
}
impl ReqClazz {
@@ -641,6 +651,7 @@ impl ReqClazz {
course_sections: None,
is_notified: None,
completed_at: None,
org_id: None,
}
};
@@ -677,6 +688,7 @@ impl ReqClazz {
course_sections: the_kecheng.course_sections,
is_notified: the_kecheng.is_notified,
completed_at: the_kecheng.completed_at,
org_id: the_kecheng.org_id,
}
}
}
@@ -692,6 +704,7 @@ pub struct ReqClazzRepeat {
pub repeat_end: Option<NaiveDateTime>,
pub repeat_status: Option<String>,
pub latest_clazz_created_at: Option<NaiveDateTime>,
pub org_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -719,6 +732,7 @@ pub struct CourseHourPackage {
pub created_by: Option<String>,
pub updated_at: Option<NaiveDateTime>,
pub is_delete: Option<bool>,
pub org_id: Option<String>,
}
#[derive(Queryable, Serialize, Deserialize, Debug, Clone)]
@@ -732,6 +746,7 @@ pub struct HourTransaction {
pub operator_hty_id: Option<String>,
pub remark: Option<String>,
pub created_at: NaiveDateTime,
pub org_id: Option<String>,
}
#[derive(Queryable, Serialize, Deserialize, Debug, Clone)]
@@ -747,6 +762,7 @@ pub struct ClazzAttendance {
pub created_at: NaiveDateTime,
pub created_by: Option<String>,
pub is_delete: Option<bool>,
pub org_id: Option<String>,
}
#[derive(Insertable, Clone, Debug)]
@@ -763,6 +779,7 @@ pub struct NewClazzAttendance {
pub created_at: NaiveDateTime,
pub created_by: Option<String>,
pub is_delete: Option<bool>,
pub org_id: Option<String>,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
@@ -780,6 +797,166 @@ pub struct ReqBatchClazzAttendance {
pub items: Vec<ReqClazzAttendanceItem>,
}
#[derive(
AsChangeset,
Associations,
Identifiable,
PartialEq,
Serialize,
Deserialize,
Queryable,
Debug,
Insertable,
Clone,
)]
#[diesel(table_name = clazz_leave_request)]
#[diesel(belongs_to(Clazz, foreign_key = clazz_id))]
pub struct ClazzLeaveRequest {
pub id: String,
pub org_id: String,
pub clazz_id: String,
pub student_id: String,
pub teacher_id: Option<String>,
pub leave_type: String,
pub reason: Option<String>,
pub request_status: String,
pub created_at: NaiveDateTime,
pub created_by: Option<String>,
pub reviewed_at: Option<NaiveDateTime>,
pub reviewed_by: Option<String>,
pub is_delete: bool,
}
#[derive(Serialize, Deserialize, Debug, Clone)]
pub struct ReqClazzLeaveRequest {
pub id: Option<String>,
pub org_id: Option<String>,
pub clazz_id: Option<String>,
pub student_id: Option<String>,
pub teacher_id: Option<String>,
pub leave_type: Option<String>,
pub reason: Option<String>,
pub request_status: Option<String>,
}
#[derive(QueryableByName, Serialize, Deserialize, Debug, Clone)]
pub struct TeacherHourStatsRow {
#[diesel(sql_type = diesel::sql_types::Varchar)]
pub teacher_id: String,
#[diesel(sql_type = diesel::sql_types::BigInt)]
pub clazz_count: i64,
#[diesel(sql_type = diesel::sql_types::BigInt)]
pub student_count: i64,
#[diesel(sql_type = diesel::sql_types::Double)]
pub total_hours: f64,
}
impl ClazzLeaveRequest {
pub fn create(
payload: &ClazzLeaveRequest,
conn: &mut PgConnection,
) -> anyhow::Result<ClazzLeaveRequest> {
use crate::schema::clazz_leave_request::dsl::*;
insert_into(clazz_leave_request)
.values(payload)
.get_result(conn)
.map_err(|e| anyhow!(HtyErr {
code: HtyErrCode::DbErr,
reason: Some(e.to_string()),
}))
}
pub fn update_status(
request_id: &String,
target_status: &String,
reviewer_id: &Option<String>,
conn: &mut PgConnection,
) -> anyhow::Result<ClazzLeaveRequest> {
diesel::update(
clazz_leave_request::table.filter(clazz_leave_request::id.eq(request_id)),
)
.set((
clazz_leave_request::request_status.eq(target_status),
clazz_leave_request::reviewed_at.eq(Some(current_local_datetime())),
clazz_leave_request::reviewed_by.eq(reviewer_id.clone()),
))
.get_result(conn)
.map_err(|e| anyhow!(HtyErr {
code: HtyErrCode::DbErr,
reason: Some(e.to_string()),
}))
}
pub fn list_by_org_and_scope(
org: &String,
clazz: &Option<String>,
student: &Option<String>,
teacher: &Option<String>,
conn: &mut PgConnection,
) -> anyhow::Result<Vec<ClazzLeaveRequest>> {
let mut query = clazz_leave_request::table
.into_boxed()
.filter(clazz_leave_request::org_id.eq(org))
.filter(clazz_leave_request::is_delete.eq(false));
if let Some(clazz_id) = clazz {
query = query.filter(clazz_leave_request::clazz_id.eq(clazz_id));
}
if let Some(student_id) = student {
query = query.filter(clazz_leave_request::student_id.eq(student_id));
}
if let Some(teacher_id) = teacher {
query = query.filter(clazz_leave_request::teacher_id.eq(teacher_id));
}
query
.order(clazz_leave_request::created_at.desc())
.load::<ClazzLeaveRequest>(conn)
.map_err(|e| anyhow!(HtyErr {
code: HtyErrCode::DbErr,
reason: Some(e.to_string()),
}))
}
}
impl Clazz {
pub fn teacher_hour_stats_by_org_and_date_range(
org: &String,
start_date: &String,
end_date: &String,
target_teacher_id: Option<&String>,
conn: &mut PgConnection,
) -> anyhow::Result<Vec<TeacherHourStatsRow>> {
let teacher_filter = if let Some(teacher_id) = target_teacher_id {
format!(" and c.created_by = '{}'", teacher_id)
} else {
String::new()
};
let query_text = format!(
"select c.created_by as teacher_id,
count(distinct c.id)::bigint as clazz_count,
count(distinct a.student_id)::bigint as student_count,
coalesce(sum(a.deducted_hours), 0)::double precision as total_hours
from clazz c
left join clazz_attendance a on c.id = a.clazz_id
where c.org_id = '{org}'
and c.start_from >= '{start}'
and c.end_by <= '{end}'
and (a.is_delete is null or a.is_delete = false)
{teacher_filter}
group by c.created_by",
org = org,
start = start_date,
end = end_date,
teacher_filter = teacher_filter,
);
sql_query(query_text)
.load::<TeacherHourStatsRow>(conn)
.map_err(|e| anyhow!(HtyErr {
code: HtyErrCode::DbErr,
reason: Some(e.to_string()),
}))
}
}
impl ClazzAttendance {
pub fn find_all_active_by_clazz_id(
clazz_id_in: &String,
@@ -824,6 +1001,7 @@ impl ClazzAttendance {
created_at: now,
created_by: operator_id.clone(),
is_delete: Some(false),
org_id: None,
})
.collect();
+25
View File
@@ -23,6 +23,7 @@ diesel::table! {
course_sections -> Nullable<Jsonb>,
is_notified -> Nullable<Bool>,
completed_at -> Nullable<Timestamp>,
org_id -> Nullable<Varchar>,
}
}
@@ -39,6 +40,7 @@ diesel::table! {
created_at -> Timestamp,
created_by -> Nullable<Varchar>,
is_delete -> Nullable<Bool>,
org_id -> Nullable<Varchar>,
}
}
@@ -53,6 +55,7 @@ diesel::table! {
repeat_end -> Nullable<Timestamp>,
repeat_status -> Nullable<Varchar>,
latest_clazz_created_at -> Nullable<Timestamp>,
org_id -> Nullable<Varchar>,
}
}
@@ -70,6 +73,7 @@ diesel::table! {
created_by -> Nullable<Varchar>,
updated_at -> Nullable<Timestamp>,
is_delete -> Nullable<Bool>,
org_id -> Nullable<Varchar>,
}
}
@@ -84,10 +88,30 @@ diesel::table! {
operator_hty_id -> Nullable<Varchar>,
remark -> Nullable<Varchar>,
created_at -> Timestamp,
org_id -> Nullable<Varchar>,
}
}
diesel::table! {
clazz_leave_request (id) {
id -> Varchar,
org_id -> Varchar,
clazz_id -> Varchar,
student_id -> Varchar,
teacher_id -> Nullable<Varchar>,
leave_type -> Varchar,
reason -> Nullable<Varchar>,
request_status -> Varchar,
created_at -> Timestamp,
created_by -> Nullable<Varchar>,
reviewed_at -> Nullable<Timestamp>,
reviewed_by -> Nullable<Varchar>,
is_delete -> Bool,
}
}
diesel::joinable!(clazz_attendance -> clazz (clazz_id));
diesel::joinable!(clazz_leave_request -> clazz (clazz_id));
diesel::joinable!(clazz_attendance -> course_hour_package (course_hour_package_id));
diesel::joinable!(clazz_repeat -> clazz (clazz_id));
diesel::joinable!(hour_transaction -> clazz (clazz_id));
@@ -96,6 +120,7 @@ diesel::joinable!(hour_transaction -> course_hour_package (package_id));
diesel::allow_tables_to_appear_in_same_query!(
clazz,
clazz_attendance,
clazz_leave_request,
clazz_repeat,
course_hour_package,
hour_transaction,