Skip to content

Commit c31a5f0

Browse files
fix(organizations): replace dynamic-import cleanup with onOrganizationRemoved extension point (#3863) (#3865)
* feat(organizations): add onOrganizationRemoved subscriber registry (#3863) * refactor(organizations): run org-removal handlers via registry, drop dynamic-import cleanup (#3863) * test(organizations): drop dead tasks.service mock from orgRemoval suite (#3863) * feat(tasks): register org-removal cleanup handler at boot via tasks.init (#3863) * test(tasks): assert org-removal registration is boot-only (absent-module no-op) (#3863) * fix(organizations): jsdoc, set-based registry, and remove() contract (#3863) - tasks.init: add JSDoc @returns on default export (mirrors billing.init pattern) - orgRemoval.registry: handlers Array → Set (de-dupes fn refs; clear() replaces length=0) - organizations.crud.service remove(): update JSDoc to document cleanup handlers
1 parent a3be432 commit c31a5f0

6 files changed

Lines changed: 264 additions & 13 deletions

File tree

Lines changed: 46 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,46 @@
1+
/**
2+
* @module organizations/lib/orgRemoval.registry
3+
* @description Subscriber registry for organization-removal cleanup.
4+
* Optional modules register a handler at load time via `onOrganizationRemoved`;
5+
* the organization crud service runs them sequentially on org delete and
6+
* propagates their errors (no silent swallow). Config-free, import-safe leaf —
7+
* it must not import organization/tasks services (avoids an import cycle).
8+
*/
9+
10+
const handlers = new Set();
11+
12+
/**
13+
* @function onOrganizationRemoved
14+
* @description Register a cleanup handler fired when an organization is removed.
15+
* Uses a Set so duplicate registrations (e.g. a module init running twice) are
16+
* silently de-duped — each handler function identity is unique in the Set.
17+
* @param {Function} fn - async (payload) => void; payload is { organizationId, organization }.
18+
* @returns {void}
19+
*/
20+
export const onOrganizationRemoved = (fn) => {
21+
if (typeof fn !== 'function') throw new TypeError('onOrganizationRemoved: fn must be a function');
22+
handlers.add(fn);
23+
};
24+
25+
/**
26+
* @function runOrganizationRemovedHandlers
27+
* @description Run every registered handler sequentially. Errors propagate (not swallowed).
28+
* @param {Object} payload - { organizationId, organization }.
29+
* @returns {Promise<void>}
30+
*/
31+
export const runOrganizationRemovedHandlers = async (payload) => {
32+
for (const fn of handlers) {
33+
await fn(payload); // sequential: each handler must resolve before the next runs
34+
}
35+
};
36+
37+
/**
38+
* @function _reset
39+
* @description Test helper — clears all registered handlers.
40+
* @returns {void}
41+
*/
42+
export const _reset = () => {
43+
handlers.clear();
44+
};
45+
46+
export default { onOrganizationRemoved, runOrganizationRemovedHandlers, _reset };

modules/organizations/services/organizations.crud.service.js

Lines changed: 9 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,7 @@ import MembershipRepository from '../repositories/organizations.membership.repos
2525
import UserService from '../../users/services/users.service.js';
2626
import { slugify } from '../helpers/organizations.slug.js';
2727
import { MEMBERSHIP_STATUSES, MEMBERSHIP_ROLES } from '../lib/constants.js';
28+
import { runOrganizationRemovedHandlers } from '../lib/orgRemoval.registry.js';
2829

2930
/**
3031
* @function list
@@ -170,7 +171,11 @@ const update = async (organization, body) => {
170171

171172
/**
172173
* @function remove
173-
* @description Service to delete an organization and all its memberships.
174+
* @description Service to delete an organization, all its memberships, and any
175+
* data owned by optional modules that registered an org-removal handler via
176+
* `onOrganizationRemoved`. Handlers run sequentially after membership cleanup;
177+
* any handler error propagates and aborts the delete before the repository
178+
* removal (no silent swallow).
174179
* @param {Object} organization - The organization to delete.
175180
* @returns {Promise<Object>} A promise resolving to a confirmation of the deletion.
176181
*/
@@ -192,18 +197,9 @@ const remove = async (organization) => {
192197
await UserService.updateById(u._id, { currentOrganization: nextOrg });
193198
}));
194199

195-
// Delete all tasks belonging to this organization (Task module may not exist in all projects)
196-
try {
197-
const TasksService = (await import('../../tasks/services/tasks.service.js')).default;
198-
await TasksService.deleteMany({ organizationId: orgId });
199-
} catch (err) {
200-
// Only swallow module-not-found errors; re-throw data/runtime failures
201-
if (err && (err.code === 'ERR_MODULE_NOT_FOUND' || err.code === 'MODULE_NOT_FOUND')) {
202-
// Tasks module not available — skip cleanup
203-
} else {
204-
throw err;
205-
}
206-
}
200+
// Run org-removal cleanup handlers registered by optional modules (e.g. tasks).
201+
// Errors propagate and abort the delete before the repository removal.
202+
await runOrganizationRemovedHandlers({ organizationId: orgId, organization });
207203

