add project settings

This commit is contained in:
कारतोफ्फेलस्क्रिप्ट™ 2024-12-03 20:58:24 +01:00
parent 1a915239c6
commit 57f1a3fc59
No known key found for this signature in database
32 changed files with 191 additions and 125 deletions

View file

@ -13,9 +13,11 @@ describe('CreateProjectDto', () => {
name: 'with name and emoji icon', name: 'with name and emoji icon',
request: { request: {
name: 'My Awesome Project', name: 'My Awesome Project',
icon: { settings: {
type: 'emoji', icon: {
value: '🚀', type: 'emoji',
value: '🚀',
},
}, },
}, },
}, },
@ -23,9 +25,11 @@ describe('CreateProjectDto', () => {
name: 'with name and regular icon', name: 'with name and regular icon',
request: { request: {
name: 'My Awesome Project', name: 'My Awesome Project',
icon: { settings: {
type: 'icon', icon: {
value: 'blah', type: 'icon',
value: 'blah',
},
}, },
}, },
}, },
@ -39,28 +43,31 @@ describe('CreateProjectDto', () => {
test.each([ test.each([
{ {
name: 'missing name', name: 'missing name',
request: { icon: { type: 'emoji', value: '🚀' } }, request: { settings: { icon: { type: 'emoji', value: '🚀' } } },
expectedErrorPath: ['name'], expectedErrorPath: ['name'],
}, },
{ {
name: 'empty name', name: 'empty name',
request: { name: '', icon: { type: 'emoji', value: '🚀' } }, request: { name: '', settings: { icon: { type: 'emoji', value: '🚀' } } },
expectedErrorPath: ['name'], expectedErrorPath: ['name'],
}, },
{ {
name: 'name too long', name: 'name too long',
request: { name: 'a'.repeat(256), icon: { type: 'emoji', value: '🚀' } }, request: { name: 'a'.repeat(256), settings: { icon: { type: 'emoji', value: '🚀' } } },
expectedErrorPath: ['name'], expectedErrorPath: ['name'],
}, },
{ {
name: 'invalid icon type', name: 'invalid icon type',
request: { name: 'My Awesome Project', icon: { type: 'invalid', value: '🚀' } }, request: {
expectedErrorPath: ['icon', 'type'], name: 'My Awesome Project',
settings: { icon: { type: 'invalid', value: '🚀' } },
},
expectedErrorPath: ['settings', 'icon', 'type'],
}, },
{ {
name: 'invalid icon value', name: 'invalid icon value',
request: { name: 'My Awesome Project', icon: { type: 'emoji', value: '' } }, request: { name: 'My Awesome Project', settings: { icon: { type: 'emoji', value: '' } } },
expectedErrorPath: ['icon', 'value'], expectedErrorPath: ['settings', 'icon', 'value'],
}, },
])('should fail validation for $name', ({ request, expectedErrorPath }) => { ])('should fail validation for $name', ({ request, expectedErrorPath }) => {
const result = CreateProjectDto.safeParse(request); const result = CreateProjectDto.safeParse(request);

View file

@ -13,9 +13,11 @@ describe('UpdateProjectDto', () => {
name: 'with name and emoji icon', name: 'with name and emoji icon',
request: { request: {
name: 'My Updated Project', name: 'My Updated Project',
icon: { settings: {
type: 'emoji', icon: {
value: '🚀', type: 'emoji',
value: '🚀',
},
}, },
}, },
}, },
@ -23,9 +25,11 @@ describe('UpdateProjectDto', () => {
name: 'with name and regular icon', name: 'with name and regular icon',
request: { request: {
name: 'My Updated Project', name: 'My Updated Project',
icon: { settings: {
type: 'icon', icon: {
value: 'blah', type: 'icon',
value: 'blah',
},
}, },
}, },
}, },
@ -44,9 +48,11 @@ describe('UpdateProjectDto', () => {
name: 'with all fields', name: 'with all fields',
request: { request: {
name: 'My Updated Project', name: 'My Updated Project',
icon: { settings: {
type: 'emoji', icon: {
value: '🚀', type: 'emoji',
value: '🚀',
},
}, },
relations: [ relations: [
{ {
@ -76,13 +82,13 @@ describe('UpdateProjectDto', () => {
}, },
{ {
name: 'invalid icon type', name: 'invalid icon type',
request: { icon: { type: 'invalid', value: '🚀' } }, request: { settings: { icon: { type: 'invalid', value: '🚀' } } },
expectedErrorPath: ['icon', 'type'], expectedErrorPath: ['settings', 'icon', 'type'],
}, },
{ {
name: 'invalid icon value', name: 'invalid icon value',
request: { icon: { type: 'emoji', value: '' } }, request: { settings: { icon: { type: 'emoji', value: '' } } },
expectedErrorPath: ['icon', 'value'], expectedErrorPath: ['settings', 'icon', 'value'],
}, },
{ {
name: 'invalid relations userId', name: 'invalid relations userId',

View file

@ -1,8 +1,8 @@
import { Z } from 'zod-class'; import { Z } from 'zod-class';
import { projectIconSchema, projectNameSchema } from '../../schemas/project.schema'; import { projectNameSchema, projectSettingsSchema } from '../../schemas/project.schema';
export class CreateProjectDto extends Z.class({ export class CreateProjectDto extends Z.class({
name: projectNameSchema, name: projectNameSchema,
icon: projectIconSchema.optional(), settings: projectSettingsSchema.optional(),
}) {} }) {}

View file

@ -2,13 +2,13 @@ import { z } from 'zod';
import { Z } from 'zod-class'; import { Z } from 'zod-class';
import { import {
projectIconSchema,
projectNameSchema, projectNameSchema,
projectSettingsSchema,
projectRelationSchema, projectRelationSchema,
} from '../../schemas/project.schema'; } from '../../schemas/project.schema';
export class UpdateProjectDto extends Z.class({ export class UpdateProjectDto extends Z.class({
name: projectNameSchema.optional(), name: projectNameSchema.optional(),
icon: projectIconSchema.optional(), settings: projectSettingsSchema.optional(),
relations: z.array(projectRelationSchema).optional(), relations: z.array(projectRelationSchema).optional(),
}) {} }) {}

View file

@ -15,6 +15,7 @@ export { passwordSchema } from './schemas/password.schema';
export type { export type {
ProjectType, ProjectType,
ProjectIcon, ProjectIcon,
ProjectSettings,
ProjectRole, ProjectRole,
ProjectRelation, ProjectRelation,
} from './schemas/project.schema'; } from './schemas/project.schema';

View file

@ -1,9 +1,9 @@
import { import {
projectNameSchema, projectNameSchema,
projectTypeSchema, projectTypeSchema,
projectIconSchema,
projectRoleSchema, projectRoleSchema,
projectRelationSchema, projectRelationSchema,
projectSettingsSchema,
} from '../project.schema'; } from '../project.schema';
describe('project.schema', () => { describe('project.schema', () => {
@ -29,31 +29,33 @@ describe('project.schema', () => {
}); });
}); });
describe('projectIconSchema', () => { describe('projectSettingsSchema', () => {
test.each([ describe('.icon', () => {
{ test.each([
name: 'valid emoji icon', {
value: { type: 'emoji', value: '🚀' }, name: 'valid emoji icon',
expected: true, value: { type: 'emoji', value: '🚀' },
}, expected: true,
{ },
name: 'valid icon', {
value: { type: 'icon', value: 'blah' }, name: 'valid icon',
expected: true, value: { type: 'icon', value: 'blah' },
}, expected: true,
{ },
name: 'invalid icon type', {
value: { type: 'invalid', value: '🚀' }, name: 'invalid icon type',
expected: false, value: { type: 'invalid', value: '🚀' },
}, expected: false,
{ },
name: 'empty icon value', {
value: { type: 'emoji', value: '' }, name: 'empty icon value',
expected: false, value: { type: 'emoji', value: '' },
}, expected: false,
])('should validate $name', ({ value, expected }) => { },
const result = projectIconSchema.safeParse(value); ])('should validate $name', ({ value, expected }) => {
expect(result.success).toBe(expected); const result = projectSettingsSchema.safeParse({ icon: value });
expect(result.success).toBe(expected);
});
}); });
}); });

View file

@ -5,12 +5,20 @@ export const projectNameSchema = z.string().min(1).max(255);
export const projectTypeSchema = z.enum(['personal', 'team']); export const projectTypeSchema = z.enum(['personal', 'team']);
export type ProjectType = z.infer<typeof projectTypeSchema>; export type ProjectType = z.infer<typeof projectTypeSchema>;
export const projectIconSchema = z.object({ const projectIconSchema = z.object({
type: z.enum(['emoji', 'icon']), type: z.enum(['emoji', 'icon']),
value: z.string().min(1), value: z.string().min(1),
}); });
export type ProjectIcon = z.infer<typeof projectIconSchema>; export type ProjectIcon = z.infer<typeof projectIconSchema>;
export const projectSettingsSchema = z.object({
icon: projectIconSchema.optional(),
dedicatedQueue: z.boolean().default(false).optional(),
// TODO: add timezone, callerPolicy, saving options, etc
// TODO: add 'workflowsFromSameProject` to `CallerPolicy`
});
export type ProjectSettings = z.infer<typeof projectSettingsSchema>;
export const projectRoleSchema = z.enum([ export const projectRoleSchema = z.enum([
'project:personalOwner', // personalOwner is only used for personal projects 'project:personalOwner', // personalOwner is only used for personal projects
'project:admin', 'project:admin',

View file

@ -171,7 +171,7 @@ export class ProjectController {
_res: Response, _res: Response,
@Param('projectId') projectId: string, @Param('projectId') projectId: string,
): Promise<ProjectRequest.ProjectWithRelations> { ): Promise<ProjectRequest.ProjectWithRelations> {
const [{ id, name, icon, type }, relations] = await Promise.all([ const [{ id, name, type, settings }, relations] = await Promise.all([
this.projectsService.getProject(projectId), this.projectsService.getProject(projectId),
this.projectsService.getProjectRelations(projectId), this.projectsService.getProjectRelations(projectId),
]); ]);
@ -180,8 +180,8 @@ export class ProjectController {
return { return {
id, id,
name, name,
icon,
type, type,
settings,
relations: relations.map((r) => ({ relations: relations.map((r) => ({
id: r.user.id, id: r.user.id,
email: r.user.email, email: r.user.email,
@ -206,9 +206,9 @@ export class ProjectController {
@Body payload: UpdateProjectDto, @Body payload: UpdateProjectDto,
@Param('projectId') projectId: string, @Param('projectId') projectId: string,
) { ) {
const { name, icon, relations } = payload; const { name, settings, relations } = payload;
if (name || icon) { if (name || settings) {
await this.projectsService.updateProject(projectId, { name, icon }); await this.projectsService.updateProject(projectId, { name, settings });
} }
if (relations) { if (relations) {
try { try {

View file

@ -129,6 +129,12 @@ export class Column {
options.default = isSqlite options.default = isSqlite
? "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')" ? "STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')"
: 'CURRENT_TIMESTAMP(3)'; : 'CURRENT_TIMESTAMP(3)';
}
if (type === 'json' && isSqlite) {
if (typeof this.defaultValue !== 'string') {
this.defaultValue = JSON.stringify(this.defaultValue);
}
options.default = `'${this.defaultValue as string}'`;
} else { } else {
options.default = this.defaultValue; options.default = this.defaultValue;
} }

View file

@ -1,7 +1,7 @@
import { ProjectIcon, ProjectType } from '@n8n/api-types'; import { ProjectSettings, ProjectType } from '@n8n/api-types';
import { Column, Entity, OneToMany } from '@n8n/typeorm'; import { Column, Entity, OneToMany } from '@n8n/typeorm';
import { WithTimestampsAndStringId } from './abstract-entity'; import { jsonColumnType, WithTimestampsAndStringId } from './abstract-entity';
import type { ProjectRelation } from './project-relation'; import type { ProjectRelation } from './project-relation';
import type { SharedCredentials } from './shared-credentials'; import type { SharedCredentials } from './shared-credentials';
import type { SharedWorkflow } from './shared-workflow'; import type { SharedWorkflow } from './shared-workflow';
@ -14,8 +14,8 @@ export class Project extends WithTimestampsAndStringId {
@Column({ type: 'varchar', length: 36 }) @Column({ type: 'varchar', length: 36 })
type: ProjectType; type: ProjectType;
@Column({ type: 'json', nullable: true }) @Column({ type: jsonColumnType })
icon: ProjectIcon; settings: ProjectSettings = {};
@OneToMany('ProjectRelation', 'project') @OneToMany('ProjectRelation', 'project')
projectRelations: ProjectRelation[]; projectRelations: ProjectRelation[];

View file

@ -0,0 +1,14 @@
import type { MigrationContext, ReversibleMigration } from '@/databases/types';
export class AddProjectSettings1738496517619 implements ReversibleMigration {
async up({ schemaBuilder: { addColumns, dropColumns, column } }: MigrationContext) {
// TODO: copy over the icons data before dropping that column
await addColumns('project', [column('settings').json.notNull.default({})]);
await dropColumns('project', ['icon']);
}
async down({ schemaBuilder: { addColumns, dropColumns, column } }: MigrationContext) {
await addColumns('project', [column('icon').json]);
await dropColumns('project', ['settings']);
}
}

View file

@ -78,6 +78,7 @@ import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/173
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable'; import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun'; import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
import { CreateTestCaseExecutionTable1736947513045 } from '../common/1736947513045-CreateTestCaseExecutionTable'; import { CreateTestCaseExecutionTable1736947513045 } from '../common/1736947513045-CreateTestCaseExecutionTable';
import { AddProjectSettings1738496517619 } from '../common/1738496517619-AddProjectSettings';
export const mysqlMigrations: Migration[] = [ export const mysqlMigrations: Migration[] = [
InitialMigration1588157391238, InitialMigration1588157391238,
@ -158,4 +159,5 @@ export const mysqlMigrations: Migration[] = [
AddProjectIcons1729607673469, AddProjectIcons1729607673469,
AddStatsColumnsToTestRun1736172058779, AddStatsColumnsToTestRun1736172058779,
CreateTestCaseExecutionTable1736947513045, CreateTestCaseExecutionTable1736947513045,
AddProjectSettings1738496517619,
]; ];

View file

@ -78,6 +78,7 @@ import { AddMockedNodesColumnToTestDefinition1733133775640 } from '../common/173
import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable'; import { AddManagedColumnToCredentialsTable1734479635324 } from '../common/1734479635324-AddManagedColumnToCredentialsTable';
import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun'; import { AddStatsColumnsToTestRun1736172058779 } from '../common/1736172058779-AddStatsColumnsToTestRun';
import { CreateTestCaseExecutionTable1736947513045 } from '../common/1736947513045-CreateTestCaseExecutionTable'; import { CreateTestCaseExecutionTable1736947513045 } from '../common/1736947513045-CreateTestCaseExecutionTable';
import { AddProjectSettings1738496517619 } from '../common/1738496517619-AddProjectSettings';
export const postgresMigrations: Migration[] = [ export const postgresMigrations: Migration[] = [
InitialMigration1587669153312, InitialMigration1587669153312,
@ -158,4 +159,5 @@ export const postgresMigrations: Migration[] = [
AddProjectIcons1729607673469, AddProjectIcons1729607673469,
AddStatsColumnsToTestRun1736172058779, AddStatsColumnsToTestRun1736172058779,
CreateTestCaseExecutionTable1736947513045, CreateTestCaseExecutionTable1736947513045,
AddProjectSettings1738496517619,
]; ];

View file

@ -0,0 +1,5 @@
import { AddProjectSettings1738496517619 as BaseMigration } from '../common/1738496517619-AddProjectSettings';
export class AddProjectSettings1738496517619 extends BaseMigration {
transaction = false as const;
}

View file

@ -42,6 +42,7 @@ import { AddMissingPrimaryKeyOnAnnotationTagMapping1728659839644 } from './17286
import { AddProjectIcons1729607673469 } from './1729607673469-AddProjectIcons'; import { AddProjectIcons1729607673469 } from './1729607673469-AddProjectIcons';
import { AddDescriptionToTestDefinition1731404028106 } from './1731404028106-AddDescriptionToTestDefinition'; import { AddDescriptionToTestDefinition1731404028106 } from './1731404028106-AddDescriptionToTestDefinition';
import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-MigrateTestDefinitionKeyToString'; import { MigrateTestDefinitionKeyToString1731582748663 } from './1731582748663-MigrateTestDefinitionKeyToString';
import { AddProjectSettings1738496517619 } from './1738496517619-AddProjectSettings';
import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames'; import { UniqueWorkflowNames1620821879465 } from '../common/1620821879465-UniqueWorkflowNames';
import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials'; import { UpdateWorkflowCredentials1630330987096 } from '../common/1630330987096-UpdateWorkflowCredentials';
import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds'; import { AddNodeIds1658930531669 } from '../common/1658930531669-AddNodeIds';
@ -152,6 +153,7 @@ const sqliteMigrations: Migration[] = [
AddProjectIcons1729607673469, AddProjectIcons1729607673469,
AddStatsColumnsToTestRun1736172058779, AddStatsColumnsToTestRun1736172058779,
CreateTestCaseExecutionTable1736947513045, CreateTestCaseExecutionTable1736947513045,
AddProjectSettings1738496517619,
]; ];
export { sqliteMigrations }; export { sqliteMigrations };

View file

@ -1,4 +1,4 @@
import type { ProjectIcon, ProjectRole, ProjectType } from '@n8n/api-types'; import type { ProjectRole, ProjectType, ProjectSettings } from '@n8n/api-types';
import type { Scope } from '@n8n/permissions'; import type { Scope } from '@n8n/permissions';
import type express from 'express'; import type express from 'express';
import type { import type {
@ -119,7 +119,7 @@ export namespace ListQuery {
} }
type SlimUser = Pick<IUser, 'id' | 'email' | 'firstName' | 'lastName'>; type SlimUser = Pick<IUser, 'id' | 'email' | 'firstName' | 'lastName'>;
export type SlimProject = Pick<Project, 'id' | 'type' | 'name' | 'icon'>; export type SlimProject = Pick<Project, 'id' | 'type' | 'name' | 'settings'>;
export function hasSharing( export function hasSharing(
workflows: ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[], workflows: ListQuery.Workflow.Plain[] | ListQuery.Workflow.WithSharing[],
@ -383,8 +383,8 @@ export declare namespace ProjectRequest {
type ProjectWithRelations = { type ProjectWithRelations = {
id: string; id: string;
name: string | undefined; name: string | undefined;
icon: ProjectIcon;
type: ProjectType; type: ProjectType;
settings: ProjectSettings;
relations: ProjectRelationResponse[]; relations: ProjectRelationResponse[];
scopes: Scope[]; scopes: Scope[];
}; };

View file

@ -37,7 +37,9 @@ import type {
@Service() @Service()
export class ScalingService { export class ScalingService {
private queue: JobQueue; private sharedQueue: JobQueue;
private readonly dedicatedQueues = new Map<string, JobQueue>();
constructor( constructor(
private readonly logger: Logger, private readonly logger: Logger,
@ -63,7 +65,7 @@ export class ScalingService {
const bullPrefix = this.globalConfig.queue.bull.prefix; const bullPrefix = this.globalConfig.queue.bull.prefix;
const prefix = service.toValidPrefix(bullPrefix); const prefix = service.toValidPrefix(bullPrefix);
this.queue = new BullQueue(QUEUE_NAME, { this.sharedQueue = new BullQueue(QUEUE_NAME, {
prefix, prefix,
settings: this.globalConfig.queue.bull.settings, settings: this.globalConfig.queue.bull.settings,
createClient: (type) => service.createClient({ type: `${type}(bull)` }), createClient: (type) => service.createClient({ type: `${type}(bull)` }),
@ -90,7 +92,7 @@ export class ScalingService {
this.assertWorker(); this.assertWorker();
this.assertQueue(); this.assertQueue();
void this.queue.process(JOB_TYPE_NAME, concurrency, async (job: Job) => { void this.sharedQueue.process(JOB_TYPE_NAME, concurrency, async (job: Job) => {
try { try {
if (!this.hasValidJobData(job)) { if (!this.hasValidJobData(job)) {
throw new ApplicationError('Worker received invalid job', { throw new ApplicationError('Worker received invalid job', {
@ -141,7 +143,7 @@ export class ScalingService {
private async stopMain() { private async stopMain() {
if (this.instanceSettings.isSingleMain) { if (this.instanceSettings.isSingleMain) {
await this.queue.pause(true, true); // no more jobs will be picked up await this.sharedQueue.pause(true, true); // no more jobs will be picked up
this.logger.debug('Queue paused'); this.logger.debug('Queue paused');
} }
@ -164,7 +166,7 @@ export class ScalingService {
} }
async pingQueue() { async pingQueue() {
await this.queue.client.ping(); await this.sharedQueue.client.ping();
} }
// #endregion // #endregion
@ -172,7 +174,7 @@ export class ScalingService {
// #region Jobs // #region Jobs
async getPendingJobCounts() { async getPendingJobCounts() {
const { active, waiting } = await this.queue.getJobCounts(); const { active, waiting } = await this.sharedQueue.getJobCounts();
return { active, waiting }; return { active, waiting };
} }
@ -192,7 +194,9 @@ export class ScalingService {
removeOnFail: true, removeOnFail: true,
}; };
const job = await this.queue.add(JOB_TYPE_NAME, jobData, jobOptions); const queue = this.dedicatedQueues.get(jobData.projectId!) ?? this.sharedQueue;
const job = await queue.add(JOB_TYPE_NAME, jobData, jobOptions);
const { executionId } = jobData; const { executionId } = jobData;
const jobId = job.id; const jobId = job.id;
@ -203,11 +207,15 @@ export class ScalingService {
} }
async getJob(jobId: JobId) { async getJob(jobId: JobId) {
return await this.queue.getJob(jobId); // TODO: keep a jobId -> projectId mapping in redis
// TODO: use the project specific queue if available
return await this.sharedQueue.getJob(jobId);
} }
async findJobsByStatus(statuses: JobStatus[]) { async findJobsByStatus(statuses: JobStatus[]) {
const jobs = await this.queue.getJobs(statuses); // TODO: keep a jobId -> projectId mapping in redis
// TODO: use the project specific queue if available
const jobs = await this.sharedQueue.getJobs(statuses);
return jobs.filter((job) => job !== null); return jobs.filter((job) => job !== null);
} }
@ -261,13 +269,13 @@ export class ScalingService {
* Register listeners on a `worker` process for Bull queue events. * Register listeners on a `worker` process for Bull queue events.
*/ */
private registerWorkerListeners() { private registerWorkerListeners() {
this.queue.on('global:progress', (jobId: JobId, msg: unknown) => { this.sharedQueue.on('global:progress', (jobId: JobId, msg: unknown) => {
if (!this.isJobMessage(msg)) return; if (!this.isJobMessage(msg)) return;
if (msg.kind === 'abort-job') this.jobProcessor.stopJob(jobId); if (msg.kind === 'abort-job') this.jobProcessor.stopJob(jobId);
}); });
this.queue.on('error', (error: Error) => { this.sharedQueue.on('error', (error: Error) => {
if ('code' in error && error.code === 'ECONNREFUSED') return; // handled by RedisClientService.retryStrategy if ('code' in error && error.code === 'ECONNREFUSED') return; // handled by RedisClientService.retryStrategy
/** /**
@ -290,7 +298,7 @@ export class ScalingService {
* Register listeners on a `main` or `webhook` process for Bull queue events. * Register listeners on a `main` or `webhook` process for Bull queue events.
*/ */
private registerMainOrWebhookListeners() { private registerMainOrWebhookListeners() {
this.queue.on('error', (error: Error) => { this.sharedQueue.on('error', (error: Error) => {
if ('code' in error && error.code === 'ECONNREFUSED') return; // handled by RedisClientService.retryStrategy if ('code' in error && error.code === 'ECONNREFUSED') return; // handled by RedisClientService.retryStrategy
this.logger.error('Queue errored', { error }); this.logger.error('Queue errored', { error });
@ -298,7 +306,7 @@ export class ScalingService {
throw error; throw error;
}); });
this.queue.on('global:progress', (jobId: JobId, msg: unknown) => { this.sharedQueue.on('global:progress', (jobId: JobId, msg: unknown) => {
if (!this.isJobMessage(msg)) return; if (!this.isJobMessage(msg)) return;
// completion and failure are reported via `global:progress` to convey more details // completion and failure are reported via `global:progress` to convey more details
@ -338,8 +346,8 @@ export class ScalingService {
}); });
if (this.isQueueMetricsEnabled) { if (this.isQueueMetricsEnabled) {
this.queue.on('global:completed', () => this.jobCounters.completed++); this.sharedQueue.on('global:completed', () => this.jobCounters.completed++);
this.queue.on('global:failed', () => this.jobCounters.failed++); this.sharedQueue.on('global:failed', () => this.jobCounters.failed++);
} }
} }
@ -367,7 +375,7 @@ export class ScalingService {
} }
private assertQueue() { private assertQueue() {
if (this.queue) return; if (this.sharedQueue) return;
throw new ApplicationError('This method must be called after `setupQueue`'); throw new ApplicationError('This method must be called after `setupQueue`');
} }

View file

@ -11,6 +11,7 @@ export type JobId = Job['id'];
export type JobData = { export type JobData = {
executionId: string; executionId: string;
projectId?: string;
loadStaticData: boolean; loadStaticData: boolean;
pushRef?: string; pushRef?: string;
}; };

View file

@ -81,21 +81,17 @@ export class OwnershipService {
for (const sharedEntity of shared) { for (const sharedEntity of shared) {
const { project, role } = sharedEntity; const { project, role } = sharedEntity;
const slimProject = {
id: project.id,
type: project.type,
name: project.name,
settings: project.settings,
};
if (role === 'credential:owner' || role === 'workflow:owner') { if (role === 'credential:owner' || role === 'workflow:owner') {
entity.homeProject = { entity.homeProject = slimProject;
id: project.id,
type: project.type,
name: project.name,
icon: project.icon,
};
} else { } else {
entity.sharedWithProjects.push({ entity.sharedWithProjects.push(slimProject);
id: project.id,
type: project.type,
name: project.name,
icon: project.icon,
});
} }
} }

View file

@ -188,7 +188,7 @@ export class ProjectService {
async updateProject( async updateProject(
projectId: string, projectId: string,
data: Pick<UpdateProjectDto, 'name' | 'icon'>, data: Pick<UpdateProjectDto, 'name' | 'settings'>,
): Promise<Project> { ): Promise<Project> {
const result = await this.projectRepository.update({ id: projectId, type: 'team' }, data); const result = await this.projectRepository.update({ id: projectId, type: 'team' }, data);

View file

@ -344,6 +344,7 @@ export class WorkflowRunner {
): Promise<void> { ): Promise<void> {
const jobData: JobData = { const jobData: JobData = {
executionId, executionId,
projectId: data.projectId,
loadStaticData: !!loadStaticData, loadStaticData: !!loadStaticData,
pushRef: data.pushRef, pushRef: data.pushRef,
}; };

View file

@ -10,7 +10,9 @@ import { ProjectTypes } from '@/types/projects.types';
export const createProjectSharingData = (projectType?: ProjectType): ProjectSharingData => ({ export const createProjectSharingData = (projectType?: ProjectType): ProjectSharingData => ({
id: faker.string.uuid(), id: faker.string.uuid(),
name: faker.lorem.words({ min: 1, max: 3 }), name: faker.lorem.words({ min: 1, max: 3 }),
icon: { type: 'icon', value: 'folder' }, settings: {
icon: { type: 'icon', value: 'folder' },
},
type: projectType ?? ProjectTypes.Personal, type: projectType ?? ProjectTypes.Personal,
createdAt: faker.date.past().toISOString(), createdAt: faker.date.past().toISOString(),
updatedAt: faker.date.recent().toISOString(), updatedAt: faker.date.recent().toISOString(),
@ -30,7 +32,9 @@ export function createTestProject(data: Partial<Project>): Project {
return { return {
id: faker.string.uuid(), id: faker.string.uuid(),
name: faker.lorem.words({ min: 1, max: 3 }), name: faker.lorem.words({ min: 1, max: 3 }),
icon: { type: 'icon', value: 'folder' }, settings: {
icon: { type: 'icon', value: 'folder' },
},
createdAt: faker.date.past().toISOString(), createdAt: faker.date.past().toISOString(),
updatedAt: faker.date.recent().toISOString(), updatedAt: faker.date.recent().toISOString(),
type: ProjectTypes.Team, type: ProjectTypes.Team,

View file

@ -2,8 +2,9 @@
import { computed } from 'vue'; import { computed } from 'vue';
import { useRoute, useRouter } from 'vue-router'; import { useRoute, useRouter } from 'vue-router';
import { N8nButton, N8nTooltip } from 'n8n-design-system'; import { N8nButton, N8nTooltip } from 'n8n-design-system';
import type { ProjectIcon } from '@n8n/api-types';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { type ProjectIcon, ProjectTypes } from '@/types/projects.types'; import { ProjectTypes } from '@/types/projects.types';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import ProjectTabs from '@/components/Projects/ProjectTabs.vue'; import ProjectTabs from '@/components/Projects/ProjectTabs.vue';
import { getResourcePermissions } from '@/permissions'; import { getResourcePermissions } from '@/permissions';
@ -21,7 +22,7 @@ const headerIcon = computed((): ProjectIcon => {
if (projectsStore.currentProject?.type === ProjectTypes.Personal) { if (projectsStore.currentProject?.type === ProjectTypes.Personal) {
return { type: 'icon', value: 'user' }; return { type: 'icon', value: 'user' };
} else if (projectsStore.currentProject?.name) { } else if (projectsStore.currentProject?.name) {
return projectsStore.currentProject.icon ?? { type: 'icon', value: 'layer-group' }; return projectsStore.currentProject.settings.icon ?? { type: 'icon', value: 'layer-group' };
} else { } else {
return { type: 'icon', value: 'home' }; return { type: 'icon', value: 'home' };
} }

View file

@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import type { ProjectIcon } from '@/types/projects.types'; import type { ProjectIcon } from '@n8n/api-types';
type Props = { type Props = {
icon: ProjectIcon; icon: ProjectIcon;

View file

@ -33,7 +33,7 @@ const home = computed<IMenuItem>(() => ({
const getProjectMenuItem = (project: ProjectListItem) => ({ const getProjectMenuItem = (project: ProjectListItem) => ({
id: project.id, id: project.id,
label: project.name, label: project.name,
icon: project.icon, icon: project.settings.icon,
route: { route: {
to: { to: {
name: VIEWS.PROJECTS_WORKFLOWS, name: VIEWS.PROJECTS_WORKFLOWS,

View file

@ -21,8 +21,8 @@ const processedName = computed(() => {
}); });
const projectIcon = computed(() => { const projectIcon = computed(() => {
if (props.project.icon) { if (props.project.settings.icon) {
return props.project.icon; return props.project.settings.icon;
} }
return null; return null;
}); });

View file

@ -173,7 +173,9 @@ export const useGlobalEntityCreation = () => {
try { try {
const newProject = await projectsStore.createProject({ const newProject = await projectsStore.createProject({
name: i18n.baseText('projects.settings.newProjectName'), name: i18n.baseText('projects.settings.newProjectName'),
icon: { type: 'icon', value: 'layer-group' }, settings: {
icon: { type: 'icon', value: 'layer-group' },
},
}); });
await router.push({ name: VIEWS.PROJECT_SETTINGS, params: { projectId: newProject.id } }); await router.push({ name: VIEWS.PROJECT_SETTINGS, params: { projectId: newProject.id } });
toast.showMessage({ toast.showMessage({

View file

@ -32,7 +32,6 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
const projectsCount = ref<ProjectsCount>({ const projectsCount = ref<ProjectsCount>({
personal: 0, personal: 0,
team: 0, team: 0,
public: 0,
}); });
const projectNavActiveIdState = ref<string | string[] | null>(null); const projectNavActiveIdState = ref<string | string[] | null>(null);
@ -120,14 +119,14 @@ export const useProjectsStore = defineStore(STORES.PROJECTS, () => {
): Promise<void> => { ): Promise<void> => {
await projectsApi.updateProject(rootStore.restApiContext, id, projectData); await projectsApi.updateProject(rootStore.restApiContext, id, projectData);
const projectIndex = myProjects.value.findIndex((p) => p.id === id); const projectIndex = myProjects.value.findIndex((p) => p.id === id);
const { name, icon } = projectData; const { name, settings } = projectData;
if (projectIndex !== -1) { if (projectIndex !== -1) {
myProjects.value[projectIndex].name = name; myProjects.value[projectIndex].name = name;
myProjects.value[projectIndex].icon = icon; myProjects.value[projectIndex].settings = settings;
} }
if (currentProject.value) { if (currentProject.value) {
currentProject.value.name = name; currentProject.value.name = name;
currentProject.value.icon = icon; currentProject.value.settings = settings;
} }
if (projectData.relations) { if (projectData.relations) {
await getProject(id); await getProject(id);

View file

@ -1,3 +1,4 @@
import type { ProjectSettings } from '@n8n/api-types';
import type { Scope } from '@n8n/permissions'; import type { Scope } from '@n8n/permissions';
import type { IUserResponse } from '@/Interface'; import type { IUserResponse } from '@/Interface';
import type { ProjectRole } from '@/types/roles.types'; import type { ProjectRole } from '@/types/roles.types';
@ -5,7 +6,6 @@ import type { ProjectRole } from '@/types/roles.types';
export const ProjectTypes = { export const ProjectTypes = {
Personal: 'personal', Personal: 'personal',
Team: 'team', Team: 'team',
Public: 'public',
} as const; } as const;
type ProjectTypeKeys = typeof ProjectTypes; type ProjectTypeKeys = typeof ProjectTypes;
@ -18,8 +18,8 @@ export type ProjectRelationPayload = { userId: string; role: ProjectRole };
export type ProjectSharingData = { export type ProjectSharingData = {
id: string; id: string;
name: string | null; name: string | null;
icon: ProjectIcon | null;
type: ProjectType; type: ProjectType;
settings: ProjectSettings;
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };
@ -32,8 +32,3 @@ export type ProjectListItem = ProjectSharingData & {
scopes?: Scope[]; scopes?: Scope[];
}; };
export type ProjectsCount = Record<ProjectType, number>; export type ProjectsCount = Record<ProjectType, number>;
export type ProjectIcon = {
type: 'icon' | 'emoji';
value: string;
};

View file

@ -66,7 +66,7 @@ describe('ProjectSettings', () => {
id: '123', id: '123',
type: 'team', type: 'team',
name: 'Test Project', name: 'Test Project',
icon: { type: 'icon', value: 'folder' }, settings: { icon: { type: 'icon', value: 'folder' } },
relations: [], relations: [],
createdAt: new Date().toISOString(), createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(), updatedAt: new Date().toISOString(),

View file

@ -3,11 +3,11 @@ import { computed, ref, watch, onBeforeMount, onMounted, nextTick } from 'vue';
import { useRouter } from 'vue-router'; import { useRouter } from 'vue-router';
import { deepCopy } from 'n8n-workflow'; import { deepCopy } from 'n8n-workflow';
import { N8nFormInput } from 'n8n-design-system'; import { N8nFormInput } from 'n8n-design-system';
import type { ProjectIcon } from '@n8n/api-types';
import { useUsersStore } from '@/stores/users.store'; import { useUsersStore } from '@/stores/users.store';
import type { IUser } from '@/Interface'; import type { IUser } from '@/Interface';
import { useI18n } from '@/composables/useI18n'; import { useI18n } from '@/composables/useI18n';
import { useProjectsStore } from '@/stores/projects.store'; import { useProjectsStore } from '@/stores/projects.store';
import type { ProjectIcon } from '@/types/projects.types';
import { type Project, type ProjectRelation } from '@/types/projects.types'; import { type Project, type ProjectRelation } from '@/types/projects.types';
import { useToast } from '@/composables/useToast'; import { useToast } from '@/composables/useToast';
import { VIEWS } from '@/constants'; import { VIEWS } from '@/constants';
@ -194,7 +194,9 @@ const updateProject = async () => {
try { try {
await projectsStore.updateProject(projectsStore.currentProject.id, { await projectsStore.updateProject(projectsStore.currentProject.id, {
name: formData.value.name!, name: formData.value.name!,
icon: projectIcon.value, settings: {
icon: projectIcon.value,
},
relations: formData.value.relations.map((r: ProjectRelation) => ({ relations: formData.value.relations.map((r: ProjectRelation) => ({
userId: r.id, userId: r.id,
role: r.role, role: r.role,
@ -269,8 +271,8 @@ watch(
: []; : [];
await nextTick(); await nextTick();
selectProjectNameIfMatchesDefault(); selectProjectNameIfMatchesDefault();
if (projectsStore.currentProject?.icon) { if (projectsStore.currentProject?.settings.icon) {
projectIcon.value = projectsStore.currentProject.icon; projectIcon.value = projectsStore.currentProject.settings.icon;
} }
}, },
{ immediate: true }, { immediate: true },

View file

@ -125,8 +125,10 @@ export interface IUser {
export type ProjectSharingData = { export type ProjectSharingData = {
id: string; id: string;
name: string | null; name: string | null;
icon: { type: 'emoji' | 'icon'; value: string } | null; type: 'personal' | 'team';
type: 'personal' | 'team' | 'public'; settings: {
icon?: { type: 'emoji' | 'icon'; value: string };
};
createdAt: string; createdAt: string;
updatedAt: string; updatedAt: string;
}; };