mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-18 10:36:22 +08:00
feat(core): integrate realtime features (#15003)
This commit is contained in:
@@ -5,7 +5,6 @@ import {
|
||||
listNotificationsQuery,
|
||||
MentionNotificationBodyType,
|
||||
mentionUserMutation,
|
||||
notificationCountQuery,
|
||||
NotificationObjectType,
|
||||
NotificationType,
|
||||
readAllNotificationsMutation,
|
||||
@@ -13,6 +12,7 @@ import {
|
||||
} from '@affine/graphql';
|
||||
|
||||
import { Mockers } from '../../mocks';
|
||||
import { createRealtimeClient, realtimeRequest } from '../realtime';
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
async function init() {
|
||||
@@ -270,10 +270,10 @@ e2e('should mark notification as read', async t => {
|
||||
},
|
||||
});
|
||||
}
|
||||
const count = await app.gql({
|
||||
query: notificationCountQuery,
|
||||
});
|
||||
t.is(count.currentUser!.notificationCount, 0);
|
||||
const socket = await createRealtimeClient(app, member);
|
||||
t.teardown(() => socket.disconnect());
|
||||
const count = await realtimeRequest(socket, 'notification.count.get', {});
|
||||
t.is(count.count, 0);
|
||||
|
||||
// read again should work
|
||||
for (const notification of notifications) {
|
||||
|
||||
@@ -0,0 +1,92 @@
|
||||
import type {
|
||||
RealtimeAck,
|
||||
RealtimeRequestInputOf,
|
||||
RealtimeRequestName,
|
||||
RealtimeRequestOutputOf,
|
||||
} from '@affine/realtime';
|
||||
import { io, type Socket as SocketIOClient } from 'socket.io-client';
|
||||
import type { Response } from 'supertest';
|
||||
|
||||
import type { MockedUser } from '../mocks';
|
||||
import type { TestingApp } from './create-app';
|
||||
|
||||
const REALTIME_CLIENT_VERSION = '0.26.0';
|
||||
const WS_TIMEOUT_MS = 5_000;
|
||||
|
||||
function cookieHeader(res: Response) {
|
||||
return (res.get('Set-Cookie') ?? [])
|
||||
.map(cookie => cookie.split(';')[0])
|
||||
.join('; ');
|
||||
}
|
||||
|
||||
async function withTimeout<T>(
|
||||
promise: Promise<T>,
|
||||
timeoutMs: number,
|
||||
label: string
|
||||
) {
|
||||
let timer: NodeJS.Timeout | undefined;
|
||||
const timeout = new Promise<never>((_, reject) => {
|
||||
timer = setTimeout(() => {
|
||||
reject(new Error(`Timeout (${timeoutMs}ms): ${label}`));
|
||||
}, timeoutMs);
|
||||
});
|
||||
|
||||
try {
|
||||
return await Promise.race([promise, timeout]);
|
||||
} finally {
|
||||
if (timer) clearTimeout(timer);
|
||||
}
|
||||
}
|
||||
|
||||
async function waitForConnect(socket: SocketIOClient) {
|
||||
if (socket.connected) {
|
||||
return;
|
||||
}
|
||||
|
||||
await withTimeout(
|
||||
new Promise<void>((resolve, reject) => {
|
||||
socket.once('connect', resolve);
|
||||
socket.once('connect_error', reject);
|
||||
}),
|
||||
WS_TIMEOUT_MS,
|
||||
'realtime socket connect'
|
||||
);
|
||||
}
|
||||
|
||||
export async function createRealtimeClient(app: TestingApp, user: MockedUser) {
|
||||
const login = await app.login(user);
|
||||
const socket = io(app.url, {
|
||||
transports: ['websocket'],
|
||||
reconnection: false,
|
||||
forceNew: true,
|
||||
extraHeaders: {
|
||||
cookie: cookieHeader(login),
|
||||
},
|
||||
});
|
||||
await waitForConnect(socket);
|
||||
return socket;
|
||||
}
|
||||
|
||||
export async function realtimeRequest<Op extends RealtimeRequestName>(
|
||||
socket: SocketIOClient,
|
||||
op: Op,
|
||||
input: RealtimeRequestInputOf<Op>
|
||||
): Promise<RealtimeRequestOutputOf<Op>> {
|
||||
const ack = await withTimeout(
|
||||
new Promise<RealtimeAck<RealtimeRequestOutputOf<Op>>>(resolve => {
|
||||
socket.emit(
|
||||
'realtime:request',
|
||||
{ op, input, clientVersion: REALTIME_CLIENT_VERSION },
|
||||
(res: RealtimeAck<RealtimeRequestOutputOf<Op>>) => resolve(res)
|
||||
);
|
||||
}),
|
||||
WS_TIMEOUT_MS,
|
||||
`realtime request ${op}`
|
||||
);
|
||||
|
||||
if ('error' in ack) {
|
||||
throw new Error(`${ack.error.name}: ${ack.error.message}`);
|
||||
}
|
||||
|
||||
return ack.data;
|
||||
}
|
||||
@@ -5,7 +5,6 @@ import {
|
||||
approveWorkspaceTeamMemberMutation,
|
||||
createInviteLinkMutation,
|
||||
getInviteInfoQuery,
|
||||
getMembersByWorkspaceIdQuery,
|
||||
inviteByEmailsMutation,
|
||||
leaveWorkspaceMutation,
|
||||
revokeMemberPermissionMutation,
|
||||
@@ -13,16 +12,21 @@ import {
|
||||
WorkspaceMemberStatus,
|
||||
} from '@affine/graphql';
|
||||
import { faker } from '@faker-js/faker';
|
||||
import {
|
||||
WorkspaceMemberSource,
|
||||
WorkspaceMemberStatus as PrismaWorkspaceMemberStatus,
|
||||
} from '@prisma/client';
|
||||
|
||||
import { EntitlementService } from '../../../core/entitlement';
|
||||
import { WorkspacePolicyService } from '../../../core/permission';
|
||||
import { Models } from '../../../models';
|
||||
import { Models, WorkspaceRole as ModelWorkspaceRole } from '../../../models';
|
||||
import {
|
||||
SubscriptionPlan,
|
||||
SubscriptionRecurring,
|
||||
SubscriptionStatus,
|
||||
} from '../../../plugins/payment/types';
|
||||
import { Mockers } from '../../mocks';
|
||||
import { createRealtimeClient, realtimeRequest } from '../realtime';
|
||||
import { app, e2e } from '../test';
|
||||
|
||||
const TWO_BILLION_BYTES = 2_000_000_000;
|
||||
@@ -380,39 +384,31 @@ e2e('should support pagination for member', async t => {
|
||||
userId: u2.id,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
let result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
skip: 0,
|
||||
take: 2,
|
||||
},
|
||||
const socket = await createRealtimeClient(app, owner);
|
||||
t.teardown(() => socket.disconnect());
|
||||
let result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
skip: 0,
|
||||
take: 2,
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 2);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 2);
|
||||
|
||||
result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
skip: 2,
|
||||
take: 2,
|
||||
},
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
skip: 2,
|
||||
take: 2,
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 1);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 1);
|
||||
|
||||
result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
skip: 3,
|
||||
take: 2,
|
||||
},
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
skip: 3,
|
||||
take: 2,
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 0);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 0);
|
||||
});
|
||||
|
||||
e2e('should limit member count correctly', async t => {
|
||||
@@ -428,17 +424,15 @@ e2e('should limit member count correctly', async t => {
|
||||
})
|
||||
);
|
||||
|
||||
await app.login(owner);
|
||||
const result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
skip: 0,
|
||||
take: 10,
|
||||
},
|
||||
const socket = await createRealtimeClient(app, owner);
|
||||
t.teardown(() => socket.disconnect());
|
||||
const result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
skip: 0,
|
||||
take: 10,
|
||||
});
|
||||
t.is(result.workspace.memberCount, 11);
|
||||
t.is(result.workspace.members.length, 10);
|
||||
t.is(result.memberCount, 11);
|
||||
t.is(result.members.length, 10);
|
||||
});
|
||||
|
||||
e2e('should get invite link info with status', async t => {
|
||||
@@ -634,38 +628,54 @@ e2e(
|
||||
userId: user2.id,
|
||||
});
|
||||
|
||||
await app.login(owner);
|
||||
let result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
query: 'lucy',
|
||||
},
|
||||
const socket = await createRealtimeClient(app, owner);
|
||||
t.teardown(() => socket.disconnect());
|
||||
let result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
query: 'lucy',
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 1);
|
||||
t.is(result.workspace.members[0].name, user1.name);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 1);
|
||||
t.is(result.members[0].name, user1.name);
|
||||
|
||||
result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
query: 'LUCY',
|
||||
},
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
query: 'LUCY',
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 1);
|
||||
t.is(result.workspace.members[0].name, user1.name);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 1);
|
||||
t.is(result.members[0].name, user1.name);
|
||||
|
||||
result = await app.gql({
|
||||
query: getMembersByWorkspaceIdQuery,
|
||||
variables: {
|
||||
workspaceId: workspace.id,
|
||||
query: 'jeanne_doe',
|
||||
},
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
query: 'jeanne_doe',
|
||||
});
|
||||
t.is(result.workspace.memberCount, 3);
|
||||
t.is(result.workspace.members.length, 1);
|
||||
t.is(result.workspace.members[0].email, user2.email);
|
||||
t.is(result.memberCount, 3);
|
||||
t.is(result.members.length, 1);
|
||||
t.is(result.members[0].email, user2.email);
|
||||
|
||||
const pendingEmail = `pending_search.${randomUUID()}@affine.pro`;
|
||||
const pendingUser = await app.create(Mockers.User, {
|
||||
email: pendingEmail,
|
||||
});
|
||||
await app
|
||||
.get(Models)
|
||||
.workspaceUser.set(
|
||||
workspace.id,
|
||||
pendingUser.id,
|
||||
ModelWorkspaceRole.Collaborator,
|
||||
{
|
||||
status: PrismaWorkspaceMemberStatus.Pending,
|
||||
source: WorkspaceMemberSource.Email,
|
||||
}
|
||||
);
|
||||
result = await realtimeRequest(socket, 'workspace.members.get', {
|
||||
workspaceId: workspace.id,
|
||||
query: 'pending_search',
|
||||
});
|
||||
t.is(result.memberCount, 4);
|
||||
t.is(result.members.length, 1);
|
||||
t.is(result.members[0].email, pendingEmail);
|
||||
t.is(result.members[0].status, WorkspaceMemberStatus.Pending);
|
||||
}
|
||||
);
|
||||
|
||||
+1
-1
@@ -10,5 +10,5 @@ import { CacheInterceptor } from './interceptor';
|
||||
})
|
||||
export class CacheModule {}
|
||||
export { Cache, SessionCache };
|
||||
|
||||
export { CacheInterceptor, MakeCache, PreventCache } from './interceptor';
|
||||
export { isValidCacheTtl } from './provider';
|
||||
|
||||
@@ -7,6 +7,10 @@ export interface CacheSetOptions {
|
||||
ttl?: number;
|
||||
}
|
||||
|
||||
export function isValidCacheTtl(ttl: unknown): ttl is number {
|
||||
return typeof ttl === 'number' && Number.isSafeInteger(ttl) && ttl > 0;
|
||||
}
|
||||
|
||||
export class CacheProvider {
|
||||
constructor(private readonly redis: Redis) {}
|
||||
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
export {
|
||||
Cache,
|
||||
CacheInterceptor,
|
||||
isValidCacheTtl,
|
||||
MakeCache,
|
||||
PreventCache,
|
||||
SessionCache,
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
Resolver,
|
||||
} from '@nestjs/graphql';
|
||||
|
||||
import { ActionForbidden } from '../../base';
|
||||
import { ActionForbidden, EventBus } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import { CurrentUser } from '../auth/session';
|
||||
import { UserType } from '../user';
|
||||
@@ -26,7 +26,10 @@ class GenerateAccessTokenInput {
|
||||
|
||||
@Resolver(() => AccessToken)
|
||||
export class AccessTokenResolver {
|
||||
constructor(private readonly models: Models) {}
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
@Query(() => [RevealedAccessToken], {
|
||||
deprecationReason: 'use currentUser.revealedAccessTokens',
|
||||
@@ -42,11 +45,13 @@ export class AccessTokenResolver {
|
||||
@CurrentUser() user: CurrentUser,
|
||||
@Args('input') input: GenerateAccessTokenInput
|
||||
): Promise<RevealedAccessToken> {
|
||||
return await this.models.accessToken.create({
|
||||
const token = await this.models.accessToken.create({
|
||||
userId: user.id,
|
||||
name: input.name,
|
||||
expiresAt: input.expiresAt,
|
||||
});
|
||||
this.event.emit('user.access_token.created', { userId: user.id });
|
||||
return token;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@@ -55,6 +60,7 @@ export class AccessTokenResolver {
|
||||
@Args('id') id: string
|
||||
): Promise<boolean> {
|
||||
await this.models.accessToken.revoke(id, user.id);
|
||||
this.event.emit('user.access_token.revoked', { userId: user.id });
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ import {
|
||||
} from '@nestjs/graphql';
|
||||
import { difference } from 'lodash-es';
|
||||
|
||||
import { BadRequest } from '../../base';
|
||||
import { BadRequest, EventBus } from '../../base';
|
||||
import { Feature, Models, type UserFeatureName } from '../../models';
|
||||
import { Admin } from '../common';
|
||||
import { EntitlementService } from '../entitlement';
|
||||
@@ -42,7 +42,8 @@ export class UserFeatureResolver extends AvailableUserFeatureConfig {
|
||||
export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig {
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly entitlement: EntitlementService
|
||||
private readonly entitlement: EntitlementService,
|
||||
private readonly event: EventBus
|
||||
) {
|
||||
super();
|
||||
}
|
||||
@@ -76,6 +77,11 @@ export class AdminFeatureManagementResolver extends AvailableUserFeatureConfig {
|
||||
removed.map(feature => this.models.userFeature.remove(id, feature))
|
||||
);
|
||||
|
||||
const user = await this.models.user.get(id);
|
||||
if (user) {
|
||||
this.event.emit('user.updated', user);
|
||||
}
|
||||
|
||||
return features;
|
||||
}
|
||||
|
||||
|
||||
@@ -41,6 +41,10 @@ export {
|
||||
PERMISSION_SHADOW_MISMATCH_CATEGORIES,
|
||||
PermissionDiagnosticService,
|
||||
} from './diagnostic';
|
||||
export {
|
||||
type DotToUnderline,
|
||||
mapPermissionsToGraphqlPermissions,
|
||||
} from './permission-map';
|
||||
export { WorkspacePolicyService } from './policy';
|
||||
export { PermissionProjectionChecker } from './projection-checker';
|
||||
export { PermissionService } from './service';
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
export type DotToUnderline<T extends string> =
|
||||
T extends `${infer Prefix}.${infer Suffix}`
|
||||
? `${Prefix}_${DotToUnderline<Suffix>}`
|
||||
: T;
|
||||
|
||||
export function mapPermissionsToGraphqlPermissions<A extends string>(
|
||||
permission: Record<A, boolean>
|
||||
): Record<DotToUnderline<A>, boolean> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(permission).map(([key, value]) => [
|
||||
key.replaceAll('.', '_'),
|
||||
value,
|
||||
])
|
||||
) as Record<DotToUnderline<A>, boolean>;
|
||||
}
|
||||
@@ -5,19 +5,43 @@ import {
|
||||
import test from 'ava';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { PublicDocMode } from '../../../models';
|
||||
import type { CopilotTranscriptionReader } from '../../../plugins/copilot/transcript';
|
||||
import { CopilotTranscriptRealtimeProvider } from '../../../plugins/copilot/transcript';
|
||||
import type { CurrentUser } from '../../auth';
|
||||
import { CommentRealtimeProvider } from '../../comment/realtime';
|
||||
import { NotificationRealtimeProvider } from '../../notification/realtime';
|
||||
import type { PermissionAccess } from '../../permission';
|
||||
import {
|
||||
DocRole,
|
||||
type PermissionAccess,
|
||||
WorkspaceRole,
|
||||
} from '../../permission';
|
||||
import { QuotaStateRealtimeProvider } from '../../quota/realtime';
|
||||
import { UserRealtimeProvider } from '../../user/realtime';
|
||||
import {
|
||||
DocGrantsRealtimeProvider,
|
||||
DocShareRealtimeProvider,
|
||||
} from '../../workspaces/doc-realtime';
|
||||
import {
|
||||
WorkspaceAccessRealtimeProvider,
|
||||
WorkspaceConfigRealtimeProvider,
|
||||
WorkspaceMembersRealtimeProvider,
|
||||
} from '../../workspaces/realtime';
|
||||
import { RealtimeGateway } from '../gateway';
|
||||
import {
|
||||
realtimeCommentRoom,
|
||||
realtimeDocGrantsRoom,
|
||||
realtimeDocShareStateRoom,
|
||||
realtimeNotificationRoom,
|
||||
realtimeTranscriptTaskRoom,
|
||||
realtimeUserAccessTokensRoom,
|
||||
realtimeUserProfileRoom,
|
||||
realtimeUserSettingsRoom,
|
||||
realtimeWorkspaceAccessRoom,
|
||||
realtimeWorkspaceConfigRoom,
|
||||
realtimeWorkspaceEmbeddingProgressRoom,
|
||||
realtimeWorkspaceInviteLinkRoom,
|
||||
realtimeWorkspaceMembersRoom,
|
||||
realtimeWorkspaceQuotaStateRoom,
|
||||
registerRealtimeLiveQuery,
|
||||
} from '../index';
|
||||
@@ -166,6 +190,18 @@ test('room helpers produce stable realtime room names', t => {
|
||||
realtimeWorkspaceEmbeddingProgressRoom('space'),
|
||||
'workspace:space:embedding-progress'
|
||||
);
|
||||
t.is(realtimeWorkspaceAccessRoom('space'), 'workspace:space:access');
|
||||
t.is(realtimeWorkspaceConfigRoom('space'), 'workspace:space:config');
|
||||
t.is(realtimeWorkspaceMembersRoom('space'), 'workspace:space:members');
|
||||
t.is(realtimeWorkspaceInviteLinkRoom('space'), 'workspace:space:invite-link');
|
||||
t.is(
|
||||
realtimeDocShareStateRoom('space', 'doc'),
|
||||
'workspace:space:doc:doc:share-state'
|
||||
);
|
||||
t.is(realtimeDocGrantsRoom('space', 'doc'), 'workspace:space:doc:doc:grants');
|
||||
t.is(realtimeUserProfileRoom('u1'), 'user:u1:profile');
|
||||
t.is(realtimeUserSettingsRoom('u1'), 'user:u1:settings');
|
||||
t.is(realtimeUserAccessTokensRoom('u1'), 'user:u1:access-tokens');
|
||||
t.is(
|
||||
realtimeTranscriptTaskRoom('space', 'task'),
|
||||
'copilot:transcript:space:task'
|
||||
@@ -225,6 +261,484 @@ test('realtime providers expose runtime injection metadata for registry dependen
|
||||
QuotaStateRealtimeProvider
|
||||
).includes(RealtimeRegistry)
|
||||
);
|
||||
t.true(
|
||||
Reflect.getMetadata(
|
||||
'design:paramtypes',
|
||||
WorkspaceAccessRealtimeProvider
|
||||
).includes(RealtimeRegistry)
|
||||
);
|
||||
t.true(
|
||||
Reflect.getMetadata(
|
||||
'design:paramtypes',
|
||||
WorkspaceConfigRealtimeProvider
|
||||
).includes(RealtimeRegistry)
|
||||
);
|
||||
t.true(
|
||||
Reflect.getMetadata(
|
||||
'design:paramtypes',
|
||||
WorkspaceMembersRealtimeProvider
|
||||
).includes(RealtimeRegistry)
|
||||
);
|
||||
t.true(
|
||||
Reflect.getMetadata('design:paramtypes', DocShareRealtimeProvider).includes(
|
||||
RealtimeRegistry
|
||||
)
|
||||
);
|
||||
t.true(
|
||||
Reflect.getMetadata(
|
||||
'design:paramtypes',
|
||||
DocGrantsRealtimeProvider
|
||||
).includes(RealtimeRegistry)
|
||||
);
|
||||
t.true(
|
||||
Reflect.getMetadata('design:paramtypes', UserRealtimeProvider).includes(
|
||||
RealtimeRegistry
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('workspace realtime providers register access, config, members and invite link handlers', async t => {
|
||||
const registry = new RealtimeRegistry();
|
||||
const assertions: unknown[] = [];
|
||||
const ac = {
|
||||
user(userId: string) {
|
||||
return {
|
||||
workspace(workspaceId: string) {
|
||||
return {
|
||||
async assert(action: string) {
|
||||
assertions.push({ userId, workspaceId, action });
|
||||
},
|
||||
async permissions() {
|
||||
return {
|
||||
role: WorkspaceRole.Admin,
|
||||
permissions: { 'Workspace.Read': true },
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as PermissionAccess;
|
||||
const models = {
|
||||
workspace: {
|
||||
get: async () => ({
|
||||
enableAi: true,
|
||||
enableSharing: false,
|
||||
enableUrlPreview: true,
|
||||
enableDocEmbedding: false,
|
||||
}),
|
||||
},
|
||||
workspaceUser: {
|
||||
search: async () => [],
|
||||
paginate: async () => [
|
||||
[
|
||||
{
|
||||
id: 'invite',
|
||||
type: WorkspaceRole.Collaborator,
|
||||
status: 'Accepted',
|
||||
user: {
|
||||
id: 'u1',
|
||||
name: 'User',
|
||||
email: 'u1@affine.pro',
|
||||
avatarUrl: null,
|
||||
},
|
||||
},
|
||||
],
|
||||
1,
|
||||
],
|
||||
count: async () => 1,
|
||||
},
|
||||
};
|
||||
const workspaceService = {
|
||||
isTeamWorkspace: async () => true,
|
||||
};
|
||||
const cache = {
|
||||
get: async () => ({ inviteId: 'invite-link' }),
|
||||
ttl: async () => 10,
|
||||
};
|
||||
const url = {
|
||||
link: (path: string) => `https://app.affine.pro${path}`,
|
||||
};
|
||||
|
||||
new WorkspaceAccessRealtimeProvider(
|
||||
ac,
|
||||
workspaceService as never,
|
||||
registry
|
||||
).onModuleInit();
|
||||
new WorkspaceConfigRealtimeProvider(
|
||||
ac,
|
||||
models as never,
|
||||
registry
|
||||
).onModuleInit();
|
||||
new WorkspaceMembersRealtimeProvider(
|
||||
cache as never,
|
||||
url as never,
|
||||
ac,
|
||||
models as never,
|
||||
registry
|
||||
).onModuleInit();
|
||||
|
||||
t.deepEqual(
|
||||
await registry.getRequest('workspace.access.get').handle(user, {
|
||||
workspaceId: 'space',
|
||||
}),
|
||||
{
|
||||
access: {
|
||||
role: 'Admin',
|
||||
permissions: { Workspace_Read: true },
|
||||
team: true,
|
||||
},
|
||||
}
|
||||
);
|
||||
t.deepEqual(
|
||||
await registry.getRequest('workspace.config.get').handle(user, {
|
||||
workspaceId: 'space',
|
||||
}),
|
||||
{
|
||||
config: {
|
||||
enableAi: true,
|
||||
enableSharing: false,
|
||||
enableUrlPreview: true,
|
||||
enableDocEmbedding: false,
|
||||
},
|
||||
}
|
||||
);
|
||||
t.like(
|
||||
await registry.getRequest('workspace.members.get').handle(user, {
|
||||
workspaceId: 'space',
|
||||
take: 1000,
|
||||
}),
|
||||
{ memberCount: 1 }
|
||||
);
|
||||
t.like(
|
||||
await registry.getRequest('workspace.invite-link.get').handle(user, {
|
||||
workspaceId: 'space',
|
||||
}),
|
||||
{
|
||||
inviteLink: {
|
||||
link: 'https://app.affine.pro/invite/invite-link',
|
||||
},
|
||||
}
|
||||
);
|
||||
t.is(
|
||||
registry
|
||||
.getTopic('workspace.access.changed')
|
||||
.room(user, { workspaceId: 'space' }),
|
||||
realtimeWorkspaceAccessRoom('space')
|
||||
);
|
||||
t.is(
|
||||
registry
|
||||
.getTopic('workspace.config.changed')
|
||||
.room(user, { workspaceId: 'space' }),
|
||||
realtimeWorkspaceConfigRoom('space')
|
||||
);
|
||||
t.is(
|
||||
registry
|
||||
.getTopic('workspace.members.changed')
|
||||
.room(user, { workspaceId: 'space' }),
|
||||
realtimeWorkspaceMembersRoom('space')
|
||||
);
|
||||
t.is(
|
||||
registry
|
||||
.getTopic('workspace.invite-link.changed')
|
||||
.room(user, { workspaceId: 'space' }),
|
||||
realtimeWorkspaceInviteLinkRoom('space')
|
||||
);
|
||||
t.true(
|
||||
assertions.some(
|
||||
item =>
|
||||
JSON.stringify(item) ===
|
||||
JSON.stringify({
|
||||
userId: 'u1',
|
||||
workspaceId: 'space',
|
||||
action: 'Workspace.Users.Read',
|
||||
})
|
||||
)
|
||||
);
|
||||
});
|
||||
|
||||
test('doc realtime providers register share state and grants handlers', async t => {
|
||||
const registry = new RealtimeRegistry();
|
||||
const assertedActions: string[] = [];
|
||||
const ac = {
|
||||
user(userId: string) {
|
||||
return {
|
||||
doc(workspaceId: string, docId: string) {
|
||||
return {
|
||||
async assert(action: string) {
|
||||
t.deepEqual(
|
||||
{ userId, workspaceId, docId },
|
||||
{
|
||||
userId: 'u1',
|
||||
workspaceId: 'space',
|
||||
docId: 'doc',
|
||||
}
|
||||
);
|
||||
assertedActions.push(action);
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
},
|
||||
} as unknown as PermissionAccess;
|
||||
const models = {
|
||||
doc: {
|
||||
getDocInfo: async () => ({
|
||||
public: true,
|
||||
mode: PublicDocMode.Page,
|
||||
defaultRole: DocRole.Reader,
|
||||
}),
|
||||
},
|
||||
docUser: {
|
||||
findDirectGrantDocIdsByUser: async () => [],
|
||||
paginate: async () => [
|
||||
[
|
||||
{
|
||||
userId: 'u2',
|
||||
type: DocRole.Manager,
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
},
|
||||
],
|
||||
1,
|
||||
],
|
||||
},
|
||||
user: {
|
||||
getWorkspaceUsers: async () => [
|
||||
{
|
||||
id: 'u2',
|
||||
name: 'User 2',
|
||||
email: 'u2@affine.pro',
|
||||
avatarUrl: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
const grants = {
|
||||
paginateGrantedUsers: async () => ({
|
||||
totalCount: 1,
|
||||
pageInfo: { endCursor: null, hasNextPage: false },
|
||||
edges: [
|
||||
{
|
||||
node: {
|
||||
type: DocRole.Manager,
|
||||
user: {
|
||||
id: 'u2',
|
||||
name: 'User 2',
|
||||
email: 'u2@affine.pro',
|
||||
avatarUrl: null,
|
||||
},
|
||||
},
|
||||
},
|
||||
],
|
||||
}),
|
||||
};
|
||||
|
||||
new DocShareRealtimeProvider(ac, models as never, registry).onModuleInit();
|
||||
new DocGrantsRealtimeProvider(
|
||||
ac,
|
||||
models as never,
|
||||
grants as never,
|
||||
registry
|
||||
).onModuleInit();
|
||||
|
||||
t.deepEqual(
|
||||
await registry.getRequest('doc.share-state.get').handle(user, {
|
||||
workspaceId: 'space',
|
||||
docId: 'doc',
|
||||
}),
|
||||
{
|
||||
state: {
|
||||
public: true,
|
||||
mode: 'Page',
|
||||
defaultRole: 'Reader',
|
||||
},
|
||||
}
|
||||
);
|
||||
t.like(
|
||||
await registry.getRequest('doc.grants.get').handle(user, {
|
||||
workspaceId: 'space',
|
||||
docId: 'doc',
|
||||
pagination: { first: 10 },
|
||||
}),
|
||||
{ totalCount: 1 }
|
||||
);
|
||||
t.deepEqual(assertedActions, ['Doc.Read', 'Doc.Users.Read']);
|
||||
t.is(
|
||||
registry
|
||||
.getTopic('doc.share-state.changed')
|
||||
.room(user, { workspaceId: 'space', docId: 'doc' }),
|
||||
realtimeDocShareStateRoom('space', 'doc')
|
||||
);
|
||||
t.is(
|
||||
registry
|
||||
.getTopic('doc.grants.changed')
|
||||
.room(user, { workspaceId: 'space', docId: 'doc' }),
|
||||
realtimeDocGrantsRoom('space', 'doc')
|
||||
);
|
||||
});
|
||||
|
||||
test('user realtime provider snapshots private profile settings and access tokens without plaintext token', async t => {
|
||||
const registry = new RealtimeRegistry();
|
||||
const models = {
|
||||
user: {
|
||||
get: async () => ({
|
||||
id: 'u1',
|
||||
name: 'User',
|
||||
email: 'u1@affine.pro',
|
||||
avatarUrl: null,
|
||||
emailVerifiedAt: new Date(0),
|
||||
password: 'hash',
|
||||
disabled: false,
|
||||
}),
|
||||
},
|
||||
userSettings: {
|
||||
get: async () => ({
|
||||
receiveInvitationEmail: true,
|
||||
receiveMentionEmail: false,
|
||||
receiveCommentEmail: true,
|
||||
}),
|
||||
},
|
||||
userFeature: {
|
||||
list: async () => ['administrator'],
|
||||
},
|
||||
accessToken: {
|
||||
list: async () => [
|
||||
{
|
||||
id: 'token',
|
||||
name: 'Token',
|
||||
createdAt: new Date('2026-01-01T00:00:00.000Z'),
|
||||
expiresAt: null,
|
||||
},
|
||||
],
|
||||
},
|
||||
};
|
||||
|
||||
new UserRealtimeProvider(models as never, registry).onModuleInit();
|
||||
|
||||
t.deepEqual(await registry.getRequest('user.profile.get').handle(user, {}), {
|
||||
user: {
|
||||
id: 'u1',
|
||||
name: 'User',
|
||||
email: 'u1@affine.pro',
|
||||
emailVerified: true,
|
||||
hasPassword: true,
|
||||
avatarUrl: null,
|
||||
features: ['Admin'],
|
||||
},
|
||||
});
|
||||
t.deepEqual(
|
||||
await registry
|
||||
.getRequest('user.profile.get')
|
||||
.handle(undefined as never, {}),
|
||||
{ user: null }
|
||||
);
|
||||
t.is(
|
||||
registry.getTopic('user.profile.changed').room(user, {}),
|
||||
realtimeUserProfileRoom('u1')
|
||||
);
|
||||
t.is(
|
||||
registry.getTopic('user.settings.changed').room(user, {}),
|
||||
realtimeUserSettingsRoom('u1')
|
||||
);
|
||||
t.is(
|
||||
registry.getTopic('user.access-tokens.changed').room(user, {}),
|
||||
realtimeUserAccessTokensRoom('u1')
|
||||
);
|
||||
t.deepEqual(await registry.getRequest('user.settings.get').handle(user, {}), {
|
||||
settings: {
|
||||
receiveInvitationEmail: true,
|
||||
receiveMentionEmail: false,
|
||||
receiveCommentEmail: true,
|
||||
},
|
||||
});
|
||||
t.deepEqual(
|
||||
await registry.getRequest('user.access-tokens.get').handle(user, {}),
|
||||
{
|
||||
tokens: [
|
||||
{
|
||||
id: 'token',
|
||||
name: 'Token',
|
||||
createdAt: '2026-01-01T00:00:00.000Z',
|
||||
expiresAt: null,
|
||||
},
|
||||
],
|
||||
}
|
||||
);
|
||||
});
|
||||
|
||||
test('new realtime providers publish changed events from domain events', t => {
|
||||
const published: unknown[][] = [];
|
||||
const publisher = {
|
||||
publish: (...args: unknown[]) => published.push(args),
|
||||
publishChanged: (...args: unknown[]) => published.push(args),
|
||||
} as unknown as RealtimePublisher;
|
||||
|
||||
const workspaceAccess = new WorkspaceAccessRealtimeProvider(
|
||||
{} as never,
|
||||
{} as never,
|
||||
undefined,
|
||||
publisher
|
||||
);
|
||||
workspaceAccess.onMembersUpdated({ workspaceId: 'space' });
|
||||
|
||||
const workspaceConfig = new WorkspaceConfigRealtimeProvider(
|
||||
{} as never,
|
||||
{} as never,
|
||||
undefined,
|
||||
publisher
|
||||
);
|
||||
workspaceConfig.onWorkspaceUpdated({ id: 'space' } as never);
|
||||
|
||||
const workspaceMembers = new WorkspaceMembersRealtimeProvider(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
undefined,
|
||||
publisher
|
||||
);
|
||||
workspaceMembers.onInviteLinkCreated({ workspaceId: 'space' });
|
||||
|
||||
const docShare = new DocShareRealtimeProvider(
|
||||
{} as never,
|
||||
{} as never,
|
||||
undefined,
|
||||
publisher
|
||||
);
|
||||
docShare.onPublicStateChanged({ workspaceId: 'space', docId: 'doc' });
|
||||
|
||||
const docGrants = new DocGrantsRealtimeProvider(
|
||||
{} as never,
|
||||
{} as never,
|
||||
{} as never,
|
||||
undefined,
|
||||
publisher
|
||||
);
|
||||
docGrants.onOwnerChanged({
|
||||
workspaceId: 'space',
|
||||
docId: 'doc',
|
||||
userId: 'u2',
|
||||
});
|
||||
|
||||
const userProvider = new UserRealtimeProvider(
|
||||
{} as never,
|
||||
undefined,
|
||||
publisher
|
||||
);
|
||||
userProvider.onUserAccessTokenCreated({ userId: 'u1' });
|
||||
|
||||
t.deepEqual(
|
||||
published.map(args => args[0]),
|
||||
[
|
||||
'workspace.access.changed',
|
||||
'workspace.config.changed',
|
||||
'workspace.invite-link.changed',
|
||||
'doc.share-state.changed',
|
||||
'doc.grants.changed',
|
||||
'user.access-tokens.changed',
|
||||
]
|
||||
);
|
||||
});
|
||||
|
||||
test('quota realtime provider exposes effective quota state snapshots', async t => {
|
||||
|
||||
@@ -16,12 +16,21 @@ export { RealtimePublisher } from './publisher';
|
||||
export { RealtimeRegistry } from './registry';
|
||||
export {
|
||||
realtimeCommentRoom,
|
||||
realtimeDocGrantsRoom,
|
||||
realtimeDocShareStateRoom,
|
||||
realtimeNotificationRoom,
|
||||
realtimeTranscriptTaskRoom,
|
||||
realtimeUserAccessTokensRoom,
|
||||
realtimeUserProfileRoom,
|
||||
realtimeUserQuotaStateRoom,
|
||||
realtimeUserRoom,
|
||||
realtimeUserSettingsRoom,
|
||||
realtimeWorkspaceAccessRoom,
|
||||
realtimeWorkspaceConfigRoom,
|
||||
realtimeWorkspaceDocRoom,
|
||||
realtimeWorkspaceEmbeddingProgressRoom,
|
||||
realtimeWorkspaceInviteLinkRoom,
|
||||
realtimeWorkspaceMembersRoom,
|
||||
realtimeWorkspaceQuotaStateRoom,
|
||||
realtimeWorkspaceRoom,
|
||||
} from './rooms';
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
import {
|
||||
getRealtimeInputKey,
|
||||
type RealtimeEvent,
|
||||
type RealtimeTopicEventOf,
|
||||
type RealtimeTopicInputOf,
|
||||
type RealtimeTopicName,
|
||||
} from '@affine/realtime';
|
||||
import { Injectable, Logger } from '@nestjs/common';
|
||||
@@ -44,6 +46,20 @@ export class RealtimePublisher {
|
||||
}
|
||||
}
|
||||
|
||||
publishChanged<Topic extends RealtimeTopicName>(
|
||||
topic: Topic,
|
||||
input: RealtimeTopicInputOf<Topic>,
|
||||
reason: string,
|
||||
options?: { room?: string }
|
||||
) {
|
||||
this.publish(
|
||||
topic,
|
||||
input,
|
||||
{ changed: true, reason } as RealtimeTopicEventOf<Topic>,
|
||||
options
|
||||
);
|
||||
}
|
||||
|
||||
publishLocal(payload: RealtimePublishPayload) {
|
||||
const handler = this.registry.getTopic(payload.topic);
|
||||
const room = payload.room ?? handler.room(null, payload.input as never);
|
||||
|
||||
@@ -40,3 +40,39 @@ export function realtimeUserQuotaStateRoom(userId: string) {
|
||||
export function realtimeWorkspaceQuotaStateRoom(workspaceId: string) {
|
||||
return realtimeWorkspaceRoom(workspaceId, 'quota-state');
|
||||
}
|
||||
|
||||
export function realtimeWorkspaceAccessRoom(workspaceId: string) {
|
||||
return realtimeWorkspaceRoom(workspaceId, 'access');
|
||||
}
|
||||
|
||||
export function realtimeWorkspaceConfigRoom(workspaceId: string) {
|
||||
return realtimeWorkspaceRoom(workspaceId, 'config');
|
||||
}
|
||||
|
||||
export function realtimeWorkspaceMembersRoom(workspaceId: string) {
|
||||
return realtimeWorkspaceRoom(workspaceId, 'members');
|
||||
}
|
||||
|
||||
export function realtimeWorkspaceInviteLinkRoom(workspaceId: string) {
|
||||
return realtimeWorkspaceRoom(workspaceId, 'invite-link');
|
||||
}
|
||||
|
||||
export function realtimeDocShareStateRoom(workspaceId: string, docId: string) {
|
||||
return realtimeWorkspaceDocRoom(workspaceId, docId, 'share-state');
|
||||
}
|
||||
|
||||
export function realtimeDocGrantsRoom(workspaceId: string, docId: string) {
|
||||
return realtimeWorkspaceDocRoom(workspaceId, docId, 'grants');
|
||||
}
|
||||
|
||||
export function realtimeUserProfileRoom(userId: string) {
|
||||
return realtimeUserRoom(userId, 'profile');
|
||||
}
|
||||
|
||||
export function realtimeUserSettingsRoom(userId: string) {
|
||||
return realtimeUserRoom(userId, 'settings');
|
||||
}
|
||||
|
||||
export function realtimeUserAccessTokensRoom(userId: string) {
|
||||
return realtimeUserRoom(userId, 'access-tokens');
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ import { Module } from '@nestjs/common';
|
||||
import { PermissionModule } from '../permission';
|
||||
import { StorageModule } from '../storage';
|
||||
import { UserAvatarController } from './controller';
|
||||
import { UserRealtimeProvider } from './realtime';
|
||||
import {
|
||||
UserManagementResolver,
|
||||
UserResolver,
|
||||
@@ -11,7 +12,12 @@ import {
|
||||
|
||||
@Module({
|
||||
imports: [StorageModule, PermissionModule],
|
||||
providers: [UserResolver, UserManagementResolver, UserSettingsResolver],
|
||||
providers: [
|
||||
UserResolver,
|
||||
UserManagementResolver,
|
||||
UserSettingsResolver,
|
||||
UserRealtimeProvider,
|
||||
],
|
||||
controllers: [UserAvatarController],
|
||||
})
|
||||
export class UserModule {}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
import type {
|
||||
AccessTokenSnapshot,
|
||||
CurrentUserProfileSnapshot,
|
||||
UserSettingsSnapshot,
|
||||
} from '@affine/realtime';
|
||||
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { AuthenticationRequired, OnEvent, UserNotFound } from '../../base';
|
||||
import { Feature, Models } from '../../models';
|
||||
import { sessionUser } from '../auth/service';
|
||||
import { AvailableUserFeatureConfig } from '../features/types';
|
||||
import { registerRealtimeLiveQuery } from '../realtime/provider';
|
||||
import { RealtimePublisher } from '../realtime/publisher';
|
||||
import { RealtimeRegistry } from '../realtime/registry';
|
||||
import {
|
||||
realtimeUserAccessTokensRoom,
|
||||
realtimeUserProfileRoom,
|
||||
realtimeUserSettingsRoom,
|
||||
} from '../realtime/rooms';
|
||||
|
||||
const emptyInput = z.object({}).strict();
|
||||
|
||||
function assertAuthenticated(user?: { id: string }) {
|
||||
if (!user) {
|
||||
throw new AuthenticationRequired();
|
||||
}
|
||||
return user;
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class UserRealtimeProvider
|
||||
extends AvailableUserFeatureConfig
|
||||
implements OnModuleInit
|
||||
{
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
@Optional() private readonly registry?: RealtimeRegistry,
|
||||
@Optional() private readonly publisher?: RealtimePublisher
|
||||
) {
|
||||
super();
|
||||
}
|
||||
|
||||
onModuleInit() {
|
||||
if (!this.registry) return;
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'user.profile.get',
|
||||
input: emptyInput,
|
||||
handle: async user => ({
|
||||
user: user ? await this.getProfile(user.id) : null,
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'user.profile.changed',
|
||||
input: emptyInput,
|
||||
authorize: async () => {},
|
||||
room: user => {
|
||||
if (!user) {
|
||||
throw new Error('Authenticated user is required');
|
||||
}
|
||||
return realtimeUserProfileRoom(user.id);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'user.settings.get',
|
||||
input: emptyInput,
|
||||
handle: async user => ({
|
||||
settings: await this.getSettings(assertAuthenticated(user).id),
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'user.settings.changed',
|
||||
input: emptyInput,
|
||||
authorize: async () => {},
|
||||
room: user => {
|
||||
if (!user) {
|
||||
throw new Error('Authenticated user is required');
|
||||
}
|
||||
return realtimeUserSettingsRoom(user.id);
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'user.access-tokens.get',
|
||||
input: emptyInput,
|
||||
handle: async user => ({
|
||||
tokens: await this.getAccessTokens(assertAuthenticated(user).id),
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'user.access-tokens.changed',
|
||||
input: emptyInput,
|
||||
authorize: async () => {},
|
||||
room: user => {
|
||||
if (!user) {
|
||||
throw new Error('Authenticated user is required');
|
||||
}
|
||||
return realtimeUserAccessTokensRoom(user.id);
|
||||
},
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('user.updated', { suppressError: true })
|
||||
onUserUpdated(user: Events['user.updated']) {
|
||||
this.publisher?.publishChanged('user.profile.changed', {}, 'user-updated', {
|
||||
room: realtimeUserProfileRoom(user.id),
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('user.settings.updated', { suppressError: true })
|
||||
onUserSettingsUpdated({ userId }: Events['user.settings.updated']) {
|
||||
this.publisher?.publishChanged(
|
||||
'user.settings.changed',
|
||||
{},
|
||||
'settings-updated',
|
||||
{ room: realtimeUserSettingsRoom(userId) }
|
||||
);
|
||||
}
|
||||
|
||||
@OnEvent('user.access_token.created', { suppressError: true })
|
||||
onUserAccessTokenCreated({ userId }: Events['user.access_token.created']) {
|
||||
this.publishAccessTokens(userId, 'access-token-created');
|
||||
}
|
||||
|
||||
@OnEvent('user.access_token.revoked', { suppressError: true })
|
||||
onUserAccessTokenRevoked({ userId }: Events['user.access_token.revoked']) {
|
||||
this.publishAccessTokens(userId, 'access-token-revoked');
|
||||
}
|
||||
|
||||
private async getProfile(
|
||||
userId: string
|
||||
): Promise<CurrentUserProfileSnapshot> {
|
||||
const user = await this.models.user.get(userId);
|
||||
if (!user) {
|
||||
throw new UserNotFound();
|
||||
}
|
||||
const current = sessionUser(user);
|
||||
return {
|
||||
id: current.id,
|
||||
name: current.name,
|
||||
email: current.email,
|
||||
emailVerified: current.emailVerified,
|
||||
hasPassword: current.hasPassword,
|
||||
avatarUrl: current.avatarUrl ?? null,
|
||||
features: (await this.models.userFeature.list(userId))
|
||||
.filter(feature => this.availableUserFeatures().has(feature))
|
||||
.map(feature => this.serializeFeature(feature)),
|
||||
};
|
||||
}
|
||||
|
||||
private serializeFeature(feature: string) {
|
||||
return (
|
||||
Object.entries(Feature).find(([, value]) => value === feature)?.[0] ??
|
||||
feature
|
||||
);
|
||||
}
|
||||
|
||||
private async getSettings(userId: string): Promise<UserSettingsSnapshot> {
|
||||
return await this.models.userSettings.get(userId);
|
||||
}
|
||||
|
||||
private async getAccessTokens(
|
||||
userId: string
|
||||
): Promise<AccessTokenSnapshot[]> {
|
||||
const tokens = await this.models.accessToken.list(userId);
|
||||
return tokens.map(token => ({
|
||||
id: token.id,
|
||||
name: token.name,
|
||||
createdAt: token.createdAt.toISOString(),
|
||||
expiresAt: token.expiresAt?.toISOString() ?? null,
|
||||
}));
|
||||
}
|
||||
|
||||
private publishAccessTokens(userId: string, reason: string) {
|
||||
this.publisher?.publishChanged('user.access-tokens.changed', {}, reason, {
|
||||
room: realtimeUserAccessTokensRoom(userId),
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,7 @@ import { isNil, omitBy } from 'lodash-es';
|
||||
|
||||
import {
|
||||
CannotDeleteOwnAccount,
|
||||
EventBus,
|
||||
type FileUpload,
|
||||
ImageFormatNotSupported,
|
||||
OneMB,
|
||||
@@ -186,7 +187,10 @@ export class UserResolver {
|
||||
|
||||
@Resolver(() => UserType)
|
||||
export class UserSettingsResolver {
|
||||
constructor(private readonly models: Models) {}
|
||||
constructor(
|
||||
private readonly models: Models,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
@Mutation(() => Boolean, {
|
||||
name: 'updateSettings',
|
||||
@@ -199,6 +203,7 @@ export class UserSettingsResolver {
|
||||
) {
|
||||
UserSettingsSchema.parse(input);
|
||||
await this.models.userSettings.set(user.id, input);
|
||||
this.event.emit('user.settings.updated', { userId: user.id });
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Injectable } from '@nestjs/common';
|
||||
|
||||
import { paginate, PaginationInput } from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import type { WorkspaceUserType } from '../user';
|
||||
|
||||
@Injectable()
|
||||
export class DocGrantsService {
|
||||
constructor(private readonly models: Models) {}
|
||||
|
||||
async paginateGrantedUsers(
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
pagination: PaginationInput
|
||||
) {
|
||||
const [permissions, totalCount] = await this.models.docUser.paginate(
|
||||
workspaceId,
|
||||
docId,
|
||||
pagination
|
||||
);
|
||||
const workspaceUsers = await this.models.user.getWorkspaceUsers(
|
||||
permissions.map(p => p.userId)
|
||||
);
|
||||
const workspaceUsersMap = new Map(
|
||||
workspaceUsers.map(user => [user.id, user])
|
||||
);
|
||||
|
||||
return paginate(
|
||||
permissions.map(permission => {
|
||||
const user = workspaceUsersMap.get(permission.userId);
|
||||
if (!user) {
|
||||
throw new Error(`Doc grant user ${permission.userId} not found`);
|
||||
}
|
||||
return {
|
||||
...permission,
|
||||
user: user as WorkspaceUserType,
|
||||
};
|
||||
}),
|
||||
'createdAt',
|
||||
pagination,
|
||||
totalCount
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,221 @@
|
||||
import type {
|
||||
DocShareStateSnapshot,
|
||||
PaginatedDocGrantedUsersSnapshot,
|
||||
} from '@affine/realtime';
|
||||
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { OnEvent, PaginationInput } from '../../base';
|
||||
import { DocRole, Models, PublicDocMode } from '../../models';
|
||||
import { PermissionAccess } from '../permission';
|
||||
import { registerRealtimeLiveQuery } from '../realtime/provider';
|
||||
import { RealtimePublisher } from '../realtime/publisher';
|
||||
import { RealtimeRegistry } from '../realtime/registry';
|
||||
import {
|
||||
realtimeDocGrantsRoom,
|
||||
realtimeDocShareStateRoom,
|
||||
} from '../realtime/rooms';
|
||||
import { DocGrantsService } from './doc-grants';
|
||||
|
||||
const docInput = z
|
||||
.object({ workspaceId: z.string(), docId: z.string() })
|
||||
.strict();
|
||||
|
||||
@Injectable()
|
||||
export class DocShareRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly models: Models,
|
||||
@Optional() private readonly registry?: RealtimeRegistry,
|
||||
@Optional() private readonly publisher?: RealtimePublisher
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (!this.registry) return;
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'doc.share-state.get',
|
||||
input: docInput,
|
||||
handle: async (user, input) => ({
|
||||
state: await this.getShareState(
|
||||
user.id,
|
||||
input.workspaceId,
|
||||
input.docId
|
||||
),
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'doc.share-state.changed',
|
||||
input: docInput,
|
||||
authorize: async (user, input) => {
|
||||
await this.assertRead(user.id, input.workspaceId, input.docId);
|
||||
},
|
||||
room: (_user, input) =>
|
||||
realtimeDocShareStateRoom(input.workspaceId, input.docId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('doc.public_state.changed', { suppressError: true })
|
||||
onPublicStateChanged({
|
||||
workspaceId,
|
||||
docId,
|
||||
}: Events['doc.public_state.changed']) {
|
||||
this.publish(workspaceId, docId, 'public-state-changed');
|
||||
}
|
||||
|
||||
@OnEvent('doc.default_role.changed', { suppressError: true })
|
||||
onDefaultRoleChanged({
|
||||
workspaceId,
|
||||
docId,
|
||||
}: Events['doc.default_role.changed']) {
|
||||
this.publish(workspaceId, docId, 'default-role-changed');
|
||||
}
|
||||
|
||||
private async getShareState(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
docId: string
|
||||
): Promise<DocShareStateSnapshot | null> {
|
||||
await this.assertRead(userId, workspaceId, docId);
|
||||
const doc = await this.models.doc.getDocInfo(workspaceId, docId);
|
||||
if (!doc) {
|
||||
return null;
|
||||
}
|
||||
return {
|
||||
public: doc.public,
|
||||
mode: PublicDocMode[doc.mode],
|
||||
defaultRole: DocRole[doc.defaultRole],
|
||||
};
|
||||
}
|
||||
|
||||
private async assertRead(userId: string, workspaceId: string, docId: string) {
|
||||
await this.ac.user(userId).doc(workspaceId, docId).assert('Doc.Read');
|
||||
}
|
||||
|
||||
private publish(workspaceId: string, docId: string, reason: string) {
|
||||
this.publisher?.publishChanged(
|
||||
'doc.share-state.changed',
|
||||
{ workspaceId, docId },
|
||||
reason,
|
||||
{ room: realtimeDocShareStateRoom(workspaceId, docId) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DocGrantsRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly models: Models,
|
||||
private readonly grants: DocGrantsService,
|
||||
@Optional() private readonly registry?: RealtimeRegistry,
|
||||
@Optional() private readonly publisher?: RealtimePublisher
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (!this.registry) return;
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'doc.grants.get',
|
||||
input: z
|
||||
.object({
|
||||
workspaceId: z.string(),
|
||||
docId: z.string(),
|
||||
pagination: z
|
||||
.object({
|
||||
first: z.number().int().positive(),
|
||||
offset: z.number().int().nonnegative().optional(),
|
||||
after: z.string().optional(),
|
||||
})
|
||||
.strict(),
|
||||
})
|
||||
.strict(),
|
||||
handle: async (user, input) =>
|
||||
this.getGrants(user.id, input.workspaceId, input.docId, {
|
||||
first: input.pagination.first,
|
||||
offset: input.pagination.offset ?? 0,
|
||||
after: input.pagination.after,
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'doc.grants.changed',
|
||||
input: docInput,
|
||||
authorize: async (user, input) => {
|
||||
await this.assertRead(user.id, input.workspaceId, input.docId);
|
||||
},
|
||||
room: (_user, input) =>
|
||||
realtimeDocGrantsRoom(input.workspaceId, input.docId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('doc.grants.changed', { suppressError: true })
|
||||
onGrantsChanged({ workspaceId, docId }: Events['doc.grants.changed']) {
|
||||
this.publish(workspaceId, docId, 'grants-changed');
|
||||
}
|
||||
|
||||
@OnEvent('doc.owner.changed', { suppressError: true })
|
||||
onOwnerChanged({ workspaceId, docId }: Events['doc.owner.changed']) {
|
||||
this.publish(workspaceId, docId, 'owner-changed');
|
||||
}
|
||||
|
||||
@OnEvent('user.updated', { suppressError: true })
|
||||
async onUserUpdated(user: Events['user.updated']) {
|
||||
const grants = await this.models.docUser.findDirectGrantDocIdsByUser(
|
||||
user.id
|
||||
);
|
||||
for (const grant of grants) {
|
||||
this.publish(grant.workspaceId, grant.docId, 'user-updated');
|
||||
}
|
||||
}
|
||||
|
||||
private async getGrants(
|
||||
userId: string,
|
||||
workspaceId: string,
|
||||
docId: string,
|
||||
input: PaginationInput
|
||||
): Promise<PaginatedDocGrantedUsersSnapshot> {
|
||||
await this.assertRead(userId, workspaceId, docId);
|
||||
const pagination = PaginationInput.decode.transform(input, {} as never);
|
||||
const page = await this.grants.paginateGrantedUsers(
|
||||
workspaceId,
|
||||
docId,
|
||||
pagination
|
||||
);
|
||||
|
||||
return {
|
||||
totalCount: page.totalCount,
|
||||
pageInfo: {
|
||||
endCursor: page.pageInfo.endCursor ?? null,
|
||||
hasNextPage: page.pageInfo.hasNextPage,
|
||||
},
|
||||
edges: page.edges.map(edge => ({
|
||||
node: {
|
||||
role: DocRole[edge.node.type],
|
||||
user: {
|
||||
id: edge.node.user.id,
|
||||
name: edge.node.user.name,
|
||||
email: edge.node.user.email,
|
||||
avatarUrl: edge.node.user.avatarUrl ?? null,
|
||||
},
|
||||
},
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
private async assertRead(userId: string, workspaceId: string, docId: string) {
|
||||
await this.ac.user(userId).doc(workspaceId, docId).assert('Doc.Users.Read');
|
||||
}
|
||||
|
||||
private publish(workspaceId: string, docId: string, reason: string) {
|
||||
this.publisher?.publishChanged(
|
||||
'doc.grants.changed',
|
||||
{ workspaceId, docId },
|
||||
reason,
|
||||
{ room: realtimeDocGrantsRoom(workspaceId, docId) }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -26,6 +26,12 @@ declare global {
|
||||
workspaceId: string;
|
||||
quantity: number;
|
||||
};
|
||||
'workspace.invite_link.created': {
|
||||
workspaceId: string;
|
||||
};
|
||||
'workspace.invite_link.revoked': {
|
||||
workspaceId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -10,7 +10,17 @@ import { QuotaModule } from '../quota';
|
||||
import { StorageModule } from '../storage';
|
||||
import { UserModule } from '../user';
|
||||
import { WorkspacesController } from './controller';
|
||||
import { DocGrantsService } from './doc-grants';
|
||||
import {
|
||||
DocGrantsRealtimeProvider,
|
||||
DocShareRealtimeProvider,
|
||||
} from './doc-realtime';
|
||||
import { WorkspaceEvents } from './event';
|
||||
import {
|
||||
WorkspaceAccessRealtimeProvider,
|
||||
WorkspaceConfigRealtimeProvider,
|
||||
WorkspaceMembersRealtimeProvider,
|
||||
} from './realtime';
|
||||
import {
|
||||
DocHistoryResolver,
|
||||
DocResolver,
|
||||
@@ -44,7 +54,13 @@ import { WorkspaceStatsJob } from './stats.job';
|
||||
DocHistoryResolver,
|
||||
WorkspaceBlobResolver,
|
||||
WorkspaceService,
|
||||
DocGrantsService,
|
||||
WorkspaceEvents,
|
||||
WorkspaceAccessRealtimeProvider,
|
||||
WorkspaceConfigRealtimeProvider,
|
||||
WorkspaceMembersRealtimeProvider,
|
||||
DocShareRealtimeProvider,
|
||||
DocGrantsRealtimeProvider,
|
||||
AdminWorkspaceResolver,
|
||||
WorkspaceStatsJob,
|
||||
],
|
||||
|
||||
@@ -0,0 +1,401 @@
|
||||
import {
|
||||
WORKSPACE_MEMBERS_REQUEST_TAKE_MAX,
|
||||
type WorkspaceAccessSnapshot,
|
||||
type WorkspaceConfigSnapshot,
|
||||
type WorkspaceInviteLinkSnapshot,
|
||||
type WorkspaceMemberSnapshot,
|
||||
} from '@affine/realtime';
|
||||
import { Injectable, OnModuleInit, Optional } from '@nestjs/common';
|
||||
import { z } from 'zod';
|
||||
|
||||
import {
|
||||
Cache,
|
||||
isValidCacheTtl,
|
||||
OnEvent,
|
||||
QueryTooLong,
|
||||
URLHelper,
|
||||
} from '../../base';
|
||||
import { Models } from '../../models';
|
||||
import type { WorkspaceUserCompat } from '../../models/workspace-user-compat';
|
||||
import type { CurrentUser } from '../auth';
|
||||
import {
|
||||
mapPermissionsToGraphqlPermissions,
|
||||
PermissionAccess,
|
||||
WorkspaceRole,
|
||||
} from '../permission';
|
||||
import { registerRealtimeLiveQuery } from '../realtime/provider';
|
||||
import { RealtimePublisher } from '../realtime/publisher';
|
||||
import { RealtimeRegistry } from '../realtime/registry';
|
||||
import {
|
||||
realtimeWorkspaceAccessRoom,
|
||||
realtimeWorkspaceConfigRoom,
|
||||
realtimeWorkspaceInviteLinkRoom,
|
||||
realtimeWorkspaceMembersRoom,
|
||||
} from '../realtime/rooms';
|
||||
import { WorkspaceService } from './service';
|
||||
|
||||
const workspaceInput = z.object({ workspaceId: z.string() }).strict();
|
||||
|
||||
function serializeWorkspaceMember(
|
||||
row: WorkspaceUserCompat
|
||||
): WorkspaceMemberSnapshot {
|
||||
if (!row.user) {
|
||||
throw new Error('Workspace member user is required');
|
||||
}
|
||||
const role = WorkspaceRole[row.type as WorkspaceRole];
|
||||
return {
|
||||
...row.user,
|
||||
avatarUrl: row.user.avatarUrl ?? null,
|
||||
permission: role,
|
||||
role,
|
||||
inviteId: row.id,
|
||||
emailVerified: null,
|
||||
status: row.status,
|
||||
};
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceAccessRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly workspaceService: WorkspaceService,
|
||||
@Optional() private readonly registry?: RealtimeRegistry,
|
||||
@Optional() private readonly publisher?: RealtimePublisher
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (!this.registry) return;
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'workspace.access.get',
|
||||
input: workspaceInput,
|
||||
handle: async (user, input) => ({
|
||||
access: await this.getAccess(user, input.workspaceId),
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'workspace.access.changed',
|
||||
input: workspaceInput,
|
||||
authorize: async (user, input) => {
|
||||
await this.ac
|
||||
.user(user.id)
|
||||
.workspace(input.workspaceId)
|
||||
.assert('Workspace.Read');
|
||||
},
|
||||
room: (_user, input) => realtimeWorkspaceAccessRoom(input.workspaceId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('workspace.members.updated', { suppressError: true })
|
||||
onMembersUpdated({ workspaceId }: Events['workspace.members.updated']) {
|
||||
this.publish(workspaceId, 'members-updated');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.members.roleChanged', { suppressError: true })
|
||||
onMemberRoleChanged({
|
||||
workspaceId,
|
||||
}: Events['workspace.members.roleChanged']) {
|
||||
this.publish(workspaceId, 'member-role-changed');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.owner.changed', { suppressError: true })
|
||||
onWorkspaceOwnerChanged({ workspaceId }: Events['workspace.owner.changed']) {
|
||||
this.publish(workspaceId, 'owner-changed');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.quota_state.changed', { suppressError: true })
|
||||
onWorkspaceQuotaStateChanged({
|
||||
workspaceId,
|
||||
}: Events['workspace.quota_state.changed']) {
|
||||
this.publish(workspaceId, 'quota-state-changed');
|
||||
}
|
||||
|
||||
private async getAccess(
|
||||
user: CurrentUser,
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceAccessSnapshot> {
|
||||
await this.ac.user(user.id).workspace(workspaceId).assert('Workspace.Read');
|
||||
const { role, permissions } = await this.ac
|
||||
.user(user.id)
|
||||
.workspace(workspaceId)
|
||||
.permissions();
|
||||
|
||||
return {
|
||||
role: role ? WorkspaceRole[role] : WorkspaceRole[WorkspaceRole.External],
|
||||
permissions: mapPermissionsToGraphqlPermissions(permissions),
|
||||
team: await this.workspaceService.isTeamWorkspace(workspaceId),
|
||||
};
|
||||
}
|
||||
|
||||
private publish(workspaceId: string, reason: string) {
|
||||
this.publisher?.publishChanged(
|
||||
'workspace.access.changed',
|
||||
{ workspaceId },
|
||||
reason,
|
||||
{ room: realtimeWorkspaceAccessRoom(workspaceId) }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceConfigRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly models: Models,
|
||||
@Optional() private readonly registry?: RealtimeRegistry,
|
||||
@Optional() private readonly publisher?: RealtimePublisher
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (!this.registry) return;
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'workspace.config.get',
|
||||
input: workspaceInput,
|
||||
handle: async (user, input) => ({
|
||||
config: await this.getConfig(user, input.workspaceId),
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'workspace.config.changed',
|
||||
input: workspaceInput,
|
||||
authorize: async (user, input) => {
|
||||
await this.assertRead(user.id, input.workspaceId);
|
||||
},
|
||||
room: (_user, input) => realtimeWorkspaceConfigRoom(input.workspaceId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('workspace.updated', { suppressError: true })
|
||||
onWorkspaceUpdated(workspace: Events['workspace.updated']) {
|
||||
this.publisher?.publishChanged(
|
||||
'workspace.config.changed',
|
||||
{ workspaceId: workspace.id },
|
||||
'workspace-updated',
|
||||
{ room: realtimeWorkspaceConfigRoom(workspace.id) }
|
||||
);
|
||||
}
|
||||
|
||||
private async getConfig(
|
||||
user: CurrentUser,
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceConfigSnapshot> {
|
||||
await this.assertRead(user.id, workspaceId);
|
||||
const workspace = await this.models.workspace.get(workspaceId);
|
||||
return {
|
||||
enableAi: Boolean(workspace?.enableAi),
|
||||
enableSharing: Boolean(workspace?.enableSharing),
|
||||
enableUrlPreview: Boolean(workspace?.enableUrlPreview),
|
||||
enableDocEmbedding: Boolean(workspace?.enableDocEmbedding),
|
||||
};
|
||||
}
|
||||
|
||||
private async assertRead(userId: string, workspaceId: string) {
|
||||
await this.ac
|
||||
.user(userId)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Settings.Read');
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class WorkspaceMembersRealtimeProvider implements OnModuleInit {
|
||||
constructor(
|
||||
private readonly cache: Cache,
|
||||
private readonly url: URLHelper,
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly models: Models,
|
||||
@Optional() private readonly registry?: RealtimeRegistry,
|
||||
@Optional() private readonly publisher?: RealtimePublisher
|
||||
) {}
|
||||
|
||||
onModuleInit() {
|
||||
if (!this.registry) return;
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'workspace.members.get',
|
||||
input: z
|
||||
.object({
|
||||
workspaceId: z.string(),
|
||||
skip: z.number().int().nonnegative().optional(),
|
||||
take: z.number().int().nonnegative().optional(),
|
||||
query: z.string().optional(),
|
||||
})
|
||||
.strict(),
|
||||
handle: async (user, input) => this.getMembers(user, input),
|
||||
},
|
||||
topic: {
|
||||
name: 'workspace.members.changed',
|
||||
input: workspaceInput,
|
||||
authorize: async (user, input) => {
|
||||
await this.assertMembersRead(user.id, input.workspaceId);
|
||||
},
|
||||
room: (_user, input) => realtimeWorkspaceMembersRoom(input.workspaceId),
|
||||
},
|
||||
});
|
||||
|
||||
registerRealtimeLiveQuery(this.registry, {
|
||||
request: {
|
||||
name: 'workspace.invite-link.get',
|
||||
input: workspaceInput,
|
||||
handle: async (user, input) => ({
|
||||
inviteLink: await this.getInviteLink(user, input.workspaceId),
|
||||
}),
|
||||
},
|
||||
topic: {
|
||||
name: 'workspace.invite-link.changed',
|
||||
input: workspaceInput,
|
||||
authorize: async (user, input) => {
|
||||
await this.assertInviteManage(user.id, input.workspaceId);
|
||||
},
|
||||
room: (_user, input) =>
|
||||
realtimeWorkspaceInviteLinkRoom(input.workspaceId),
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
@OnEvent('workspace.members.updated', { suppressError: true })
|
||||
onMembersUpdated({ workspaceId }: Events['workspace.members.updated']) {
|
||||
this.publishMembers(workspaceId, 'members-updated');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.members.roleChanged', { suppressError: true })
|
||||
onMemberRoleChanged({
|
||||
workspaceId,
|
||||
}: Events['workspace.members.roleChanged']) {
|
||||
this.publishMembers(workspaceId, 'member-role-changed');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.owner.changed', { suppressError: true })
|
||||
onWorkspaceOwnerChanged({ workspaceId }: Events['workspace.owner.changed']) {
|
||||
this.publishMembers(workspaceId, 'owner-changed');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.invite_link.created', { suppressError: true })
|
||||
onInviteLinkCreated({
|
||||
workspaceId,
|
||||
}: Events['workspace.invite_link.created']) {
|
||||
this.publishInviteLink(workspaceId, 'invite-link-created');
|
||||
}
|
||||
|
||||
@OnEvent('workspace.invite_link.revoked', { suppressError: true })
|
||||
onInviteLinkRevoked({
|
||||
workspaceId,
|
||||
}: Events['workspace.invite_link.revoked']) {
|
||||
this.publishInviteLink(workspaceId, 'invite-link-revoked');
|
||||
}
|
||||
|
||||
@OnEvent('user.updated', { suppressError: true })
|
||||
async onUserUpdated(user: Events['user.updated']) {
|
||||
const workspaceIds = await this.models.workspaceUser.getUserWorkspaceIds(
|
||||
user.id
|
||||
);
|
||||
for (const workspaceId of workspaceIds) {
|
||||
this.publishMembers(workspaceId, 'user-updated');
|
||||
}
|
||||
}
|
||||
|
||||
private async getMembers(
|
||||
user: CurrentUser,
|
||||
input: {
|
||||
workspaceId: string;
|
||||
skip?: number;
|
||||
take?: number;
|
||||
query?: string;
|
||||
}
|
||||
) {
|
||||
await this.assertMembersRead(user.id, input.workspaceId);
|
||||
|
||||
const pagination = {
|
||||
offset: Math.max(input.skip ?? 0, 0),
|
||||
first: Math.min(
|
||||
Math.max(input.take ?? 8, 1),
|
||||
WORKSPACE_MEMBERS_REQUEST_TAKE_MAX
|
||||
),
|
||||
};
|
||||
|
||||
if (input.query) {
|
||||
if (input.query.length > 255) {
|
||||
throw new QueryTooLong({ max: 255 });
|
||||
}
|
||||
const members = await this.models.workspaceUser.search(
|
||||
input.workspaceId,
|
||||
input.query,
|
||||
pagination
|
||||
);
|
||||
return {
|
||||
members: members.map(serializeWorkspaceMember),
|
||||
memberCount: await this.models.workspaceUser.count(input.workspaceId),
|
||||
};
|
||||
}
|
||||
|
||||
const [members, memberCount] = await this.models.workspaceUser.paginate(
|
||||
input.workspaceId,
|
||||
pagination
|
||||
);
|
||||
return {
|
||||
members: members.map(serializeWorkspaceMember),
|
||||
memberCount,
|
||||
};
|
||||
}
|
||||
|
||||
private async getInviteLink(
|
||||
user: CurrentUser,
|
||||
workspaceId: string
|
||||
): Promise<WorkspaceInviteLinkSnapshot | null> {
|
||||
await this.assertInviteManage(user.id, workspaceId);
|
||||
|
||||
const cacheId = `workspace:inviteLink:${workspaceId}`;
|
||||
const id = await this.cache.get<{ inviteId: string }>(cacheId);
|
||||
if (!id) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const expireTime = await this.cache.ttl(cacheId);
|
||||
if (!isValidCacheTtl(expireTime)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
link: this.url.link(`/invite/${id.inviteId}`),
|
||||
expireTime: new Date(Date.now() + expireTime * 1000).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
private async assertMembersRead(userId: string, workspaceId: string) {
|
||||
await this.ac
|
||||
.user(userId)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Users.Read');
|
||||
}
|
||||
|
||||
private async assertInviteManage(userId: string, workspaceId: string) {
|
||||
await this.ac
|
||||
.user(userId)
|
||||
.workspace(workspaceId)
|
||||
.assert('Workspace.Users.Manage');
|
||||
}
|
||||
|
||||
private publishMembers(workspaceId: string, reason: string) {
|
||||
this.publisher?.publishChanged(
|
||||
'workspace.members.changed',
|
||||
{ workspaceId },
|
||||
reason,
|
||||
{ room: realtimeWorkspaceMembersRoom(workspaceId) }
|
||||
);
|
||||
}
|
||||
|
||||
private publishInviteLink(workspaceId: string, reason: string) {
|
||||
this.publisher?.publishChanged(
|
||||
'workspace.invite-link.changed',
|
||||
{ workspaceId },
|
||||
reason,
|
||||
{ room: realtimeWorkspaceInviteLinkRoom(workspaceId) }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,7 @@ import {
|
||||
DocActionDenied,
|
||||
DocDefaultRoleCanNotBeOwner,
|
||||
DocNotFound,
|
||||
EventBus,
|
||||
ExpectToGrantDocUserRoles,
|
||||
ExpectToPublishDoc,
|
||||
ExpectToRevokeDocUserRoles,
|
||||
@@ -37,16 +38,15 @@ import {
|
||||
DOC_ACTIONS,
|
||||
DocAction,
|
||||
DocRole,
|
||||
type DotToUnderline,
|
||||
mapPermissionsToGraphqlPermissions,
|
||||
PermissionAccess,
|
||||
PermissionService,
|
||||
} from '../../permission';
|
||||
import { PublicUserType, WorkspaceUserType } from '../../user';
|
||||
import { DocGrantsService } from '../doc-grants';
|
||||
import { WorkspaceType } from '../types';
|
||||
import { TimeBucket, TimeWindow } from './analytics-types';
|
||||
import {
|
||||
DotToUnderline,
|
||||
mapPermissionsToGraphqlPermissions,
|
||||
} from './workspace';
|
||||
|
||||
registerEnumType(PublicDocMode, {
|
||||
name: 'PublicDocMode',
|
||||
@@ -298,7 +298,8 @@ export class WorkspaceDocResolver {
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly permission: PermissionService,
|
||||
private readonly models: Models,
|
||||
private readonly cache: Cache
|
||||
private readonly cache: Cache,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
@ResolveField(() => WorkspaceDocMeta, {
|
||||
@@ -442,6 +443,7 @@ export class WorkspaceDocResolver {
|
||||
await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish');
|
||||
|
||||
const doc = await this.models.doc.publish(workspaceId, docId, mode);
|
||||
this.event.emit('doc.public_state.changed', { workspaceId, docId });
|
||||
|
||||
this.logger.log(
|
||||
`Publish page ${docId} with mode ${mode} in workspace ${workspaceId}`
|
||||
@@ -467,6 +469,7 @@ export class WorkspaceDocResolver {
|
||||
await this.ac.user(user.id).doc(workspaceId, docId).assert('Doc.Publish');
|
||||
|
||||
const doc = await this.models.doc.unpublish(workspaceId, docId);
|
||||
this.event.emit('doc.public_state.changed', { workspaceId, docId });
|
||||
|
||||
this.logger.log(`Revoke public doc ${docId} in workspace ${workspaceId}`);
|
||||
|
||||
@@ -535,7 +538,9 @@ export class DocResolver {
|
||||
|
||||
constructor(
|
||||
private readonly ac: PermissionAccess,
|
||||
private readonly models: Models
|
||||
private readonly models: Models,
|
||||
private readonly grants: DocGrantsService,
|
||||
private readonly event: EventBus
|
||||
) {}
|
||||
|
||||
@ResolveField(() => PublicUserType, {
|
||||
@@ -660,28 +665,11 @@ export class DocResolver {
|
||||
@Args('pagination', PaginationInput.decode) pagination: PaginationInput
|
||||
): Promise<PaginatedGrantedDocUserType> {
|
||||
await this.ac.user(user.id).doc(doc).assert('Doc.Users.Read');
|
||||
|
||||
const [permissions, totalCount] = await this.models.docUser.paginate(
|
||||
return await this.grants.paginateGrantedUsers(
|
||||
doc.workspaceId,
|
||||
doc.docId,
|
||||
pagination
|
||||
);
|
||||
|
||||
const workspaceUsers = await this.models.user.getWorkspaceUsers(
|
||||
permissions.map(p => p.userId)
|
||||
);
|
||||
|
||||
const workspaceUsersMap = new Map(workspaceUsers.map(wu => [wu.id, wu]));
|
||||
|
||||
return paginate(
|
||||
permissions.map(p => ({
|
||||
...p,
|
||||
user: workspaceUsersMap.get(p.userId) as WorkspaceUserType,
|
||||
})),
|
||||
'createdAt',
|
||||
pagination,
|
||||
totalCount
|
||||
);
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@@ -713,6 +701,10 @@ export class DocResolver {
|
||||
input.userIds,
|
||||
input.role
|
||||
);
|
||||
this.event.emit('doc.grants.changed', {
|
||||
workspaceId: input.workspaceId,
|
||||
docId: input.docId,
|
||||
});
|
||||
|
||||
const info = {
|
||||
...pairs,
|
||||
@@ -749,6 +741,10 @@ export class DocResolver {
|
||||
input.docId,
|
||||
input.userId
|
||||
);
|
||||
this.event.emit('doc.grants.changed', {
|
||||
workspaceId: input.workspaceId,
|
||||
docId: input.docId,
|
||||
});
|
||||
|
||||
const info = {
|
||||
...pairs,
|
||||
@@ -791,6 +787,11 @@ export class DocResolver {
|
||||
input.docId,
|
||||
input.userId
|
||||
);
|
||||
this.event.emit('doc.owner.changed', {
|
||||
workspaceId: input.workspaceId,
|
||||
docId: input.docId,
|
||||
userId: input.userId,
|
||||
});
|
||||
this.logger.log(`Transfer doc owner (${JSON.stringify(info)})`);
|
||||
} else {
|
||||
await this.ac.user(user.id).doc(input).assert('Doc.Users.Manage');
|
||||
@@ -800,6 +801,10 @@ export class DocResolver {
|
||||
input.userId,
|
||||
input.role
|
||||
);
|
||||
this.event.emit('doc.grants.changed', {
|
||||
workspaceId: input.workspaceId,
|
||||
docId: input.docId,
|
||||
});
|
||||
this.logger.log(`Update doc user role (${JSON.stringify(info)})`);
|
||||
}
|
||||
|
||||
@@ -851,6 +856,10 @@ export class DocResolver {
|
||||
input.docId,
|
||||
input.role
|
||||
);
|
||||
this.event.emit('doc.default_role.changed', {
|
||||
workspaceId: input.workspaceId,
|
||||
docId: input.docId,
|
||||
});
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
CanNotRevokeYourself,
|
||||
EventBus,
|
||||
InvalidInvitation,
|
||||
isValidCacheTtl,
|
||||
mapAnyError,
|
||||
MemberNotFoundInSpace,
|
||||
NoMoreSeat,
|
||||
@@ -258,7 +259,7 @@ export class WorkspaceMemberResolver {
|
||||
const id = await this.cache.get<{ inviteId: string }>(cacheId);
|
||||
if (id) {
|
||||
const expireTime = await this.cache.ttl(cacheId);
|
||||
if (Number.isSafeInteger(expireTime)) {
|
||||
if (isValidCacheTtl(expireTime)) {
|
||||
return {
|
||||
link: this.url.link(`/invite/${id.inviteId}`),
|
||||
expireTime: new Date(Date.now() + expireTime * 1000), // Convert seconds to milliseconds
|
||||
@@ -284,7 +285,7 @@ export class WorkspaceMemberResolver {
|
||||
const invite = await this.cache.get<{ inviteId: string }>(cacheWorkspaceId);
|
||||
if (typeof invite?.inviteId === 'string') {
|
||||
const expireTime = await this.cache.ttl(cacheWorkspaceId);
|
||||
if (Number.isSafeInteger(expireTime)) {
|
||||
if (isValidCacheTtl(expireTime)) {
|
||||
return {
|
||||
link: this.url.link(`/invite/${invite.inviteId}`),
|
||||
expireTime: new Date(Date.now() + expireTime * 1000), // Convert seconds to milliseconds
|
||||
@@ -300,6 +301,7 @@ export class WorkspaceMemberResolver {
|
||||
{ workspaceId, inviterUserId: user.id },
|
||||
{ ttl: expireTime }
|
||||
);
|
||||
this.event.emit('workspace.invite_link.created', { workspaceId });
|
||||
return {
|
||||
link: this.url.link(`/invite/${inviteId}`),
|
||||
expireTime: new Date(Date.now() + expireTime),
|
||||
@@ -317,7 +319,13 @@ export class WorkspaceMemberResolver {
|
||||
.assert('Workspace.Users.Manage');
|
||||
|
||||
const cacheId = `workspace:inviteLink:${workspaceId}`;
|
||||
return await this.cache.delete(cacheId);
|
||||
const invite = await this.cache.get<{ inviteId: string }>(cacheId);
|
||||
const deleted = await this.cache.delete(cacheId);
|
||||
if (invite?.inviteId) {
|
||||
await this.cache.delete(`workspace:inviteLinkId:${invite.inviteId}`);
|
||||
}
|
||||
this.event.emit('workspace.invite_link.revoked', { workspaceId });
|
||||
return deleted;
|
||||
}
|
||||
|
||||
@Mutation(() => Boolean)
|
||||
@@ -406,6 +414,11 @@ export class WorkspaceMemberResolver {
|
||||
}
|
||||
|
||||
await this.models.workspaceUser.set(workspaceId, userId, newRole);
|
||||
if (role.status !== WorkspaceMemberStatus.Accepted) {
|
||||
this.event.emit('workspace.members.updated', {
|
||||
workspaceId,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
return true;
|
||||
@@ -608,6 +621,10 @@ export class WorkspaceMemberResolver {
|
||||
WorkspaceMemberStatus.Accepted
|
||||
);
|
||||
|
||||
this.event.emit('workspace.members.updated', {
|
||||
workspaceId: role.workspaceId,
|
||||
});
|
||||
|
||||
await this.workspaceService.sendInvitationAcceptedNotification(
|
||||
role.inviterId ??
|
||||
(await this.models.workspaceUser.getOwner(role.workspaceId)).id,
|
||||
@@ -640,6 +657,7 @@ export class WorkspaceMemberResolver {
|
||||
);
|
||||
|
||||
await this.workspaceService.sendReviewRequestNotification(role.id);
|
||||
this.event.emit('workspace.members.updated', { workspaceId });
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
@@ -19,7 +19,9 @@ import {
|
||||
} from '../../../base';
|
||||
import { Models } from '../../../models';
|
||||
import { CurrentUser } from '../../auth';
|
||||
import type { DotToUnderline } from '../../permission';
|
||||
import {
|
||||
mapPermissionsToGraphqlPermissions,
|
||||
PermissionAccess,
|
||||
WORKSPACE_ACTIONS,
|
||||
WorkspaceAction,
|
||||
@@ -29,22 +31,6 @@ import { QuotaService, WorkspaceQuotaType } from '../../quota';
|
||||
import { WorkspaceService } from '../service';
|
||||
import { UpdateWorkspaceInput, WorkspaceType } from '../types';
|
||||
|
||||
export type DotToUnderline<T extends string> =
|
||||
T extends `${infer Prefix}.${infer Suffix}`
|
||||
? `${Prefix}_${DotToUnderline<Suffix>}`
|
||||
: T;
|
||||
|
||||
export function mapPermissionsToGraphqlPermissions<A extends string>(
|
||||
permission: Record<A, boolean>
|
||||
): Record<DotToUnderline<A>, boolean> {
|
||||
return Object.fromEntries(
|
||||
Object.entries(permission).map(([key, value]) => [
|
||||
key.replaceAll('.', '_'),
|
||||
value,
|
||||
])
|
||||
) as Record<DotToUnderline<A>, boolean>;
|
||||
}
|
||||
|
||||
const WorkspacePermissions = registerObjectType<
|
||||
Record<DotToUnderline<WorkspaceAction>, boolean>
|
||||
>(
|
||||
|
||||
@@ -5,6 +5,17 @@ import { BaseModel } from './base';
|
||||
|
||||
const REDACTED_TOKEN = '[REDACTED]';
|
||||
|
||||
declare global {
|
||||
interface Events {
|
||||
'user.access_token.created': {
|
||||
userId: string;
|
||||
};
|
||||
'user.access_token.revoked': {
|
||||
userId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export interface CreateAccessTokenInput {
|
||||
userId: string;
|
||||
name: string;
|
||||
|
||||
@@ -10,6 +10,20 @@ import { BaseModel } from './base';
|
||||
import { DocRole } from './common';
|
||||
import { docRoleFromNew } from './permission-write';
|
||||
|
||||
declare global {
|
||||
interface Events {
|
||||
'doc.grants.changed': {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
};
|
||||
'doc.owner.changed': {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
userId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
@Injectable()
|
||||
export class DocUserModel extends BaseModel {
|
||||
/**
|
||||
@@ -124,6 +138,14 @@ export class DocUserModel extends BaseModel {
|
||||
return grants.map(grant => this.docGrantToCompat(grant));
|
||||
}
|
||||
|
||||
async findDirectGrantDocIdsByUser(userId: string) {
|
||||
return await this.db.docGrant.findMany({
|
||||
where: { principalType: 'user', principalId: userId },
|
||||
select: { workspaceId: true, docId: true },
|
||||
distinct: ['workspaceId', 'docId'],
|
||||
});
|
||||
}
|
||||
|
||||
count(workspaceId: string, docId: string) {
|
||||
return this.db.docGrant.count({
|
||||
where: {
|
||||
|
||||
@@ -19,6 +19,14 @@ declare global {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
};
|
||||
'doc.public_state.changed': {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
};
|
||||
'doc.default_role.changed': {
|
||||
workspaceId: string;
|
||||
docId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -4,6 +4,14 @@ import z from 'zod';
|
||||
|
||||
import { BaseModel } from './base';
|
||||
|
||||
declare global {
|
||||
interface Events {
|
||||
'user.settings.updated': {
|
||||
userId: string;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
export const UserSettingsSchema = z.object({
|
||||
receiveInvitationEmail: z.boolean().default(true),
|
||||
receiveMentionEmail: z.boolean().default(true),
|
||||
|
||||
@@ -221,7 +221,7 @@ export class UserModel extends BaseModel {
|
||||
});
|
||||
|
||||
this.logger.debug(`User [${user.id}] updated`);
|
||||
this.event.emit('user.updated', user);
|
||||
this.event.emitDetached('user.updated', user);
|
||||
return user;
|
||||
}
|
||||
|
||||
|
||||
@@ -186,36 +186,68 @@ export async function searchCompatRows(
|
||||
pagination: { first: number; offset: number; after?: string | Date }
|
||||
) {
|
||||
const after = pagination.after
|
||||
? Prisma.sql`AND wm.created_at >= ${pagination.after}::timestamptz`
|
||||
? Prisma.sql`AND created_at >= ${pagination.after}::timestamptz`
|
||||
: Prisma.empty;
|
||||
const rows = await db.$queryRaw<WorkspaceUserCompatRow[]>`
|
||||
SELECT
|
||||
COALESCE(wm.legacy_permission_id, wm.id) AS id,
|
||||
wm.workspace_id AS "workspaceId",
|
||||
wm.user_id AS "userId",
|
||||
CASE wm.role
|
||||
WHEN 'owner' THEN ${WorkspaceRole.Owner}
|
||||
WHEN 'admin' THEN ${WorkspaceRole.Admin}
|
||||
ELSE ${WorkspaceRole.Collaborator}
|
||||
END AS type,
|
||||
'Accepted'::"WorkspaceMemberStatus" AS status,
|
||||
CASE wm.source
|
||||
WHEN 'link' THEN 'Link'::"WorkspaceMemberSource"
|
||||
ELSE 'Email'::"WorkspaceMemberSource"
|
||||
END AS source,
|
||||
NULL::varchar AS "inviterId",
|
||||
wm.created_at AS "createdAt",
|
||||
wm.updated_at AS "updatedAt",
|
||||
u.name AS "userName",
|
||||
u.email AS "userEmail",
|
||||
u.avatar_url AS "userAvatarUrl"
|
||||
FROM workspace_members wm
|
||||
INNER JOIN users u ON u.id = wm.user_id
|
||||
WHERE wm.workspace_id = ${workspaceId}
|
||||
AND wm.state = 'active'
|
||||
AND (u.email ILIKE ${`%${query}%`} OR u.name ILIKE ${`%${query}%`})
|
||||
SELECT *
|
||||
FROM (
|
||||
SELECT
|
||||
COALESCE(wm.legacy_permission_id, wm.id) AS id,
|
||||
wm.workspace_id AS "workspaceId",
|
||||
wm.user_id AS "userId",
|
||||
CASE wm.role
|
||||
WHEN 'owner' THEN ${WorkspaceRole.Owner}
|
||||
WHEN 'admin' THEN ${WorkspaceRole.Admin}
|
||||
ELSE ${WorkspaceRole.Collaborator}
|
||||
END AS type,
|
||||
'Accepted'::"WorkspaceMemberStatus" AS status,
|
||||
CASE wm.source
|
||||
WHEN 'link' THEN 'Link'::"WorkspaceMemberSource"
|
||||
ELSE 'Email'::"WorkspaceMemberSource"
|
||||
END AS source,
|
||||
NULL::varchar AS "inviterId",
|
||||
wm.created_at AS "createdAt",
|
||||
wm.updated_at AS "updatedAt",
|
||||
u.name AS "userName",
|
||||
u.email AS "userEmail",
|
||||
u.avatar_url AS "userAvatarUrl",
|
||||
wm.created_at AS created_at
|
||||
FROM workspace_members wm
|
||||
INNER JOIN users u ON u.id = wm.user_id
|
||||
WHERE wm.workspace_id = ${workspaceId}
|
||||
AND wm.state = 'active'
|
||||
UNION ALL
|
||||
SELECT
|
||||
COALESCE(wi.legacy_permission_id, wi.id) AS id,
|
||||
wi.workspace_id AS "workspaceId",
|
||||
wi.invitee_user_id AS "userId",
|
||||
CASE wi.requested_role
|
||||
WHEN 'admin' THEN ${WorkspaceRole.Admin}
|
||||
ELSE ${WorkspaceRole.Collaborator}
|
||||
END AS type,
|
||||
CASE wi.status
|
||||
WHEN 'waiting_review' THEN 'UnderReview'::"WorkspaceMemberStatus"
|
||||
WHEN 'waiting_seat' THEN 'NeedMoreSeat'::"WorkspaceMemberStatus"
|
||||
ELSE 'Pending'::"WorkspaceMemberStatus"
|
||||
END AS status,
|
||||
CASE wi.kind
|
||||
WHEN 'link' THEN 'Link'::"WorkspaceMemberSource"
|
||||
ELSE 'Email'::"WorkspaceMemberSource"
|
||||
END AS source,
|
||||
wi.inviter_user_id AS "inviterId",
|
||||
wi.created_at AS "createdAt",
|
||||
wi.updated_at AS "updatedAt",
|
||||
u.name AS "userName",
|
||||
u.email AS "userEmail",
|
||||
u.avatar_url AS "userAvatarUrl",
|
||||
wi.created_at AS created_at
|
||||
FROM workspace_invitations wi
|
||||
INNER JOIN users u ON u.id = wi.invitee_user_id
|
||||
WHERE wi.workspace_id = ${workspaceId}
|
||||
) roles
|
||||
WHERE ("userEmail" ILIKE ${`%${query}%`} OR "userName" ILIKE ${`%${query}%`})
|
||||
${after}
|
||||
ORDER BY wm.created_at ASC
|
||||
ORDER BY created_at ASC
|
||||
OFFSET ${pagination.offset}
|
||||
LIMIT ${pagination.first}
|
||||
`;
|
||||
|
||||
@@ -422,6 +422,26 @@ export class WorkspaceUserModel extends BaseModel {
|
||||
return await findUserActiveWorkspaceRoles(this.db, userId, filter);
|
||||
}
|
||||
|
||||
async getUserWorkspaceIds(userId: string) {
|
||||
const [roles, invitations] = await Promise.all([
|
||||
this.db.workspaceMember.findMany({
|
||||
where: { userId, state: 'active' },
|
||||
select: { workspaceId: true },
|
||||
}),
|
||||
this.db.workspaceInvitation.findMany({
|
||||
where: { inviteeUserId: userId },
|
||||
select: { workspaceId: true },
|
||||
}),
|
||||
]);
|
||||
|
||||
return Array.from(
|
||||
new Set([
|
||||
...roles.map(role => role.workspaceId),
|
||||
...invitations.map(invitation => invitation.workspaceId),
|
||||
])
|
||||
);
|
||||
}
|
||||
|
||||
async hasSharedWorkspace(userId: string, otherUserId: string) {
|
||||
return await hasSharedWorkspace(this.db, userId, otherUserId);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user