diff --git a/packages/backend/server/src/__tests__/e2e/workspace/admin-analytics.spec.ts b/packages/backend/server/src/__tests__/e2e/workspace/admin-analytics.spec.ts index 08e7bd8ffb..5a286a09d8 100644 --- a/packages/backend/server/src/__tests__/e2e/workspace/admin-analytics.spec.ts +++ b/packages/backend/server/src/__tests__/e2e/workspace/admin-analytics.spec.ts @@ -390,6 +390,43 @@ e2e( } ); +e2e('adminWorkspace should serialize member roles as enum names', async t => { + const admin = await app.create(Mockers.User, { + feature: 'administrator', + }); + await app.login(admin); + + const owner = await app.create(Mockers.User); + const workspace = await app.create(Mockers.Workspace, { + owner: { id: owner.id }, + }); + + const result = await gql( + ` + query AdminWorkspace($id: String!) { + adminWorkspace(id: $id) { + id + members(skip: 0, take: 20) { + id + role + status + } + } + } + `, + { id: workspace.id } + ); + + t.falsy(result.errors); + t.is(result.data!.adminWorkspace.id, workspace.id); + t.true( + result.data!.adminWorkspace.members.some( + (member: { id: string; role: string }) => + member.id === owner.id && member.role === 'Owner' + ) + ); +}); + e2e( 'adminDashboard should carry forward missing sync and storage samples', async t => { @@ -524,6 +561,36 @@ e2e( dashboard.workspaceStorageBytes ); t.is(blobHistory[blobHistory.length - 1], dashboard.blobStorageBytes); + + const baselineResult = await gql( + ` + query AdminDashboard($input: AdminDashboardInput) { + adminDashboard(input: $input) { + workspaceStorageHistory { + value + } + blobStorageHistory { + value + } + } + } + `, + { + input: { + storageHistoryDays: 2, + }, + } + ); + + t.falsy(baselineResult.errors); + t.is( + baselineResult.data!.adminDashboard.workspaceStorageHistory[0].value, + workspaceHistory[1] + ); + t.is( + baselineResult.data!.adminDashboard.blobStorageHistory[0].value, + blobHistory[1] + ); } finally { clock.restore(); } diff --git a/packages/backend/server/src/core/workspaces/resolvers/admin.ts b/packages/backend/server/src/core/workspaces/resolvers/admin.ts index 20457a03e2..658b2f65d4 100644 --- a/packages/backend/server/src/core/workspaces/resolvers/admin.ts +++ b/packages/backend/server/src/core/workspaces/resolvers/admin.ts @@ -30,7 +30,6 @@ import { Models, WorkspaceFeatureName, WorkspaceMemberStatus, - WorkspaceRole, } from '../../../models'; import { Admin } from '../../common'; import { WorkspaceUserType } from '../../user'; @@ -60,6 +59,16 @@ registerEnumType(AdminSharedLinksOrder, { name: 'AdminSharedLinksOrder', }); +enum AdminWorkspaceMemberRole { + Owner = 'Owner', + Admin = 'Admin', + Collaborator = 'Collaborator', +} + +registerEnumType(AdminWorkspaceMemberRole, { + name: 'AdminWorkspaceMemberRole', +}); + function hasSelectedField( selections: readonly SelectionNode[], fieldName: string, @@ -145,8 +154,8 @@ class AdminWorkspaceMember { @Field(() => String, { nullable: true }) avatarUrl?: string | null; - @Field(() => WorkspaceRole) - role!: WorkspaceRole; + @Field(() => AdminWorkspaceMemberRole) + role!: `${AdminWorkspaceMemberRole}`; @Field(() => WorkspaceMemberStatus) status!: WorkspaceMemberStatus; @@ -494,18 +503,7 @@ export class AdminWorkspaceResolver { }) async adminWorkspace(@Args('id') id: string) { this.assertCloudOnly(); - const { rows } = await this.models.workspace.adminListWorkspaces({ - first: 1, - skip: 0, - keyword: id, - order: 'createdAt', - includeTotal: false, - }); - const row = rows.find(r => r.id === id); - if (!row) { - return null; - } - return row; + return await this.models.workspace.adminGetWorkspace(id); } @Query(() => AdminDashboard, { @@ -608,46 +606,11 @@ export class AdminWorkspaceResolver { after: undefined, }; - if (query) { - const list = await this.models.workspaceUser.search( - workspaceId, - query, - pagination - ); - return list.flatMap(({ user, status, type }) => - user - ? [ - { - id: user.id, - name: user.name, - email: user.email, - avatarUrl: user.avatarUrl, - role: type, - status, - }, - ] - : [] - ); - } - - const [list] = await this.models.workspaceUser.paginate( - workspaceId, - pagination - ); - return list.flatMap(({ user, status, type }) => - user - ? [ - { - id: user.id, - name: user.name, - email: user.email, - avatarUrl: user.avatarUrl, - role: type, - status, - }, - ] - : [] - ); + return await this.models.workspaceUser.adminListMembers(workspaceId, { + first: pagination.first, + offset: pagination.offset, + query, + }); } @ResolveField(() => [AdminWorkspaceSharedLink], { @@ -677,18 +640,7 @@ export class AdminWorkspaceResolver { await this.models.workspace.update(id, updates); } - const { rows } = await this.models.workspace.adminListWorkspaces({ - first: 1, - skip: 0, - keyword: id, - order: 'createdAt', - includeTotal: false, - }); - const row = rows.find(r => r.id === id); - if (!row) { - return null; - } - return row; + return await this.models.workspace.adminGetWorkspace(id); } private mapSort(orderBy?: AdminWorkspaceSort) { diff --git a/packages/backend/server/src/core/workspaces/stats.job.ts b/packages/backend/server/src/core/workspaces/stats.job.ts index 447cc05df6..f9bbd659b0 100644 --- a/packages/backend/server/src/core/workspaces/stats.job.ts +++ b/packages/backend/server/src/core/workspaces/stats.job.ts @@ -276,8 +276,9 @@ export class WorkspaceStatsJob { ), member_stats AS ( SELECT workspace_id, COUNT(*) AS member_count - FROM workspace_user_permissions + FROM workspace_members WHERE workspace_id IN (SELECT workspace_id FROM targets) + AND state = 'active' GROUP BY workspace_id ), public_page_stats AS ( diff --git a/packages/backend/server/src/models/workspace-analytics.ts b/packages/backend/server/src/models/workspace-analytics.ts index 7a1d8a87c0..870f6d2c6f 100644 --- a/packages/backend/server/src/models/workspace-analytics.ts +++ b/packages/backend/server/src/models/workspace-analytics.ts @@ -9,7 +9,6 @@ import { } from '../base/graphql/pagination'; import { CacheRedis } from '../base/redis'; import { BaseModel } from './base'; -import { WorkspaceRole } from './common'; const DEFAULT_STORAGE_HISTORY_DAYS = 30; const DEFAULT_SYNC_HISTORY_HOURS = 48; @@ -323,11 +322,11 @@ export class WorkspaceAnalyticsModel extends BaseModel { ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id LEFT JOIN LATERAL ( SELECT user_id - FROM workspace_user_permissions + FROM workspace_members WHERE workspace_id = wp.workspace_id - AND type = ${WorkspaceRole.Owner} - AND status = 'Accepted'::"WorkspaceMemberStatus" - ORDER BY created_at ASC + AND role = 'owner' + AND state = 'active' + ORDER BY created_at ASC, id ASC LIMIT 1 ) owner ON TRUE WHERE wp.public = TRUE @@ -391,32 +390,42 @@ export class WorkspaceAnalyticsModel extends BaseModel { blobStorageBytes: bigint | number; }[] >` - WITH days AS ( - SELECT generate_series(${storageFrom}::date, ${currentDay}::date, interval '1 day')::date AS day + WITH baseline_day AS ( + SELECT max(date) AS date + FROM workspace_admin_stats_daily + WHERE date < ${storageFrom}::date ), - grouped AS ( + baseline AS ( + SELECT + baseline_day.date, + COALESCE(SUM(snapshot_size), 0) AS workspace_storage_bytes, + COALESCE(SUM(blob_size), 0) AS blob_storage_bytes + FROM baseline_day + JOIN workspace_admin_stats_daily stats + ON stats.date = baseline_day.date + WHERE baseline_day.date IS NOT NULL + GROUP BY baseline_day.date + ), + in_window AS ( SELECT date, COALESCE(SUM(snapshot_size), 0) AS workspace_storage_bytes, COALESCE(SUM(blob_size), 0) AS blob_storage_bytes FROM workspace_admin_stats_daily - WHERE date <= ${currentDay}::date + WHERE date BETWEEN ${storageFrom}::date AND ${currentDay}::date GROUP BY date ) SELECT - days.day AS date, - COALESCE(snapshot.workspace_storage_bytes, 0) AS "workspaceStorageBytes", - COALESCE(snapshot.blob_storage_bytes, 0) AS "blobStorageBytes" - FROM days - LEFT JOIN LATERAL ( - SELECT - workspace_storage_bytes, - blob_storage_bytes - FROM grouped - WHERE date <= days.day - ORDER BY date DESC - LIMIT 1 - ) snapshot ON TRUE + date, + workspace_storage_bytes AS "workspaceStorageBytes", + blob_storage_bytes AS "blobStorageBytes" + FROM baseline + UNION ALL + SELECT + date, + workspace_storage_bytes AS "workspaceStorageBytes", + blob_storage_bytes AS "blobStorageBytes" + FROM in_window ORDER BY date ASC `, this.db.$queryRaw<{ conversations: bigint | number }[]>` @@ -435,11 +444,36 @@ export class WorkspaceAnalyticsModel extends BaseModel { const currentBlobStorageBytes = Number( storageCurrent[0]?.blobStorageBytes ?? 0 ); - const storageHistorySeries = storageHistory.map(row => ({ - date: row.date, - workspaceStorageBytes: Number(row.workspaceStorageBytes ?? 0), - blobStorageBytes: Number(row.blobStorageBytes ?? 0), - })); + const storageByDate = new Map( + storageHistory.map(row => [ + asDateOnlyString(row.date), + { + workspaceStorageBytes: Number(row.workspaceStorageBytes ?? 0), + blobStorageBytes: Number(row.blobStorageBytes ?? 0), + }, + ]) + ); + const storageHistorySeries: Array<{ + date: Date; + workspaceStorageBytes: number; + blobStorageBytes: number; + }> = []; + const baselineStorage = + storageHistory.find(row => row.date < storageFrom) ?? null; + let carriedStorageValue = { + workspaceStorageBytes: Number( + baselineStorage?.workspaceStorageBytes ?? 0 + ), + blobStorageBytes: Number(baselineStorage?.blobStorageBytes ?? 0), + }; + for (let day = storageFrom; day <= currentDay; day = addUtcDays(day, 1)) { + carriedStorageValue = + storageByDate.get(asDateOnlyString(day)) ?? carriedStorageValue; + storageHistorySeries.push({ + date: day, + ...carriedStorageValue, + }); + } if (storageHistorySeries.length > 0) { const lastPoint = storageHistorySeries[storageHistorySeries.length - 1]; if (asDateOnlyString(lastPoint.date) === asDateOnlyString(currentDay)) { @@ -596,11 +630,11 @@ export class WorkspaceAnalyticsModel extends BaseModel { ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id LEFT JOIN LATERAL ( SELECT user_id - FROM workspace_user_permissions + FROM workspace_members WHERE workspace_id = wp.workspace_id - AND type = ${WorkspaceRole.Owner} - AND status = 'Accepted'::"WorkspaceMemberStatus" - ORDER BY created_at ASC + AND role = 'owner' + AND state = 'active' + ORDER BY created_at ASC, id ASC LIMIT 1 ) owner ON TRUE WHERE wp.public = TRUE @@ -861,10 +895,10 @@ export class WorkspaceAnalyticsModel extends BaseModel { mla.last_doc_id AS "lastDocId" FROM workspace_member_last_access mla INNER JOIN users u ON u.id = mla.user_id - INNER JOIN workspace_user_permissions wur - ON wur.workspace_id = mla.workspace_id - AND wur.user_id = mla.user_id - AND wur.status = 'Accepted'::"WorkspaceMemberStatus" + INNER JOIN workspace_members wm + ON wm.workspace_id = mla.workspace_id + AND wm.user_id = mla.user_id + AND wm.state = 'active' WHERE mla.workspace_id = ${input.workspaceId} AND mla.last_doc_id = ${input.docId} ${windowCondition} @@ -1075,10 +1109,10 @@ export class WorkspaceAnalyticsModel extends BaseModel { SELECT COUNT(*) AS total FROM workspace_member_last_access mla INNER JOIN users u ON u.id = mla.user_id - INNER JOIN workspace_user_permissions wur - ON wur.workspace_id = mla.workspace_id - AND wur.user_id = mla.user_id - AND wur.status = 'Accepted'::"WorkspaceMemberStatus" + INNER JOIN workspace_members wm + ON wm.workspace_id = mla.workspace_id + AND wm.user_id = mla.user_id + AND wm.state = 'active' WHERE mla.workspace_id = ${workspaceId} AND mla.last_doc_id = ${docId} ${windowCondition} diff --git a/packages/backend/server/src/models/workspace-user.ts b/packages/backend/server/src/models/workspace-user.ts index 2cd2ebf3cf..9c71d49a3b 100644 --- a/packages/backend/server/src/models/workspace-user.ts +++ b/packages/backend/server/src/models/workspace-user.ts @@ -1,6 +1,7 @@ import { Injectable } from '@nestjs/common'; import { Transactional } from '@nestjs-cls/transactional'; import { + Prisma, WorkspaceMemberSource, WorkspaceMemberStatus, WorkspaceUserRole, @@ -23,6 +24,15 @@ import { export { WorkspaceMemberStatus }; +export type AdminWorkspaceMember = { + id: string; + name: string; + email: string; + avatarUrl: string | null; + role: 'Owner' | 'Admin' | 'Collaborator'; + status: WorkspaceMemberStatus; +}; + declare global { interface Events { 'workspace.owner.changed': { @@ -467,6 +477,66 @@ export class WorkspaceUserModel extends BaseModel { }); } + async adminListMembers( + workspaceId: string, + options: { + first: number; + offset: number; + query?: string | null; + } + ): Promise { + const keyword = options.query?.trim(); + const keywordCondition = keyword + ? Prisma.sql`AND (u.name ILIKE ${`%${keyword}%`} OR u.email ILIKE ${`%${keyword}%`})` + : Prisma.empty; + + return await this.db.$queryRaw` + SELECT * + FROM ( + SELECT + u.id, + u.name, + u.email, + u.avatar_url AS "avatarUrl", + CASE wm.role + WHEN 'owner' THEN 'Owner' + WHEN 'admin' THEN 'Admin' + ELSE 'Collaborator' + END AS role, + 'Accepted'::"WorkspaceMemberStatus" AS status, + 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' + ${keywordCondition} + UNION ALL + SELECT + u.id, + u.name, + u.email, + u.avatar_url AS "avatarUrl", + CASE wi.requested_role + WHEN 'admin' THEN 'Admin' + ELSE 'Collaborator' + END AS role, + CASE wi.status + WHEN 'waiting_review' THEN 'UnderReview'::"WorkspaceMemberStatus" + WHEN 'waiting_seat' THEN 'NeedMoreSeat'::"WorkspaceMemberStatus" + ELSE 'Pending'::"WorkspaceMemberStatus" + END AS status, + 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} + ${keywordCondition} + ) members + ORDER BY created_at ASC, id ASC + OFFSET ${options.offset} + LIMIT ${options.first} + `; + } + @Transactional() async allocateSeats(workspaceId: string, limit: number) { return await allocateWorkspaceSeats( diff --git a/packages/backend/server/src/models/workspace.ts b/packages/backend/server/src/models/workspace.ts index 084d5de7ad..3bc0b69c4b 100644 --- a/packages/backend/server/src/models/workspace.ts +++ b/packages/backend/server/src/models/workspace.ts @@ -1,11 +1,10 @@ import { Injectable } from '@nestjs/common'; import { Transactional } from '@nestjs-cls/transactional'; -import { Prisma, type Workspace, WorkspaceMemberStatus } from '@prisma/client'; +import { Prisma, type Workspace } from '@prisma/client'; import { EventBus } from '../base'; import { BaseModel } from './base'; import type { WorkspaceFeatureName } from './common'; -import { WorkspaceRole } from './common/role'; type RawWorkspaceSummary = { id: string; @@ -292,6 +291,82 @@ export class WorkspaceModel extends BaseModel { ${featuresHaving} ` : Prisma.empty; + const workspaceOnlyGroupAndHaving = + features.length > 0 + ? Prisma.sql` + GROUP BY w.id, + w.public, + w.created_at, + w.name, + w.avatar_key, + w.enable_ai, + w.enable_sharing, + w.enable_url_preview, + w.enable_doc_embedding + ${featuresHaving} + ` + : Prisma.empty; + + if (!keyword) { + const rows = await this.db.$queryRaw` + WITH filtered AS ( + SELECT w.id, + w.public, + w.created_at AS "createdAt", + w.name, + w.avatar_key AS "avatarKey", + w.enable_ai AS "enableAi", + w.enable_sharing AS "enableSharing", + w.enable_url_preview AS "enableUrlPreview", + w.enable_doc_embedding AS "enableDocEmbedding" + FROM workspaces w + ${featureJoin} + WHERE ${ + this.buildAdminFlagWhere(flags).length + ? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ') + : Prisma.sql`TRUE` + } + ${workspaceOnlyGroupAndHaving} + ), + page AS ( + SELECT f.*, + COALESCE(s.snapshot_count, 0) AS "snapshotCount", + COALESCE(s.snapshot_size, 0) AS "snapshotSize", + COALESCE(s.blob_count, 0) AS "blobCount", + COALESCE(s.blob_size, 0) AS "blobSize", + COALESCE(s.member_count, 0) AS "memberCount", + COALESCE(s.public_page_count, 0) AS "publicPageCount", + COALESCE(s.features, ARRAY[]::text[]) AS features + FROM filtered f + LEFT JOIN workspace_admin_stats s ON s.workspace_id = f.id + ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))} + LIMIT ${options.first} + OFFSET ${options.skip} + ) + SELECT p.*, + o.owner_id AS "ownerId", + o.owner_name AS "ownerName", + o.owner_email AS "ownerEmail", + o.owner_avatar_url AS "ownerAvatarUrl" + FROM page p + LEFT JOIN LATERAL ( + SELECT u.id AS owner_id, + u.name AS owner_name, + u.email AS owner_email, + u.avatar_url AS owner_avatar_url + FROM workspace_members AS wm + JOIN users u ON wm.user_id = u.id + WHERE wm.workspace_id = p.id + AND wm.role = 'owner' + AND wm.state = 'active' + ORDER BY u.created_at ASC, wm.id ASC + LIMIT 1 + ) o ON TRUE + ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))} + `; + + return { rows: this.mapAdminWorkspaceRows(rows), total }; + } const rows = await this.db.$queryRaw` WITH filtered AS ( @@ -314,12 +389,12 @@ export class WorkspaceModel extends BaseModel { u.name AS owner_name, u.email AS owner_email, u.avatar_url AS owner_avatar_url - FROM workspace_user_permissions AS wur - JOIN users u ON wur.user_id = u.id - WHERE wur.workspace_id = w.id - AND wur.type = ${WorkspaceRole.Owner} - AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`} - ORDER BY u.created_at ASC + FROM workspace_members AS wm + JOIN users u ON wm.user_id = u.id + WHERE wm.workspace_id = w.id + AND wm.role = 'owner' + AND wm.state = 'active' + ORDER BY u.created_at ASC, wm.id ASC LIMIT 1 ) o ON TRUE ${featureJoin} @@ -359,7 +434,11 @@ export class WorkspaceModel extends BaseModel { OFFSET ${options.skip} `; - const mapped = rows.map(row => ({ + return { rows: this.mapAdminWorkspaceRows(rows), total }; + } + + private mapAdminWorkspaceRows(rows: RawWorkspaceSummary[]) { + return rows.map(row => ({ id: row.id, public: row.public, createdAt: row.createdAt, @@ -385,8 +464,50 @@ export class WorkspaceModel extends BaseModel { } : null, })); + } - return { rows: mapped, total }; + async adminGetWorkspace(id: string) { + const rows = await this.db.$queryRaw` + SELECT w.id, + w.public, + w.created_at AS "createdAt", + w.name, + w.avatar_key AS "avatarKey", + w.enable_ai AS "enableAi", + w.enable_sharing AS "enableSharing", + w.enable_url_preview AS "enableUrlPreview", + w.enable_doc_embedding AS "enableDocEmbedding", + o.owner_id AS "ownerId", + o.owner_name AS "ownerName", + o.owner_email AS "ownerEmail", + o.owner_avatar_url AS "ownerAvatarUrl", + COALESCE(s.snapshot_count, 0) AS "snapshotCount", + COALESCE(s.snapshot_size, 0) AS "snapshotSize", + COALESCE(s.blob_count, 0) AS "blobCount", + COALESCE(s.blob_size, 0) AS "blobSize", + COALESCE(s.member_count, 0) AS "memberCount", + COALESCE(s.public_page_count, 0) AS "publicPageCount", + COALESCE(s.features, ARRAY[]::text[]) AS features + FROM workspaces w + LEFT JOIN LATERAL ( + SELECT u.id AS owner_id, + u.name AS owner_name, + u.email AS owner_email, + u.avatar_url AS owner_avatar_url + FROM workspace_members AS wm + JOIN users u ON wm.user_id = u.id + WHERE wm.workspace_id = w.id + AND wm.role = 'owner' + AND wm.state = 'active' + ORDER BY u.created_at ASC, wm.id ASC + LIMIT 1 + ) o ON TRUE + LEFT JOIN workspace_admin_stats s ON s.workspace_id = w.id + WHERE w.id = ${id} + LIMIT 1 + `; + + return this.mapAdminWorkspaceRows(rows)[0] ?? null; } async adminCountWorkspaces(options: { @@ -404,6 +525,27 @@ export class WorkspaceModel extends BaseModel { const features = options.features ?? []; const flags = options.flags ?? {}; + if (!keyword && features.length === 0) { + const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>` + SELECT COUNT(*) AS total + FROM workspaces w + WHERE ${ + this.buildAdminFlagWhere(flags).length + ? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ') + : Prisma.sql`TRUE` + } + `; + + return row?.total ? Number(row.total) : 0; + } + + const featureJoin = + features.length > 0 + ? Prisma.sql` + LEFT JOIN workspace_features wf + ON wf.workspace_id = w.id AND wf.activated = TRUE + ` + : Prisma.empty; const featuresHaving = features.length > 0 ? Prisma.sql` @@ -415,13 +557,25 @@ export class WorkspaceModel extends BaseModel { ` : Prisma.empty; - const featureJoin = - features.length > 0 - ? Prisma.sql` - LEFT JOIN workspace_features wf - ON wf.workspace_id = w.id AND wf.activated = TRUE - ` - : Prisma.empty; + if (!keyword) { + const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>` + WITH filtered AS ( + SELECT w.id + FROM workspaces w + ${featureJoin} + WHERE ${ + this.buildAdminFlagWhere(flags).length + ? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ') + : Prisma.sql`TRUE` + } + GROUP BY w.id + ${featuresHaving} + ) + SELECT COUNT(*) AS total FROM filtered + `; + + return row?.total ? Number(row.total) : 0; + } const groupAndHaving = features.length > 0 @@ -438,15 +592,15 @@ export class WorkspaceModel extends BaseModel { o.owner_email AS "ownerEmail" FROM workspaces w LEFT JOIN LATERAL ( - SELECT wur.workspace_id, + SELECT wm.workspace_id, u.id AS owner_id, u.email AS owner_email - FROM workspace_user_permissions AS wur - JOIN users u ON wur.user_id = u.id - WHERE wur.workspace_id = w.id - AND wur.type = ${WorkspaceRole.Owner} - AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`} - ORDER BY u.created_at ASC + FROM workspace_members AS wm + JOIN users u ON wm.user_id = u.id + WHERE wm.workspace_id = w.id + AND wm.role = 'owner' + AND wm.state = 'active' + ORDER BY u.created_at ASC, wm.id ASC LIMIT 1 ) o ON TRUE ${featureJoin} @@ -519,20 +673,20 @@ export class WorkspaceModel extends BaseModel { ) { switch (order) { case 'snapshotSize': - return `"snapshotSize" DESC NULLS LAST, "createdAt" DESC`; + return `"snapshotSize" DESC NULLS LAST, "createdAt" DESC, "id" ASC`; case 'blobCount': - return `"blobCount" DESC NULLS LAST, "createdAt" DESC`; + return `"blobCount" DESC NULLS LAST, "createdAt" DESC, "id" ASC`; case 'blobSize': - return `"blobSize" DESC NULLS LAST, "createdAt" DESC`; + return `"blobSize" DESC NULLS LAST, "createdAt" DESC, "id" ASC`; case 'snapshotCount': - return `"snapshotCount" DESC NULLS LAST, "createdAt" DESC`; + return `"snapshotCount" DESC NULLS LAST, "createdAt" DESC, "id" ASC`; case 'memberCount': - return `"memberCount" DESC NULLS LAST, "createdAt" DESC`; + return `"memberCount" DESC NULLS LAST, "createdAt" DESC, "id" ASC`; case 'publicPageCount': - return `"publicPageCount" DESC NULLS LAST, "createdAt" DESC`; + return `"publicPageCount" DESC NULLS LAST, "createdAt" DESC, "id" ASC`; case 'createdAt': default: - return `"createdAt" DESC`; + return `"createdAt" DESC, "id" ASC`; } } // #endregion diff --git a/packages/backend/server/src/schema.gql b/packages/backend/server/src/schema.gql index fa7608c4fd..897526ae2d 100644 --- a/packages/backend/server/src/schema.gql +++ b/packages/backend/server/src/schema.gql @@ -163,10 +163,16 @@ type AdminWorkspaceMember { email: String! id: String! name: String! - role: Permission! + role: AdminWorkspaceMemberRole! status: WorkspaceMemberStatus! } +enum AdminWorkspaceMemberRole { + Admin + Collaborator + Owner +} + type AdminWorkspaceSharedLink { docId: String! publishedAt: DateTime @@ -2953,4 +2959,4 @@ type tokenType { refresh: String! sessionToken: String token: String! -} \ No newline at end of file +} diff --git a/packages/common/graphql/src/schema.ts b/packages/common/graphql/src/schema.ts index 9f75a820c3..3690cfd83d 100644 --- a/packages/common/graphql/src/schema.ts +++ b/packages/common/graphql/src/schema.ts @@ -213,10 +213,16 @@ export interface AdminWorkspaceMember { email: Scalars['String']['output']; id: Scalars['String']['output']; name: Scalars['String']['output']; - role: Permission; + role: AdminWorkspaceMemberRole; status: WorkspaceMemberStatus; } +export enum AdminWorkspaceMemberRole { + Admin = 'Admin', + Collaborator = 'Collaborator', + Owner = 'Owner', +} + export interface AdminWorkspaceSharedLink { __typename?: 'AdminWorkspaceSharedLink'; docId: Scalars['String']['output']; @@ -4013,7 +4019,7 @@ export type AdminWorkspaceQuery = { name: string; email: string; avatarUrl: string | null; - role: Permission; + role: AdminWorkspaceMemberRole; status: WorkspaceMemberStatus; }>; } | null; diff --git a/packages/frontend/admin/src/modules/workspaces/components/columns.tsx b/packages/frontend/admin/src/modules/workspaces/components/columns.tsx index 3115da4a58..28c01481d6 100644 --- a/packages/frontend/admin/src/modules/workspaces/components/columns.tsx +++ b/packages/frontend/admin/src/modules/workspaces/components/columns.tsx @@ -102,14 +102,14 @@ export const useColumns = () => { }, { accessorKey: 'members', - header: () =>
Members
, + header: () =>
Active Members
, cell: ({ row }) => { const ws = row.original; return (
{ws.memberCount} - members + active members
{ws.publicPageCount} diff --git a/packages/frontend/admin/src/modules/workspaces/components/workspace-panel.tsx b/packages/frontend/admin/src/modules/workspaces/components/workspace-panel.tsx index cffd51e3f8..c5e16fe6fa 100644 --- a/packages/frontend/admin/src/modules/workspaces/components/workspace-panel.tsx +++ b/packages/frontend/admin/src/modules/workspaces/components/workspace-panel.tsx @@ -235,7 +235,10 @@ function WorkspacePanelContent({ value={formatBytes(workspace.blobSize)} /> - +
-
Members
+
+ Members and Invitations +
{memberList.length === 0 ? (
- No members. + No members or invitations.
) : ( memberList.map(member => ( @@ -270,8 +275,15 @@ function WorkspacePanelContent({ {member.email}
-
- {member.role} +
+
+ {member.role} +
+ {member.status !== 'Accepted' ? ( +
+ {member.status} +
+ ) : null}
))