feat(twenty-orm): introduce WorkspaceScopedRepository for core/metadata workspace-scoped entities (#20953)
## Summary Adds a third tenancy enforcement layer for entities that live in shared schemas (`core`, `metadata`) and carry a `workspaceId` column — previously the only safeguard at this layer was developer discipline (remembering to put `workspaceId` in every WHERE clause). ### The three layers, after this PR | Layer | Scope | How it's enforced | |---|---|---| | 1. Workspace data | per-workspace schema (companies, people, custom objects) | `twentyORMManager.getRepository(workspace, E)` — physical isolation (own data source) | | 2. Metadata | shared `metadata` schema (objectMetadata, fieldMetadata, views, roles…) | Flat-entity-maps cache — workspace-scoped in-memory map, lookups by id within it | | 3. Core (new) | shared `core` schema (agent threads/turns/messages, app tokens, etc.) | `WorkspaceScopedRepository<T>` — `workspaceId` is a required positional argument on every read/write | ## What's in the PR ### The wrapper (`packages/twenty-server/src/engine/twenty-orm/workspace-scoped-repository/`) - `WorkspaceScopedRepository<T extends WorkspaceScopedEntity>` — wraps a TypeORM `Repository<T>`, requires `workspaceId` on every `find`/`findOne`/`findOneOrFail`/`update`/`delete`/`softDelete`/`insert`/`save`/`count` call, merging it into the WHERE or stamping it on the entity. `createQueryBuilder` is an explicit escape hatch (caller scopes manually). - Provided via Nest DI with `@InjectWorkspaceScopedRepository(EntityClass)` and the `provideWorkspaceScopedRepository(EntityClass)` provider factory. - 19 unit tests cover the merge behavior, override-on-conflict, and the array-where (OR) case. ### Lint enforcement (`packages/twenty-oxlint-rules/rules/prefer-workspace-scoped-repository.ts`) - New `twenty/prefer-workspace-scoped-repository` rule (level: **error**). - Blacklist of entity names: raw `@InjectRepository(E)` is rejected if `E` is on the list. - Initial list: `AgentTurnEntity`, `AgentMessageEntity`, `AgentMessagePartEntity`, `AgentChatThreadEntity`, `AgentTurnEvaluationEntity`, `AgentEntity`. - Designed to grow over time as more consumers are migrated. - 5 rule tests. ### Migration in this PR All consumers of the six blacklisted entities, including: - AI agent / chat / monitor resolvers, services, and jobs - `AgentService`, `AiAgentRoleService`, `AiAgentWorkflowAction`, `ApplicationService`, `WorkspaceFlatAgentMapCacheService` - Admin-panel chat (migrated where the lookup is workspace-known; one documented `eslint-disable` on the threadId-discovery lookup that necessarily precedes the `allowImpersonation` permission check) - `AiAgentRoleService` unit spec updated to mock the scoped wrapper ## Future work (deliberately not in this PR) A standalone audit identified ~14 additional `core`/`metadata` entities with `workspaceId` that currently use raw `@InjectRepository` and could be added to the blacklist. Notable candidates: `UserWorkspaceEntity` (42 sites), `AppTokenEntity` (10), `FileEntity` (7), `BillingCustomerEntity`/`BillingSubscriptionEntity` (~22 combined). Each should be its own PR — the migration is mechanical but the surface is wide. ## Test plan - [x] `npx nx typecheck twenty-server` — clean - [x] `npx nx lint twenty-server` — 0 warnings, 0 errors - [x] `npx jest workspace-scoped-repository` — 19/19 pass - [x] `npx nx test twenty-oxlint-rules` — 215/215 pass - [x] `npx jest src/engine/metadata-modules/ai` — 44/44 pass - [ ] Manual smoke: end-to-end AI agent chat send/receive (reviewer) - [ ] Manual smoke: AI agent monitor — list turns, run evaluation (reviewer) - [ ] Manual smoke: admin-panel chat thread inspection (reviewer)
This commit is contained in:
+351
@@ -0,0 +1,351 @@
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
type FakeEntity = {
|
||||
id: string;
|
||||
status: string;
|
||||
workspaceId: string;
|
||||
[key: string]: unknown;
|
||||
};
|
||||
|
||||
const WORKSPACE_ID = 'workspace-1';
|
||||
const OTHER_WORKSPACE_ID = 'workspace-2';
|
||||
|
||||
const createMockRepository = (): jest.Mocked<Repository<FakeEntity>> =>
|
||||
({
|
||||
findOne: jest.fn().mockResolvedValue(null),
|
||||
findOneOrFail: jest.fn(),
|
||||
find: jest.fn().mockResolvedValue([]),
|
||||
count: jest.fn().mockResolvedValue(0),
|
||||
update: jest.fn(),
|
||||
delete: jest.fn(),
|
||||
softDelete: jest.fn(),
|
||||
insert: jest.fn(),
|
||||
upsert: jest.fn(),
|
||||
save: jest.fn(),
|
||||
createQueryBuilder: jest.fn(),
|
||||
}) as unknown as jest.Mocked<Repository<FakeEntity>>;
|
||||
|
||||
describe('WorkspaceScopedRepository', () => {
|
||||
let repository: jest.Mocked<Repository<FakeEntity>>;
|
||||
let scoped: WorkspaceScopedRepository<FakeEntity>;
|
||||
|
||||
beforeEach(() => {
|
||||
repository = createMockRepository();
|
||||
scoped = new WorkspaceScopedRepository(repository);
|
||||
});
|
||||
|
||||
describe('workspaceId guard', () => {
|
||||
// TypeORM drops `undefined` values from WHERE/criteria, so a
|
||||
// missing workspaceId would otherwise produce an unscoped query.
|
||||
// Each public method must trip before reaching the repository.
|
||||
it.each([
|
||||
['findOne', () => scoped.findOne(undefined as never, { where: {} })],
|
||||
[
|
||||
'findOneOrFail',
|
||||
() => scoped.findOneOrFail(undefined as never, { where: {} }),
|
||||
],
|
||||
['find', () => scoped.find(undefined as never)],
|
||||
['count', () => scoped.count(undefined as never)],
|
||||
['update', () => scoped.update(undefined as never, {}, {})],
|
||||
['delete', () => scoped.delete(undefined as never, {})],
|
||||
['softDelete', () => scoped.softDelete(undefined as never, {})],
|
||||
['insert', () => scoped.insert(undefined as never, {})],
|
||||
['upsert', () => scoped.upsert(undefined as never, {}, ['id'])],
|
||||
['save', () => scoped.save(undefined as never, {})],
|
||||
['saveMany', () => scoped.saveMany(undefined as never, [{}])],
|
||||
])('%s throws when workspaceId is undefined', (_name, call) => {
|
||||
expect(call).toThrow(/workspaceId must be a non-empty string/);
|
||||
});
|
||||
|
||||
it.each([null, ''])('throws when workspaceId is %p', (badWorkspaceId) => {
|
||||
expect(() =>
|
||||
scoped.findOne(badWorkspaceId as never, { where: {} }),
|
||||
).toThrow(/workspaceId must be a non-empty string/);
|
||||
});
|
||||
});
|
||||
|
||||
describe('findOne', () => {
|
||||
it('merges workspaceId into a plain where clause', async () => {
|
||||
await scoped.findOne(WORKSPACE_ID, { where: { id: 'a' } });
|
||||
|
||||
expect(repository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'a', workspaceId: WORKSPACE_ID },
|
||||
});
|
||||
});
|
||||
|
||||
it('merges workspaceId into every clause of an OR (array) where', async () => {
|
||||
await scoped.findOne(WORKSPACE_ID, {
|
||||
where: [{ id: 'a' }, { status: 'queued' }],
|
||||
});
|
||||
|
||||
expect(repository.findOne).toHaveBeenCalledWith({
|
||||
where: [
|
||||
{ id: 'a', workspaceId: WORKSPACE_ID },
|
||||
{ status: 'queued', workspaceId: WORKSPACE_ID },
|
||||
],
|
||||
});
|
||||
});
|
||||
|
||||
it('throws if the caller includes workspaceId in the WHERE clause', () => {
|
||||
expect(() =>
|
||||
scoped.findOne(WORKSPACE_ID, {
|
||||
where: { id: 'a', workspaceId: OTHER_WORKSPACE_ID } as never,
|
||||
}),
|
||||
).toThrow(/do not include `workspaceId`/);
|
||||
|
||||
expect(repository.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('throws if any clause of an array WHERE includes workspaceId', () => {
|
||||
expect(() =>
|
||||
scoped.findOne(WORKSPACE_ID, {
|
||||
where: [
|
||||
{ id: 'a' },
|
||||
{ id: 'b', workspaceId: OTHER_WORKSPACE_ID } as never,
|
||||
],
|
||||
}),
|
||||
).toThrow(/do not include `workspaceId`/);
|
||||
});
|
||||
|
||||
it('places workspaceId first in the merged WHERE clause', async () => {
|
||||
await scoped.findOne(WORKSPACE_ID, {
|
||||
where: { id: 'a', status: 'queued' },
|
||||
});
|
||||
|
||||
const callArg = repository.findOne.mock.calls[0][0];
|
||||
const whereKeys = Object.keys(
|
||||
(callArg as { where: Record<string, unknown> }).where,
|
||||
);
|
||||
|
||||
expect(whereKeys[0]).toBe('workspaceId');
|
||||
});
|
||||
|
||||
it('preserves relations and other options', async () => {
|
||||
await scoped.findOne(WORKSPACE_ID, {
|
||||
where: { id: 'a' },
|
||||
relations: ['messages'],
|
||||
select: ['id'],
|
||||
});
|
||||
|
||||
expect(repository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'a', workspaceId: WORKSPACE_ID },
|
||||
relations: ['messages'],
|
||||
select: ['id'],
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('find', () => {
|
||||
it('adds workspaceId when no where is provided', async () => {
|
||||
await scoped.find(WORKSPACE_ID);
|
||||
|
||||
expect(repository.find).toHaveBeenCalledWith({
|
||||
where: { workspaceId: WORKSPACE_ID },
|
||||
});
|
||||
});
|
||||
|
||||
it('merges workspaceId into provided where', async () => {
|
||||
await scoped.find(WORKSPACE_ID, { where: { status: 'queued' } });
|
||||
|
||||
expect(repository.find).toHaveBeenCalledWith({
|
||||
where: { status: 'queued', workspaceId: WORKSPACE_ID },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('update', () => {
|
||||
it('merges workspaceId into the criteria, not the patch', async () => {
|
||||
await scoped.update(WORKSPACE_ID, { id: 'a' }, { status: 'completed' });
|
||||
|
||||
expect(repository.update).toHaveBeenCalledWith(
|
||||
{ id: 'a', workspaceId: WORKSPACE_ID },
|
||||
{ status: 'completed' },
|
||||
);
|
||||
});
|
||||
|
||||
it('throws if the caller includes workspaceId in the criteria', () => {
|
||||
expect(() =>
|
||||
scoped.update(
|
||||
WORKSPACE_ID,
|
||||
{ id: 'a', workspaceId: OTHER_WORKSPACE_ID } as never,
|
||||
{ status: 'completed' },
|
||||
),
|
||||
).toThrow(/do not include `workspaceId`/);
|
||||
|
||||
expect(repository.update).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('delete and softDelete', () => {
|
||||
it('delete merges workspaceId into criteria', async () => {
|
||||
await scoped.delete(WORKSPACE_ID, { id: 'a' });
|
||||
|
||||
expect(repository.delete).toHaveBeenCalledWith({
|
||||
id: 'a',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('softDelete merges workspaceId into criteria', async () => {
|
||||
await scoped.softDelete(WORKSPACE_ID, { id: 'a' });
|
||||
|
||||
expect(repository.softDelete).toHaveBeenCalledWith({
|
||||
id: 'a',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('insert', () => {
|
||||
it('stamps workspaceId on a single entity', async () => {
|
||||
await scoped.insert(WORKSPACE_ID, { id: 'a', status: 'queued' });
|
||||
|
||||
expect(repository.insert).toHaveBeenCalledWith({
|
||||
id: 'a',
|
||||
status: 'queued',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
});
|
||||
|
||||
it('stamps workspaceId on each entity in an array', async () => {
|
||||
await scoped.insert(WORKSPACE_ID, [
|
||||
{ id: 'a', status: 'queued' },
|
||||
{ id: 'b', status: 'sent' },
|
||||
]);
|
||||
|
||||
expect(repository.insert).toHaveBeenCalledWith([
|
||||
{ id: 'a', status: 'queued', workspaceId: WORKSPACE_ID },
|
||||
{ id: 'b', status: 'sent', workspaceId: WORKSPACE_ID },
|
||||
]);
|
||||
});
|
||||
|
||||
it('overrides caller-supplied workspaceId on the entity', async () => {
|
||||
await scoped.insert(WORKSPACE_ID, {
|
||||
id: 'a',
|
||||
workspaceId: OTHER_WORKSPACE_ID,
|
||||
});
|
||||
|
||||
expect(repository.insert).toHaveBeenCalledWith({
|
||||
id: 'a',
|
||||
workspaceId: WORKSPACE_ID,
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('upsert', () => {
|
||||
it('stamps workspaceId on a single entity and forwards conflict opts', async () => {
|
||||
await scoped.upsert(WORKSPACE_ID, { id: 'a', status: 'queued' }, ['id']);
|
||||
|
||||
expect(repository.upsert).toHaveBeenCalledWith(
|
||||
{ id: 'a', status: 'queued', workspaceId: WORKSPACE_ID },
|
||||
['id'],
|
||||
);
|
||||
});
|
||||
|
||||
it('stamps workspaceId on each entity in an array', async () => {
|
||||
await scoped.upsert(
|
||||
WORKSPACE_ID,
|
||||
[
|
||||
{ id: 'a', status: 'queued' },
|
||||
{ id: 'b', status: 'sent' },
|
||||
],
|
||||
{ conflictPaths: ['id'] },
|
||||
);
|
||||
|
||||
expect(repository.upsert).toHaveBeenCalledWith(
|
||||
[
|
||||
{ id: 'a', status: 'queued', workspaceId: WORKSPACE_ID },
|
||||
{ id: 'b', status: 'sent', workspaceId: WORKSPACE_ID },
|
||||
],
|
||||
{ conflictPaths: ['id'] },
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('save', () => {
|
||||
it('stamps workspaceId on the entity passed to save', async () => {
|
||||
await scoped.save(WORKSPACE_ID, { id: 'a', status: 'queued' });
|
||||
|
||||
expect(repository.save).toHaveBeenCalledWith(
|
||||
{ id: 'a', status: 'queued', workspaceId: WORKSPACE_ID },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('saveMany stamps workspaceId on each entity', async () => {
|
||||
await scoped.saveMany(WORKSPACE_ID, [
|
||||
{ id: 'a', status: 'queued' },
|
||||
{ id: 'b', status: 'sent' },
|
||||
]);
|
||||
|
||||
expect(repository.save).toHaveBeenCalledWith(
|
||||
[
|
||||
{ id: 'a', status: 'queued', workspaceId: WORKSPACE_ID },
|
||||
{ id: 'b', status: 'sent', workspaceId: WORKSPACE_ID },
|
||||
],
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
it('overrides caller-supplied workspaceId on the entity', async () => {
|
||||
await scoped.save(WORKSPACE_ID, {
|
||||
id: 'a',
|
||||
workspaceId: OTHER_WORKSPACE_ID,
|
||||
});
|
||||
|
||||
expect(repository.save).toHaveBeenCalledWith(
|
||||
{ id: 'a', workspaceId: WORKSPACE_ID },
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe('count', () => {
|
||||
it('merges workspaceId into where', async () => {
|
||||
await scoped.count(WORKSPACE_ID, { where: { status: 'queued' } });
|
||||
|
||||
expect(repository.count).toHaveBeenCalledWith({
|
||||
where: { status: 'queued', workspaceId: WORKSPACE_ID },
|
||||
});
|
||||
});
|
||||
|
||||
it('adds workspaceId when no options are provided', async () => {
|
||||
await scoped.count(WORKSPACE_ID);
|
||||
|
||||
expect(repository.count).toHaveBeenCalledWith({
|
||||
where: { workspaceId: WORKSPACE_ID },
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe('createQueryBuilder', () => {
|
||||
it('returns the underlying QueryBuilder unchanged (escape hatch)', () => {
|
||||
scoped.createQueryBuilder('t');
|
||||
|
||||
expect(repository.createQueryBuilder).toHaveBeenCalledWith('t');
|
||||
});
|
||||
});
|
||||
|
||||
describe('withManager', () => {
|
||||
it('returns a new wrapper bound to the manager-provided repository', async () => {
|
||||
const txRepository = createMockRepository();
|
||||
const manager = {
|
||||
getRepository: jest.fn().mockReturnValue(txRepository),
|
||||
} as unknown as import('typeorm').EntityManager;
|
||||
(repository as unknown as { target: unknown }).target = 'FakeEntity';
|
||||
|
||||
const tx = scoped.withManager(manager);
|
||||
|
||||
expect(tx).not.toBe(scoped);
|
||||
expect(manager.getRepository).toHaveBeenCalledWith('FakeEntity');
|
||||
|
||||
await tx.findOne(WORKSPACE_ID, { where: { id: 'a' } });
|
||||
|
||||
expect(txRepository.findOne).toHaveBeenCalledWith({
|
||||
where: { id: 'a', workspaceId: WORKSPACE_ID },
|
||||
});
|
||||
expect(repository.findOne).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
import { type EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type';
|
||||
|
||||
const getEntityName = (entity: EntityClassOrSchema): string => {
|
||||
if (typeof entity === 'function') {
|
||||
return entity.name;
|
||||
}
|
||||
|
||||
return entity.options?.name ?? entity.constructor.name;
|
||||
};
|
||||
|
||||
export const getWorkspaceScopedRepositoryToken = (
|
||||
entity: EntityClassOrSchema,
|
||||
): string => `WorkspaceScopedRepository<${getEntityName(entity)}>`;
|
||||
+9
@@ -0,0 +1,9 @@
|
||||
import { Inject } from '@nestjs/common';
|
||||
|
||||
import { type EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type';
|
||||
|
||||
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
|
||||
|
||||
export const InjectWorkspaceScopedRepository = (
|
||||
entity: EntityClassOrSchema,
|
||||
): ParameterDecorator => Inject(getWorkspaceScopedRepositoryToken(entity));
|
||||
+19
@@ -0,0 +1,19 @@
|
||||
import { type Provider } from '@nestjs/common';
|
||||
import { getRepositoryToken } from '@nestjs/typeorm';
|
||||
|
||||
import { type EntityClassOrSchema } from '@nestjs/typeorm/dist/interfaces/entity-class-or-schema.type';
|
||||
import { type Repository } from 'typeorm';
|
||||
|
||||
import { getWorkspaceScopedRepositoryToken } from 'src/engine/twenty-orm/workspace-scoped-repository/get-workspace-scoped-repository-token.util';
|
||||
import { type WorkspaceScopedEntity } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-entity.type';
|
||||
import { WorkspaceScopedRepository } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-repository';
|
||||
|
||||
// Requires TypeOrmModule.forFeature([entity]) in the same module.
|
||||
export const provideWorkspaceScopedRepository = (
|
||||
entity: EntityClassOrSchema,
|
||||
): Provider => ({
|
||||
provide: getWorkspaceScopedRepositoryToken(entity),
|
||||
useFactory: <T extends WorkspaceScopedEntity>(repository: Repository<T>) =>
|
||||
new WorkspaceScopedRepository<T>(repository),
|
||||
inject: [getRepositoryToken(entity)],
|
||||
});
|
||||
+3
@@ -0,0 +1,3 @@
|
||||
import { type ObjectLiteral } from 'typeorm';
|
||||
|
||||
export type WorkspaceScopedEntity = ObjectLiteral & { workspaceId: string };
|
||||
+217
@@ -0,0 +1,217 @@
|
||||
import {
|
||||
type DeepPartial,
|
||||
type DeleteResult,
|
||||
type EntityManager,
|
||||
type FindManyOptions,
|
||||
type FindOneOptions,
|
||||
type FindOptionsWhere,
|
||||
type InsertResult,
|
||||
type Repository,
|
||||
type SaveOptions,
|
||||
type SelectQueryBuilder,
|
||||
type UpdateResult,
|
||||
} from 'typeorm';
|
||||
import { type QueryDeepPartialEntity } from 'typeorm/query-builder/QueryPartialEntity';
|
||||
import { type UpsertOptions } from 'typeorm/repository/UpsertOptions';
|
||||
|
||||
import { type WorkspaceScopedEntity } from 'src/engine/twenty-orm/workspace-scoped-repository/workspace-scoped-entity.type';
|
||||
|
||||
// Wraps a TypeORM Repository to scope every operation by workspaceId.
|
||||
// For workspace-data entities, use WorkspaceRepository instead.
|
||||
export class WorkspaceScopedRepository<T extends WorkspaceScopedEntity> {
|
||||
constructor(private readonly repository: Repository<T>) {}
|
||||
|
||||
findOne(workspaceId: string, options: FindOneOptions<T>): Promise<T | null> {
|
||||
this.assertWorkspaceId(workspaceId);
|
||||
|
||||
return this.repository.findOne({
|
||||
...options,
|
||||
where: this.mergeWorkspaceIdIntoWhere(workspaceId, options.where),
|
||||
});
|
||||
}
|
||||
|
||||
findOneOrFail(workspaceId: string, options: FindOneOptions<T>): Promise<T> {
|
||||
this.assertWorkspaceId(workspaceId);
|
||||
|
||||
return this.repository.findOneOrFail({
|
||||
...options,
|
||||
where: this.mergeWorkspaceIdIntoWhere(workspaceId, options.where),
|
||||
});
|
||||
}
|
||||
|
||||
find(workspaceId: string, options?: FindManyOptions<T>): Promise<T[]> {
|
||||
this.assertWorkspaceId(workspaceId);
|
||||
|
||||
return this.repository.find({
|
||||
...options,
|
||||
where: this.mergeWorkspaceIdIntoWhere(workspaceId, options?.where),
|
||||
});
|
||||
}
|
||||
|
||||
count(workspaceId: string, options?: FindManyOptions<T>): Promise<number> {
|
||||
this.assertWorkspaceId(workspaceId);
|
||||
|
||||
return this.repository.count({
|
||||
...options,
|
||||
where: this.mergeWorkspaceIdIntoWhere(workspaceId, options?.where),
|
||||
});
|
||||
}
|
||||
|
||||
update(
|
||||
workspaceId: string,
|
||||
criteria: FindOptionsWhere<T>,
|
||||
partialEntity: QueryDeepPartialEntity<T>,
|
||||
): Promise<UpdateResult> {
|
||||
this.assertWorkspaceId(workspaceId);
|
||||
|
||||
return this.repository.update(
|
||||
this.mergeWorkspaceIdIntoCriteria(workspaceId, criteria),
|
||||
partialEntity,
|
||||
);
|
||||
}
|
||||
|
||||
delete(
|
||||
workspaceId: string,
|
||||
criteria: FindOptionsWhere<T>,
|
||||
): Promise<DeleteResult> {
|
||||
this.assertWorkspaceId(workspaceId);
|
||||
|
||||
return this.repository.delete(
|
||||
this.mergeWorkspaceIdIntoCriteria(workspaceId, criteria),
|
||||
);
|
||||
}
|
||||
|
||||
softDelete(
|
||||
workspaceId: string,
|
||||
criteria: FindOptionsWhere<T>,
|
||||
): Promise<UpdateResult> {
|
||||
this.assertWorkspaceId(workspaceId);
|
||||
|
||||
return this.repository.softDelete(
|
||||
this.mergeWorkspaceIdIntoCriteria(workspaceId, criteria),
|
||||
);
|
||||
}
|
||||
|
||||
insert(
|
||||
workspaceId: string,
|
||||
entity: QueryDeepPartialEntity<T> | QueryDeepPartialEntity<T>[],
|
||||
): Promise<InsertResult> {
|
||||
this.assertWorkspaceId(workspaceId);
|
||||
|
||||
return this.repository.insert(
|
||||
this.stampWorkspaceIdOnEntities(workspaceId, entity),
|
||||
);
|
||||
}
|
||||
|
||||
upsert(
|
||||
workspaceId: string,
|
||||
entity: QueryDeepPartialEntity<T> | QueryDeepPartialEntity<T>[],
|
||||
conflictPathsOrOptions: string[] | UpsertOptions<T>,
|
||||
): Promise<InsertResult> {
|
||||
this.assertWorkspaceId(workspaceId);
|
||||
|
||||
return this.repository.upsert(
|
||||
this.stampWorkspaceIdOnEntities(workspaceId, entity),
|
||||
conflictPathsOrOptions,
|
||||
);
|
||||
}
|
||||
|
||||
save<E extends DeepPartial<T>>(
|
||||
workspaceId: string,
|
||||
entity: E,
|
||||
options?: SaveOptions,
|
||||
): Promise<E & T> {
|
||||
this.assertWorkspaceId(workspaceId);
|
||||
|
||||
return this.repository.save({ ...entity, workspaceId } as E, options);
|
||||
}
|
||||
|
||||
saveMany<E extends DeepPartial<T>>(
|
||||
workspaceId: string,
|
||||
entities: E[],
|
||||
options?: SaveOptions,
|
||||
): Promise<(E & T)[]> {
|
||||
this.assertWorkspaceId(workspaceId);
|
||||
|
||||
return this.repository.save(
|
||||
entities.map((entity) => ({ ...entity, workspaceId }) as E),
|
||||
options,
|
||||
);
|
||||
}
|
||||
|
||||
// Escape hatch. Caller MUST add the workspaceId predicate themselves.
|
||||
createQueryBuilder(alias?: string): SelectQueryBuilder<T> {
|
||||
return this.repository.createQueryBuilder(alias);
|
||||
}
|
||||
|
||||
// Returns a wrapper bound to the given EntityManager (transactions).
|
||||
withManager(manager: EntityManager): WorkspaceScopedRepository<T> {
|
||||
return new WorkspaceScopedRepository<T>(
|
||||
manager.getRepository(this.repository.target),
|
||||
);
|
||||
}
|
||||
|
||||
// TypeORM drops `undefined` values from WHERE, which would emit an
|
||||
// unscoped query. Reject falsy workspaceId at the boundary.
|
||||
private assertWorkspaceId(workspaceId: string): void {
|
||||
if (
|
||||
workspaceId === undefined ||
|
||||
workspaceId === null ||
|
||||
workspaceId === ''
|
||||
) {
|
||||
throw new Error(
|
||||
'WorkspaceScopedRepository: workspaceId must be a non-empty string.',
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private mergeWorkspaceIdIntoWhere(
|
||||
workspaceId: string,
|
||||
where: FindOneOptions<T>['where'] | undefined,
|
||||
): FindOptionsWhere<T> | FindOptionsWhere<T>[] {
|
||||
if (where === undefined) {
|
||||
return { workspaceId } as FindOptionsWhere<T>;
|
||||
}
|
||||
|
||||
if (Array.isArray(where)) {
|
||||
return where.map((clause) =>
|
||||
this.prependWorkspaceId(workspaceId, clause),
|
||||
);
|
||||
}
|
||||
|
||||
return this.prependWorkspaceId(workspaceId, where as FindOptionsWhere<T>);
|
||||
}
|
||||
|
||||
private mergeWorkspaceIdIntoCriteria(
|
||||
workspaceId: string,
|
||||
criteria: FindOptionsWhere<T>,
|
||||
): FindOptionsWhere<T> {
|
||||
return this.prependWorkspaceId(workspaceId, criteria);
|
||||
}
|
||||
|
||||
private prependWorkspaceId(
|
||||
workspaceId: string,
|
||||
clause: FindOptionsWhere<T>,
|
||||
): FindOptionsWhere<T> {
|
||||
if ('workspaceId' in clause) {
|
||||
throw new Error(
|
||||
'WorkspaceScopedRepository: do not include `workspaceId` in the WHERE clause — it is provided as the first argument and merged automatically.',
|
||||
);
|
||||
}
|
||||
|
||||
return { workspaceId, ...clause } as FindOptionsWhere<T>;
|
||||
}
|
||||
|
||||
private stampWorkspaceIdOnEntities(
|
||||
workspaceId: string,
|
||||
entity: QueryDeepPartialEntity<T> | QueryDeepPartialEntity<T>[],
|
||||
): QueryDeepPartialEntity<T> | QueryDeepPartialEntity<T>[] {
|
||||
if (Array.isArray(entity)) {
|
||||
return entity.map(
|
||||
(item) => ({ ...item, workspaceId }) as QueryDeepPartialEntity<T>,
|
||||
);
|
||||
}
|
||||
|
||||
return { ...entity, workspaceId } as QueryDeepPartialEntity<T>;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user