208204
const result = await OrganizationsRepository.remove(organization);
209205
return result;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
/**
2+
* Unit tests — OrgCrudService.remove() fires the org-removal registry and propagates handler errors.
3+
*/
4+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
5+
6+
const mockOrgRemove = jest.fn().mockResolvedValue({ acknowledged: true });
7+
const mockMembershipDeleteMany = jest.fn().mockResolvedValue({ deletedCount: 0 });
8+
const mockMembershipList = jest.fn().mockResolvedValue([]);
9+
const mockUpdateById = jest.fn().mockResolvedValue({});
10+
const mockFindWithFilter = jest.fn().mockResolvedValue([]);
11+
12+
jest.unstable_mockModule('../../../lib/services/logger.js', () => ({
13+
default: { error: jest.fn(), warn: jest.fn(), info: jest.fn() },
14+
}));
15+
16+
jest.unstable_mockModule('../../../config/index.js', () => ({
17+
default: { app: {}, get: jest.fn() },
18+
}));
19+
20+
jest.unstable_mockModule('../repositories/organizations.repository.js', () => ({
21+
default: {
22+
remove: mockOrgRemove,
23+
findOne: jest.fn().mockResolvedValue(null),
24+
},
25+
}));
26+
27+
jest.unstable_mockModule('../repositories/organizations.membership.repository.js', () => ({
28+
default: {
29+
deleteMany: mockMembershipDeleteMany,
30+
list: mockMembershipList,
31+
},
32+
}));
33+
34+
jest.unstable_mockModule('../../users/services/users.service.js', () => ({
35+
default: {
36+
updateById: mockUpdateById,
37+
findWithFilter: mockFindWithFilter,
38+
getBrut: jest.fn(),
39+
},
40+
}));
41+
42+
const { default: OrgCrudService } = await import('../services/organizations.crud.service.js');
43+
const { onOrganizationRemoved, _reset } = await import('../lib/orgRemoval.registry.js');
44+
45+
describe('OrgCrudService.remove() — org-removal registry', () => {
46+
const organization = { _id: 'org-1' };
47+
48+
beforeEach(() => {
49+
jest.clearAllMocks();
50+
_reset();
51+
});
52+
53+
test('fires every registered handler with { organizationId, organization }', async () => {
54+
const handler = jest.fn().mockResolvedValue(undefined);
55+
onOrganizationRemoved(handler);
56+
57+
await OrgCrudService.remove(organization);
58+
59+
expect(handler).toHaveBeenCalledTimes(1);
60+
expect(handler).toHaveBeenCalledWith({ organizationId: 'org-1', organization });
61+
expect(mockOrgRemove).toHaveBeenCalledWith(organization);
62+
});
63+
64+
test('propagates a handler error and aborts before the repository remove', async () => {
65+
onOrganizationRemoved(async () => {
66+
throw new Error('tasks cleanup failed');
67+
});
68+
69+
await expect(OrgCrudService.remove(organization)).rejects.toThrow('tasks cleanup failed');
70+
expect(mockOrgRemove).not.toHaveBeenCalled();
71+
});
72+
73+
test('with zero handlers registered, removes the organization without throwing', async () => {
74+
await expect(OrgCrudService.remove(organization)).resolves.toEqual({ acknowledged: true });
75+
expect(mockOrgRemove).toHaveBeenCalledWith(organization);
76+
});
77+
});
Lines changed: 67 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,67 @@
1+
/**
2+
* Unit tests for the organization-removal subscriber registry.
3+
*/
4+
import { describe, test, expect, beforeEach, jest } from '@jest/globals';
5+
6+
import { onOrganizationRemoved, runOrganizationRemovedHandlers, _reset } from '../lib/orgRemoval.registry.js';
7+
8+
describe('orgRemoval.registry', () => {
9+
beforeEach(() => {
10+
_reset();
11+
});
12+
13+
test('runs a registered handler with the payload', async () => {
14+
const handler = jest.fn().mockResolvedValue(undefined);
15+
onOrganizationRemoved(handler);
16+
17+
const payload = { organizationId: 'org-1', organization: { _id: 'org-1' } };
18+
await runOrganizationRemovedHandlers(payload);
19+
20+
expect(handler).toHaveBeenCalledTimes(1);
21+
expect(handler).toHaveBeenCalledWith(payload);
22+
});
23+
24+
test('runs multiple handlers sequentially in registration order', async () => {
25+
const order = [];
26+
onOrganizationRemoved(async () => {
27+
order.push('first');
28+
});
29+
onOrganizationRemoved(async () => {
30+
order.push('second');
31+
});
32+
33+
await runOrganizationRemovedHandlers({ organizationId: 'org-1' });
34+
35+
expect(order).toEqual(['first', 'second']);
36+
});
37+
38+
test('propagates a handler error (does not swallow) and aborts the remaining handlers', async () => {
39+
const boom = new Error('cleanup failed');
40+
const after = jest.fn().mockResolvedValue(undefined);
41+
onOrganizationRemoved(async () => {
42+
throw boom;
43+
});
44+
onOrganizationRemoved(after);
45+
46+
await expect(runOrganizationRemovedHandlers({ organizationId: 'org-1' })).rejects.toThrow('cleanup failed');
47+
expect(after).not.toHaveBeenCalled();
48+
});
49+
50+
test('runs zero handlers without throwing when none are registered', async () => {
51+
await expect(runOrganizationRemovedHandlers({ organizationId: 'org-1' })).resolves.toBeUndefined();
52+
});
53+
54+
test('rejects a non-function registration', () => {
55+
expect(() => onOrganizationRemoved('not-a-fn')).toThrow(TypeError);
56+
});
57+
58+
test('_reset clears registered handlers', async () => {
59+
const handler = jest.fn().mockResolvedValue(undefined);
60+
onOrganizationRemoved(handler);
61+
_reset();
62+
63+
await runOrganizationRemovedHandlers({ organizationId: 'org-1' });
64+
65+
expect(handler).not.toHaveBeenCalled();
66+
});
67+
});

