@@ -39,6 +39,110 @@ import {
3939 setDatabaseEmergencyDeactivated ,
4040} from "../services/system_health_runtime.service.js" ;
4141
42+ const ORG_USER_ROLE = "org_user" ;
43+ const ORG_ADMIN_ROLE = "org_admin" ;
44+
45+ function isOrgAdminRole ( role ) {
46+ return String ( role || "" ) . trim ( ) . toLowerCase ( ) === ORG_ADMIN_ROLE ;
47+ }
48+
49+ function isOrgUserRole ( role ) {
50+ return String ( role || "" ) . trim ( ) . toLowerCase ( ) === ORG_USER_ROLE ;
51+ }
52+
53+ function isOrganizationScopedRole ( role ) {
54+ const normalized = String ( role || "" ) . trim ( ) . toLowerCase ( ) ;
55+ return normalized === ORG_USER_ROLE || normalized === ORG_ADMIN_ROLE ;
56+ }
57+
58+ function getActorOrganizationId ( req ) {
59+ return String ( req . user ?. organization_id || req . user ?. organizationId || "" ) . trim ( ) ;
60+ }
61+
62+ function getScopedUserRoleFilter ( req ) {
63+ return isOrgAdminRole ( req . user ?. role ) ? [ ORG_USER_ROLE ] : [ ] ;
64+ }
65+
66+ function getScopedOrganizationIdFilter ( req ) {
67+ return isOrgAdminRole ( req . user ?. role ) ? getActorOrganizationId ( req ) : "" ;
68+ }
69+
70+ async function assertOrgScopedUserAccess ( req , userId ) {
71+ if ( ! isOrgAdminRole ( req . user ?. role ) ) {
72+ return { allowed : true , user : null } ;
73+ }
74+
75+ const actorOrganizationId = getActorOrganizationId ( req ) ;
76+ if ( ! actorOrganizationId ) {
77+ return {
78+ allowed : false ,
79+ status : 403 ,
80+ message : "Org-admin access requires an organization ID." ,
81+ } ;
82+ }
83+
84+ const user = await findUserById ( userId ) ;
85+ if ( ! user ) {
86+ return { allowed : false , status : 404 , message : "User not found" } ;
87+ }
88+
89+ if ( ! isOrgUserRole ( user . role ) || String ( user . organization_id || "" ) . trim ( ) !== actorOrganizationId ) {
90+ return {
91+ allowed : false ,
92+ status : 403 ,
93+ message : "Org-admin access is limited to org users in the same organization." ,
94+ } ;
95+ }
96+
97+ return { allowed : true , user } ;
98+ }
99+
100+ async function assertOrgScopedSupportTicketAccess ( req , ticketId ) {
101+ if ( ! isOrgAdminRole ( req . user ?. role ) ) {
102+ return { allowed : true } ;
103+ }
104+
105+ const actorOrganizationId = getActorOrganizationId ( req ) ;
106+ if ( ! actorOrganizationId ) {
107+ return {
108+ allowed : false ,
109+ status : 403 ,
110+ message : "Org-admin access requires an organization ID." ,
111+ } ;
112+ }
113+
114+ const { rows } = await query (
115+ `
116+ SELECT
117+ t.id,
118+ t.user_id,
119+ u.role as user_role,
120+ u.organization_id
121+ FROM support_tickets t
122+ LEFT JOIN users u ON u.id = t.user_id
123+ WHERE t.id = $1
124+ LIMIT 1
125+ ` ,
126+ [ ticketId ]
127+ ) ;
128+ const ticket = rows [ 0 ] || null ;
129+ if ( ! ticket ) {
130+ return { allowed : false , status : 404 , message : "Ticket not found" } ;
131+ }
132+ if (
133+ ! ticket . user_id ||
134+ ! isOrgUserRole ( ticket . user_role ) ||
135+ String ( ticket . organization_id || "" ) . trim ( ) !== actorOrganizationId
136+ ) {
137+ return {
138+ allowed : false ,
139+ status : 403 ,
140+ message : "Org-admin access is limited to support tickets from org users in the same organization." ,
141+ } ;
142+ }
143+ return { allowed : true , ticket } ;
144+ }
145+
42146const SYSTEM_HEALTH_SERVICES = [
43147 {
44148 id : "database_connection" ,
@@ -332,12 +436,33 @@ export const listUsersAdmin = asyncHandler(async (req, res) => {
332436 const limit = Math . min ( Number ( req . query . limit ) || 50 , 200 ) ;
333437 const offset = Math . max ( Number ( req . query . offset ) || 0 , 0 ) ;
334438 const queryText = String ( req . query . q || "" ) . trim ( ) ;
439+ const roleFilter = getScopedUserRoleFilter ( req ) ;
440+ const organizationIdFilter = getScopedOrganizationIdFilter ( req ) ;
335441
336- const { users, total } = await listUsers ( { limit, offset, queryText } ) ;
442+ const { users, total } = await listUsers ( {
443+ limit,
444+ offset,
445+ queryText,
446+ roleFilter,
447+ organizationIdFilter,
448+ } ) ;
337449 res . json ( { users, total } ) ;
338450} ) ;
339451
340452export const listUserOptionsAdmin = asyncHandler ( async ( _req , res ) => {
453+ const params = [ ] ;
454+ const where = [ ] ;
455+ let i = 1 ;
456+ if ( isOrgAdminRole ( _req . user ?. role ) ) {
457+ const actorOrganizationId = getActorOrganizationId ( _req ) ;
458+ if ( ! actorOrganizationId ) {
459+ return res . json ( { users : [ ] } ) ;
460+ }
461+ where . push ( `lower(role) = $${ i ++ } ` ) ;
462+ params . push ( ORG_USER_ROLE ) ;
463+ where . push ( `organization_id = $${ i ++ } ` ) ;
464+ params . push ( actorOrganizationId ) ;
465+ }
341466 const { rows } = await query (
342467 `
343468 SELECT
@@ -347,20 +472,28 @@ export const listUserOptionsAdmin = asyncHandler(async (_req, res) => {
347472 full_name,
348473 COALESCE(NULLIF(trim(full_name), ''), NULLIF(trim(username), ''), email) AS display_name
349474 FROM users
475+ ${ where . length ? `WHERE ${ where . join ( " AND " ) } ` : "" }
350476 ORDER BY lower(COALESCE(NULLIF(trim(full_name), ''), NULLIF(trim(username), ''), email)) ASC
351- `
477+ ` ,
478+ params
352479 ) ;
353480 res . json ( { users : rows } ) ;
354481} ) ;
355482
356483export const getUserAdmin = asyncHandler ( async ( req , res ) => {
357- const user = await findUserById ( req . params . id ) ;
484+ const access = await assertOrgScopedUserAccess ( req , req . params . id ) ;
485+ if ( ! access . allowed ) return res . status ( access . status ) . json ( { message : access . message } ) ;
486+ const user = access . user || await findUserById ( req . params . id ) ;
358487 if ( ! user ) return res . status ( 404 ) . json ( { message : "User not found" } ) ;
359488 res . json ( { user } ) ;
360489} ) ;
361490
362491export const updateUserAdmin = asyncHandler ( async ( req , res ) => {
363492 const userId = req . params . id ;
493+ const access = await assertOrgScopedUserAccess ( req , userId ) ;
494+ if ( ! access . allowed ) return res . status ( access . status ) . json ( { message : access . message } ) ;
495+ const currentUser = access . user || await findUserById ( userId ) ;
496+ if ( ! currentUser ) return res . status ( 404 ) . json ( { message : "User not found" } ) ;
364497 const updates = { } ;
365498
366499 const allowedFields = [
@@ -369,6 +502,7 @@ export const updateUserAdmin = asyncHandler(async (req, res) => {
369502 "fullName" ,
370503 "location" ,
371504 "role" ,
505+ "organizationId" ,
372506 "phoneNumber" ,
373507 "bio" ,
374508 "avatarUrl" ,
@@ -407,11 +541,46 @@ export const updateUserAdmin = asyncHandler(async (req, res) => {
407541
408542 if (
409543 updates . role !== undefined &&
410- ! [ "user" , "admin" , "support_admin" , "analyst" ] . includes ( updates . role )
544+ ! [ "user" , "org_user" , " admin" , "org_admin ", "support_admin" , "analyst" ] . includes ( updates . role )
411545 ) {
412546 return res . status ( 400 ) . json ( { message : "Invalid role" } ) ;
413547 }
414548
549+ if ( isOrgAdminRole ( req . user ?. role ) && updates . role !== undefined && updates . role !== ORG_USER_ROLE ) {
550+ return res . status ( 403 ) . json ( {
551+ message : "Org-admin can only manage users with role org_user." ,
552+ } ) ;
553+ }
554+ if ( isOrgAdminRole ( req . user ?. role ) ) {
555+ const actorOrganizationId = getActorOrganizationId ( req ) ;
556+ if ( ! actorOrganizationId ) {
557+ return res . status ( 403 ) . json ( { message : "Org-admin access requires an organization ID." } ) ;
558+ }
559+ if ( updates . organizationId !== undefined && String ( updates . organizationId || "" ) . trim ( ) !== actorOrganizationId ) {
560+ return res . status ( 403 ) . json ( {
561+ message : "Org-admin can only manage users in the same organization." ,
562+ } ) ;
563+ }
564+ updates . organizationId = actorOrganizationId ;
565+ }
566+
567+ const effectiveRole = String ( updates . role ?? currentUser . role ?? "" ) . trim ( ) . toLowerCase ( ) ;
568+ const effectiveOrganizationId = String (
569+ updates . organizationId !== undefined
570+ ? updates . organizationId
571+ : currentUser . organization_id ?? currentUser . organizationId ?? ""
572+ ) . trim ( ) ;
573+
574+ if ( isOrganizationScopedRole ( effectiveRole ) && ! effectiveOrganizationId ) {
575+ return res . status ( 400 ) . json ( {
576+ message : "organizationId is required for org_user and org_admin roles." ,
577+ } ) ;
578+ }
579+
580+ if ( ! isOrganizationScopedRole ( effectiveRole ) && ( updates . role !== undefined || updates . organizationId !== undefined ) ) {
581+ updates . organizationId = null ;
582+ }
583+
415584 const updated = await updateUserById ( userId , updates ) ;
416585 if ( ! updated ) return res . status ( 404 ) . json ( { message : "User not found" } ) ;
417586
@@ -467,14 +636,31 @@ export const listRecordsAdminController = asyncHandler(async (req, res) => {
467636 const userId = req . query . userId ? String ( req . query . userId ) : undefined ;
468637 const queryText = req . query . q ? String ( req . query . q ) . trim ( ) : undefined ;
469638 const type = req . query . type ? String ( req . query . type ) : undefined ;
639+ const roleFilter = getScopedUserRoleFilter ( req ) ;
640+ const organizationIdFilter = getScopedOrganizationIdFilter ( req ) ;
641+
642+ if ( userId ) {
643+ const access = await assertOrgScopedUserAccess ( req , userId ) ;
644+ if ( ! access . allowed ) return res . status ( access . status ) . json ( { message : access . message } ) ;
645+ }
470646
471- const records = await listRecordsAdmin ( { userId, queryText, type, limit, offset } ) ;
647+ const records = await listRecordsAdmin ( {
648+ userId,
649+ queryText,
650+ type,
651+ limit,
652+ offset,
653+ roleFilter,
654+ organizationIdFilter,
655+ } ) ;
472656 res . json ( { records } ) ;
473657} ) ;
474658
475659export const getRecordAdmin = asyncHandler ( async ( req , res ) => {
476660 const record = await getRecordByIdAdmin ( req . params . id ) ;
477661 if ( ! record ) return res . status ( 404 ) . json ( { message : "Record not found" } ) ;
662+ const access = await assertOrgScopedUserAccess ( req , record . user_id ) ;
663+ if ( ! access . allowed ) return res . status ( access . status ) . json ( { message : access . message } ) ;
478664 res . json ( { record } ) ;
479665} ) ;
480666
@@ -483,6 +669,8 @@ export const updateRecordAdminController = asyncHandler(async (req, res) => {
483669
484670 const existing = await getRecordByIdAdmin ( req . params . id ) ;
485671 if ( ! existing ) return res . status ( 404 ) . json ( { message : "Record not found" } ) ;
672+ const access = await assertOrgScopedUserAccess ( req , existing . user_id ) ;
673+ if ( ! access . allowed ) return res . status ( access . status ) . json ( { message : access . message } ) ;
486674
487675 if ( type !== undefined && ! [ "income" , "expense" ] . includes ( type ) ) {
488676 return res . status ( 400 ) . json ( { message : "Invalid type" } ) ;
@@ -524,6 +712,8 @@ export const deleteRecordAdminController = asyncHandler(async (req, res) => {
524712 const deleteReceiptFlag = req . query . deleteReceipt === "true" ;
525713 const record = await getRecordByIdAdmin ( req . params . id ) ;
526714 if ( ! record ) return res . status ( 404 ) . json ( { message : "Record not found" } ) ;
715+ const access = await assertOrgScopedUserAccess ( req , record . user_id ) ;
716+ if ( ! access . allowed ) return res . status ( access . status ) . json ( { message : access . message } ) ;
527717
528718 const linkedReceiptId = record . linked_receipt_id ;
529719 if ( linkedReceiptId ) {
@@ -554,13 +744,38 @@ export const deleteRecordAdminController = asyncHandler(async (req, res) => {
554744} ) ;
555745
556746export const getAdminStatsController = asyncHandler ( async ( _req , res ) => {
747+ const organizationId = getScopedOrganizationIdFilter ( _req ) ;
748+ const scopedToOrganization = isOrgAdminRole ( _req . user ?. role ) && organizationId ;
749+ if ( isOrgAdminRole ( _req . user ?. role ) && ! organizationId ) {
750+ return res . json ( { stats : { total_users : 0 , total_records : 0 , total_receipts : 0 } } ) ;
751+ }
557752 const { rows } = await query (
558- `
559- SELECT
560- (SELECT COUNT(*)::int FROM users) AS total_users,
561- (SELECT COUNT(*)::int FROM records) AS total_records,
562- (SELECT COUNT(*)::int FROM receipts) AS total_receipts
563- `
753+ scopedToOrganization
754+ ? `
755+ SELECT
756+ (SELECT COUNT(*)::int FROM users WHERE lower(role) = 'org_user' AND organization_id = $1) AS total_users,
757+ (
758+ SELECT COUNT(*)::int
759+ FROM records r
760+ JOIN users u ON u.id = r.user_id
761+ WHERE lower(u.role) = 'org_user'
762+ AND u.organization_id = $1
763+ ) AS total_records,
764+ (
765+ SELECT COUNT(*)::int
766+ FROM receipts r
767+ JOIN users u ON u.id = r.user_id
768+ WHERE lower(u.role) = 'org_user'
769+ AND u.organization_id = $1
770+ ) AS total_receipts
771+ `
772+ : `
773+ SELECT
774+ (SELECT COUNT(*)::int FROM users) AS total_users,
775+ (SELECT COUNT(*)::int FROM records) AS total_records,
776+ (SELECT COUNT(*)::int FROM receipts) AS total_receipts
777+ ` ,
778+ scopedToOrganization ? [ organizationId ] : [ ]
564779 ) ;
565780 res . json ( { stats : rows [ 0 ] || { total_users : 0 , total_records : 0 , total_receipts : 0 } } ) ;
566781} ) ;
@@ -590,6 +805,8 @@ export const listReceiptsAdminController = asyncHandler(async (req, res) => {
590805 if ( ! userId ) {
591806 return res . status ( 400 ) . json ( { message : "userId is required" } ) ;
592807 }
808+ const access = await assertOrgScopedUserAccess ( req , userId ) ;
809+ if ( ! access . allowed ) return res . status ( access . status ) . json ( { message : access . message } ) ;
593810
594811 const { rows } = await query (
595812 `
@@ -618,6 +835,8 @@ export const listBudgetSheetsAdminController = asyncHandler(async (req, res) =>
618835 if ( ! userId ) {
619836 return res . status ( 400 ) . json ( { message : "userId is required" } ) ;
620837 }
838+ const access = await assertOrgScopedUserAccess ( req , userId ) ;
839+ if ( ! access . allowed ) return res . status ( access . status ) . json ( { message : access . message } ) ;
621840
622841 const where = [ "user_id = $1" ] ;
623842 const values = [ userId ] ;
@@ -675,9 +894,18 @@ export const listAuditLogAdmin = asyncHandler(async (req, res) => {
675894 i += 1 ;
676895 }
677896 if ( scope === "admins" ) {
678- where . push ( `u.role IN ('admin', 'support_admin', 'analyst')` ) ;
897+ where . push ( `u.role IN ('admin', 'org_admin', ' support_admin', 'analyst')` ) ;
679898 } else if ( scope === "users" ) {
680- where . push ( `u.role = 'user'` ) ;
899+ where . push ( `u.role IN ('user', 'org_user')` ) ;
900+ }
901+ if ( isOrgAdminRole ( req . user ?. role ) ) {
902+ const actorOrganizationId = getActorOrganizationId ( req ) ;
903+ if ( ! actorOrganizationId ) {
904+ return res . json ( { auditLog : [ ] } ) ;
905+ }
906+ where . push ( `u.role = 'org_user'` ) ;
907+ where . push ( `u.organization_id = $${ i ++ } ` ) ;
908+ params . push ( actorOrganizationId ) ;
681909 }
682910 params . push ( limit ) ;
683911
@@ -722,13 +950,17 @@ export const listSupportTicketsAdmin = asyncHandler(async (req, res) => {
722950 queryText : q ,
723951 limit,
724952 offset,
953+ roleFilter : getScopedUserRoleFilter ( req ) ,
954+ organizationIdFilter : getScopedOrganizationIdFilter ( req ) ,
725955 } ) ;
726956 res . json ( { tickets } ) ;
727957} ) ;
728958
729959export const updateSupportTicketAdmin = asyncHandler ( async ( req , res ) => {
730960 const id = String ( req . params . id || "" ) . trim ( ) ;
731961 if ( ! id ) return res . status ( 400 ) . json ( { message : "Ticket id is required" } ) ;
962+ const access = await assertOrgScopedSupportTicketAccess ( req , id ) ;
963+ if ( ! access . allowed ) return res . status ( access . status ) . json ( { message : access . message } ) ;
732964
733965 const hasStatus = req . body ?. status !== undefined ;
734966 const hasAdminNote = req . body ?. adminNote !== undefined ;
0 commit comments