diff --git a/packages/backend/server/migrations/20250303104449_add_workspace_name_and_avatar_key/migration.sql b/packages/backend/server/migrations/20250303104449_add_workspace_name_and_avatar_key/migration.sql new file mode 100644 index 0000000000..4cc1acf515 --- /dev/null +++ b/packages/backend/server/migrations/20250303104449_add_workspace_name_and_avatar_key/migration.sql @@ -0,0 +1,3 @@ +-- AlterTable +ALTER TABLE "workspaces" ADD COLUMN "avatar_key" VARCHAR, +ADD COLUMN "name" VARCHAR; diff --git a/packages/backend/server/schema.prisma b/packages/backend/server/schema.prisma index b69edff2d5..0eb7fc36ec 100644 --- a/packages/backend/server/schema.prisma +++ b/packages/backend/server/schema.prisma @@ -101,6 +101,8 @@ model Workspace { // workspace level feature flags enableAi Boolean @default(true) @map("enable_ai") enableUrlPreview Boolean @default(false) @map("enable_url_preview") + name String? @db.VarChar + avatarKey String? @map("avatar_key") @db.VarChar features WorkspaceFeature[] docs WorkspaceDoc[] diff --git a/packages/backend/server/src/core/doc/__tests__/event.spec.ts b/packages/backend/server/src/core/doc/__tests__/event.spec.ts index 9acc4d465c..5a91f194c9 100644 --- a/packages/backend/server/src/core/doc/__tests__/event.spec.ts +++ b/packages/backend/server/src/core/doc/__tests__/event.spec.ts @@ -118,3 +118,66 @@ test('should ignore update doc content to database when snapshot parse failed', const content = await models.doc.getMeta(workspace.id, docId); t.is(content, null); }); + +test('should update workspace content to database when workspace is updated', async t => { + const { docReader, models, adapter, listener } = t.context; + const updates: Buffer[] = []; + { + const doc = new YDoc(); + doc.on('update', data => { + updates.push(Buffer.from(data)); + }); + + const text = doc.getText('content'); + text.insert(0, 'hello'); + text.insert(5, 'world'); + } + await adapter.pushDocUpdates(workspace.id, workspace.id, updates); + await adapter.getDoc(workspace.id, workspace.id); + + mock.method(docReader, 'parseWorkspaceContent', () => { + return { + name: 'test workspace name', + avatarKey: 'test avatar key', + }; + }); + + await listener.markDocContentCacheStale({ + workspaceId: workspace.id, + docId: workspace.id, + blob: Buffer.from([]), + }); + const content = await models.workspace.get(workspace.id); + t.truthy(content); + t.is(content!.name, 'test workspace name'); + t.is(content!.avatarKey, 'test avatar key'); +}); + +test('should ignore update workspace content to database when parse workspace content return null', async t => { + const { models, adapter, listener } = t.context; + const updates: Buffer[] = []; + { + const doc = new YDoc(); + doc.on('update', data => { + updates.push(Buffer.from(data)); + }); + + const text = doc.getText('content'); + text.insert(0, 'hello'); + text.insert(5, 'world'); + } + await adapter.pushDocUpdates(workspace.id, workspace.id, updates); + const doc = await adapter.getDoc(workspace.id, workspace.id); + + const spy = Sinon.spy(models.workspace, 'update'); + await listener.markDocContentCacheStale({ + workspaceId: workspace.id, + docId: workspace.id, + blob: Buffer.from(doc!.bin), + }); + t.is(spy.callCount, 0); + const content = await models.workspace.get(workspace.id); + t.truthy(content); + t.is(content!.name, null); + t.is(content!.avatarKey, null); +}); diff --git a/packages/backend/server/src/core/doc/__tests__/reader-from-database.spec.ts b/packages/backend/server/src/core/doc/__tests__/reader-from-database.spec.ts index a3bb9f727d..b7b88e4e74 100644 --- a/packages/backend/server/src/core/doc/__tests__/reader-from-database.spec.ts +++ b/packages/backend/server/src/core/doc/__tests__/reader-from-database.spec.ts @@ -193,7 +193,7 @@ test('should get workspace content with default avatar', async t => { user.id ); - const track = mock.method(docReader, 'parseWorkspaceContent', () => ({ + mock.method(docReader, 'parseWorkspaceContent', () => ({ name: 'Test Workspace', avatarKey: '', })); @@ -206,7 +206,6 @@ test('should get workspace content with default avatar', async t => { avatarKey: '', avatarUrl: undefined, }); - t.is(track.mock.callCount(), 1); }); test('should get workspace content with custom avatar', async t => { @@ -239,7 +238,7 @@ test('should get workspace content with custom avatar', async t => { Buffer.from('mock avatar image data here') ); - const track = mock.method(docReader, 'parseWorkspaceContent', () => ({ + mock.method(docReader, 'parseWorkspaceContent', () => ({ name: 'Test Workspace', avatarKey, })); @@ -252,5 +251,4 @@ test('should get workspace content with custom avatar', async t => { avatarKey, avatarUrl: `http://localhost:3010/api/workspaces/${workspace.id}/blobs/${avatarKey}`, }); - t.is(track.mock.callCount(), 1); }); diff --git a/packages/backend/server/src/core/doc/event.ts b/packages/backend/server/src/core/doc/event.ts index ead62833b1..b61c030d32 100644 --- a/packages/backend/server/src/core/doc/event.ts +++ b/packages/backend/server/src/core/doc/event.ts @@ -26,6 +26,13 @@ export class DocEventsListener { return; } await this.models.doc.upsertMeta(workspaceId, docId, content); + } else { + // update workspace content to database + const content = this.docReader.parseWorkspaceContent(blob); + if (!content) { + return; + } + await this.models.workspace.update(workspaceId, content); } } } diff --git a/packages/backend/server/src/core/doc/reader.ts b/packages/backend/server/src/core/doc/reader.ts index a15f0bdf5a..d6dac09848 100644 --- a/packages/backend/server/src/core/doc/reader.ts +++ b/packages/backend/server/src/core/doc/reader.ts @@ -12,7 +12,6 @@ import { Config, CryptoHelper, getOrGenRequestId, - URLHelper, UserFriendlyError, } from '../../base'; import { WorkspaceBlobStorage } from '../storage'; @@ -79,6 +78,7 @@ export abstract class DocReader { return content; } + // TODO(@fengmk2): should remove this method after frontend support workspace content update async getWorkspaceContent( workspaceId: string ): Promise { @@ -129,8 +129,7 @@ export class DatabaseDocReader extends DocReader { constructor( protected override readonly cache: Cache, protected readonly workspace: PgWorkspaceDocStorageAdapter, - protected readonly blobStorage: WorkspaceBlobStorage, - protected readonly url: URLHelper + protected readonly blobStorage: WorkspaceBlobStorage ) { super(cache); } @@ -178,9 +177,7 @@ export class DatabaseDocReader extends DocReader { } let avatarUrl: string | undefined; if (content.avatarKey) { - avatarUrl = this.url.link( - `/api/workspaces/${workspaceId}/blobs/${content.avatarKey}` - ); + avatarUrl = this.blobStorage.getAvatarUrl(workspaceId, content.avatarKey); } return { id: workspaceId, @@ -200,10 +197,9 @@ export class RpcDocReader extends DatabaseDocReader { private readonly crypto: CryptoHelper, protected override readonly cache: Cache, protected override readonly workspace: PgWorkspaceDocStorageAdapter, - protected override readonly blobStorage: WorkspaceBlobStorage, - protected override readonly url: URLHelper + protected override readonly blobStorage: WorkspaceBlobStorage ) { - super(cache, workspace, blobStorage, url); + super(cache, workspace, blobStorage); } private async fetch( diff --git a/packages/backend/server/src/core/storage/wrappers/blob.ts b/packages/backend/server/src/core/storage/wrappers/blob.ts index 9122a857f7..f41c9feca9 100644 --- a/packages/backend/server/src/core/storage/wrappers/blob.ts +++ b/packages/backend/server/src/core/storage/wrappers/blob.ts @@ -11,6 +11,7 @@ import { PutObjectMetadata, type StorageProvider, StorageProviderFactory, + URLHelper, } from '../../../base'; declare global { @@ -35,7 +36,8 @@ export class WorkspaceBlobStorage { private readonly config: Config, private readonly event: EventBus, private readonly storageFactory: StorageProviderFactory, - private readonly db: PrismaClient + private readonly db: PrismaClient, + private readonly url: URLHelper ) { this.provider = this.storageFactory.create(this.config.storages.blob); } @@ -140,6 +142,10 @@ export class WorkspaceBlobStorage { return sum._sum.size ?? 0; } + getAvatarUrl(workspaceId: string, avatarKey: string) { + return this.url.link(`/api/workspaces/${workspaceId}/blobs/${avatarKey}`); + } + private trySyncBlobsMeta(workspaceId: string, blobs: ListObjectsMetadata[]) { for (const blob of blobs) { this.event.emit('workspace.blob.sync', { diff --git a/packages/backend/server/src/models/workspace.ts b/packages/backend/server/src/models/workspace.ts index 565afbba64..31ae241600 100644 --- a/packages/backend/server/src/models/workspace.ts +++ b/packages/backend/server/src/models/workspace.ts @@ -16,7 +16,7 @@ declare global { export type { Workspace }; export type UpdateWorkspaceInput = Pick< Partial, - 'public' | 'enableAi' | 'enableUrlPreview' + 'public' | 'enableAi' | 'enableUrlPreview' | 'name' | 'avatarKey' >; @Injectable()