modules/tasks/tasks.init.js

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
/**
2+
* @module tasks/tasks.init
3+
* @description Boot-time wiring for the tasks module. Auto-discovered by the
4+
* modules glob and invoked by initModulesConfiguration at startup.
5+
* Registers an org-removal cleanup handler so org-scoped tasks are deleted
6+
* when an organization is removed.
7+
*/
8+
import TasksService from './services/tasks.service.js';
9+
import { onOrganizationRemoved } from '../organizations/lib/orgRemoval.registry.js';
10+
11+
/**
12+
* Tasks module initialisation.
13+
* Registers an org-removal cleanup handler so all tasks scoped to an
14+
* organization are deleted when that organization is removed.
15+
*
16+
* @returns {Promise<void>}
17+
*/
18+
export default async () => {
19+
onOrganizationRemoved(({ organizationId }) => TasksService.deleteMany({ organizationId }));
20+
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/**
2+
* Unit tests — tasks.init registers an org-removal cleanup handler that delegates to TasksService.deleteMany.
3+
*/
4+
import { jest, describe, test, expect, beforeEach } from '@jest/globals';
5+
6+
const mockOnOrganizationRemoved = jest.fn();
7+
const mockDeleteMany = jest.fn().mockResolvedValue({ deletedCount: 0 });
8+
9+
jest.unstable_mockModule('../../organizations/lib/orgRemoval.registry.js', () => ({
10+
onOrganizationRemoved: mockOnOrganizationRemoved,
11+
}));
12+
13+
jest.unstable_mockModule('../services/tasks.service.js', () => ({
14+
default: { deleteMany: mockDeleteMany },
15+
}));
16+
17+
const { default: tasksInit } = await import('../tasks.init.js');
18+
19+
describe('tasks.init', () => {
20+
beforeEach(() => {
21+
jest.clearAllMocks();
22+
});
23+
24+
test('registers a single org-removal handler at boot', async () => {
25+
await tasksInit({});
26+
expect(mockOnOrganizationRemoved).toHaveBeenCalledTimes(1);
27+
expect(typeof mockOnOrganizationRemoved.mock.calls[0][0]).toBe('function');
28+
});
29+
30+
test('the registered handler delegates to TasksService.deleteMany scoped by organizationId', async () => {
31+
await tasksInit({});
32+
const handler = mockOnOrganizationRemoved.mock.calls[0][0];
33+
34+
await handler({ organizationId: 'org-1', organization: { _id: 'org-1' } });
35+
36+
expect(mockDeleteMany).toHaveBeenCalledWith({ organizationId: 'org-1' });
37+
});
38+
39+
test('importing tasks.init does not itself register — registration only happens on invocation (boot)', async () => {
40+
// Module import alone must not register; only the default export (run at boot) does.
41+
expect(mockOnOrganizationRemoved).not.toHaveBeenCalled();
42+
await tasksInit({});
43+
expect(mockOnOrganizationRemoved).toHaveBeenCalledTimes(1);
44+
});
45+
});

0 commit comments

Comments
 (0)