feat: reduce backend (#14251)

#### PR Dependency Tree


* **PR #14251** 👈

This tree was auto-generated by
[Charcoal](https://github.com/danerwilliams/charcoal)

<!-- This is an auto-generated comment: release notes by coderabbit.ai
-->
## Summary by CodeRabbit

* **New Features**
* Current user profile now exposes access tokens, revealed tokens, and
detailed calendar accounts/subscriptions.
* Workspace now exposes permissions, calendars, calendar events, and a
workspace-scoped blob upload part URL.
  * New document-update mutation for applying doc updates.

* **API Changes**
  * validateAppConfig is now a query (mutation deprecated).
* Several legacy top-level calendar/blob endpoints deprecated in favor
of user/workspace fields.

* **Refactor**
* Calendar, blob-upload and access-token surfaces reorganized to use
user/workspace-centric fields.

<sub>✏️ Tip: You can customize this high-level summary in your review
settings.</sub>
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-01-14 00:01:07 +08:00
committed by GitHub
parent 7c440686ad
commit 7c24b2521a
41 changed files with 978 additions and 503 deletions

View File

@@ -273,7 +273,7 @@ e2e('should mark notification as read', async t => {
const count = await app.gql({
query: notificationCountQuery,
});
t.is(count.currentUser!.notificationCount, 0);
t.is(count.currentUser!.notifications.totalCount, 0);
// read again should work
for (const notification of notifications) {

View File

@@ -1,8 +1,8 @@
import { Module } from '@nestjs/common';
import { AccessTokenResolver } from './resolver';
import { AccessTokenResolver, UserAccessTokenResolver } from './resolver';
@Module({
providers: [AccessTokenResolver],
providers: [AccessTokenResolver, UserAccessTokenResolver],
})
export class AccessTokenModule {}

View File

@@ -3,34 +3,17 @@ import {
Field,
InputType,
Mutation,
ObjectType,
Parent,
Query,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { ActionForbidden } from '../../base';
import { Models } from '../../models';
import { CurrentUser } from '../auth/session';
@ObjectType()
class AccessToken {
@Field()
id!: string;
@Field()
name!: string;
@Field()
createdAt!: Date;
@Field(() => Date, { nullable: true })
expiresAt!: Date | null;
}
@ObjectType()
class RevealedAccessToken extends AccessToken {
@Field()
token!: string;
}
import { UserType } from '../user';
import { AccessToken, RevealedAccessToken } from './types';
@InputType()
class GenerateAccessTokenInput {
@@ -45,12 +28,16 @@ class GenerateAccessTokenInput {
export class AccessTokenResolver {
constructor(private readonly models: Models) {}
@Query(() => [AccessToken])
@Query(() => [AccessToken], {
deprecationReason: 'use currentUser.accessTokens',
})
async accessTokens(@CurrentUser() user: CurrentUser): Promise<AccessToken[]> {
return await this.models.accessToken.list(user.id);
}
@Query(() => [RevealedAccessToken])
@Query(() => [RevealedAccessToken], {
deprecationReason: 'use currentUser.revealedAccessTokens',
})
async revealedAccessTokens(
@CurrentUser() user: CurrentUser
): Promise<RevealedAccessToken[]> {
@@ -78,3 +65,30 @@ export class AccessTokenResolver {
return true;
}
}
@Resolver(() => UserType)
export class UserAccessTokenResolver {
constructor(private readonly models: Models) {}
@ResolveField(() => [AccessToken])
async accessTokens(
@CurrentUser() currentUser: CurrentUser,
@Parent() user: UserType
): Promise<AccessToken[]> {
if (!currentUser || currentUser.id !== user.id) {
throw new ActionForbidden();
}
return await this.models.accessToken.list(user.id);
}
@ResolveField(() => [RevealedAccessToken])
async revealedAccessTokens(
@CurrentUser() currentUser: CurrentUser,
@Parent() user: UserType
): Promise<RevealedAccessToken[]> {
if (!currentUser || currentUser.id !== user.id) {
throw new ActionForbidden();
}
return await this.models.accessToken.list(user.id, true);
}
}

View File

@@ -0,0 +1,22 @@
import { Field, ObjectType } from '@nestjs/graphql';
@ObjectType()
export class AccessToken {
@Field()
id!: string;
@Field()
name!: string;
@Field()
createdAt!: Date;
@Field(() => Date, { nullable: true })
expiresAt!: Date | null;
}
@ObjectType()
export class RevealedAccessToken extends AccessToken {
@Field()
token!: string;
}

View File

@@ -230,13 +230,31 @@ export class AppConfigResolver {
return await this.service.updateConfig(me.id, updates);
}
@Mutation(() => [AppConfigValidateResult], {
@Query(() => [AppConfigValidateResult], {
description: 'validate app configuration',
})
async validateAppConfig(
@Args('updates', { type: () => [UpdateAppConfigInput] })
updates: UpdateAppConfigInput[]
): Promise<AppConfigValidateResult[]> {
return this.validateConfigInternal(updates);
}
@Mutation(() => [AppConfigValidateResult], {
description: 'validate app configuration',
deprecationReason: 'use Query.validateAppConfig',
name: 'validateAppConfig',
})
async validateAppConfigMutation(
@Args('updates', { type: () => [UpdateAppConfigInput] })
updates: UpdateAppConfigInput[]
): Promise<AppConfigValidateResult[]> {
return this.validateConfigInternal(updates);
}
private validateConfigInternal(
updates: UpdateAppConfigInput[]
): AppConfigValidateResult[] {
const errors = this.service.validateConfig(updates);
return updates.map(update => {

View File

@@ -156,6 +156,19 @@ export class WorkspaceBlobResolver {
return this.storage.totalSize(workspace.id);
}
@ResolveField(() => BlobUploadPart, {
description: 'Get blob upload part url',
})
async blobUploadPartUrl(
@CurrentUser() user: CurrentUser,
@Parent() workspace: WorkspaceType,
@Args('key') key: string,
@Args('uploadId') uploadId: string,
@Args('partNumber', { type: () => Int }) partNumber: number
): Promise<BlobUploadPart> {
return this.getUploadPart(user, workspace.id, key, uploadId, partNumber);
}
@Query(() => WorkspaceBlobSizes, {
deprecationReason: 'use `user.quotaUsage` instead',
})
@@ -399,13 +412,40 @@ export class WorkspaceBlobResolver {
return key;
}
@Mutation(() => BlobUploadPart)
@Mutation(() => BlobUploadPart, {
deprecationReason: 'use WorkspaceType.blobUploadPartUrl',
})
async getBlobUploadPartUrl(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('key') key: string,
@Args('uploadId') uploadId: string,
@Args('partNumber', { type: () => Int }) partNumber: number
): Promise<BlobUploadPart> {
return this.getUploadPart(user, workspaceId, key, uploadId, partNumber);
}
@Mutation(() => Boolean)
async abortBlobUpload(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('key') key: string,
@Args('uploadId') uploadId: string
) {
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Blobs.Write');
return this.storage.abortMultipartUpload(workspaceId, key, uploadId);
}
private async getUploadPart(
user: CurrentUser,
workspaceId: string,
key: string,
uploadId: string,
partNumber: number
): Promise<BlobUploadPart> {
await this.ac
.user(user.id)
@@ -429,21 +469,6 @@ export class WorkspaceBlobResolver {
};
}
@Mutation(() => Boolean)
async abortBlobUpload(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string,
@Args('key') key: string,
@Args('uploadId') uploadId: string
) {
await this.ac
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.Blobs.Write');
return this.storage.abortMultipartUpload(workspaceId, key, uploadId);
}
@Mutation(() => Boolean)
async deleteBlob(
@CurrentUser() user: CurrentUser,

View File

@@ -9,7 +9,14 @@ import { CalendarController } from './controller';
import { CalendarCronJobs } from './cron';
import { CalendarOAuthService } from './oauth';
import { CalendarProviderFactory, CalendarProviders } from './providers';
import { CalendarResolver } from './resolver';
import {
CalendarAccountResolver,
CalendarMutationResolver,
CalendarServerConfigResolver,
UserCalendarResolver,
WorkspaceCalendarEventsResolver,
WorkspaceCalendarResolver,
} from './resolver';
import { CalendarService } from './service';
@Module({
@@ -20,7 +27,12 @@ import { CalendarService } from './service';
CalendarService,
CalendarOAuthService,
CalendarCronJobs,
CalendarResolver,
CalendarServerConfigResolver,
UserCalendarResolver,
CalendarAccountResolver,
WorkspaceCalendarResolver,
WorkspaceCalendarEventsResolver,
CalendarMutationResolver,
],
controllers: [CalendarController],
})

View File

@@ -2,13 +2,17 @@ import {
Args,
GraphQLISODateTime,
Mutation,
Query,
Parent,
ResolveField,
Resolver,
} from '@nestjs/graphql';
import { AuthenticationRequired } from '../../base';
import { ActionForbidden, AuthenticationRequired } from '../../base';
import { CurrentUser } from '../../core/auth';
import { ServerConfigType } from '../../core/config/types';
import { AccessController } from '../../core/permission';
import { UserType } from '../../core/user';
import { WorkspaceType } from '../../core/workspaces';
import { Models } from '../../models';
import { CalendarOAuthService } from './oauth';
import { CalendarProviderFactory, CalendarProviderName } from './providers';
@@ -22,70 +26,100 @@ import {
WorkspaceCalendarObjectType,
} from './types';
@Resolver(() => CalendarAccountObjectType)
export class CalendarResolver {
constructor(
private readonly calendar: CalendarService,
private readonly oauth: CalendarOAuthService,
private readonly models: Models,
private readonly access: AccessController,
private readonly providerFactory: CalendarProviderFactory
) {}
@Resolver(() => ServerConfigType)
export class CalendarServerConfigResolver {
constructor(private readonly providerFactory: CalendarProviderFactory) {}
@Query(() => [CalendarAccountObjectType])
async calendarAccounts(@CurrentUser() user: CurrentUser) {
@ResolveField(() => [CalendarProviderName])
calendarProviders() {
return this.providerFactory.providers;
}
}
@Resolver(() => UserType)
export class UserCalendarResolver {
constructor(private readonly calendar: CalendarService) {}
@ResolveField(() => [CalendarAccountObjectType])
async calendarAccounts(
@CurrentUser() currentUser: CurrentUser,
@Parent() user: UserType
) {
if (!currentUser || currentUser.id !== user.id) {
throw new ActionForbidden();
}
return await this.calendar.listAccounts(user.id);
}
}
@Query(() => [CalendarSubscriptionObjectType])
async calendarAccountCalendars(
@Resolver(() => CalendarAccountObjectType)
export class CalendarAccountResolver {
constructor(private readonly calendar: CalendarService) {}
@ResolveField(() => [CalendarSubscriptionObjectType])
async calendars(
@CurrentUser() user: CurrentUser,
@Args('accountId') accountId: string
@Parent() account: CalendarAccountObjectType
) {
return await this.calendar.listAccountCalendars(user.id, accountId);
return await this.calendar.listAccountCalendars(user.id, account.id);
}
}
@Query(() => [WorkspaceCalendarObjectType])
async workspaceCalendars(
@Resolver(() => WorkspaceType)
export class WorkspaceCalendarResolver {
constructor(
private readonly calendar: CalendarService,
private readonly access: AccessController
) {}
@ResolveField(() => [WorkspaceCalendarObjectType])
async calendars(
@CurrentUser() user: CurrentUser,
@Args('workspaceId') workspaceId: string
@Parent() workspace: WorkspaceType
) {
await this.access
.user(user.id)
.workspace(workspaceId)
.assert('Workspace.CreateDoc');
return await this.calendar.getWorkspaceCalendars(workspaceId);
.workspace(workspace.id)
.assert('Workspace.Settings.Read');
return await this.calendar.getWorkspaceCalendars(workspace.id);
}
}
@Query(() => [CalendarEventObjectType])
async calendarEvents(
@Resolver(() => WorkspaceCalendarObjectType)
export class WorkspaceCalendarEventsResolver {
constructor(
private readonly calendar: CalendarService,
private readonly access: AccessController
) {}
@ResolveField(() => [CalendarEventObjectType])
async events(
@CurrentUser() user: CurrentUser,
@Args('workspaceCalendarId') workspaceCalendarId: string,
@Parent() calendar: WorkspaceCalendarObjectType,
@Args({ name: 'from', type: () => GraphQLISODateTime }) from: Date,
@Args({ name: 'to', type: () => GraphQLISODateTime }) to: Date
) {
const workspaceCalendar =
await this.models.workspaceCalendar.get(workspaceCalendarId);
if (!workspaceCalendar) {
return [];
}
await this.access
.user(user.id)
.workspace(workspaceCalendar.workspaceId)
.assert('Workspace.CreateDoc');
.workspace(calendar.workspaceId)
.assert('Workspace.Settings.Read');
return await this.calendar.listWorkspaceEvents({
workspaceCalendarId,
workspaceCalendarId: calendar.id,
from,
to,
});
}
}
@Query(() => [CalendarProviderName])
async calendarProviders() {
return this.providerFactory.providers;
}
@Resolver(() => CalendarAccountObjectType)
export class CalendarMutationResolver {
constructor(
private readonly calendar: CalendarService,
private readonly oauth: CalendarOAuthService,
private readonly models: Models,
private readonly access: AccessController
) {}
@Mutation(() => String)
async linkCalendarAccount(

View File

@@ -825,6 +825,7 @@ export class CopilotResolver {
@Query(() => String, {
description:
'Apply updates to a doc using LLM and return the merged markdown.',
deprecationReason: 'use Mutation.applyDocUpdates',
})
async applyDocUpdates(
@CurrentUser() user: CurrentUser,
@@ -836,6 +837,35 @@ export class CopilotResolver {
op: string,
@Args({ name: 'updates', type: () => String })
updates: string
): Promise<string> {
return this.applyDocUpdatesInternal(user, workspaceId, docId, op, updates);
}
@Mutation(() => String, {
description:
'Apply updates to a doc using LLM and return the merged markdown.',
name: 'applyDocUpdates',
})
async applyDocUpdatesMutation(
@CurrentUser() user: CurrentUser,
@Args({ name: 'workspaceId', type: () => String })
workspaceId: string,
@Args({ name: 'docId', type: () => String })
docId: string,
@Args({ name: 'op', type: () => String })
op: string,
@Args({ name: 'updates', type: () => String })
updates: string
): Promise<string> {
return this.applyDocUpdatesInternal(user, workspaceId, docId, op, updates);
}
private async applyDocUpdatesInternal(
user: CurrentUser,
workspaceId: string,
docId: string,
op: string,
updates: string
): Promise<string> {
await this.assertPermission(user, { workspaceId, docId });

View File

@@ -230,64 +230,57 @@ export class IndexerService {
docId,
docSnapshot.blob
);
if (result) {
await this.write(
SearchTable.doc,
[
{
workspaceId,
docId,
title: result.title,
summary: result.summary,
// NOTE(@fengmk): journal is not supported yet
// journal: result.journal,
createdByUserId: docSnapshot.createdBy ?? '',
updatedByUserId: docSnapshot.updatedBy ?? '',
createdAt: docSnapshot.createdAt,
updatedAt: docSnapshot.updatedAt,
},
],
options
);
await this.deleteBlocksByDocId(workspaceId, docId, options);
await this.write(
SearchTable.block,
result.blocks.map(block => ({
await this.write(
SearchTable.doc,
[
{
workspaceId,
docId,
blockId: block.blockId,
content: block.content ?? '',
flavour: block.flavour,
blob: block.blob,
refDocId: block.refDocId,
ref: block.ref,
parentFlavour: block.parentFlavour,
parentBlockId: block.parentBlockId,
additional: block.additional
? JSON.stringify(block.additional)
: undefined,
markdownPreview: undefined,
title: result.title,
summary: result.summary,
// NOTE(@fengmk): journal is not supported yet
// journal: result.journal,
createdByUserId: docSnapshot.createdBy ?? '',
updatedByUserId: docSnapshot.updatedBy ?? '',
createdAt: docSnapshot.createdAt,
updatedAt: docSnapshot.updatedAt,
})),
options
);
await this.queue.add('copilot.embedding.updateDoc', {
},
],
options
);
await this.deleteBlocksByDocId(workspaceId, docId, options);
await this.write(
SearchTable.block,
result.blocks.map(block => ({
workspaceId,
docId,
});
this.logger.verbose(
`synced doc ${workspaceId}/${docId} with ${result.blocks.length} blocks`
);
} else {
this.logger.warn(
`failed to parse ${workspaceId}/${docId}, no result returned`,
metadata
);
}
blockId: block.blockId,
content: block.content ?? '',
flavour: block.flavour,
blob: block.blob,
refDocId: block.refDocId,
ref: block.ref,
parentFlavour: block.parentFlavour,
parentBlockId: block.parentBlockId,
additional: block.additional
? JSON.stringify(block.additional)
: undefined,
markdownPreview: undefined,
createdByUserId: docSnapshot.createdBy ?? '',
updatedByUserId: docSnapshot.updatedBy ?? '',
createdAt: docSnapshot.createdAt,
updatedAt: docSnapshot.updatedAt,
})),
options
);
await this.queue.add('copilot.embedding.updateDoc', {
workspaceId,
docId,
});
this.logger.verbose(
`synced doc ${workspaceId}/${docId} with ${result.blocks.length} blocks`
);
} catch (err) {
this.logger.warn(
`failed to parse ${workspaceId}/${docId}: ${err}`,

View File

@@ -193,6 +193,7 @@ type BlobUploadedPart {
}
type CalendarAccountObjectType {
calendars: [CalendarSubscriptionObjectType!]!
calendarsCount: Int!
createdAt: DateTime!
displayName: String
@@ -1384,6 +1385,9 @@ type Mutation {
"""Update workspace flags and features for admin"""
adminUpdateWorkspace(input: AdminUpdateWorkspaceInput!): AdminWorkspace
"""Apply updates to a doc using LLM and return the merged markdown."""
applyDocUpdates(docId: String!, op: String!, updates: String!, workspaceId: String!): String!
approveMember(userId: String!, workspaceId: String!): Boolean!
"""Ban an user"""
@@ -1449,7 +1453,7 @@ type Mutation {
forkCopilotSession(options: ForkChatSessionInput!): String!
generateLicenseKey(sessionId: String!): String!
generateUserAccessToken(input: GenerateAccessTokenInput!): RevealedAccessToken!
getBlobUploadPartUrl(key: String!, partNumber: Int!, uploadId: String!, workspaceId: String!): BlobUploadPart!
getBlobUploadPartUrl(key: String!, partNumber: Int!, uploadId: String!, workspaceId: String!): BlobUploadPart! @deprecated(reason: "use WorkspaceType.blobUploadPartUrl")
grantDocUserRoles(input: GrantDocUserRolesInput!): Boolean!
grantMember(permission: Permission!, userId: String!, workspaceId: String!): Boolean!
@@ -1572,7 +1576,7 @@ type Mutation {
uploadCommentAttachment(attachment: Upload!, docId: String!, workspaceId: String!): String!
"""validate app configuration"""
validateAppConfig(updates: [UpdateAppConfigInput!]!): [AppConfigValidateResult!]!
validateAppConfig(updates: [UpdateAppConfigInput!]!): [AppConfigValidateResult!]! @deprecated(reason: "use Query.validateAppConfig")
verifyEmail(token: String!): Boolean!
}
@@ -1750,7 +1754,7 @@ type PublicUserType {
}
type Query {
accessTokens: [AccessToken!]!
accessTokens: [AccessToken!]! @deprecated(reason: "use currentUser.accessTokens")
"""Get workspace detail for admin"""
adminWorkspace(id: String!): AdminWorkspace
@@ -1765,11 +1769,7 @@ type Query {
appConfig: JSONObject!
"""Apply updates to a doc using LLM and return the merged markdown."""
applyDocUpdates(docId: String!, op: String!, updates: String!, workspaceId: String!): String!
calendarAccountCalendars(accountId: String!): [CalendarSubscriptionObjectType!]!
calendarAccounts: [CalendarAccountObjectType!]!
calendarEvents(from: DateTime!, to: DateTime!, workspaceCalendarId: String!): [CalendarEventObjectType!]!
calendarProviders: [CalendarProviderType!]!
applyDocUpdates(docId: String!, op: String!, updates: String!, workspaceId: String!): String! @deprecated(reason: "use Mutation.applyDocUpdates")
collectAllBlobSizes: WorkspaceBlobSizes! @deprecated(reason: "use `user.quotaUsage` instead")
"""Get current user"""
@@ -1794,7 +1794,7 @@ type Query {
"""query workspace embedding status"""
queryWorkspaceEmbeddingStatus(workspaceId: String!): ContextWorkspaceEmbeddingStatus!
revealedAccessTokens: [RevealedAccessToken!]!
revealedAccessTokens: [RevealedAccessToken!]! @deprecated(reason: "use currentUser.revealedAccessTokens")
"""server config"""
serverConfig: ServerConfigType!
@@ -1814,9 +1814,11 @@ type Query {
"""Get users count"""
usersCount(filter: ListUserInput): Int!
"""validate app configuration"""
validateAppConfig(updates: [UpdateAppConfigInput!]!): [AppConfigValidateResult!]!
"""Get workspace by id"""
workspace(id: String!): WorkspaceType!
workspaceCalendars(workspaceId: String!): [WorkspaceCalendarObjectType!]!
"""Get workspace role permissions"""
workspaceRolePermissions(id: String!): WorkspaceRolePermissions! @deprecated(reason: "use WorkspaceType[permissions] instead")
@@ -2048,6 +2050,7 @@ type ServerConfigType {
"""server base url"""
baseUrl: String!
calendarProviders: [CalendarProviderType!]!
"""credentials requirement"""
credentialsRequirement: CredentialsRequirementType!
@@ -2345,8 +2348,11 @@ type UserSettingsType {
}
type UserType {
accessTokens: [AccessToken!]!
"""User avatar url"""
avatarUrl: String
calendarAccounts: [CalendarAccountObjectType!]!
copilot(workspaceId: String): Copilot!
"""User email verified"""
@@ -2382,6 +2388,7 @@ type UserType {
notifications(pagination: PaginationInput!): PaginatedNotificationObjectType!
quota: UserQuotaType!
quotaUsage: UserQuotaUsageType!
revealedAccessTokens: [RevealedAccessToken!]!
"""Get user settings"""
settings: UserSettingsType!
@@ -2421,6 +2428,7 @@ type WorkspaceCalendarObjectType {
createdByUserId: String!
displayNameOverride: String
enabled: Boolean!
events(from: DateTime!, to: DateTime!): [CalendarEventObjectType!]!
id: String!
items: [WorkspaceCalendarItemObjectType!]!
workspaceId: String!
@@ -2511,11 +2519,15 @@ type WorkspaceType {
"""Search a specific table with aggregate"""
aggregate(input: AggregateInput!): AggregateResultObjectType!
"""Get blob upload part url"""
blobUploadPartUrl(key: String!, partNumber: Int!, uploadId: String!): BlobUploadPart!
"""List blobs of workspace"""
blobs: [ListedBlob!]!
"""Blobs size of workspace"""
blobsSize: Int!
calendars: [WorkspaceCalendarObjectType!]!
"""Get comment changes of a doc"""
commentChanges(docId: String!, pagination: PaginationInput!): PaginatedCommentChangeObjectType!