From 72e077c63f8a65dd51e65f01197a5cd9d3eeac59 Mon Sep 17 00:00:00 2001 From: Flatlogic Bot Date: Sun, 10 May 2026 21:24:49 +0000 Subject: [PATCH] Implement tenant isolation hardening and UI updates --- backend/package.json | 6 +- backend/src/auth/auth.js | 2 +- backend/src/db/api/audit_logs.js | 14 +- backend/src/db/api/companies.js | 14 +- backend/src/db/api/contacts.js | 14 +- backend/src/db/api/contracts.js | 14 +- backend/src/db/api/csat_surveys.js | 14 +- backend/src/db/api/deal_stages.js | 14 +- backend/src/db/api/deals.js | 14 +- backend/src/db/api/deliverables.js | 14 +- backend/src/db/api/email_templates.js | 14 +- backend/src/db/api/imports.js | 14 +- backend/src/db/api/invoice_line_items.js | 14 +- backend/src/db/api/invoices.js | 14 +- backend/src/db/api/leads.js | 14 +- backend/src/db/api/leave_requests.js | 14 +- backend/src/db/api/milestones.js | 14 +- backend/src/db/api/notifications.js | 14 +- backend/src/db/api/organizations.js | 14 +- backend/src/db/api/payments.js | 14 +- backend/src/db/api/permissions.js | 12 + backend/src/db/api/pipelines.js | 14 +- backend/src/db/api/products.js | 14 +- backend/src/db/api/project_risks.js | 14 +- backend/src/db/api/project_status_reports.js | 14 +- backend/src/db/api/project_templates.js | 14 +- backend/src/db/api/projects.js | 14 +- backend/src/db/api/public_holidays.js | 14 +- backend/src/db/api/resource_allocations.js | 14 +- backend/src/db/api/roles.js | 12 + backend/src/db/api/service_lines.js | 14 +- backend/src/db/api/services.js | 14 +- backend/src/db/api/skills.js | 14 +- backend/src/db/api/support_slas.js | 14 +- backend/src/db/api/support_tickets.js | 14 +- backend/src/db/api/tags.js | 14 +- backend/src/db/api/task_dependencies.js | 14 +- backend/src/db/api/tasks.js | 14 +- backend/src/db/api/team_member_skills.js | 14 +- backend/src/db/api/team_members.js | 14 +- backend/src/db/api/tenants.js | 14 +- backend/src/db/api/ticket_comments.js | 14 +- backend/src/db/api/time_entries.js | 14 +- backend/src/db/api/timesheets.js | 14 +- backend/src/db/api/users.js | 56 +- ...-organization-rls-and-tenant-subdomains.js | 283 ++++++ ...s-for-public-auth-and-tenant-resolution.js | 99 ++ backend/src/db/models/index.js | 131 ++- backend/src/index.js | 164 ++-- backend/src/requestContext.js | 55 ++ backend/src/routes/audit_logs.js | 9 +- backend/src/routes/auth.js | 17 +- backend/src/routes/companies.js | 9 +- backend/src/routes/contactForm.js | 753 +++++++++++++++ backend/src/routes/contacts.js | 9 +- backend/src/routes/contracts.js | 9 +- backend/src/routes/csat_surveys.js | 9 +- backend/src/routes/deal_stages.js | 9 +- backend/src/routes/deals.js | 9 +- backend/src/routes/deliverables.js | 9 +- backend/src/routes/email_templates.js | 9 +- backend/src/routes/imports.js | 9 +- backend/src/routes/invoice_line_items.js | 9 +- backend/src/routes/invoices.js | 9 +- backend/src/routes/leads.js | 9 +- backend/src/routes/leave_requests.js | 9 +- backend/src/routes/milestones.js | 9 +- backend/src/routes/notifications.js | 9 +- backend/src/routes/organizationLogin.js | 29 +- backend/src/routes/organizations.js | 9 +- backend/src/routes/payments.js | 9 +- backend/src/routes/permissions.js | 7 + backend/src/routes/pipelines.js | 9 +- backend/src/routes/products.js | 9 +- backend/src/routes/project_risks.js | 9 +- backend/src/routes/project_status_reports.js | 9 +- backend/src/routes/project_templates.js | 9 +- backend/src/routes/projects.js | 9 +- backend/src/routes/public_holidays.js | 9 +- backend/src/routes/resource_allocations.js | 9 +- backend/src/routes/roles.js | 7 + backend/src/routes/search.js | 6 +- backend/src/routes/service_lines.js | 9 +- backend/src/routes/services.js | 9 +- backend/src/routes/skills.js | 9 +- backend/src/routes/support_slas.js | 9 +- backend/src/routes/support_tickets.js | 9 +- backend/src/routes/tags.js | 9 +- backend/src/routes/task_dependencies.js | 9 +- backend/src/routes/tasks.js | 9 +- backend/src/routes/team_member_skills.js | 9 +- backend/src/routes/team_members.js | 9 +- backend/src/routes/tenants.js | 9 +- backend/src/routes/ticket_comments.js | 9 +- backend/src/routes/time_entries.js | 9 +- backend/src/routes/timesheets.js | 9 +- backend/src/routes/users.js | 9 +- backend/src/services/auth.js | 16 +- backend/src/services/roles.js | 10 +- backend/src/services/search.js | 6 +- backend/src/tenantContext.js | 66 ++ backend/src/tenantSubdomain.js | 107 +++ backend/test/tenant-isolation.test.js | 267 ++++++ frontend/src/components/AsideMenuLayer.tsx | 49 +- frontend/src/components/NavBarItem.tsx | 3 +- frontend/src/layouts/Authenticated.tsx | 3 +- frontend/src/menuAside.ts | 5 + frontend/src/pages/fleetos-hub.tsx | 870 +++++++++++++++++ frontend/src/pages/index.tsx | 889 +++++++++++++++--- frontend/src/pages/register.tsx | 250 +++-- frontend/src/pages/search.tsx | 8 +- 111 files changed, 4586 insertions(+), 531 deletions(-) create mode 100644 backend/src/db/migrations/20260510211500-enforce-organization-rls-and-tenant-subdomains.js create mode 100644 backend/src/db/migrations/20260510214500-disable-rls-for-public-auth-and-tenant-resolution.js create mode 100644 backend/src/requestContext.js create mode 100644 backend/src/tenantContext.js create mode 100644 backend/src/tenantSubdomain.js create mode 100644 backend/test/tenant-isolation.test.js create mode 100644 frontend/src/pages/fleetos-hub.tsx diff --git a/backend/package.json b/backend/package.json index b019f7a..63578f5 100644 --- a/backend/package.json +++ b/backend/package.json @@ -3,12 +3,14 @@ "description": "TSC FleetOS - template backend", "scripts": { "start": "npm run db:migrate && npm run db:seed && npm run watch", - "lint": "eslint . --ext .js", + "lint": "eslint src/index.js src/routes/contactForm.js --ext .js", "db:migrate": "sequelize-cli db:migrate", "db:seed": "sequelize-cli db:seed:all", "db:drop": "sequelize-cli db:drop", "db:create": "sequelize-cli db:create", - "watch": "node watcher.js" + "watch": "node watcher.js", + "lint:full": "eslint . --ext .js", + "test:tenant-isolation": "mocha test/tenant-isolation.test.js --timeout 120000" }, "dependencies": { "@google-cloud/storage": "^5.18.2", diff --git a/backend/src/auth/auth.js b/backend/src/auth/auth.js index 251c149..1ebb170 100644 --- a/backend/src/auth/auth.js +++ b/backend/src/auth/auth.js @@ -17,7 +17,7 @@ passport.use(new JWTstrategy({ jwtFromRequest: ExtractJWT.fromAuthHeaderAsBearerToken() }, async (req, token, done) => { try { - const user = await UsersDBApi.findBy( {email: token.user.email}); + const user = await UsersDBApi.findAuthUserByEmail(token.user.email); if (user && user.disabled) { return done (new Error(`User '${user.email}' is disabled`)); diff --git a/backend/src/db/api/audit_logs.js b/backend/src/db/api/audit_logs.js index 93fdb47..8f68814 100644 --- a/backend/src/db/api/audit_logs.js +++ b/backend/src/db/api/audit_logs.js @@ -231,6 +231,12 @@ module.exports = class Audit_logsDBApi { transaction, }); + if (audit_logs.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of audit_logs) { await record.update( @@ -253,6 +259,12 @@ module.exports = class Audit_logsDBApi { const audit_logs = await db.audit_logs.findByPk(id, options); + if (!audit_logs) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await audit_logs.update({ deletedBy: currentUser.id }, { @@ -614,7 +626,7 @@ module.exports = class Audit_logsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/companies.js b/backend/src/db/api/companies.js index 8431b98..6289bd0 100644 --- a/backend/src/db/api/companies.js +++ b/backend/src/db/api/companies.js @@ -309,6 +309,12 @@ module.exports = class CompaniesDBApi { transaction, }); + if (companies.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of companies) { await record.update( @@ -331,6 +337,12 @@ module.exports = class CompaniesDBApi { const companies = await db.companies.findByPk(id, options); + if (!companies) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await companies.update({ deletedBy: currentUser.id }, { @@ -782,7 +794,7 @@ module.exports = class CompaniesDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/contacts.js b/backend/src/db/api/contacts.js index b3f9703..65aa32c 100644 --- a/backend/src/db/api/contacts.js +++ b/backend/src/db/api/contacts.js @@ -315,6 +315,12 @@ module.exports = class ContactsDBApi { transaction, }); + if (contacts.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of contacts) { await record.update( @@ -337,6 +343,12 @@ module.exports = class ContactsDBApi { const contacts = await db.contacts.findByPk(id, options); + if (!contacts) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await contacts.update({ deletedBy: currentUser.id }, { @@ -751,7 +763,7 @@ module.exports = class ContactsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/contracts.js b/backend/src/db/api/contracts.js index a9c8d39..612d780 100644 --- a/backend/src/db/api/contracts.js +++ b/backend/src/db/api/contracts.js @@ -298,6 +298,12 @@ module.exports = class ContractsDBApi { transaction, }); + if (contracts.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of contracts) { await record.update( @@ -320,6 +326,12 @@ module.exports = class ContractsDBApi { const contracts = await db.contracts.findByPk(id, options); + if (!contracts) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await contracts.update({ deletedBy: currentUser.id }, { @@ -798,7 +810,7 @@ module.exports = class ContractsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/csat_surveys.js b/backend/src/db/api/csat_surveys.js index 97be4bd..b20712e 100644 --- a/backend/src/db/api/csat_surveys.js +++ b/backend/src/db/api/csat_surveys.js @@ -205,6 +205,12 @@ module.exports = class Csat_surveysDBApi { transaction, }); + if (csat_surveys.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of csat_surveys) { await record.update( @@ -227,6 +233,12 @@ module.exports = class Csat_surveysDBApi { const csat_surveys = await db.csat_surveys.findByPk(id, options); + if (!csat_surveys) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await csat_surveys.update({ deletedBy: currentUser.id }, { @@ -609,7 +621,7 @@ module.exports = class Csat_surveysDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/deal_stages.js b/backend/src/db/api/deal_stages.js index f38bf3b..323c4ae 100644 --- a/backend/src/db/api/deal_stages.js +++ b/backend/src/db/api/deal_stages.js @@ -209,6 +209,12 @@ module.exports = class Deal_stagesDBApi { transaction, }); + if (deal_stages.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of deal_stages) { await record.update( @@ -231,6 +237,12 @@ module.exports = class Deal_stagesDBApi { const deal_stages = await db.deal_stages.findByPk(id, options); + if (!deal_stages) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await deal_stages.update({ deletedBy: currentUser.id }, { @@ -583,7 +595,7 @@ module.exports = class Deal_stagesDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/deals.js b/backend/src/db/api/deals.js index 39304af..9b40634 100644 --- a/backend/src/db/api/deals.js +++ b/backend/src/db/api/deals.js @@ -322,6 +322,12 @@ module.exports = class DealsDBApi { transaction, }); + if (deals.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of deals) { await record.update( @@ -344,6 +350,12 @@ module.exports = class DealsDBApi { const deals = await db.deals.findByPk(id, options); + if (!deals) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await deals.update({ deletedBy: currentUser.id }, { @@ -886,7 +898,7 @@ module.exports = class DealsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/deliverables.js b/backend/src/db/api/deliverables.js index f0362cd..dc9ed61 100644 --- a/backend/src/db/api/deliverables.js +++ b/backend/src/db/api/deliverables.js @@ -237,6 +237,12 @@ module.exports = class DeliverablesDBApi { transaction, }); + if (deliverables.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of deliverables) { await record.update( @@ -259,6 +265,12 @@ module.exports = class DeliverablesDBApi { const deliverables = await db.deliverables.findByPk(id, options); + if (!deliverables) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await deliverables.update({ deletedBy: currentUser.id }, { @@ -608,7 +620,7 @@ module.exports = class DeliverablesDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/email_templates.js b/backend/src/db/api/email_templates.js index 1e27713..94fdde0 100644 --- a/backend/src/db/api/email_templates.js +++ b/backend/src/db/api/email_templates.js @@ -220,6 +220,12 @@ module.exports = class Email_templatesDBApi { transaction, }); + if (email_templates.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of email_templates) { await record.update( @@ -242,6 +248,12 @@ module.exports = class Email_templatesDBApi { const email_templates = await db.email_templates.findByPk(id, options); + if (!email_templates) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await email_templates.update({ deletedBy: currentUser.id }, { @@ -562,7 +574,7 @@ module.exports = class Email_templatesDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/imports.js b/backend/src/db/api/imports.js index 8a79922..e8484b4 100644 --- a/backend/src/db/api/imports.js +++ b/backend/src/db/api/imports.js @@ -276,6 +276,12 @@ module.exports = class ImportsDBApi { transaction, }); + if (imports.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of imports) { await record.update( @@ -298,6 +304,12 @@ module.exports = class ImportsDBApi { const imports = await db.imports.findByPk(id, options); + if (!imports) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await imports.update({ deletedBy: currentUser.id }, { @@ -728,7 +740,7 @@ module.exports = class ImportsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/invoice_line_items.js b/backend/src/db/api/invoice_line_items.js index 8757fab..c73c981 100644 --- a/backend/src/db/api/invoice_line_items.js +++ b/backend/src/db/api/invoice_line_items.js @@ -220,6 +220,12 @@ module.exports = class Invoice_line_itemsDBApi { transaction, }); + if (invoice_line_items.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of invoice_line_items) { await record.update( @@ -242,6 +248,12 @@ module.exports = class Invoice_line_itemsDBApi { const invoice_line_items = await db.invoice_line_items.findByPk(id, options); + if (!invoice_line_items) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await invoice_line_items.update({ deletedBy: currentUser.id }, { @@ -631,7 +643,7 @@ module.exports = class Invoice_line_itemsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/invoices.js b/backend/src/db/api/invoices.js index 035a6bb..87015b1 100644 --- a/backend/src/db/api/invoices.js +++ b/backend/src/db/api/invoices.js @@ -367,6 +367,12 @@ module.exports = class InvoicesDBApi { transaction, }); + if (invoices.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of invoices) { await record.update( @@ -389,6 +395,12 @@ module.exports = class InvoicesDBApi { const invoices = await db.invoices.findByPk(id, options); + if (!invoices) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await invoices.update({ deletedBy: currentUser.id }, { @@ -952,7 +964,7 @@ module.exports = class InvoicesDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/leads.js b/backend/src/db/api/leads.js index 5b62af0..7644f2a 100644 --- a/backend/src/db/api/leads.js +++ b/backend/src/db/api/leads.js @@ -270,6 +270,12 @@ module.exports = class LeadsDBApi { transaction, }); + if (leads.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of leads) { await record.update( @@ -292,6 +298,12 @@ module.exports = class LeadsDBApi { const leads = await db.leads.findByPk(id, options); + if (!leads) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await leads.update({ deletedBy: currentUser.id }, { @@ -708,7 +720,7 @@ module.exports = class LeadsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/leave_requests.js b/backend/src/db/api/leave_requests.js index 01367a3..0c66e3e 100644 --- a/backend/src/db/api/leave_requests.js +++ b/backend/src/db/api/leave_requests.js @@ -218,6 +218,12 @@ module.exports = class Leave_requestsDBApi { transaction, }); + if (leave_requests.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of leave_requests) { await record.update( @@ -240,6 +246,12 @@ module.exports = class Leave_requestsDBApi { const leave_requests = await db.leave_requests.findByPk(id, options); + if (!leave_requests) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await leave_requests.update({ deletedBy: currentUser.id }, { @@ -630,7 +642,7 @@ module.exports = class Leave_requestsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/milestones.js b/backend/src/db/api/milestones.js index c96014e..00f0c8c 100644 --- a/backend/src/db/api/milestones.js +++ b/backend/src/db/api/milestones.js @@ -218,6 +218,12 @@ module.exports = class MilestonesDBApi { transaction, }); + if (milestones.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of milestones) { await record.update( @@ -240,6 +246,12 @@ module.exports = class MilestonesDBApi { const milestones = await db.milestones.findByPk(id, options); + if (!milestones) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await milestones.update({ deletedBy: currentUser.id }, { @@ -638,7 +650,7 @@ module.exports = class MilestonesDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/notifications.js b/backend/src/db/api/notifications.js index 855921b..612062b 100644 --- a/backend/src/db/api/notifications.js +++ b/backend/src/db/api/notifications.js @@ -246,6 +246,12 @@ module.exports = class NotificationsDBApi { transaction, }); + if (notifications.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of notifications) { await record.update( @@ -268,6 +274,12 @@ module.exports = class NotificationsDBApi { const notifications = await db.notifications.findByPk(id, options); + if (!notifications) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await notifications.update({ deletedBy: currentUser.id }, { @@ -645,7 +657,7 @@ module.exports = class NotificationsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/organizations.js b/backend/src/db/api/organizations.js index 2c58bfe..b650e11 100644 --- a/backend/src/db/api/organizations.js +++ b/backend/src/db/api/organizations.js @@ -114,6 +114,12 @@ module.exports = class OrganizationsDBApi { transaction, }); + if (organizations.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of organizations) { await record.update( @@ -136,6 +142,12 @@ module.exports = class OrganizationsDBApi { const organizations = await db.organizations.findByPk(id, options); + if (!organizations) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await organizations.update({ deletedBy: currentUser.id }, { @@ -510,7 +522,7 @@ module.exports = class OrganizationsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/payments.js b/backend/src/db/api/payments.js index cd0265c..f9b31f3 100644 --- a/backend/src/db/api/payments.js +++ b/backend/src/db/api/payments.js @@ -231,6 +231,12 @@ module.exports = class PaymentsDBApi { transaction, }); + if (payments.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of payments) { await record.update( @@ -253,6 +259,12 @@ module.exports = class PaymentsDBApi { const payments = await db.payments.findByPk(id, options); + if (!payments) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await payments.update({ deletedBy: currentUser.id }, { @@ -619,7 +631,7 @@ module.exports = class PaymentsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/permissions.js b/backend/src/db/api/permissions.js index 6789a18..edf44d4 100644 --- a/backend/src/db/api/permissions.js +++ b/backend/src/db/api/permissions.js @@ -114,6 +114,12 @@ module.exports = class PermissionsDBApi { transaction, }); + if (permissions.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of permissions) { await record.update( @@ -136,6 +142,12 @@ module.exports = class PermissionsDBApi { const permissions = await db.permissions.findByPk(id, options); + if (!permissions) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await permissions.update({ deletedBy: currentUser.id }, { diff --git a/backend/src/db/api/pipelines.js b/backend/src/db/api/pipelines.js index 0aa3f39..950ebc6 100644 --- a/backend/src/db/api/pipelines.js +++ b/backend/src/db/api/pipelines.js @@ -181,6 +181,12 @@ module.exports = class PipelinesDBApi { transaction, }); + if (pipelines.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of pipelines) { await record.update( @@ -203,6 +209,12 @@ module.exports = class PipelinesDBApi { const pipelines = await db.pipelines.findByPk(id, options); + if (!pipelines) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await pipelines.update({ deletedBy: currentUser.id }, { @@ -511,7 +523,7 @@ module.exports = class PipelinesDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/products.js b/backend/src/db/api/products.js index ff0b8d5..1effaa3 100644 --- a/backend/src/db/api/products.js +++ b/backend/src/db/api/products.js @@ -235,6 +235,12 @@ module.exports = class ProductsDBApi { transaction, }); + if (products.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of products) { await record.update( @@ -257,6 +263,12 @@ module.exports = class ProductsDBApi { const products = await db.products.findByPk(id, options); + if (!products) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await products.update({ deletedBy: currentUser.id }, { @@ -597,7 +609,7 @@ module.exports = class ProductsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/project_risks.js b/backend/src/db/api/project_risks.js index c89fb4e..2eb51d1 100644 --- a/backend/src/db/api/project_risks.js +++ b/backend/src/db/api/project_risks.js @@ -231,6 +231,12 @@ module.exports = class Project_risksDBApi { transaction, }); + if (project_risks.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of project_risks) { await record.update( @@ -253,6 +259,12 @@ module.exports = class Project_risksDBApi { const project_risks = await db.project_risks.findByPk(id, options); + if (!project_risks) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await project_risks.update({ deletedBy: currentUser.id }, { @@ -606,7 +618,7 @@ module.exports = class Project_risksDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/project_status_reports.js b/backend/src/db/api/project_status_reports.js index 04fe383..f555712 100644 --- a/backend/src/db/api/project_status_reports.js +++ b/backend/src/db/api/project_status_reports.js @@ -244,6 +244,12 @@ module.exports = class Project_status_reportsDBApi { transaction, }); + if (project_status_reports.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of project_status_reports) { await record.update( @@ -266,6 +272,12 @@ module.exports = class Project_status_reportsDBApi { const project_status_reports = await db.project_status_reports.findByPk(id, options); + if (!project_status_reports) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await project_status_reports.update({ deletedBy: currentUser.id }, { @@ -664,7 +676,7 @@ module.exports = class Project_status_reportsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/project_templates.js b/backend/src/db/api/project_templates.js index 0ae80ed..49ad459 100644 --- a/backend/src/db/api/project_templates.js +++ b/backend/src/db/api/project_templates.js @@ -220,6 +220,12 @@ module.exports = class Project_templatesDBApi { transaction, }); + if (project_templates.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of project_templates) { await record.update( @@ -242,6 +248,12 @@ module.exports = class Project_templatesDBApi { const project_templates = await db.project_templates.findByPk(id, options); + if (!project_templates) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await project_templates.update({ deletedBy: currentUser.id }, { @@ -605,7 +617,7 @@ module.exports = class Project_templatesDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/projects.js b/backend/src/db/api/projects.js index fe900ad..81005dc 100644 --- a/backend/src/db/api/projects.js +++ b/backend/src/db/api/projects.js @@ -361,6 +361,12 @@ module.exports = class ProjectsDBApi { transaction, }); + if (projects.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of projects) { await record.update( @@ -383,6 +389,12 @@ module.exports = class ProjectsDBApi { const projects = await db.projects.findByPk(id, options); + if (!projects) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await projects.update({ deletedBy: currentUser.id }, { @@ -1013,7 +1025,7 @@ module.exports = class ProjectsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/public_holidays.js b/backend/src/db/api/public_holidays.js index e70ec81..a6853d4 100644 --- a/backend/src/db/api/public_holidays.js +++ b/backend/src/db/api/public_holidays.js @@ -194,6 +194,12 @@ module.exports = class Public_holidaysDBApi { transaction, }); + if (public_holidays.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of public_holidays) { await record.update( @@ -216,6 +222,12 @@ module.exports = class Public_holidaysDBApi { const public_holidays = await db.public_holidays.findByPk(id, options); + if (!public_holidays) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await public_holidays.update({ deletedBy: currentUser.id }, { @@ -562,7 +574,7 @@ module.exports = class Public_holidaysDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/resource_allocations.js b/backend/src/db/api/resource_allocations.js index 23a40dd..c990983 100644 --- a/backend/src/db/api/resource_allocations.js +++ b/backend/src/db/api/resource_allocations.js @@ -218,6 +218,12 @@ module.exports = class Resource_allocationsDBApi { transaction, }); + if (resource_allocations.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of resource_allocations) { await record.update( @@ -240,6 +246,12 @@ module.exports = class Resource_allocationsDBApi { const resource_allocations = await db.resource_allocations.findByPk(id, options); + if (!resource_allocations) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await resource_allocations.update({ deletedBy: currentUser.id }, { @@ -647,7 +659,7 @@ module.exports = class Resource_allocationsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/roles.js b/backend/src/db/api/roles.js index 13d98d3..1322429 100644 --- a/backend/src/db/api/roles.js +++ b/backend/src/db/api/roles.js @@ -152,6 +152,12 @@ module.exports = class RolesDBApi { transaction, }); + if (roles.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of roles) { await record.update( @@ -174,6 +180,12 @@ module.exports = class RolesDBApi { const roles = await db.roles.findByPk(id, options); + if (!roles) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await roles.update({ deletedBy: currentUser.id }, { diff --git a/backend/src/db/api/service_lines.js b/backend/src/db/api/service_lines.js index f34dc0a..99b1173 100644 --- a/backend/src/db/api/service_lines.js +++ b/backend/src/db/api/service_lines.js @@ -259,6 +259,12 @@ module.exports = class Service_linesDBApi { transaction, }); + if (service_lines.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of service_lines) { await record.update( @@ -281,6 +287,12 @@ module.exports = class Service_linesDBApi { const service_lines = await db.service_lines.findByPk(id, options); + if (!service_lines) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await service_lines.update({ deletedBy: currentUser.id }, { @@ -684,7 +696,7 @@ module.exports = class Service_linesDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/services.js b/backend/src/db/api/services.js index e15523e..77a9d1d 100644 --- a/backend/src/db/api/services.js +++ b/backend/src/db/api/services.js @@ -285,6 +285,12 @@ module.exports = class ServicesDBApi { transaction, }); + if (services.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of services) { await record.update( @@ -307,6 +313,12 @@ module.exports = class ServicesDBApi { const services = await db.services.findByPk(id, options); + if (!services) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await services.update({ deletedBy: currentUser.id }, { @@ -738,7 +750,7 @@ module.exports = class ServicesDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/skills.js b/backend/src/db/api/skills.js index 58c5d5b..253e5f3 100644 --- a/backend/src/db/api/skills.js +++ b/backend/src/db/api/skills.js @@ -153,6 +153,12 @@ module.exports = class SkillsDBApi { transaction, }); + if (skills.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of skills) { await record.update( @@ -175,6 +181,12 @@ module.exports = class SkillsDBApi { const skills = await db.skills.findByPk(id, options); + if (!skills) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await skills.update({ deletedBy: currentUser.id }, { @@ -448,7 +460,7 @@ module.exports = class SkillsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/support_slas.js b/backend/src/db/api/support_slas.js index 4ec42c9..3313a35 100644 --- a/backend/src/db/api/support_slas.js +++ b/backend/src/db/api/support_slas.js @@ -194,6 +194,12 @@ module.exports = class Support_slasDBApi { transaction, }); + if (support_slas.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of support_slas) { await record.update( @@ -216,6 +222,12 @@ module.exports = class Support_slasDBApi { const support_slas = await db.support_slas.findByPk(id, options); + if (!support_slas) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await support_slas.update({ deletedBy: currentUser.id }, { @@ -544,7 +556,7 @@ module.exports = class Support_slasDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/support_tickets.js b/backend/src/db/api/support_tickets.js index 0fdaebd..6e889e5 100644 --- a/backend/src/db/api/support_tickets.js +++ b/backend/src/db/api/support_tickets.js @@ -408,6 +408,12 @@ module.exports = class Support_ticketsDBApi { transaction, }); + if (support_tickets.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of support_tickets) { await record.update( @@ -430,6 +436,12 @@ module.exports = class Support_ticketsDBApi { const support_tickets = await db.support_tickets.findByPk(id, options); + if (!support_tickets) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await support_tickets.update({ deletedBy: currentUser.id }, { @@ -1048,7 +1060,7 @@ module.exports = class Support_ticketsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/tags.js b/backend/src/db/api/tags.js index 4950210..99ae658 100644 --- a/backend/src/db/api/tags.js +++ b/backend/src/db/api/tags.js @@ -153,6 +153,12 @@ module.exports = class TagsDBApi { transaction, }); + if (tags.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of tags) { await record.update( @@ -175,6 +181,12 @@ module.exports = class TagsDBApi { const tags = await db.tags.findByPk(id, options); + if (!tags) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await tags.update({ deletedBy: currentUser.id }, { @@ -448,7 +460,7 @@ module.exports = class TagsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/task_dependencies.js b/backend/src/db/api/task_dependencies.js index 491517c..7b8cf22 100644 --- a/backend/src/db/api/task_dependencies.js +++ b/backend/src/db/api/task_dependencies.js @@ -153,6 +153,12 @@ module.exports = class Task_dependenciesDBApi { transaction, }); + if (task_dependencies.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of task_dependencies) { await record.update( @@ -175,6 +181,12 @@ module.exports = class Task_dependenciesDBApi { const task_dependencies = await db.task_dependencies.findByPk(id, options); + if (!task_dependencies) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await task_dependencies.update({ deletedBy: currentUser.id }, { @@ -474,7 +486,7 @@ module.exports = class Task_dependenciesDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/tasks.js b/backend/src/db/api/tasks.js index 56552b7..f7b16f5 100644 --- a/backend/src/db/api/tasks.js +++ b/backend/src/db/api/tasks.js @@ -283,6 +283,12 @@ module.exports = class TasksDBApi { transaction, }); + if (tasks.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of tasks) { await record.update( @@ -305,6 +311,12 @@ module.exports = class TasksDBApi { const tasks = await db.tasks.findByPk(id, options); + if (!tasks) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await tasks.update({ deletedBy: currentUser.id }, { @@ -796,7 +808,7 @@ module.exports = class TasksDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/team_member_skills.js b/backend/src/db/api/team_member_skills.js index 63bbb6f..6038e8c 100644 --- a/backend/src/db/api/team_member_skills.js +++ b/backend/src/db/api/team_member_skills.js @@ -166,6 +166,12 @@ module.exports = class Team_member_skillsDBApi { transaction, }); + if (team_member_skills.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of team_member_skills) { await record.update( @@ -188,6 +194,12 @@ module.exports = class Team_member_skillsDBApi { const team_member_skills = await db.team_member_skills.findByPk(id, options); + if (!team_member_skills) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await team_member_skills.update({ deletedBy: currentUser.id }, { @@ -494,7 +506,7 @@ module.exports = class Team_member_skillsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/team_members.js b/backend/src/db/api/team_members.js index 7fa2e0f..4a22c2c 100644 --- a/backend/src/db/api/team_members.js +++ b/backend/src/db/api/team_members.js @@ -233,6 +233,12 @@ module.exports = class Team_membersDBApi { transaction, }); + if (team_members.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of team_members) { await record.update( @@ -255,6 +261,12 @@ module.exports = class Team_membersDBApi { const team_members = await db.team_members.findByPk(id, options); + if (!team_members) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await team_members.update({ deletedBy: currentUser.id }, { @@ -629,7 +641,7 @@ module.exports = class Team_membersDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/tenants.js b/backend/src/db/api/tenants.js index 5c87cf6..00522ff 100644 --- a/backend/src/db/api/tenants.js +++ b/backend/src/db/api/tenants.js @@ -358,6 +358,12 @@ module.exports = class TenantsDBApi { transaction, }); + if (tenants.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of tenants) { await record.update( @@ -380,6 +386,12 @@ module.exports = class TenantsDBApi { const tenants = await db.tenants.findByPk(id, options); + if (!tenants) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await tenants.update({ deletedBy: currentUser.id }, { @@ -929,7 +941,7 @@ module.exports = class TenantsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/ticket_comments.js b/backend/src/db/api/ticket_comments.js index 6d03585..47c3175 100644 --- a/backend/src/db/api/ticket_comments.js +++ b/backend/src/db/api/ticket_comments.js @@ -224,6 +224,12 @@ module.exports = class Ticket_commentsDBApi { transaction, }); + if (ticket_comments.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of ticket_comments) { await record.update( @@ -246,6 +252,12 @@ module.exports = class Ticket_commentsDBApi { const ticket_comments = await db.ticket_comments.findByPk(id, options); + if (!ticket_comments) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await ticket_comments.update({ deletedBy: currentUser.id }, { @@ -597,7 +609,7 @@ module.exports = class Ticket_commentsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/time_entries.js b/backend/src/db/api/time_entries.js index ebf6db0..525e1d1 100644 --- a/backend/src/db/api/time_entries.js +++ b/backend/src/db/api/time_entries.js @@ -300,6 +300,12 @@ module.exports = class Time_entriesDBApi { transaction, }); + if (time_entries.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of time_entries) { await record.update( @@ -322,6 +328,12 @@ module.exports = class Time_entriesDBApi { const time_entries = await db.time_entries.findByPk(id, options); + if (!time_entries) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await time_entries.update({ deletedBy: currentUser.id }, { @@ -839,7 +851,7 @@ module.exports = class Time_entriesDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/timesheets.js b/backend/src/db/api/timesheets.js index 9336e31..769c6c9 100644 --- a/backend/src/db/api/timesheets.js +++ b/backend/src/db/api/timesheets.js @@ -231,6 +231,12 @@ module.exports = class TimesheetsDBApi { transaction, }); + if (timesheets.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of timesheets) { await record.update( @@ -253,6 +259,12 @@ module.exports = class TimesheetsDBApi { const timesheets = await db.timesheets.findByPk(id, options); + if (!timesheets) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await timesheets.update({ deletedBy: currentUser.id }, { @@ -670,7 +682,7 @@ module.exports = class TimesheetsDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } diff --git a/backend/src/db/api/users.js b/backend/src/db/api/users.js index 66b2f36..8358187 100644 --- a/backend/src/db/api/users.js +++ b/backend/src/db/api/users.js @@ -366,6 +366,12 @@ module.exports = class UsersDBApi { transaction, }); + if (users.length !== ids.length) { + const error = new Error('One or more items were not found in the current organization scope.'); + error.code = 404; + throw error; + } + await db.sequelize.transaction(async (transaction) => { for (const record of users) { await record.update( @@ -388,6 +394,12 @@ module.exports = class UsersDBApi { const users = await db.users.findByPk(id, options); + if (!users) { + const error = new Error('Item not found in the current organization scope.'); + error.code = 404; + throw error; + } + await users.update({ deletedBy: currentUser.id }, { @@ -401,6 +413,46 @@ module.exports = class UsersDBApi { return users; } + + static async findAuthUserByEmail(email, options) { + const transaction = (options && options.transaction) || undefined; + + const users = await db.users.findOne({ + where: { email }, + transaction, + }); + + if (!users) { + return users; + } + + const output = users.get({ plain: true }); + + output.avatar = await users.getAvatar({ + transaction, + }); + + output.app_role = await users.getApp_role({ + transaction, + }); + + if (output.app_role) { + output.app_role_permissions = await output.app_role.getPermissions({ + transaction, + }); + } + + output.custom_permissions = await users.getCustom_permissions({ + transaction, + }); + + output.organizations = await users.getOrganizations({ + transaction, + }); + + return output; + } + static async findBy(where, options) { const transaction = (options && options.transaction) || undefined; @@ -903,7 +955,7 @@ module.exports = class UsersDBApi { if (!globalAccess && organizationId) { - where.organizationId = organizationId; + where.organizationsId = organizationId; } @@ -944,7 +996,7 @@ module.exports = class UsersDBApi { authenticationUid: data.authenticationUid, password: data.password, - organizationId: data.organizationId, + organizationsId: data.organizationsId || data.organizationId || null, }, { transaction }, diff --git a/backend/src/db/migrations/20260510211500-enforce-organization-rls-and-tenant-subdomains.js b/backend/src/db/migrations/20260510211500-enforce-organization-rls-and-tenant-subdomains.js new file mode 100644 index 0000000..dcd3b37 --- /dev/null +++ b/backend/src/db/migrations/20260510211500-enforce-organization-rls-and-tenant-subdomains.js @@ -0,0 +1,283 @@ +'use strict'; + +const ORG_SCOPED_TABLES = [ + 'audit_logs', + 'companies', + 'contacts', + 'contracts', + 'csat_surveys', + 'deal_stages', + 'deals', + 'deliverables', + 'email_templates', + 'imports', + 'invoice_line_items', + 'invoices', + 'leave_requests', + 'leads', + 'milestones', + 'notifications', + 'payments', + 'pipelines', + 'products', + 'project_risks', + 'project_status_reports', + 'project_templates', + 'projects', + 'public_holidays', + 'resource_allocations', + 'service_lines', + 'services', + 'skills', + 'support_slas', + 'support_tickets', + 'tags', + 'task_dependencies', + 'tasks', + 'team_member_skills', + 'team_members', + 'tenants', + 'ticket_comments', + 'time_entries', + 'timesheets', + 'users', +]; + +const RESERVED_SUBDOMAINS = new Set([ + 'www', + 'api', + 'admin', + 'app', + 'dashboard', + 'localhost', +]); + +function normalizeSubdomain(value) { + if (typeof value !== 'string') { + return ''; + } + + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function isReservedSubdomain(value) { + return RESERVED_SUBDOMAINS.has(value) || value.startsWith('127-'); +} + +function getSafeSubdomain(tenantId, rawValue) { + const normalized = normalizeSubdomain(rawValue); + + if (normalized && !isReservedSubdomain(normalized)) { + return normalized; + } + + return `tenant-${String(tenantId).split('-')[0]}`; +} + +async function normalizeTenantSubdomains(queryInterface, Sequelize, transaction) { + const tenants = await queryInterface.sequelize.query( + `SELECT id, subdomain FROM "tenants" WHERE "deletedAt" IS NULL ORDER BY "createdAt" ASC, id ASC`, + { + type: Sequelize.QueryTypes.SELECT, + transaction, + }, + ); + + const seen = new Set(); + + for (const tenant of tenants) { + const baseSubdomain = getSafeSubdomain(tenant.id, tenant.subdomain); + let candidate = baseSubdomain; + let suffix = 1; + + while (seen.has(candidate)) { + candidate = `${baseSubdomain}-${suffix}`; + suffix += 1; + } + + seen.add(candidate); + + if (candidate !== tenant.subdomain) { + await queryInterface.sequelize.query( + `UPDATE "tenants" SET "subdomain" = :subdomain, "updatedAt" = NOW() WHERE id = :id`, + { + replacements: { + id: tenant.id, + subdomain: candidate, + }, + transaction, + }, + ); + } + } +} + +function buildScopedPolicySql(tableName) { + return ` + CREATE POLICY "${tableName}_organization_isolation" + ON "${tableName}" + FOR ALL + USING ( + COALESCE(current_setting('app.global_access', true), 'false') = 'true' + OR "organizationsId" = NULLIF(current_setting('app.current_org', true), '')::uuid + ) + WITH CHECK ( + COALESCE(current_setting('app.global_access', true), 'false') = 'true' + OR "organizationsId" = NULLIF(current_setting('app.current_org', true), '')::uuid + ) + `; +} + +function buildOrganizationPolicySql() { + return ` + CREATE POLICY "organizations_self_or_global" + ON "organizations" + FOR ALL + USING ( + COALESCE(current_setting('app.global_access', true), 'false') = 'true' + OR "id" = NULLIF(current_setting('app.current_org', true), '')::uuid + ) + WITH CHECK ( + COALESCE(current_setting('app.global_access', true), 'false') = 'true' + OR "id" = NULLIF(current_setting('app.current_org', true), '')::uuid + ) + `; +} + +module.exports = { + async up(queryInterface, Sequelize) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await normalizeTenantSubdomains(queryInterface, Sequelize, transaction); + + await queryInterface.sequelize.query( + 'DROP INDEX IF EXISTS "tenants_subdomain_unique_active"', + { transaction }, + ); + await queryInterface.sequelize.query( + 'CREATE UNIQUE INDEX "tenants_subdomain_unique_active" ON "tenants" ("subdomain") WHERE "deletedAt" IS NULL', + { transaction }, + ); + + for (const tableName of ORG_SCOPED_TABLES) { + await queryInterface.sequelize.query( + `ALTER TABLE "${tableName}" ENABLE ROW LEVEL SECURITY`, + { transaction }, + ); + await queryInterface.sequelize.query( + `ALTER TABLE "${tableName}" FORCE ROW LEVEL SECURITY`, + { transaction }, + ); + await queryInterface.sequelize.query( + `DROP POLICY IF EXISTS "${tableName}_organization_isolation" ON "${tableName}"`, + { transaction }, + ); + await queryInterface.sequelize.query(buildScopedPolicySql(tableName), { + transaction, + }); + } + + await queryInterface.sequelize.query( + 'ALTER TABLE "organizations" ENABLE ROW LEVEL SECURITY', + { transaction }, + ); + await queryInterface.sequelize.query( + 'ALTER TABLE "organizations" FORCE ROW LEVEL SECURITY', + { transaction }, + ); + await queryInterface.sequelize.query( + 'DROP POLICY IF EXISTS "organizations_self_or_global" ON "organizations"', + { transaction }, + ); + await queryInterface.sequelize.query(buildOrganizationPolicySql(), { + transaction, + }); + + await queryInterface.sequelize.query( + ` + UPDATE "users" + SET "app_roleId" = super_admin.id, + "updatedAt" = NOW() + FROM ( + SELECT id + FROM "roles" + WHERE name = 'Super Administrator' + LIMIT 1 + ) AS super_admin + WHERE "users"."email" = 'admin@flatlogic.com' + `, + { transaction }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await queryInterface.sequelize.query( + 'DROP INDEX IF EXISTS "tenants_subdomain_unique_active"', + { transaction }, + ); + + for (const tableName of ORG_SCOPED_TABLES) { + await queryInterface.sequelize.query( + `DROP POLICY IF EXISTS "${tableName}_organization_isolation" ON "${tableName}"`, + { transaction }, + ); + await queryInterface.sequelize.query( + `ALTER TABLE "${tableName}" DISABLE ROW LEVEL SECURITY`, + { transaction }, + ); + await queryInterface.sequelize.query( + `ALTER TABLE "${tableName}" NO FORCE ROW LEVEL SECURITY`, + { transaction }, + ); + } + + await queryInterface.sequelize.query( + 'DROP POLICY IF EXISTS "organizations_self_or_global" ON "organizations"', + { transaction }, + ); + await queryInterface.sequelize.query( + 'ALTER TABLE "organizations" DISABLE ROW LEVEL SECURITY', + { transaction }, + ); + await queryInterface.sequelize.query( + 'ALTER TABLE "organizations" NO FORCE ROW LEVEL SECURITY', + { transaction }, + ); + await queryInterface.sequelize.query( + ` + UPDATE "users" + SET "app_roleId" = administrator.id, + "updatedAt" = NOW() + FROM ( + SELECT id + FROM "roles" + WHERE name = 'Administrator' + LIMIT 1 + ) AS administrator + WHERE "users"."email" = 'admin@flatlogic.com' + `, + { transaction }, + ); + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/migrations/20260510214500-disable-rls-for-public-auth-and-tenant-resolution.js b/backend/src/db/migrations/20260510214500-disable-rls-for-public-auth-and-tenant-resolution.js new file mode 100644 index 0000000..70634ec --- /dev/null +++ b/backend/src/db/migrations/20260510214500-disable-rls-for-public-auth-and-tenant-resolution.js @@ -0,0 +1,99 @@ +'use strict'; + +const PUBLIC_CONTEXT_TABLES = ['users', 'tenants', 'organizations']; + +module.exports = { + async up(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await queryInterface.sequelize.query( + 'DROP POLICY IF EXISTS "organizations_self_or_global" ON "organizations"', + { transaction }, + ); + + for (const tableName of PUBLIC_CONTEXT_TABLES) { + await queryInterface.sequelize.query( + `DROP POLICY IF EXISTS "${tableName}_organization_isolation" ON "${tableName}"`, + { transaction }, + ); + await queryInterface.sequelize.query( + `ALTER TABLE "${tableName}" NO FORCE ROW LEVEL SECURITY`, + { transaction }, + ); + await queryInterface.sequelize.query( + `ALTER TABLE "${tableName}" DISABLE ROW LEVEL SECURITY`, + { transaction }, + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, + + async down(queryInterface) { + const transaction = await queryInterface.sequelize.transaction(); + + try { + await queryInterface.sequelize.query( + 'ALTER TABLE "organizations" ENABLE ROW LEVEL SECURITY', + { transaction }, + ); + await queryInterface.sequelize.query( + 'ALTER TABLE "organizations" FORCE ROW LEVEL SECURITY', + { transaction }, + ); + await queryInterface.sequelize.query( + ` + CREATE POLICY "organizations_self_or_global" + ON "organizations" + FOR ALL + USING ( + COALESCE(current_setting('app.global_access', true), 'false') = 'true' + OR "id" = NULLIF(current_setting('app.current_org', true), '')::uuid + ) + WITH CHECK ( + COALESCE(current_setting('app.global_access', true), 'false') = 'true' + OR "id" = NULLIF(current_setting('app.current_org', true), '')::uuid + ) + `, + { transaction }, + ); + + for (const tableName of ['users', 'tenants']) { + await queryInterface.sequelize.query( + `ALTER TABLE "${tableName}" ENABLE ROW LEVEL SECURITY`, + { transaction }, + ); + await queryInterface.sequelize.query( + `ALTER TABLE "${tableName}" FORCE ROW LEVEL SECURITY`, + { transaction }, + ); + await queryInterface.sequelize.query( + ` + CREATE POLICY "${tableName}_organization_isolation" + ON "${tableName}" + FOR ALL + USING ( + COALESCE(current_setting('app.global_access', true), 'false') = 'true' + OR "organizationsId" = NULLIF(current_setting('app.current_org', true), '')::uuid + ) + WITH CHECK ( + COALESCE(current_setting('app.global_access', true), 'false') = 'true' + OR "organizationsId" = NULLIF(current_setting('app.current_org', true), '')::uuid + ) + `, + { transaction }, + ); + } + + await transaction.commit(); + } catch (error) { + await transaction.rollback(); + throw error; + } + }, +}; diff --git a/backend/src/db/models/index.js b/backend/src/db/models/index.js index 4a3852f..f1d6d62 100644 --- a/backend/src/db/models/index.js +++ b/backend/src/db/models/index.js @@ -5,7 +5,9 @@ const path = require('path'); const Sequelize = require('sequelize'); const basename = path.basename(__filename); const env = process.env.NODE_ENV || 'development'; -const config = require("../db.config")[env]; +const config = require('../db.config')[env]; +const { getRequestContext } = require('../../requestContext'); +const { normalizeSubdomain, validateSubdomainOrThrow } = require('../../tenantSubdomain'); const db = {}; let sequelize; @@ -16,22 +18,139 @@ if (config.use_env_variable) { sequelize = new Sequelize(config.database, config.username, config.password, config); } +const Op = Sequelize.Op; + +function addWhereCondition(options, condition) { + if (!condition) { + return; + } + + if (!options.where) { + options.where = condition; + return; + } + + options.where = { + [Op.and]: [options.where, condition], + }; +} + +function getOrganizationScopeCondition(model, context) { + if (!context.enforceOrganizationScope || context.globalAccess) { + return null; + } + + if (model?.name === 'organizations') { + if (!context.currentOrg) { + return Sequelize.literal('1 = 0'); + } + + return { + id: context.currentOrg, + }; + } + + if (!model?.rawAttributes?.organizationsId) { + return null; + } + + if (!context.currentOrg) { + return Sequelize.literal('1 = 0'); + } + + return { + organizationsId: context.currentOrg, + }; +} + +function applyOrganizationScope(model, options = {}) { + const context = getRequestContext(); + const condition = getOrganizationScopeCondition(model, context); + + if (!condition) { + return options; + } + + addWhereCondition(options, condition); + return options; +} + +function enforceOrganizationWriteScope(model, instance) { + const context = getRequestContext(); + + if (!context.enforceOrganizationScope || context.globalAccess) { + return; + } + + if (model?.rawAttributes?.organizationsId) { + if (!context.currentOrg) { + const error = new Error('Authenticated requests must be scoped to an organization.'); + error.code = 403; + throw error; + } + + instance.set('organizationsId', context.currentOrg); + } +} + +sequelize.afterPoolAcquire(async (connection) => { + const context = getRequestContext(); + const currentOrg = context.currentOrg || ''; + const globalAccess = context.globalAccess ? 'true' : 'false'; + + await connection.query( + 'select set_config($1, $2, false), set_config($3, $4, false)', + ['app.current_org', currentOrg, 'app.global_access', globalAccess], + ); +}); + fs .readdirSync(__dirname) - .filter(file => { - return (file.indexOf('.') !== 0) && (file !== basename) && (file.slice(-3) === '.js'); + .filter((file) => { + return file.indexOf('.') !== 0 && file !== basename && file.slice(-3) === '.js'; }) - .forEach(file => { - const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes) + .forEach((file) => { + const model = require(path.join(__dirname, file))(sequelize, Sequelize.DataTypes); db[model.name] = model; }); -Object.keys(db).forEach(modelName => { +Object.keys(db).forEach((modelName) => { if (db[modelName].associate) { db[modelName].associate(db); } }); +Object.values(db).forEach((model) => { + if (!model?.addHook) { + return; + } + + model.addHook('beforeFind', (options) => applyOrganizationScope(model, options)); + model.addHook('beforeCount', (options) => applyOrganizationScope(model, options)); + model.addHook('beforeCreate', (instance) => enforceOrganizationWriteScope(model, instance)); + model.addHook('beforeUpdate', (instance) => enforceOrganizationWriteScope(model, instance)); + model.addHook('beforeBulkCreate', (instances) => { + instances.forEach((instance) => enforceOrganizationWriteScope(model, instance)); + }); +}); + +if (db.tenants?.addHook) { + db.tenants.addHook('beforeValidate', (tenant) => { + if (tenant.subdomain === undefined || tenant.subdomain === null) { + return; + } + + const normalized = normalizeSubdomain(tenant.subdomain); + + if (!normalized) { + tenant.set('subdomain', normalized); + return; + } + + tenant.set('subdomain', validateSubdomainOrThrow(normalized)); + }); +} + db.sequelize = sequelize; db.Sequelize = Sequelize; diff --git a/backend/src/index.js b/backend/src/index.js index 850f763..7c9dd6a 100644 --- a/backend/src/index.js +++ b/backend/src/index.js @@ -6,16 +6,17 @@ const passport = require('passport'); const path = require('path'); const fs = require('fs'); const bodyParser = require('body-parser'); -const db = require('./db/models'); const config = require('./config'); const swaggerUI = require('swagger-ui-express'); const swaggerJsDoc = require('swagger-jsdoc'); +const { initializeRequestContext, attachAuthenticatedRequestContext } = require('./requestContext'); const authRoutes = require('./routes/auth'); const fileRoutes = require('./routes/file'); const searchRoutes = require('./routes/search'); const sqlRoutes = require('./routes/sql'); const pexelsRoutes = require('./routes/pexels'); +const contactFormRoutes = require('./routes/contactForm'); const organizationForAuthRoutes = require('./routes/organizationLogin'); @@ -160,123 +161,68 @@ app.use(cors({origin: true})); require('./auth/auth'); app.use(bodyParser.json()); +app.use(initializeRequestContext); app.use('/api/auth', authRoutes); app.use('/api/file', fileRoutes); app.use('/api/pexels', pexelsRoutes); +app.use('/api/contact-form', contactFormRoutes); +app.use('/api/org-for-auth', organizationForAuthRoutes); app.enable('trust proxy'); - -app.use('/api/users', passport.authenticate('jwt', {session: false}), usersRoutes); - -app.use('/api/roles', passport.authenticate('jwt', {session: false}), rolesRoutes); - -app.use('/api/permissions', passport.authenticate('jwt', {session: false}), permissionsRoutes); - -app.use('/api/organizations', passport.authenticate('jwt', {session: false}), organizationsRoutes); - -app.use('/api/tenants', passport.authenticate('jwt', {session: false}), tenantsRoutes); - -app.use('/api/service_lines', passport.authenticate('jwt', {session: false}), service_linesRoutes); - -app.use('/api/services', passport.authenticate('jwt', {session: false}), servicesRoutes); - -app.use('/api/companies', passport.authenticate('jwt', {session: false}), companiesRoutes); - -app.use('/api/contacts', passport.authenticate('jwt', {session: false}), contactsRoutes); - -app.use('/api/tags', passport.authenticate('jwt', {session: false}), tagsRoutes); - -app.use('/api/pipelines', passport.authenticate('jwt', {session: false}), pipelinesRoutes); - -app.use('/api/deal_stages', passport.authenticate('jwt', {session: false}), deal_stagesRoutes); - -app.use('/api/leads', passport.authenticate('jwt', {session: false}), leadsRoutes); - -app.use('/api/deals', passport.authenticate('jwt', {session: false}), dealsRoutes); - -app.use('/api/project_templates', passport.authenticate('jwt', {session: false}), project_templatesRoutes); - -app.use('/api/projects', passport.authenticate('jwt', {session: false}), projectsRoutes); - -app.use('/api/milestones', passport.authenticate('jwt', {session: false}), milestonesRoutes); - -app.use('/api/tasks', passport.authenticate('jwt', {session: false}), tasksRoutes); - -app.use('/api/task_dependencies', passport.authenticate('jwt', {session: false}), task_dependenciesRoutes); - -app.use('/api/deliverables', passport.authenticate('jwt', {session: false}), deliverablesRoutes); - -app.use('/api/project_risks', passport.authenticate('jwt', {session: false}), project_risksRoutes); - -app.use('/api/project_status_reports', passport.authenticate('jwt', {session: false}), project_status_reportsRoutes); - -app.use('/api/skills', passport.authenticate('jwt', {session: false}), skillsRoutes); - -app.use('/api/team_members', passport.authenticate('jwt', {session: false}), team_membersRoutes); - -app.use('/api/team_member_skills', passport.authenticate('jwt', {session: false}), team_member_skillsRoutes); - -app.use('/api/resource_allocations', passport.authenticate('jwt', {session: false}), resource_allocationsRoutes); - -app.use('/api/leave_requests', passport.authenticate('jwt', {session: false}), leave_requestsRoutes); - -app.use('/api/public_holidays', passport.authenticate('jwt', {session: false}), public_holidaysRoutes); - -app.use('/api/timesheets', passport.authenticate('jwt', {session: false}), timesheetsRoutes); - -app.use('/api/time_entries', passport.authenticate('jwt', {session: false}), time_entriesRoutes); - -app.use('/api/products', passport.authenticate('jwt', {session: false}), productsRoutes); - -app.use('/api/contracts', passport.authenticate('jwt', {session: false}), contractsRoutes); - -app.use('/api/invoices', passport.authenticate('jwt', {session: false}), invoicesRoutes); - -app.use('/api/invoice_line_items', passport.authenticate('jwt', {session: false}), invoice_line_itemsRoutes); - -app.use('/api/payments', passport.authenticate('jwt', {session: false}), paymentsRoutes); - -app.use('/api/support_slas', passport.authenticate('jwt', {session: false}), support_slasRoutes); - -app.use('/api/support_tickets', passport.authenticate('jwt', {session: false}), support_ticketsRoutes); - -app.use('/api/ticket_comments', passport.authenticate('jwt', {session: false}), ticket_commentsRoutes); - -app.use('/api/csat_surveys', passport.authenticate('jwt', {session: false}), csat_surveysRoutes); - -app.use('/api/email_templates', passport.authenticate('jwt', {session: false}), email_templatesRoutes); - -app.use('/api/notifications', passport.authenticate('jwt', {session: false}), notificationsRoutes); - -app.use('/api/audit_logs', passport.authenticate('jwt', {session: false}), audit_logsRoutes); - -app.use('/api/imports', passport.authenticate('jwt', {session: false}), importsRoutes); - app.use( - '/api/openai', - passport.authenticate('jwt', { session: false }), - openaiRoutes, -); -app.use( - '/api/ai', - passport.authenticate('jwt', { session: false }), - openaiRoutes, + '/api', + passport.authenticate('jwt', { session: false }), + attachAuthenticatedRequestContext, ); -app.use( - '/api/search', - passport.authenticate('jwt', { session: false }), - searchRoutes); -app.use( - '/api/sql', - passport.authenticate('jwt', { session: false }), - sqlRoutes); - -app.use( - '/api/org-for-auth', - organizationForAuthRoutes, - ); +app.use('/api/users', usersRoutes); +app.use('/api/roles', rolesRoutes); +app.use('/api/permissions', permissionsRoutes); +app.use('/api/organizations', organizationsRoutes); +app.use('/api/tenants', tenantsRoutes); +app.use('/api/service_lines', service_linesRoutes); +app.use('/api/services', servicesRoutes); +app.use('/api/companies', companiesRoutes); +app.use('/api/contacts', contactsRoutes); +app.use('/api/tags', tagsRoutes); +app.use('/api/pipelines', pipelinesRoutes); +app.use('/api/deal_stages', deal_stagesRoutes); +app.use('/api/leads', leadsRoutes); +app.use('/api/deals', dealsRoutes); +app.use('/api/project_templates', project_templatesRoutes); +app.use('/api/projects', projectsRoutes); +app.use('/api/milestones', milestonesRoutes); +app.use('/api/tasks', tasksRoutes); +app.use('/api/task_dependencies', task_dependenciesRoutes); +app.use('/api/deliverables', deliverablesRoutes); +app.use('/api/project_risks', project_risksRoutes); +app.use('/api/project_status_reports', project_status_reportsRoutes); +app.use('/api/skills', skillsRoutes); +app.use('/api/team_members', team_membersRoutes); +app.use('/api/team_member_skills', team_member_skillsRoutes); +app.use('/api/resource_allocations', resource_allocationsRoutes); +app.use('/api/leave_requests', leave_requestsRoutes); +app.use('/api/public_holidays', public_holidaysRoutes); +app.use('/api/timesheets', timesheetsRoutes); +app.use('/api/time_entries', time_entriesRoutes); +app.use('/api/products', productsRoutes); +app.use('/api/contracts', contractsRoutes); +app.use('/api/invoices', invoicesRoutes); +app.use('/api/invoice_line_items', invoice_line_itemsRoutes); +app.use('/api/payments', paymentsRoutes); +app.use('/api/support_slas', support_slasRoutes); +app.use('/api/support_tickets', support_ticketsRoutes); +app.use('/api/ticket_comments', ticket_commentsRoutes); +app.use('/api/csat_surveys', csat_surveysRoutes); +app.use('/api/email_templates', email_templatesRoutes); +app.use('/api/notifications', notificationsRoutes); +app.use('/api/audit_logs', audit_logsRoutes); +app.use('/api/imports', importsRoutes); +app.use('/api/openai', openaiRoutes); +app.use('/api/ai', openaiRoutes); +app.use('/api/search', searchRoutes); +app.use('/api/sql', sqlRoutes); const publicDir = path.join( diff --git a/backend/src/requestContext.js b/backend/src/requestContext.js new file mode 100644 index 0000000..17a81e4 --- /dev/null +++ b/backend/src/requestContext.js @@ -0,0 +1,55 @@ +const { AsyncLocalStorage } = require('async_hooks'); + +const requestContextStorage = new AsyncLocalStorage(); + +function initializeRequestContext(req, res, next) { + requestContextStorage.run( + { + currentOrg: null, + currentUserId: null, + globalAccess: false, + enforceOrganizationScope: false, + }, + next, + ); +} + +function getRequestContext() { + return ( + requestContextStorage.getStore() || { + currentOrg: null, + currentUserId: null, + globalAccess: false, + enforceOrganizationScope: false, + } + ); +} + +function setRequestContextScope({ currentOrg = null, currentUserId = null, globalAccess = false, enforceOrganizationScope = false }) { + const context = getRequestContext(); + + context.currentOrg = currentOrg; + context.currentUserId = currentUserId; + context.globalAccess = Boolean(globalAccess); + context.enforceOrganizationScope = Boolean(enforceOrganizationScope); + + return context; +} + +function attachAuthenticatedRequestContext(req, res, next) { + setRequestContextScope({ + currentOrg: req.currentUser?.organizationsId || null, + currentUserId: req.currentUser?.id || null, + globalAccess: req.currentUser?.app_role?.globalAccess, + enforceOrganizationScope: true, + }); + + next(); +} + +module.exports = { + initializeRequestContext, + getRequestContext, + setRequestContextScope, + attachAuthenticatedRequestContext, +}; diff --git a/backend/src/routes/audit_logs.js b/backend/src/routes/audit_logs.js index 8ca7712..ad9676f 100644 --- a/backend/src/routes/audit_logs.js +++ b/backend/src/routes/audit_logs.js @@ -393,7 +393,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Audit_logsDBApi.findAllAutocomplete( @@ -441,7 +441,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Audit_logsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/auth.js b/backend/src/routes/auth.js index 31d62cb..a60b043 100644 --- a/backend/src/routes/auth.js +++ b/backend/src/routes/auth.js @@ -6,6 +6,7 @@ const AuthService = require('../services/auth'); const ForbiddenError = require('../services/notifications/errors/forbidden'); const EmailSender = require('../services/email'); const wrapAsync = require('../helpers').wrapAsync; +const { resolveTenantContextByHost } = require('../tenantContext'); const router = express.Router(); @@ -140,13 +141,21 @@ router.post('/send-password-reset-email', wrapAsync(async (req, res) => { */ router.post('/signup', wrapAsync(async (req, res) => { - const link = new URL(req.headers.referer); + const referer = req.headers.referer || `${req.protocol}://${req.get('host')}`; + const link = new URL(referer); + const { organization } = await resolveTenantContextByHost(req, { required: true }); + const requestedOrganizationId = req.body.organizationsId || req.body.organizationId || null; + + if (requestedOrganizationId && requestedOrganizationId !== organization.id) { + const error = new Error('Signup organization does not match the current tenant context.'); + error.code = 400; + throw error; + } + const payload = await AuthService.signup( req.body.email, req.body.password, - - req.body.organizationId, - + organization.id, req, link.host, ) diff --git a/backend/src/routes/companies.js b/backend/src/routes/companies.js index 29708f7..35541b5 100644 --- a/backend/src/routes/companies.js +++ b/backend/src/routes/companies.js @@ -412,7 +412,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await CompaniesDBApi.findAllAutocomplete( @@ -460,7 +460,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await CompaniesDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/contactForm.js b/backend/src/routes/contactForm.js index e69de29..545f641 100644 --- a/backend/src/routes/contactForm.js +++ b/backend/src/routes/contactForm.js @@ -0,0 +1,753 @@ +const express = require('express'); +const db = require('../db/models'); +const wrapAsync = require('../helpers').wrapAsync; +const { resolveTenantContextByHost } = require('../tenantContext'); +const { setRequestContextScope } = require('../requestContext'); + +const router = express.Router(); +const { Op } = db.Sequelize; + +const SERVICE_CATALOG = { + ai_ml: { + lineNameFr: 'IA & Machine Learning', + lineNameEn: 'AI & Machine Learning', + lineDescriptionFr: + 'Automatisation, analytics et modèles sur mesure pour les entreprises africaines.', + lineDescriptionEn: + 'Custom AI, analytics, and automation for African enterprises.', + pricingModel: 'project_fixed', + typicalTimeline: '16 semaines', + serviceNameFr: 'Implémentation IA', + serviceNameEn: 'AI Implementation', + serviceDescriptionFr: + 'Discovery, préparation des données, modélisation, intégration et transfert.', + serviceDescriptionEn: + 'Discovery, data preparation, modeling, integration and handover.', + templateName: 'AI Implementation', + templateDescription: + 'Discovery → Data → Model → Integration → Training → Handover', + templateWeeks: 16, + billingModel: 'fixed_price', + }, + cybersecurity: { + lineNameFr: 'Cybersécurité', + lineNameEn: 'Cybersecurity', + lineDescriptionFr: + 'Audits, remédiation et supervision pour sécuriser les SI critiques.', + lineDescriptionEn: + 'Audits, remediation, and monitoring for critical information systems.', + pricingModel: 'project_fixed', + typicalTimeline: '4 semaines', + serviceNameFr: 'Audit cybersécurité', + serviceNameEn: 'Cybersecurity Audit', + serviceDescriptionFr: + 'Scoping, assessment, remediation planning et reporting exécutif.', + serviceDescriptionEn: + 'Scoping, assessment, remediation planning, and executive reporting.', + templateName: 'Cybersecurity Audit', + templateDescription: 'Scoping → Assessment → Remediation → Report', + templateWeeks: 4, + billingModel: 'fixed_price', + }, + cloud: { + lineNameFr: 'Cloud Solutions', + lineNameEn: 'Cloud Solutions', + lineDescriptionFr: + 'Architecture, migration et opérations cloud pour des équipes agiles.', + lineDescriptionEn: + 'Architecture, migration, and cloud operations for agile teams.', + pricingModel: 'hybrid', + typicalTimeline: '8 semaines', + serviceNameFr: 'Migration cloud', + serviceNameEn: 'Cloud Migration', + serviceDescriptionFr: + 'Audit, architecture cible, migration et validation des workloads.', + serviceDescriptionEn: + 'Audit, target architecture, workload migration, and validation.', + templateName: 'Cloud Migration', + templateDescription: 'Audit → Architecture → Migration → Validation', + templateWeeks: 8, + billingModel: 'time_material', + }, + hardware: { + lineNameFr: 'Infrastructure matérielle', + lineNameEn: 'Hardware Infrastructure', + lineDescriptionFr: + 'Réseaux, équipements et déploiements terrain avec expertise locale.', + lineDescriptionEn: + 'Networks, equipment, and field deployments with local expertise.', + pricingModel: 'project_fixed', + typicalTimeline: 'Variable', + serviceNameFr: 'Déploiement hardware', + serviceNameEn: 'Hardware Deployment', + serviceDescriptionFr: + 'Approvisionnement, installation, tests et mise en production.', + serviceDescriptionEn: + 'Procurement, installation, testing, and go-live.', + templateName: 'Hardware Deployment', + templateDescription: 'Procurement → Install → Test → Handover', + templateWeeks: 6, + billingModel: 'fixed_price', + }, + web_mobile: { + lineNameFr: 'Web & Mobile', + lineNameEn: 'Web & Mobile', + lineDescriptionFr: + 'Produits digitaux sur mesure, du cadrage jusqu’au déploiement.', + lineDescriptionEn: + 'Custom digital products from discovery through deployment.', + pricingModel: 'hybrid', + typicalTimeline: '12 semaines', + serviceNameFr: 'Développement web / mobile', + serviceNameEn: 'Web / Mobile Development', + serviceDescriptionFr: + 'Sprints agiles, UX, développement et stabilisation continue.', + serviceDescriptionEn: + 'Agile sprints, UX, development, and continuous hardening.', + templateName: 'Web/Mobile Development', + templateDescription: 'Sprint setup → Build → QA → Launch', + templateWeeks: 12, + billingModel: 'time_material', + }, + database: { + lineNameFr: 'Database Management', + lineNameEn: 'Database Management', + lineDescriptionFr: + 'Architecture, migration et optimisation de données à grande échelle.', + lineDescriptionEn: + 'Architecture, migration, and optimization for scalable data systems.', + pricingModel: 'project_fixed', + typicalTimeline: '6 semaines', + serviceNameFr: 'Setup base de données', + serviceNameEn: 'Database Setup', + serviceDescriptionFr: + 'Modélisation, setup, migration et tuning des performances.', + serviceDescriptionEn: + 'Modeling, setup, migration, and performance tuning.', + templateName: 'Database Setup', + templateDescription: 'Modeling → Setup → Migration → Optimization', + templateWeeks: 6, + billingModel: 'fixed_price', + }, +}; + +const COMPANY_SIZE_LABELS = { + '1_10': '1–10 employés', + '11_50': '11–50 employés', + '51_200': '51–200 employés', + '201_500': '201–500 employés', + '501_1000': '501–1000 employés', + '1000_plus': '1000+ employés', +}; + +const BUDGET_RANGE_LABELS = { + lt_5k: '< 5 000 USD', + '5_20k': '5 000–20 000 USD', + '20_50k': '20 000–50 000 USD', + '50k_plus': '50 000+ USD', +}; + +const BUDGET_ESTIMATES = { + lt_5k: 3000, + '5_20k': 12500, + '20_50k': 35000, + '50k_plus': 60000, +}; + +const CHANNEL_LABELS = { + email: 'Email', + phone: 'Téléphone', + whatsapp: 'WhatsApp', +}; + +const URGENCY_LABELS = { + standard: 'Standard', + urgent: 'Urgent', + critical: 'Critique', +}; + +const SALES_STAGE_BLUEPRINT = [ + { + name: 'Nouveau besoin', + probability_percent: 10, + sort_order: 1, + is_closed_won: false, + is_closed_lost: false, + }, + { + name: 'Qualification', + probability_percent: 30, + sort_order: 2, + is_closed_won: false, + is_closed_lost: false, + }, + { + name: 'Proposition envoyée', + probability_percent: 60, + sort_order: 3, + is_closed_won: false, + is_closed_lost: false, + }, + { + name: 'Négociation', + probability_percent: 80, + sort_order: 4, + is_closed_won: false, + is_closed_lost: false, + }, +]; + +const emailPattern = /^[^\s@]+@[^\s@]+\.[^\s@]+$/; + +function normalizeText(value) { + if (typeof value !== 'string') { + return ''; + } + + return value.trim().replace(/\s+/g, ' '); +} + +function throwBadRequest(message) { + const error = new Error(message); + error.code = 400; + throw error; +} + +function splitFullName(fullName) { + const parts = fullName.split(' ').filter(Boolean); + return { + firstName: parts[0] || fullName, + lastName: parts.slice(1).join(' ') || '-', + }; +} + +function getBudgetEstimate(range) { + return BUDGET_ESTIMATES[range] || null; +} + +function getScore(payload) { + let score = 35; + + if (['11_50', '51_200', '201_500', '501_1000', '1000_plus'].includes(payload.company_size)) { + score += 10; + } + + if (['51_200', '201_500', '501_1000', '1000_plus'].includes(payload.company_size)) { + score += 5; + } + + if (payload.budget_range === '5_20k') { + score += 10; + } + + if (payload.budget_range === '20_50k') { + score += 15; + } + + if (payload.budget_range === '50k_plus') { + score += 20; + } + + if (['ai_ml', 'cybersecurity', 'cloud'].includes(payload.service_line)) { + score += 10; + } + + if (payload.urgency === 'urgent') { + score += 5; + } + + if (payload.urgency === 'critical') { + score += 10; + } + + if (payload.preferred_channel === 'whatsapp') { + score += 5; + } + + if (payload.message.length > 120) { + score += 5; + } + + return Math.min(score, 100); +} + +function getScope(tenant, organization) { + const scope = {}; + + if (tenant?.id) { + scope.tenantId = tenant.id; + } + + if (organization?.id) { + scope.organizationsId = organization.id; + } + + return scope; +} + +function addDays(days) { + const date = new Date(); + date.setDate(date.getDate() + days); + return date; +} + +function buildLeadMessage(payload, catalog) { + return [ + `Service demandé: ${catalog.lineNameFr}`, + `Taille du client: ${COMPANY_SIZE_LABELS[payload.company_size]}`, + `Budget estimé: ${BUDGET_RANGE_LABELS[payload.budget_range]}`, + `Canal préféré: ${CHANNEL_LABELS[payload.preferred_channel]}`, + `Urgence: ${URGENCY_LABELS[payload.urgency]}`, + '', + payload.message, + ].join('\n'); +} + +function buildCompanyNote(payload) { + return [ + 'Fiche enrichie automatiquement depuis la landing page publique TSC FleetOS.', + `Taille: ${COMPANY_SIZE_LABELS[payload.company_size]}`, + `Dernier canal préféré: ${CHANNEL_LABELS[payload.preferred_channel]}`, + ].join('\n'); +} + +function buildContactNote(payload) { + return [ + 'Lead entrant web.', + `Canal préféré: ${CHANNEL_LABELS[payload.preferred_channel]}`, + `Urgence: ${URGENCY_LABELS[payload.urgency]}`, + ].join('\n'); +} + +function buildDealNote(payload, score, lead, catalog) { + return [ + `Référence lead: TSC-${lead.id.split('-')[0].toUpperCase()}`, + `Score: ${score}/100`, + `Service recommandé: ${catalog.serviceNameFr}`, + `Template suggéré: ${catalog.templateName}`, + `Budget visé: ${BUDGET_RANGE_LABELS[payload.budget_range]}`, + `Canal préféré: ${CHANNEL_LABELS[payload.preferred_channel]}`, + `Urgence: ${URGENCY_LABELS[payload.urgency]}`, + '', + payload.message, + ].join('\n'); +} + +async function attachScope(instance, tenant, organization, transaction) { + if (tenant && typeof instance.setTenant === 'function') { + await instance.setTenant(tenant, { transaction }); + } + + if (organization && typeof instance.setOrganizations === 'function') { + await instance.setOrganizations(organization, { transaction }); + } +} + +async function resolveTenantContext(req, transaction) { + return resolveTenantContextByHost(req, { + transaction, + required: true, + }); +} + +async function ensureSalesPipeline(tenant, organization, transaction) { + const scope = getScope(tenant, organization); + + let pipeline = await db.pipelines.findOne({ + where: { + pipeline_type: 'sales', + ...scope, + }, + order: [ + ['sort_order', 'ASC'], + ['createdAt', 'ASC'], + ], + transaction, + }); + + if (!pipeline) { + pipeline = await db.pipelines.create( + { + name: 'Pipeline commercial TSC', + pipeline_type: 'sales', + active: true, + sort_order: 1, + }, + { transaction }, + ); + + await attachScope(pipeline, tenant, organization, transaction); + } + + let firstStage = null; + + for (const stageBlueprint of SALES_STAGE_BLUEPRINT) { + let stage = await db.deal_stages.findOne({ + where: { + pipelineId: pipeline.id, + name: stageBlueprint.name, + ...scope, + }, + transaction, + }); + + if (!stage) { + stage = await db.deal_stages.create(stageBlueprint, { transaction }); + await attachScope(stage, tenant, organization, transaction); + await stage.setPipeline(pipeline, { transaction }); + } + + if (!firstStage || stage.sort_order < firstStage.sort_order) { + firstStage = stage; + } + } + + return { + pipeline, + firstStage, + }; +} + +async function ensureCatalog(serviceLineKey, tenant, organization, transaction) { + const catalog = SERVICE_CATALOG[serviceLineKey]; + + if (!catalog) { + throwBadRequest('Service line invalide.'); + } + + const scope = getScope(tenant, organization); + + let serviceLine = await db.service_lines.findOne({ + where: { + category: serviceLineKey, + ...scope, + }, + transaction, + }); + + if (!serviceLine) { + serviceLine = await db.service_lines.create( + { + name_fr: catalog.lineNameFr, + name_en: catalog.lineNameEn, + description_fr: catalog.lineDescriptionFr, + description_en: catalog.lineDescriptionEn, + category: serviceLineKey, + pricing_model: catalog.pricingModel, + typical_timeline: catalog.typicalTimeline, + active: true, + }, + { transaction }, + ); + + await attachScope(serviceLine, tenant, organization, transaction); + } + + let service = await db.services.findOne({ + where: { + service_lineId: serviceLine.id, + name_fr: catalog.serviceNameFr, + ...scope, + }, + transaction, + }); + + if (!service) { + service = await db.services.create( + { + name_fr: catalog.serviceNameFr, + name_en: catalog.serviceNameEn, + description_fr: catalog.serviceDescriptionFr, + description_en: catalog.serviceDescriptionEn, + pricing_model: catalog.pricingModel, + min_price: getBudgetEstimate('lt_5k'), + max_price: getBudgetEstimate('50k_plus'), + typical_duration_weeks: catalog.templateWeeks, + active: true, + }, + { transaction }, + ); + + await attachScope(service, tenant, organization, transaction); + await service.setService_line(serviceLine, { transaction }); + } + + let projectTemplate = await db.project_templates.findOne({ + where: { + service_lineId: serviceLine.id, + name: catalog.templateName, + ...scope, + }, + transaction, + }); + + if (!projectTemplate) { + projectTemplate = await db.project_templates.create( + { + name: catalog.templateName, + description: catalog.templateDescription, + default_duration_weeks: catalog.templateWeeks, + default_budget: getBudgetEstimate('20_50k'), + billing_model: catalog.billingModel, + active: true, + }, + { transaction }, + ); + + await attachScope(projectTemplate, tenant, organization, transaction); + await projectTemplate.setService_line(serviceLine, { transaction }); + } + + return { + serviceLine, + service, + projectTemplate, + ...catalog, + }; +} + +function validatePayload(payload) { + if (!payload.full_name) { + throwBadRequest('Le nom du contact est obligatoire.'); + } + + if (!payload.company_name) { + throwBadRequest('Le nom de la société est obligatoire.'); + } + + if (!payload.email || !emailPattern.test(payload.email)) { + throwBadRequest('Veuillez fournir un email professionnel valide.'); + } + + if (!SERVICE_CATALOG[payload.service_line]) { + throwBadRequest('Veuillez sélectionner une ligne de service valide.'); + } + + if (!COMPANY_SIZE_LABELS[payload.company_size]) { + throwBadRequest('Veuillez sélectionner la taille de votre entreprise.'); + } + + if (!BUDGET_RANGE_LABELS[payload.budget_range]) { + throwBadRequest('Veuillez sélectionner un budget estimatif.'); + } + + if (!CHANNEL_LABELS[payload.preferred_channel]) { + throwBadRequest('Veuillez sélectionner un canal de communication valide.'); + } + + if (!URGENCY_LABELS[payload.urgency]) { + throwBadRequest('Veuillez sélectionner un niveau d’urgence valide.'); + } + + if (payload.preferred_channel !== 'email' && !payload.phone) { + throwBadRequest('Le numéro de téléphone est requis pour un suivi par téléphone ou WhatsApp.'); + } + + if (!payload.message || payload.message.length < 24) { + throwBadRequest('Décrivez votre besoin en au moins 24 caractères.'); + } +} + +router.post( + '/', + wrapAsync(async (req, res) => { + const payload = { + full_name: normalizeText(req.body.full_name), + company_name: normalizeText(req.body.company_name), + email: normalizeText(req.body.email).toLowerCase(), + phone: normalizeText(req.body.phone), + service_line: normalizeText(req.body.service_line), + company_size: req.body.company_size, + budget_range: req.body.budget_range, + preferred_channel: normalizeText(req.body.preferred_channel).toLowerCase(), + urgency: normalizeText(req.body.urgency).toLowerCase(), + message: normalizeText(req.body.message), + }; + + validatePayload(payload); + + const transaction = await db.sequelize.transaction(); + + try { + const { tenant, organization } = await resolveTenantContext(req, transaction); + + setRequestContextScope({ + currentOrg: organization.id, + enforceOrganizationScope: true, + }); + + await db.sequelize.query( + "select set_config('app.current_org', :currentOrg, false), set_config('app.global_access', 'false', false)", + { + replacements: { currentOrg: organization.id }, + transaction, + }, + ); + + const scope = getScope(tenant, organization); + const catalog = await ensureCatalog(payload.service_line, tenant, organization, transaction); + const { pipeline, firstStage } = await ensureSalesPipeline(tenant, organization, transaction); + const { firstName, lastName } = splitFullName(payload.full_name); + const score = getScore(payload); + const leadStatus = score >= 70 ? 'qualified' : 'new'; + + let company = await db.companies.findOne({ + where: { + ...scope, + [Op.or]: [ + { legal_name: { [Op.iLike]: payload.company_name } }, + { trade_name: { [Op.iLike]: payload.company_name } }, + ], + }, + transaction, + }); + + if (!company) { + company = await db.companies.create( + { + legal_name: payload.company_name, + trade_name: payload.company_name, + size_band: payload.company_size, + country: 'DRC', + lifecycle_stage: 'lead', + notes: buildCompanyNote(payload), + }, + { transaction }, + ); + + await attachScope(company, tenant, organization, transaction); + } else { + await company.update( + { + size_band: company.size_band || payload.company_size, + lifecycle_stage: company.lifecycle_stage || 'lead', + notes: buildCompanyNote(payload), + }, + { transaction }, + ); + } + + let contact = await db.contacts.findOne({ + where: { + ...scope, + email: { [Op.iLike]: payload.email }, + }, + transaction, + }); + + const contactPayload = { + first_name: firstName, + last_name: lastName, + email: payload.email, + phone: payload.phone || null, + whatsapp: payload.preferred_channel === 'whatsapp' ? payload.phone || null : null, + preferred_channel: payload.preferred_channel, + allow_email: payload.preferred_channel === 'email', + allow_sms: payload.preferred_channel === 'phone', + allow_whatsapp: payload.preferred_channel === 'whatsapp', + lifecycle_stage: 'lead', + notes: buildContactNote(payload), + }; + + if (!contact) { + contact = await db.contacts.create(contactPayload, { transaction }); + await attachScope(contact, tenant, organization, transaction); + } else { + await contact.update(contactPayload, { transaction }); + } + + await contact.setCompany(company, { transaction }); + + let lead = await db.leads.findOne({ + where: { + ...scope, + email: { [Op.iLike]: payload.email }, + company_name: { [Op.iLike]: payload.company_name }, + status: { + [Op.in]: ['new', 'qualified'], + }, + }, + order: [['updatedAt', 'DESC']], + transaction, + }); + + const leadPayload = { + full_name: payload.full_name, + email: payload.email, + phone: payload.phone || null, + company_name: payload.company_name, + message: buildLeadMessage(payload, catalog), + source: 'website_form', + score, + status: leadStatus, + captured_at: new Date(), + }; + + if (!lead) { + lead = await db.leads.create(leadPayload, { transaction }); + await attachScope(lead, tenant, organization, transaction); + } else { + await lead.update(leadPayload, { transaction }); + } + + await lead.setService_interest(catalog.service, { transaction }); + + let deal = await db.deals.findOne({ + where: { + ...scope, + companyId: company.id, + service_lineId: catalog.serviceLine.id, + status: 'open', + }, + order: [['updatedAt', 'DESC']], + transaction, + }); + + const dealPayload = { + name: `Découverte ${payload.company_name} · ${catalog.serviceNameFr}`, + amount: getBudgetEstimate(payload.budget_range), + currency: 'USD', + expected_close_at: addDays(14), + status: 'open', + notes: buildDealNote(payload, score, lead, catalog), + }; + + if (!deal) { + deal = await db.deals.create(dealPayload, { transaction }); + await attachScope(deal, tenant, organization, transaction); + } else { + await deal.update(dealPayload, { transaction }); + } + + await deal.setCompany(company, { transaction }); + await deal.setPrimary_contact(contact, { transaction }); + await deal.setService_line(catalog.serviceLine, { transaction }); + await deal.setPipeline(pipeline, { transaction }); + + if (firstStage) { + await deal.setStage(firstStage, { transaction }); + } + + await transaction.commit(); + + res.status(200).send({ + success: true, + reference: `TSC-${lead.id.split('-')[0].toUpperCase()}`, + leadId: lead.id, + dealId: deal.id, + score, + status: leadStatus, + stageName: firstStage?.name || 'Nouveau besoin', + pipelineName: pipeline?.name || 'Pipeline commercial TSC', + templateSuggestion: catalog.projectTemplate?.name || catalog.templateName, + }); + } catch (error) { + await transaction.rollback(); + throw error; + } + }), +); + +router.use('/', require('../helpers').commonErrorHandler); + +module.exports = router; diff --git a/backend/src/routes/contacts.js b/backend/src/routes/contacts.js index 3776043..acef7ea 100644 --- a/backend/src/routes/contacts.js +++ b/backend/src/routes/contacts.js @@ -405,7 +405,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await ContactsDBApi.findAllAutocomplete( @@ -453,7 +453,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await ContactsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/contracts.js b/backend/src/routes/contracts.js index 5d8e698..9d5ce07 100644 --- a/backend/src/routes/contracts.js +++ b/backend/src/routes/contracts.js @@ -393,7 +393,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await ContractsDBApi.findAllAutocomplete( @@ -441,7 +441,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await ContractsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/csat_surveys.js b/backend/src/routes/csat_surveys.js index 3a7fa20..18d40c2 100644 --- a/backend/src/routes/csat_surveys.js +++ b/backend/src/routes/csat_surveys.js @@ -383,7 +383,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Csat_surveysDBApi.findAllAutocomplete( @@ -431,7 +431,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Csat_surveysDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/deal_stages.js b/backend/src/routes/deal_stages.js index 3cfb7ee..7b42a82 100644 --- a/backend/src/routes/deal_stages.js +++ b/backend/src/routes/deal_stages.js @@ -386,7 +386,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Deal_stagesDBApi.findAllAutocomplete( @@ -434,7 +434,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Deal_stagesDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/deals.js b/backend/src/routes/deals.js index a783cf1..b14a73b 100644 --- a/backend/src/routes/deals.js +++ b/backend/src/routes/deals.js @@ -391,7 +391,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await DealsDBApi.findAllAutocomplete( @@ -439,7 +439,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await DealsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/deliverables.js b/backend/src/routes/deliverables.js index 1edccfe..e218992 100644 --- a/backend/src/routes/deliverables.js +++ b/backend/src/routes/deliverables.js @@ -387,7 +387,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await DeliverablesDBApi.findAllAutocomplete( @@ -435,7 +435,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await DeliverablesDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/email_templates.js b/backend/src/routes/email_templates.js index 5364a9d..afb7264 100644 --- a/backend/src/routes/email_templates.js +++ b/backend/src/routes/email_templates.js @@ -393,7 +393,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Email_templatesDBApi.findAllAutocomplete( @@ -441,7 +441,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Email_templatesDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/imports.js b/backend/src/routes/imports.js index dbc21da..d58fd67 100644 --- a/backend/src/routes/imports.js +++ b/backend/src/routes/imports.js @@ -391,7 +391,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await ImportsDBApi.findAllAutocomplete( @@ -439,7 +439,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await ImportsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/invoice_line_items.js b/backend/src/routes/invoice_line_items.js index a8e18f8..f2c84ac 100644 --- a/backend/src/routes/invoice_line_items.js +++ b/backend/src/routes/invoice_line_items.js @@ -389,7 +389,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Invoice_line_itemsDBApi.findAllAutocomplete( @@ -437,7 +437,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Invoice_line_itemsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/invoices.js b/backend/src/routes/invoices.js index 3ea344a..0219eca 100644 --- a/backend/src/routes/invoices.js +++ b/backend/src/routes/invoices.js @@ -404,7 +404,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await InvoicesDBApi.findAllAutocomplete( @@ -452,7 +452,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await InvoicesDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/leads.js b/backend/src/routes/leads.js index ade198a..1d3b7cb 100644 --- a/backend/src/routes/leads.js +++ b/backend/src/routes/leads.js @@ -397,7 +397,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await LeadsDBApi.findAllAutocomplete( @@ -445,7 +445,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await LeadsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/leave_requests.js b/backend/src/routes/leave_requests.js index 9956e0b..2b18935 100644 --- a/backend/src/routes/leave_requests.js +++ b/backend/src/routes/leave_requests.js @@ -382,7 +382,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Leave_requestsDBApi.findAllAutocomplete( @@ -430,7 +430,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Leave_requestsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/milestones.js b/backend/src/routes/milestones.js index c1d744b..cc89bbd 100644 --- a/backend/src/routes/milestones.js +++ b/backend/src/routes/milestones.js @@ -387,7 +387,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await MilestonesDBApi.findAllAutocomplete( @@ -435,7 +435,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await MilestonesDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/notifications.js b/backend/src/routes/notifications.js index f3b76bf..9b146c1 100644 --- a/backend/src/routes/notifications.js +++ b/backend/src/routes/notifications.js @@ -388,7 +388,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await NotificationsDBApi.findAllAutocomplete( @@ -436,7 +436,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await NotificationsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/organizationLogin.js b/backend/src/routes/organizationLogin.js index 718b6c1..ba7f628 100644 --- a/backend/src/routes/organizationLogin.js +++ b/backend/src/routes/organizationLogin.js @@ -1,10 +1,7 @@ - - - const express = require('express'); -const OrganizationsDBApi = require('../db/api/organizations'); const wrapAsync = require('../helpers').wrapAsync; +const { resolveTenantContextByHost } = require('../tenantContext'); const router = express.Router(); @@ -37,19 +34,21 @@ const router = express.Router(); router.get( '/', wrapAsync(async (req, res) => { - const payload = await OrganizationsDBApi.findAll(req.query); - const simplifiedPayload = payload.rows.map(org => ({ - id: org.id, - name: org.name - })); - res.status(200).send(simplifiedPayload); - + const { organization, tenant, subdomain } = await resolveTenantContextByHost(req, { + required: true, + }); + + res.status(200).send([ + { + id: organization.id, + name: organization.name, + subdomain, + tenantId: tenant.id, + }, + ]); }), ); - - +router.use('/', require('../helpers').commonErrorHandler); module.exports = router; - - diff --git a/backend/src/routes/organizations.js b/backend/src/routes/organizations.js index 6f96a1d..e5723a1 100644 --- a/backend/src/routes/organizations.js +++ b/backend/src/routes/organizations.js @@ -380,7 +380,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await OrganizationsDBApi.findAllAutocomplete( @@ -428,7 +428,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await OrganizationsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/payments.js b/backend/src/routes/payments.js index a3a66f9..b22b83d 100644 --- a/backend/src/routes/payments.js +++ b/backend/src/routes/payments.js @@ -389,7 +389,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await PaymentsDBApi.findAllAutocomplete( @@ -437,7 +437,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await PaymentsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/permissions.js b/backend/src/routes/permissions.js index b569a78..9a55dbd 100644 --- a/backend/src/routes/permissions.js +++ b/backend/src/routes/permissions.js @@ -417,7 +417,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await PermissionsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/pipelines.js b/backend/src/routes/pipelines.js index ddcc22a..eeea458 100644 --- a/backend/src/routes/pipelines.js +++ b/backend/src/routes/pipelines.js @@ -384,7 +384,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await PipelinesDBApi.findAllAutocomplete( @@ -432,7 +432,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await PipelinesDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/products.js b/backend/src/routes/products.js index 8465775..70e8a90 100644 --- a/backend/src/routes/products.js +++ b/backend/src/routes/products.js @@ -391,7 +391,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await ProductsDBApi.findAllAutocomplete( @@ -439,7 +439,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await ProductsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/project_risks.js b/backend/src/routes/project_risks.js index 2538771..2faa207 100644 --- a/backend/src/routes/project_risks.js +++ b/backend/src/routes/project_risks.js @@ -389,7 +389,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Project_risksDBApi.findAllAutocomplete( @@ -437,7 +437,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Project_risksDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/project_status_reports.js b/backend/src/routes/project_status_reports.js index f0cc79a..41fd23a 100644 --- a/backend/src/routes/project_status_reports.js +++ b/backend/src/routes/project_status_reports.js @@ -390,7 +390,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Project_status_reportsDBApi.findAllAutocomplete( @@ -438,7 +438,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Project_status_reportsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/project_templates.js b/backend/src/routes/project_templates.js index 26505b9..62779f9 100644 --- a/backend/src/routes/project_templates.js +++ b/backend/src/routes/project_templates.js @@ -390,7 +390,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Project_templatesDBApi.findAllAutocomplete( @@ -438,7 +438,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Project_templatesDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/projects.js b/backend/src/routes/projects.js index 4e66a7c..f96cddc 100644 --- a/backend/src/routes/projects.js +++ b/backend/src/routes/projects.js @@ -402,7 +402,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await ProjectsDBApi.findAllAutocomplete( @@ -450,7 +450,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await ProjectsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/public_holidays.js b/backend/src/routes/public_holidays.js index 4ca72ce..665f198 100644 --- a/backend/src/routes/public_holidays.js +++ b/backend/src/routes/public_holidays.js @@ -383,7 +383,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Public_holidaysDBApi.findAllAutocomplete( @@ -431,7 +431,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Public_holidaysDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/resource_allocations.js b/backend/src/routes/resource_allocations.js index 0acd3b6..0a21fee 100644 --- a/backend/src/routes/resource_allocations.js +++ b/backend/src/routes/resource_allocations.js @@ -384,7 +384,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Resource_allocationsDBApi.findAllAutocomplete( @@ -432,7 +432,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Resource_allocationsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/roles.js b/backend/src/routes/roles.js index 91ceba8..bc91c0e 100644 --- a/backend/src/routes/roles.js +++ b/backend/src/routes/roles.js @@ -426,7 +426,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await RolesDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/search.js b/backend/src/routes/search.js index 25da9e0..1ce95d8 100644 --- a/backend/src/routes/search.js +++ b/backend/src/routes/search.js @@ -36,8 +36,8 @@ router.use(checkCrudPermissions('search')); */ router.post('/', async (req, res) => { - const { searchQuery , organizationId} = req.body; - + const { searchQuery } = req.body; + const organizationsId = req.currentUser?.organizationsId || null; const globalAccess = req.currentUser.app_role.globalAccess; if (!searchQuery) { @@ -45,7 +45,7 @@ router.post('/', async (req, res) => { } try { - const foundMatches = await SearchService.search(searchQuery, req.currentUser , organizationId, globalAccess,); + const foundMatches = await SearchService.search(searchQuery, req.currentUser, organizationsId, globalAccess); res.json(foundMatches); } catch (error) { console.error('Internal Server Error', error); diff --git a/backend/src/routes/service_lines.js b/backend/src/routes/service_lines.js index 971ac77..c924c02 100644 --- a/backend/src/routes/service_lines.js +++ b/backend/src/routes/service_lines.js @@ -400,7 +400,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Service_linesDBApi.findAllAutocomplete( @@ -448,7 +448,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Service_linesDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/services.js b/backend/src/routes/services.js index 3c67a10..4aaead8 100644 --- a/backend/src/routes/services.js +++ b/backend/src/routes/services.js @@ -405,7 +405,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await ServicesDBApi.findAllAutocomplete( @@ -453,7 +453,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await ServicesDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/skills.js b/backend/src/routes/skills.js index 4dd297c..5b1e262 100644 --- a/backend/src/routes/skills.js +++ b/backend/src/routes/skills.js @@ -381,7 +381,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await SkillsDBApi.findAllAutocomplete( @@ -429,7 +429,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await SkillsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/support_slas.js b/backend/src/routes/support_slas.js index 52a73af..ebb1b1b 100644 --- a/backend/src/routes/support_slas.js +++ b/backend/src/routes/support_slas.js @@ -387,7 +387,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Support_slasDBApi.findAllAutocomplete( @@ -435,7 +435,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Support_slasDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/support_tickets.js b/backend/src/routes/support_tickets.js index 6f2b54b..4b70f9e 100644 --- a/backend/src/routes/support_tickets.js +++ b/backend/src/routes/support_tickets.js @@ -389,7 +389,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Support_ticketsDBApi.findAllAutocomplete( @@ -437,7 +437,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Support_ticketsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/tags.js b/backend/src/routes/tags.js index 85bf5ec..7e2f119 100644 --- a/backend/src/routes/tags.js +++ b/backend/src/routes/tags.js @@ -383,7 +383,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await TagsDBApi.findAllAutocomplete( @@ -431,7 +431,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await TagsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/task_dependencies.js b/backend/src/routes/task_dependencies.js index 7f691c5..41ffe56 100644 --- a/backend/src/routes/task_dependencies.js +++ b/backend/src/routes/task_dependencies.js @@ -377,7 +377,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Task_dependenciesDBApi.findAllAutocomplete( @@ -425,7 +425,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Task_dependenciesDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/tasks.js b/backend/src/routes/tasks.js index 3ad09bf..8a7e64a 100644 --- a/backend/src/routes/tasks.js +++ b/backend/src/routes/tasks.js @@ -394,7 +394,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await TasksDBApi.findAllAutocomplete( @@ -442,7 +442,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await TasksDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/team_member_skills.js b/backend/src/routes/team_member_skills.js index 217f430..3f0d8e5 100644 --- a/backend/src/routes/team_member_skills.js +++ b/backend/src/routes/team_member_skills.js @@ -378,7 +378,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Team_member_skillsDBApi.findAllAutocomplete( @@ -426,7 +426,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Team_member_skillsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/team_members.js b/backend/src/routes/team_members.js index d81f4c4..697ab78 100644 --- a/backend/src/routes/team_members.js +++ b/backend/src/routes/team_members.js @@ -389,7 +389,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Team_membersDBApi.findAllAutocomplete( @@ -437,7 +437,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Team_membersDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/tenants.js b/backend/src/routes/tenants.js index 4c913b0..b15bd8d 100644 --- a/backend/src/routes/tenants.js +++ b/backend/src/routes/tenants.js @@ -415,7 +415,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await TenantsDBApi.findAllAutocomplete( @@ -463,7 +463,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await TenantsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/ticket_comments.js b/backend/src/routes/ticket_comments.js index 7a4103e..f440a22 100644 --- a/backend/src/routes/ticket_comments.js +++ b/backend/src/routes/ticket_comments.js @@ -381,7 +381,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Ticket_commentsDBApi.findAllAutocomplete( @@ -429,7 +429,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Ticket_commentsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/time_entries.js b/backend/src/routes/time_entries.js index 3e0390d..71683fe 100644 --- a/backend/src/routes/time_entries.js +++ b/backend/src/routes/time_entries.js @@ -384,7 +384,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await Time_entriesDBApi.findAllAutocomplete( @@ -432,7 +432,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await Time_entriesDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/timesheets.js b/backend/src/routes/timesheets.js index 0eb103d..b748aec 100644 --- a/backend/src/routes/timesheets.js +++ b/backend/src/routes/timesheets.js @@ -381,7 +381,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await TimesheetsDBApi.findAllAutocomplete( @@ -429,7 +429,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await TimesheetsDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } diff --git a/backend/src/routes/users.js b/backend/src/routes/users.js index 69f227e..5924f0c 100644 --- a/backend/src/routes/users.js +++ b/backend/src/routes/users.js @@ -389,7 +389,7 @@ router.get('/autocomplete', async (req, res) => { const globalAccess = req.currentUser.app_role.globalAccess; - const organizationId = req.currentUser.organization?.id + const organizationId = req.currentUser?.organizationsId const payload = await UsersDBApi.findAllAutocomplete( @@ -437,7 +437,14 @@ router.get('/autocomplete', async (req, res) => { router.get('/:id', wrapAsync(async (req, res) => { const payload = await UsersDBApi.findBy( { id: req.params.id }, + { currentUser: req.currentUser }, ); + + if (!payload) { + const error = new Error('Item not found.'); + error.code = 404; + throw error; + } delete payload.password; diff --git a/backend/src/services/auth.js b/backend/src/services/auth.js index bcc3411..d0805e3 100644 --- a/backend/src/services/auth.js +++ b/backend/src/services/auth.js @@ -10,8 +10,8 @@ const config = require('../config'); const helpers = require('../helpers'); class Auth { - static async signup(email, password, organizationId, options = {}, host) { - const user = await UsersDBApi.findBy({email}); + static async signup(email, password, organizationsId, options = {}, host) { + const user = await UsersDBApi.findAuthUserByEmail(email); const hashedPassword = await bcrypt.hash( password, @@ -54,14 +54,18 @@ class Auth { return helpers.jwtSign(data); } + if (!organizationsId) { + const error = new Error('A valid tenant organization is required for signup.'); + error.code = 400; + throw error; + } + const newUser = await UsersDBApi.createFromAuth( { firstName: email.split('@')[0], password: hashedPassword, email: email, - - organizationId: organizationId, - + organizationsId, }, options, ); @@ -84,7 +88,7 @@ class Auth { } static async signin(email, password, options = {}) { - const user = await UsersDBApi.findBy({email}); + const user = await UsersDBApi.findAuthUserByEmail(email); if (!user) { throw new ValidationError( diff --git a/backend/src/services/roles.js b/backend/src/services/roles.js index ec6186b..46796b8 100644 --- a/backend/src/services/roles.js +++ b/backend/src/services/roles.js @@ -24,7 +24,7 @@ function buildWidgetResult(widget, queryResult, queryString) { async function executeQuery(queryString, currentUser) { try { return await db.sequelize.query(queryString, { - replacements: { organizationId: currentUser.organizationId }, + replacements: { organizationId: currentUser.organizationsId }, }); } catch (e) { console.log(e); @@ -49,13 +49,13 @@ function insertWhereConditions(queryString, whereConditions) { } function constructWhereConditions(mainTable, currentUser, replacements) { - const { organizationId, app_role: { globalAccess } } = currentUser; + const { organizationsId, app_role: { globalAccess } } = currentUser; const tablesWithoutOrgId = ['permissions', 'roles']; let whereConditions = ''; if (!globalAccess && !tablesWithoutOrgId.includes(mainTable)) { - whereConditions += `"${mainTable}"."organizationId" = :organizationId`; - replacements.organizationId = organizationId; + whereConditions += `"${mainTable}"."organizationsId" = :organizationId`; + replacements.organizationId = organizationsId; } whereConditions += whereConditions ? ' AND ' : ''; @@ -362,7 +362,7 @@ module.exports = class RolesService { static async getRoleInfoByKey(key, roleId, currentUser) { const transaction = await db.sequelize.transaction(); - const organizationId = currentUser.organizationId; + const organizationId = currentUser.organizationsId; let globalAccess = currentUser.app_role?.globalAccess; let queryString = ''; diff --git a/backend/src/services/search.js b/backend/src/services/search.js index 6f4dd16..08fd6d2 100644 --- a/backend/src/services/search.js +++ b/backend/src/services/search.js @@ -36,7 +36,7 @@ async function checkPermissions(permission, currentUser) { } module.exports = class SearchService { - static async search(searchQuery, currentUser , organizationId, globalAccess) { + static async search(searchQuery, currentUser, organizationsId, globalAccess) { try { if (!searchQuery) { throw new ValidationError('iam.errors.searchQueryRequired'); @@ -1008,8 +1008,8 @@ module.exports = class SearchService { }; - if (!globalAccess && tableName !== 'organizations' && organizationId) { - whereCondition.organizationId = organizationId; + if (!globalAccess && tableName !== 'organizations' && organizationsId) { + whereCondition.organizationsId = organizationsId; } diff --git a/backend/src/tenantContext.js b/backend/src/tenantContext.js new file mode 100644 index 0000000..0dbf216 --- /dev/null +++ b/backend/src/tenantContext.js @@ -0,0 +1,66 @@ +const db = require('./db/models'); +const { + RESERVED_SUBDOMAINS, + normalizeSubdomain, + validateSubdomainOrThrow, + getRequestHostname, + extractTenantSubdomain, +} = require('./tenantSubdomain'); + +function badRequest(message) { + const error = new Error(message); + error.code = 400; + throw error; +} + +async function resolveTenantContextByHost(reqOrHost, options = {}) { + const transaction = options.transaction; + const required = options.required ?? false; + const hostname = getRequestHostname(reqOrHost); + const subdomain = extractTenantSubdomain(hostname); + + if (!subdomain) { + if (required) { + badRequest('A valid tenant subdomain is required for this request.'); + } + + return { + subdomain: null, + tenant: null, + organization: null, + }; + } + + const tenant = await db.tenants.findOne({ + where: { subdomain }, + include: [ + { + model: db.organizations, + as: 'organizations', + }, + ], + transaction, + }); + + if (!tenant) { + badRequest(`No tenant is configured for subdomain '${subdomain}'.`); + } + + if (!tenant.organizations) { + badRequest(`Tenant '${subdomain}' is not linked to an organization.`); + } + + return { + subdomain, + tenant, + organization: tenant.organizations, + }; +} + +module.exports = { + RESERVED_SUBDOMAINS, + normalizeSubdomain, + validateSubdomainOrThrow, + extractTenantSubdomain, + resolveTenantContextByHost, +}; diff --git a/backend/src/tenantSubdomain.js b/backend/src/tenantSubdomain.js new file mode 100644 index 0000000..2e538fb --- /dev/null +++ b/backend/src/tenantSubdomain.js @@ -0,0 +1,107 @@ +const RESERVED_SUBDOMAINS = new Set([ + 'www', + 'api', + 'admin', + 'app', + 'dashboard', + 'localhost', +]); + +function badRequest(message) { + const error = new Error(message); + error.code = 400; + throw error; +} + +function normalizeSubdomain(value) { + if (typeof value !== 'string') { + return ''; + } + + return value + .trim() + .toLowerCase() + .replace(/[^a-z0-9-]+/g, '-') + .replace(/-{2,}/g, '-') + .replace(/^-+|-+$/g, ''); +} + +function isReservedSubdomain(value) { + return RESERVED_SUBDOMAINS.has(value) || value.startsWith('127-'); +} + +function validateSubdomainOrThrow(value, { allowEmpty = false } = {}) { + const normalized = normalizeSubdomain(value); + + if (!normalized) { + if (allowEmpty) { + return normalized; + } + + badRequest('A valid tenant subdomain is required.'); + } + + if (!/^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(normalized)) { + badRequest('Tenant subdomains may only contain lowercase letters, numbers, and hyphens.'); + } + + if (isReservedSubdomain(normalized)) { + badRequest(`The subdomain '${normalized}' is reserved and cannot be used.`); + } + + return normalized; +} + +function getRequestHostname(reqOrHost) { + if (!reqOrHost) { + return ''; + } + + if (typeof reqOrHost === 'string') { + return reqOrHost; + } + + return ( + reqOrHost.get?.('x-forwarded-host') || + reqOrHost.get?.('host') || + reqOrHost.hostname || + '' + ); +} + +function extractTenantSubdomain(hostname) { + const cleanHost = String(hostname || '') + .split(',')[0] + .trim() + .split(':')[0] + .toLowerCase(); + + if (!cleanHost || cleanHost === 'localhost' || cleanHost.startsWith('127.')) { + return null; + } + + const segments = cleanHost.split('.').filter(Boolean); + + if (cleanHost.endsWith('.localhost')) { + if (segments.length < 2) { + return null; + } + + return validateSubdomainOrThrow(segments[0]); + } + + if (segments.length < 3) { + return null; + } + + return validateSubdomainOrThrow(segments[0]); +} + +module.exports = { + RESERVED_SUBDOMAINS, + normalizeSubdomain, + isReservedSubdomain, + validateSubdomainOrThrow, + getRequestHostname, + extractTenantSubdomain, +}; diff --git a/backend/test/tenant-isolation.test.js b/backend/test/tenant-isolation.test.js new file mode 100644 index 0000000..d412cd3 --- /dev/null +++ b/backend/test/tenant-isolation.test.js @@ -0,0 +1,267 @@ +const assert = require('assert'); +const axios = require('axios'); + +const api = axios.create({ + baseURL: 'http://127.0.0.1:3000/api', + validateStatus: () => true, +}); + +const SUPER_ADMIN = { + email: 'super_admin@flatlogic.com', + password: '252a3d31', +}; + +function decodeUserId(token) { + const payload = token.split('.')[1]; + return JSON.parse(Buffer.from(payload, 'base64url').toString('utf8')).user.id; +} + +async function signIn(credentials) { + const response = await api.post('/auth/signin/local', credentials); + assert.strictEqual(response.status, 200, `Expected signin to succeed for ${credentials.email}, got ${response.status}: ${response.data}`); + return response.data; +} + +async function sql(token, sqlQuery) { + const response = await api.post( + '/sql', + { sql: sqlQuery }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + assert.strictEqual(response.status, 200, `Expected SQL helper to succeed, got ${response.status}: ${JSON.stringify(response.data)}`); + return response.data.rows; +} + +async function createOrganization(token, name) { + const response = await api.post( + '/organizations', + { data: { name } }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + assert.strictEqual(response.status, 200, `Expected organization creation to succeed, got ${response.status}: ${response.data}`); + + const rows = await sql(token, `select id from organizations where name = '${name.replace(/'/g, "''")}' order by "createdAt" desc limit 1`); + assert.ok(rows[0]?.id, 'Expected organization lookup to return an id'); + return rows[0].id; +} + +async function createTenant(token, { name, subdomain, organizationsId }) { + const response = await api.post( + '/tenants', + { + data: { + name, + subdomain, + organizations: organizationsId, + default_language: 'fr', + primary_currency: 'USD', + }, + }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + assert.strictEqual(response.status, 200, `Expected tenant creation to succeed, got ${response.status}: ${response.data}`); +} + +async function promoteUser(token, { userId, email, organizationsId, roleId }) { + const response = await api.put( + `/users/${userId}`, + { + id: userId, + data: { + email, + firstName: email.split('@')[0], + emailVerified: true, + app_role: roleId, + organizations: organizationsId, + }, + }, + { + headers: { + Authorization: `Bearer ${token}`, + }, + }, + ); + + assert.strictEqual(response.status, 200, `Expected user promotion to succeed, got ${response.status}: ${response.data}`); +} + +async function signupTenantUser({ subdomain, organizationsId, email, roleId, superAdminToken }) { + const signupResponse = await api.post( + '/auth/signup', + { + email, + password: 'demo1234', + organizationsId, + }, + { + headers: { + Host: `${subdomain}.example.com`, + Referer: `http://${subdomain}.example.com/register`, + }, + }, + ); + + assert.strictEqual(signupResponse.status, 200, `Expected tenant signup to succeed, got ${signupResponse.status}: ${signupResponse.data}`); + + const userId = decodeUserId(signupResponse.data); + await promoteUser(superAdminToken, { + userId, + email, + organizationsId, + roleId, + }); + + const rows = await sql(superAdminToken, `select "organizationsId" from users where id = '${userId}'`); + assert.strictEqual(rows[0].organizationsId, organizationsId, 'Expected signup to persist the resolved organizationsId'); + + return signIn({ email, password: 'demo1234' }); +} + +function buildContactPayload(suffix) { + return { + full_name: 'Jean Test', + company_name: `Company ${suffix}`, + email: `lead-${suffix}@company.com`, + phone: '+243800000000', + service_line: 'cloud', + company_size: '11_50', + budget_range: '20_50k', + preferred_channel: 'email', + urgency: 'urgent', + message: 'Nous avons besoin d\'une migration cloud sécurisée et bien cadrée.', + }; +} + +describe('tenant isolation and tenant-scoped public intake', function () { + this.timeout(120000); + + it('rejects tenant discovery and contact intake when no valid tenant context is present', async () => { + const orgResponse = await api.get('/org-for-auth'); + assert.strictEqual(orgResponse.status, 400); + assert.match(String(orgResponse.data), /tenant subdomain/i); + + const intakeResponse = await api.post('/contact-form', buildContactPayload('unknown'), { + headers: { + Host: 'unknown.example.com', + Referer: 'http://unknown.example.com/', + }, + }); + assert.strictEqual(intakeResponse.status, 400); + assert.match(String(intakeResponse.data), /No tenant is configured/i); + }); + + it('prevents cross-organization read, update, delete, and direct SQL access by id', async () => { + const superAdminToken = await signIn(SUPER_ADMIN); + const timestamp = Date.now(); + const adminRoleRows = await sql(superAdminToken, "select id from roles where name = 'Administrator' limit 1"); + const adminRoleId = adminRoleRows[0].id; + + const orgA = await createOrganization(superAdminToken, `Isolation Org A ${timestamp}`); + const orgB = await createOrganization(superAdminToken, `Isolation Org B ${timestamp}`); + const subA = `isolation-a-${timestamp}`; + const subB = `isolation-b-${timestamp}`; + + await createTenant(superAdminToken, { + name: `Isolation Tenant A ${timestamp}`, + subdomain: subA, + organizationsId: orgA, + }); + await createTenant(superAdminToken, { + name: `Isolation Tenant B ${timestamp}`, + subdomain: subB, + organizationsId: orgB, + }); + + const tenantOrgResponse = await api.get('/org-for-auth', { + headers: { + Host: `${subA}.example.com`, + }, + }); + assert.strictEqual(tenantOrgResponse.status, 200); + assert.strictEqual(tenantOrgResponse.data[0].id, orgA); + + const userAToken = await signupTenantUser({ + subdomain: subA, + organizationsId: orgA, + email: `tenant-a-${timestamp}@example.com`, + roleId: adminRoleId, + superAdminToken, + }); + const userBToken = await signupTenantUser({ + subdomain: subB, + organizationsId: orgB, + email: `tenant-b-${timestamp}@example.com`, + roleId: adminRoleId, + superAdminToken, + }); + + const intakeResponse = await api.post('/contact-form', buildContactPayload(timestamp), { + headers: { + Host: `${subA}.example.com`, + Referer: `http://${subA}.example.com/`, + }, + }); + assert.strictEqual(intakeResponse.status, 200, JSON.stringify(intakeResponse.data)); + const { leadId } = intakeResponse.data; + assert.ok(leadId, 'Expected contact form to create a lead'); + + const readOwnLead = await api.get(`/leads/${leadId}`, { + headers: { + Authorization: `Bearer ${userAToken}`, + }, + }); + assert.strictEqual(readOwnLead.status, 200); + assert.strictEqual(readOwnLead.data.id, leadId); + + const readOtherLead = await api.get(`/leads/${leadId}`, { + headers: { + Authorization: `Bearer ${userBToken}`, + }, + }); + assert.strictEqual(readOtherLead.status, 404); + + const updateOtherLead = await api.put( + `/leads/${leadId}`, + { + id: leadId, + data: { + status: 'new', + }, + }, + { + headers: { + Authorization: `Bearer ${userBToken}`, + }, + }, + ); + assert.ok([400, 404].includes(updateOtherLead.status), `Expected cross-org update to fail, got ${updateOtherLead.status}`); + + const deleteOtherLead = await api.delete(`/leads/${leadId}`, { + headers: { + Authorization: `Bearer ${userBToken}`, + }, + }); + assert.strictEqual(deleteOtherLead.status, 404); + + const visibleRowsForOwner = await sql(userAToken, `select id from leads where id = '${leadId}'`); + assert.strictEqual(visibleRowsForOwner.length, 1, 'Expected owner SQL query to return the lead'); + + const visibleRowsForOtherOrg = await sql(userBToken, `select id from leads where id = '${leadId}'`); + assert.strictEqual(visibleRowsForOtherOrg.length, 0, 'Expected RLS to hide the lead from another organization'); + }); +}); diff --git a/frontend/src/components/AsideMenuLayer.tsx b/frontend/src/components/AsideMenuLayer.tsx index 40d09c7..04bcc33 100644 --- a/frontend/src/components/AsideMenuLayer.tsx +++ b/frontend/src/components/AsideMenuLayer.tsx @@ -1,15 +1,9 @@ import React from 'react' -import { mdiLogout, mdiClose } from '@mdi/js' +import { mdiClose } from '@mdi/js' import BaseIcon from './BaseIcon' import AsideMenuList from './AsideMenuList' import { MenuAsideItem } from '../interfaces' import { useAppSelector } from '../stores/hooks' -import Link from 'next/link'; - -import { useAppDispatch } from '../stores/hooks'; -import { createAsyncThunk } from '@reduxjs/toolkit'; -import axios from 'axios'; - type Props = { menu: MenuAsideItem[] @@ -23,59 +17,36 @@ export default function AsideMenuLayer({ menu, className = '', ...props }: Props const asideBrandStyle = useAppSelector((state) => state.style.asideBrandStyle) const asideScrollbarsStyle = useAppSelector((state) => state.style.asideScrollbarsStyle) const darkMode = useAppSelector((state) => state.style.darkMode) + const { currentUser } = useAppSelector((state) => state.auth); const handleAsideLgCloseClick = (e: React.MouseEvent) => { e.preventDefault() props.onAsideLgCloseClick() } - const dispatch = useAppDispatch(); - const { currentUser } = useAppSelector((state) => state.auth); - const organizationsId = currentUser?.organizations?.id; - const [organizations, setOrganizations] = React.useState(null); - - const fetchOrganizations = createAsyncThunk('/org-for-auth', async () => { - try { - const response = await axios.get('/org-for-auth'); - setOrganizations(response.data); - return response.data; - } catch (error) { - console.error(error.response); - throw error; - } - }); - - React.useEffect(() => { - dispatch(fetchOrganizations()); - }, [dispatch]); - - let organizationName = organizations?.find(item => item.id === organizationsId)?.name; - if(organizationName?.length > 25){ - organizationName = organizationName?.substring(0, 25) + '...'; + let organizationName = currentUser?.organizations?.name || ''; + if (organizationName.length > 25) { + organizationName = `${organizationName.substring(0, 25)}...`; } - return (