Skip to content

Commit a38346d

Browse files
committed
integrated Plaid Sandbox API into app. Integrated organizational users and admins into app with full support and functionality.
1 parent 61fd751 commit a38346d

45 files changed

Lines changed: 2939 additions & 232 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

api/src/config/env.js

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,9 @@ const env = {
4141
googleClientId: process.env.GOOGLE_CLIENT_ID || "",
4242
googleClientSecret: process.env.GOOGLE_CLIENT_SECRET || "",
4343
googleRedirectUri: process.env.GOOGLE_REDIRECT_URI || "",
44+
plaidClientId: process.env.PLAID_CLIENT_ID || "",
45+
plaidSecret: process.env.PLAID_SECRET || "",
46+
plaidEnv: process.env.PLAID_ENV || "sandbox",
4447

4548
// CORS
4649
clientOrigins: (process.env.CORS_ORIGIN || "http://localhost:5500")

api/src/controllers/admin.controller.js

Lines changed: 245 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -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+
42146
const 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

340452
export 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

356483
export 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

362491
export 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

475659
export 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

556746
export 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

729959
export 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

Comments
 (0)