Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -211,6 +211,7 @@ export class EventEmitterService {
private createExtendPlainContext(docId: string, id: string) {
const user = this.cls.get('user');
const entry = this.cls.get('entry');
const collaboratorNotificationFieldNames = this.cls.get('collaboratorNotificationFieldNames');
return {
baseId: docId,
tableId: id.startsWith(IdPrefix.Table) ? id : docId,
Expand All @@ -220,6 +221,7 @@ export class EventEmitterService {
context: {
user,
entry,
collaboratorNotificationFieldNames,
},
};
}
Expand Down
1 change: 1 addition & 0 deletions apps/nestjs-backend/src/event-emitter/events/core-event.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface IEventContext {
name: OpName;
propertyKey?: string;
};
collaboratorNotificationFieldNames?: string[];
}

export interface IEventRawContext {
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,94 @@
import { describe, expect, it, vi } from 'vitest';
import { Events } from '../events';
import { CollaboratorNotificationListener } from './collaborator-notification.listener';

type ITestableListener = {
listener: (event: unknown) => Promise<void>;
fetchUserFields: (tableId: string) => Promise<unknown[]>;
};

const createListener = () => {
const notificationService = {
sendCollaboratorNotify: vi.fn(),
};
const recordService = {
getRecordsHeadWithIds: vi.fn().mockResolvedValue([]),
};
const listener = new CollaboratorNotificationListener(
{} as never,
notificationService as never,
recordService as never,
{} as never
);
const testableListener = listener as unknown as ITestableListener;
vi.spyOn(testableListener, 'fetchUserFields').mockResolvedValue([
{
baseId: 'bseTest',
tableName: '动画表',
fieldId: 'fldAnimation',
fieldName: '动画人员',
fieldOptions: JSON.stringify({ shouldNotify: true }),
},
{
baseId: 'bseTest',
tableName: '动画表',
fieldId: 'fldInspection',
fieldName: '动检人员',
fieldOptions: JSON.stringify({ shouldNotify: true }),
},
{
baseId: 'bseTest',
tableName: '动画表',
fieldId: 'fldColor',
fieldName: '上色人员',
fieldOptions: JSON.stringify({ shouldNotify: true }),
},
]);
return { listener: testableListener, notificationService };
};

const createEvent = (allowedFieldNames?: string[]) => ({
name: Events.TABLE_RECORD_CREATE,
context: {
user: { id: 'usrOwner', name: 'Owner', email: 'owner@test.com' },
collaboratorNotificationFieldNames: allowedFieldNames,
},
payload: {
tableId: 'tblTest',
record: {
id: 'recSplit',
fields: {
fldAnimation: { id: 'usrAnimator', title: 'Animator' },
fldInspection: { id: 'usrInspector', title: 'Inspector' },
fldColor: { id: 'usrColorist', title: 'Colorist' },
},
},
},
});

describe('CollaboratorNotificationListener', () => {
it('suppresses all copied user fields when the create whitelist is empty', async () => {
const { listener, notificationService } = createListener();

await listener.listener(createEvent([]));

expect(notificationService.sendCollaboratorNotify).not.toHaveBeenCalled();
});

it('notifies only whitelisted user fields for a split record create', async () => {
const { listener, notificationService } = createListener();

await listener.listener(createEvent(['动画人员']));

expect(notificationService.sendCollaboratorNotify).toHaveBeenCalledTimes(1);
expect(notificationService.sendCollaboratorNotify).toHaveBeenCalledWith(
expect.objectContaining({
toUserId: 'usrAnimator',
refRecord: expect.objectContaining({ fieldName: '动画人员', recordIds: ['recSplit'] }),
})
);
expect(notificationService.sendCollaboratorNotify).not.toHaveBeenCalledWith(
expect.objectContaining({ toUserId: 'usrColorist' })
);
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -40,7 +40,14 @@ export class CollaboratorNotificationListener {
private async listener(listenerEvent: IListenerEvent): Promise<void> {
const { tableId, record } = listenerEvent.payload;

const userFieldData = await this.fetchUserFields(tableId);
let userFieldData = await this.fetchUserFields(tableId);
if (
listenerEvent.name === Events.TABLE_RECORD_CREATE &&
listenerEvent.context.collaboratorNotificationFieldNames
) {
const allowedNames = new Set(listenerEvent.context.collaboratorNotificationFieldNames);
userFieldData = userFieldData.filter((field) => allowedNames.has(field.fieldName));
}
if (isEmpty(userFieldData)) {
return;
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { Module } from '@nestjs/common';
import { FieldOpenApiModule } from '../field/open-api/field-open-api.module';
import { NotificationModule } from '../notification/notification.module';
import { RecordOpenApiModule } from '../record/open-api/record-open-api.module';
import { RecordModule } from '../record/record.module';
import { TableOpenApiModule } from '../table/open-api/table-open-api.module';
Expand All @@ -10,7 +11,13 @@ import { WorkOrderAssignmentNotificationController } from './work-order-assignme
import { WorkOrderAssignmentNotificationService } from './work-order-assignment-notification.service';

@Module({
imports: [FieldOpenApiModule, RecordModule, RecordOpenApiModule, TableOpenApiModule],
imports: [
FieldOpenApiModule,
NotificationModule,
RecordModule,
RecordOpenApiModule,
TableOpenApiModule,
],
controllers: [FormSubmissionController, WorkOrderAssignmentNotificationController],
providers: [
FormSubmissionService,
Expand Down
Original file line number Diff line number Diff line change
@@ -1,9 +1,15 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { Body, Controller, Delete, Get, Param, Post } from '@nestjs/common';
import type {
IBatchInspectWorkOrderAssignmentRo,
INotificationWorkOrderAssignmentVo,
ISplitNotificationWorkOrderAssignmentRo,
ISubmitNotificationWorkOrderAssignmentRo,
} from '@teable/openapi';
import { submitNotificationWorkOrderAssignmentRoSchema } from '@teable/openapi';
import {
batchInspectWorkOrderAssignmentRoSchema,
splitNotificationWorkOrderAssignmentRoSchema,
submitNotificationWorkOrderAssignmentRoSchema,
} from '@teable/openapi';
import { ClsService } from 'nestjs-cls';
import type { IClsStore } from '../../types/cls';
import { ZodValidationPipe } from '../../zod.validation.pipe';
Expand Down Expand Up @@ -33,4 +39,41 @@ export class WorkOrderAssignmentNotificationController {
const currentUserId = this.cls.get('user.id');
return this.assignmentService.submitAssignment(currentUserId, notificationId, submitRo);
}

@Post(':notificationId/work-order-assignment/split')
async splitWorkOrderAssignment(
@Param('notificationId') notificationId: string,
@Body(new ZodValidationPipe(splitNotificationWorkOrderAssignmentRoSchema))
splitRo: ISplitNotificationWorkOrderAssignmentRo
): Promise<INotificationWorkOrderAssignmentVo> {
const currentUserId = this.cls.get('user.id');
return this.assignmentService.splitAnimationAssignment(currentUserId, notificationId, splitRo);
}

@Post(':notificationId/work-order-assignment/inspection/batch')
async batchInspectWorkOrderAssignment(
@Param('notificationId') notificationId: string,
@Body(new ZodValidationPipe(batchInspectWorkOrderAssignmentRoSchema))
batchRo: IBatchInspectWorkOrderAssignmentRo
): Promise<INotificationWorkOrderAssignmentVo> {
const currentUserId = this.cls.get('user.id');
return this.assignmentService.batchInspectAnimationAssignments(
currentUserId,
notificationId,
batchRo
);
}

@Delete(':notificationId/work-order-assignment/split/:splitRecordId')
async removeWorkOrderAssignmentSplit(
@Param('notificationId') notificationId: string,
@Param('splitRecordId') splitRecordId: string
): Promise<INotificationWorkOrderAssignmentVo> {
const currentUserId = this.cls.get('user.id');
return this.assignmentService.removeAnimationSplit(
currentUserId,
notificationId,
splitRecordId
);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,159 @@
import { FieldType } from '@teable/core';
import { describe, expect, it, vi } from 'vitest';
import { WorkOrderAssignmentNotificationService } from './work-order-assignment-notification.service';

type ITestableService = {
buildAnimationSplitFields: (
resolved: unknown,
splitRo: unknown
) => Promise<Record<string, unknown>>;
buildAnimationAssignmentDisplayFields: (
context: unknown,
recordId: string,
recordFields: Record<string, unknown>,
stage: unknown,
role: 'worker' | 'reviewer'
) => Promise<Record<string, unknown>>;
resolveSplitRootRecordId: (context: unknown) => Promise<string>;
};

const createService = () => {
const fieldOpenApiService = {
getFields: vi.fn(),
};
const recordService = {
getRecord: vi.fn(),
};
const service = new WorkOrderAssignmentNotificationService(
{} as never,
fieldOpenApiService as never,
recordService as never,
{} as never,
{} as never,
{} as never
);
return {
service: service as unknown as ITestableService,
fieldOpenApiService,
recordService,
};
};

const createField = (name: string, overrides: Record<string, unknown> = {}) => ({
id: `fld${name}`,
name,
type: FieldType.SingleLineText,
...overrides,
});

describe('WorkOrderAssignmentNotificationService split animation helpers', () => {
it('copies only the split base field whitelist and animation assignee fields', async () => {
const { service, fieldOpenApiService } = createService();
fieldOpenApiService.getFields.mockResolvedValue([
createField('序号'),
createField('项目名'),
createField('下发日期'),
createField('分类'),
createField('传票'),
createField('纳期'),
createField('镜头号'),
createField('动检人员', { type: FieldType.User }),
createField('一原人员', { type: FieldType.User }),
createField('动画个人计件', { type: FieldType.Number }),
createField('动画产物', { type: FieldType.Attachment }),
createField('上色人员', { type: FieldType.User }),
createField('不可创建', { recordCreate: false }),
createField('计算传票', { isComputed: true }),
]);

const fields = await service.buildAnimationSplitFields(
{
context: { tableId: 'tblTest' },
stage: { personFieldName: '动画人员', remarkFieldName: '动画备注' },
record: {
fields: {
序号: 12,
项目名: '项目 A',
下发日期: '2026-07-21',
分类: 'TV',
传票: 'V-001',
纳期: '2026-07-31',
镜头号: 'cut-001',
动检人员: { id: 'usrInspector', title: 'Inspector' },
一原人员: { id: 'usrKeyAnimator', title: 'Key Animator' },
动画个人计件: 8,
动画产物: [{ token: 'attAnimation' }],
上色人员: { id: 'usrColorist', title: 'Colorist' },
不可创建: 'skip',
计算传票: 'skip-computed',
},
},
},
{ assigneeId: 'usrSplitAnimator', remark: '分卡备注' }
);

expect(fields).toEqual({
序号: 12,
项目名: '项目 A',
下发日期: '2026-07-21',
分类: 'TV',
传票: 'V-001',
纳期: '2026-07-31',
镜头号: 'cut-001',
动检人员: { id: 'usrInspector', title: 'Inspector' },
动画人员: { id: 'usrSplitAnimator' },
动画备注: '分卡备注',
});
});

it('uses root previous-stage values only when child fields are empty', async () => {
const { service, recordService } = createService();
vi.spyOn(service, 'resolveSplitRootRecordId').mockResolvedValue('recRoot');
recordService.getRecord.mockResolvedValue({
id: 'recRoot',
fields: {
项目名: '项目 A',
镜头号: 'cut-001',
下发日期: '2026-07-21',
一原个人计件: 9,
一原备注: '主卡一原备注',
一原产物: [{ token: 'attRoot' }],
一原结算状态: '已结算',
二原备注: '主卡二原备注',
动画个人计件: 99,
},
});
const childFields = {
项目名: '项目 A',
镜头号: 'cut-001',
下发日期: '2026-07-21',
一原个人计件: 0,
一原备注: '',
一原产物: [],
一原结算状态: false,
二原备注: '子卡二原备注',
动画个人计件: 1,
};

const fields = await service.buildAnimationAssignmentDisplayFields(
{ tableId: 'tblTest' },
'recChild',
childFields,
{ stageName: '动画' },
'worker'
);

expect(fields).toEqual({
项目名: '项目 A',
镜头号: 'cut-001',
下发日期: '2026-07-21',
一原个人计件: 0,
一原备注: '主卡一原备注',
一原产物: [{ token: 'attRoot' }],
一原结算状态: false,
二原备注: '子卡二原备注',
动画个人计件: 1,
});
expect(fields).not.toBe(childFields);
});
});
Loading
Loading