fix(server): dashboard query (#15195)

#### PR Dependency Tree


* **PR #15195** 👈

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

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

* **Bug Fixes**
* Admin dashboard storage history now returns a continuous day-by-day
trend, carrying forward the last known workspace and blob storage values
for days without samples.
* Active member counts for workspace analytics were corrected to reflect
active workspace members.
* **New Features**
* Admin workspace member roles are now returned via a dedicated role
enum, serialized as enum name strings (e.g., `Owner`).
* **UI Improvements**
* Renamed key labels to “Active Members” and “Members and Invitations”;
member roles use pill badges and non-accepted statuses are shown.
* **Tests**
* Added E2E coverage for role enum serialization and strengthened
storage history assertions.
<!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
DarkSky
2026-07-05 03:04:05 +08:00
committed by GitHub
parent 7cfedbd49d
commit 296a4c2f81
10 changed files with 451 additions and 149 deletions

View File

@@ -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( e2e(
'adminDashboard should carry forward missing sync and storage samples', 'adminDashboard should carry forward missing sync and storage samples',
async t => { async t => {
@@ -524,6 +561,36 @@ e2e(
dashboard.workspaceStorageBytes dashboard.workspaceStorageBytes
); );
t.is(blobHistory[blobHistory.length - 1], dashboard.blobStorageBytes); 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 { } finally {
clock.restore(); clock.restore();
} }

View File

@@ -30,7 +30,6 @@ import {
Models, Models,
WorkspaceFeatureName, WorkspaceFeatureName,
WorkspaceMemberStatus, WorkspaceMemberStatus,
WorkspaceRole,
} from '../../../models'; } from '../../../models';
import { Admin } from '../../common'; import { Admin } from '../../common';
import { WorkspaceUserType } from '../../user'; import { WorkspaceUserType } from '../../user';
@@ -60,6 +59,16 @@ registerEnumType(AdminSharedLinksOrder, {
name: 'AdminSharedLinksOrder', name: 'AdminSharedLinksOrder',
}); });
enum AdminWorkspaceMemberRole {
Owner = 'Owner',
Admin = 'Admin',
Collaborator = 'Collaborator',
}
registerEnumType(AdminWorkspaceMemberRole, {
name: 'AdminWorkspaceMemberRole',
});
function hasSelectedField( function hasSelectedField(
selections: readonly SelectionNode[], selections: readonly SelectionNode[],
fieldName: string, fieldName: string,
@@ -145,8 +154,8 @@ class AdminWorkspaceMember {
@Field(() => String, { nullable: true }) @Field(() => String, { nullable: true })
avatarUrl?: string | null; avatarUrl?: string | null;
@Field(() => WorkspaceRole) @Field(() => AdminWorkspaceMemberRole)
role!: WorkspaceRole; role!: `${AdminWorkspaceMemberRole}`;
@Field(() => WorkspaceMemberStatus) @Field(() => WorkspaceMemberStatus)
status!: WorkspaceMemberStatus; status!: WorkspaceMemberStatus;
@@ -494,18 +503,7 @@ export class AdminWorkspaceResolver {
}) })
async adminWorkspace(@Args('id') id: string) { async adminWorkspace(@Args('id') id: string) {
this.assertCloudOnly(); this.assertCloudOnly();
const { rows } = await this.models.workspace.adminListWorkspaces({ return await this.models.workspace.adminGetWorkspace(id);
first: 1,
skip: 0,
keyword: id,
order: 'createdAt',
includeTotal: false,
});
const row = rows.find(r => r.id === id);
if (!row) {
return null;
}
return row;
} }
@Query(() => AdminDashboard, { @Query(() => AdminDashboard, {
@@ -608,46 +606,11 @@ export class AdminWorkspaceResolver {
after: undefined, after: undefined,
}; };
if (query) { return await this.models.workspaceUser.adminListMembers(workspaceId, {
const list = await this.models.workspaceUser.search( first: pagination.first,
workspaceId, offset: pagination.offset,
query, 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,
},
]
: []
);
} }
@ResolveField(() => [AdminWorkspaceSharedLink], { @ResolveField(() => [AdminWorkspaceSharedLink], {
@@ -677,18 +640,7 @@ export class AdminWorkspaceResolver {
await this.models.workspace.update(id, updates); await this.models.workspace.update(id, updates);
} }
const { rows } = await this.models.workspace.adminListWorkspaces({ return await this.models.workspace.adminGetWorkspace(id);
first: 1,
skip: 0,
keyword: id,
order: 'createdAt',
includeTotal: false,
});
const row = rows.find(r => r.id === id);
if (!row) {
return null;
}
return row;
} }
private mapSort(orderBy?: AdminWorkspaceSort) { private mapSort(orderBy?: AdminWorkspaceSort) {

View File

@@ -276,8 +276,9 @@ export class WorkspaceStatsJob {
), ),
member_stats AS ( member_stats AS (
SELECT workspace_id, COUNT(*) AS member_count SELECT workspace_id, COUNT(*) AS member_count
FROM workspace_user_permissions FROM workspace_members
WHERE workspace_id IN (SELECT workspace_id FROM targets) WHERE workspace_id IN (SELECT workspace_id FROM targets)
AND state = 'active'
GROUP BY workspace_id GROUP BY workspace_id
), ),
public_page_stats AS ( public_page_stats AS (

View File

@@ -9,7 +9,6 @@ import {
} from '../base/graphql/pagination'; } from '../base/graphql/pagination';
import { CacheRedis } from '../base/redis'; import { CacheRedis } from '../base/redis';
import { BaseModel } from './base'; import { BaseModel } from './base';
import { WorkspaceRole } from './common';
const DEFAULT_STORAGE_HISTORY_DAYS = 30; const DEFAULT_STORAGE_HISTORY_DAYS = 30;
const DEFAULT_SYNC_HISTORY_HOURS = 48; 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 ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT user_id SELECT user_id
FROM workspace_user_permissions FROM workspace_members
WHERE workspace_id = wp.workspace_id WHERE workspace_id = wp.workspace_id
AND type = ${WorkspaceRole.Owner} AND role = 'owner'
AND status = 'Accepted'::"WorkspaceMemberStatus" AND state = 'active'
ORDER BY created_at ASC ORDER BY created_at ASC, id ASC
LIMIT 1 LIMIT 1
) owner ON TRUE ) owner ON TRUE
WHERE wp.public = TRUE WHERE wp.public = TRUE
@@ -391,32 +390,42 @@ export class WorkspaceAnalyticsModel extends BaseModel {
blobStorageBytes: bigint | number; blobStorageBytes: bigint | number;
}[] }[]
>` >`
WITH days AS ( WITH baseline_day AS (
SELECT generate_series(${storageFrom}::date, ${currentDay}::date, interval '1 day')::date AS day 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 SELECT
date, date,
COALESCE(SUM(snapshot_size), 0) AS workspace_storage_bytes, COALESCE(SUM(snapshot_size), 0) AS workspace_storage_bytes,
COALESCE(SUM(blob_size), 0) AS blob_storage_bytes COALESCE(SUM(blob_size), 0) AS blob_storage_bytes
FROM workspace_admin_stats_daily FROM workspace_admin_stats_daily
WHERE date <= ${currentDay}::date WHERE date BETWEEN ${storageFrom}::date AND ${currentDay}::date
GROUP BY date GROUP BY date
) )
SELECT SELECT
days.day AS date, date,
COALESCE(snapshot.workspace_storage_bytes, 0) AS "workspaceStorageBytes", workspace_storage_bytes AS "workspaceStorageBytes",
COALESCE(snapshot.blob_storage_bytes, 0) AS "blobStorageBytes" blob_storage_bytes AS "blobStorageBytes"
FROM days FROM baseline
LEFT JOIN LATERAL ( UNION ALL
SELECT SELECT
workspace_storage_bytes, date,
blob_storage_bytes workspace_storage_bytes AS "workspaceStorageBytes",
FROM grouped blob_storage_bytes AS "blobStorageBytes"
WHERE date <= days.day FROM in_window
ORDER BY date DESC
LIMIT 1
) snapshot ON TRUE
ORDER BY date ASC ORDER BY date ASC
`, `,
this.db.$queryRaw<{ conversations: bigint | number }[]>` this.db.$queryRaw<{ conversations: bigint | number }[]>`
@@ -435,11 +444,36 @@ export class WorkspaceAnalyticsModel extends BaseModel {
const currentBlobStorageBytes = Number( const currentBlobStorageBytes = Number(
storageCurrent[0]?.blobStorageBytes ?? 0 storageCurrent[0]?.blobStorageBytes ?? 0
); );
const storageHistorySeries = storageHistory.map(row => ({ const storageByDate = new Map(
date: row.date, storageHistory.map(row => [
asDateOnlyString(row.date),
{
workspaceStorageBytes: Number(row.workspaceStorageBytes ?? 0), workspaceStorageBytes: Number(row.workspaceStorageBytes ?? 0),
blobStorageBytes: Number(row.blobStorageBytes ?? 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) { if (storageHistorySeries.length > 0) {
const lastPoint = storageHistorySeries[storageHistorySeries.length - 1]; const lastPoint = storageHistorySeries[storageHistorySeries.length - 1];
if (asDateOnlyString(lastPoint.date) === asDateOnlyString(currentDay)) { 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 ON v.workspace_id = wp.workspace_id AND v.doc_id = wp.page_id
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT user_id SELECT user_id
FROM workspace_user_permissions FROM workspace_members
WHERE workspace_id = wp.workspace_id WHERE workspace_id = wp.workspace_id
AND type = ${WorkspaceRole.Owner} AND role = 'owner'
AND status = 'Accepted'::"WorkspaceMemberStatus" AND state = 'active'
ORDER BY created_at ASC ORDER BY created_at ASC, id ASC
LIMIT 1 LIMIT 1
) owner ON TRUE ) owner ON TRUE
WHERE wp.public = TRUE WHERE wp.public = TRUE
@@ -861,10 +895,10 @@ export class WorkspaceAnalyticsModel extends BaseModel {
mla.last_doc_id AS "lastDocId" mla.last_doc_id AS "lastDocId"
FROM workspace_member_last_access mla FROM workspace_member_last_access mla
INNER JOIN users u ON u.id = mla.user_id INNER JOIN users u ON u.id = mla.user_id
INNER JOIN workspace_user_permissions wur INNER JOIN workspace_members wm
ON wur.workspace_id = mla.workspace_id ON wm.workspace_id = mla.workspace_id
AND wur.user_id = mla.user_id AND wm.user_id = mla.user_id
AND wur.status = 'Accepted'::"WorkspaceMemberStatus" AND wm.state = 'active'
WHERE mla.workspace_id = ${input.workspaceId} WHERE mla.workspace_id = ${input.workspaceId}
AND mla.last_doc_id = ${input.docId} AND mla.last_doc_id = ${input.docId}
${windowCondition} ${windowCondition}
@@ -1075,10 +1109,10 @@ export class WorkspaceAnalyticsModel extends BaseModel {
SELECT COUNT(*) AS total SELECT COUNT(*) AS total
FROM workspace_member_last_access mla FROM workspace_member_last_access mla
INNER JOIN users u ON u.id = mla.user_id INNER JOIN users u ON u.id = mla.user_id
INNER JOIN workspace_user_permissions wur INNER JOIN workspace_members wm
ON wur.workspace_id = mla.workspace_id ON wm.workspace_id = mla.workspace_id
AND wur.user_id = mla.user_id AND wm.user_id = mla.user_id
AND wur.status = 'Accepted'::"WorkspaceMemberStatus" AND wm.state = 'active'
WHERE mla.workspace_id = ${workspaceId} WHERE mla.workspace_id = ${workspaceId}
AND mla.last_doc_id = ${docId} AND mla.last_doc_id = ${docId}
${windowCondition} ${windowCondition}

View File

@@ -1,6 +1,7 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional'; import { Transactional } from '@nestjs-cls/transactional';
import { import {
Prisma,
WorkspaceMemberSource, WorkspaceMemberSource,
WorkspaceMemberStatus, WorkspaceMemberStatus,
WorkspaceUserRole, WorkspaceUserRole,
@@ -23,6 +24,15 @@ import {
export { WorkspaceMemberStatus }; export { WorkspaceMemberStatus };
export type AdminWorkspaceMember = {
id: string;
name: string;
email: string;
avatarUrl: string | null;
role: 'Owner' | 'Admin' | 'Collaborator';
status: WorkspaceMemberStatus;
};
declare global { declare global {
interface Events { interface Events {
'workspace.owner.changed': { '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<AdminWorkspaceMember[]> {
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<AdminWorkspaceMember[]>`
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() @Transactional()
async allocateSeats(workspaceId: string, limit: number) { async allocateSeats(workspaceId: string, limit: number) {
return await allocateWorkspaceSeats( return await allocateWorkspaceSeats(

View File

@@ -1,11 +1,10 @@
import { Injectable } from '@nestjs/common'; import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional'; 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 { EventBus } from '../base';
import { BaseModel } from './base'; import { BaseModel } from './base';
import type { WorkspaceFeatureName } from './common'; import type { WorkspaceFeatureName } from './common';
import { WorkspaceRole } from './common/role';
type RawWorkspaceSummary = { type RawWorkspaceSummary = {
id: string; id: string;
@@ -292,6 +291,82 @@ export class WorkspaceModel extends BaseModel {
${featuresHaving} ${featuresHaving}
` `
: Prisma.empty; : 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<RawWorkspaceSummary[]>`
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<RawWorkspaceSummary[]>` const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
WITH filtered AS ( WITH filtered AS (
@@ -314,12 +389,12 @@ export class WorkspaceModel extends BaseModel {
u.name AS owner_name, u.name AS owner_name,
u.email AS owner_email, u.email AS owner_email,
u.avatar_url AS owner_avatar_url u.avatar_url AS owner_avatar_url
FROM workspace_user_permissions AS wur FROM workspace_members AS wm
JOIN users u ON wur.user_id = u.id JOIN users u ON wm.user_id = u.id
WHERE wur.workspace_id = w.id WHERE wm.workspace_id = w.id
AND wur.type = ${WorkspaceRole.Owner} AND wm.role = 'owner'
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`} AND wm.state = 'active'
ORDER BY u.created_at ASC ORDER BY u.created_at ASC, wm.id ASC
LIMIT 1 LIMIT 1
) o ON TRUE ) o ON TRUE
${featureJoin} ${featureJoin}
@@ -359,7 +434,11 @@ export class WorkspaceModel extends BaseModel {
OFFSET ${options.skip} 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, id: row.id,
public: row.public, public: row.public,
createdAt: row.createdAt, createdAt: row.createdAt,
@@ -385,8 +464,50 @@ export class WorkspaceModel extends BaseModel {
} }
: null, : null,
})); }));
}
return { rows: mapped, total }; async adminGetWorkspace(id: string) {
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
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: { async adminCountWorkspaces(options: {
@@ -404,6 +525,27 @@ export class WorkspaceModel extends BaseModel {
const features = options.features ?? []; const features = options.features ?? [];
const flags = options.flags ?? {}; 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 = const featuresHaving =
features.length > 0 features.length > 0
? Prisma.sql` ? Prisma.sql`
@@ -415,13 +557,25 @@ export class WorkspaceModel extends BaseModel {
` `
: Prisma.empty; : Prisma.empty;
const featureJoin = if (!keyword) {
features.length > 0 const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
? Prisma.sql` WITH filtered AS (
LEFT JOIN workspace_features wf SELECT w.id
ON wf.workspace_id = w.id AND wf.activated = TRUE FROM workspaces w
` ${featureJoin}
: Prisma.empty; 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 = const groupAndHaving =
features.length > 0 features.length > 0
@@ -438,15 +592,15 @@ export class WorkspaceModel extends BaseModel {
o.owner_email AS "ownerEmail" o.owner_email AS "ownerEmail"
FROM workspaces w FROM workspaces w
LEFT JOIN LATERAL ( LEFT JOIN LATERAL (
SELECT wur.workspace_id, SELECT wm.workspace_id,
u.id AS owner_id, u.id AS owner_id,
u.email AS owner_email u.email AS owner_email
FROM workspace_user_permissions AS wur FROM workspace_members AS wm
JOIN users u ON wur.user_id = u.id JOIN users u ON wm.user_id = u.id
WHERE wur.workspace_id = w.id WHERE wm.workspace_id = w.id
AND wur.type = ${WorkspaceRole.Owner} AND wm.role = 'owner'
AND wur.status = ${Prisma.sql`${WorkspaceMemberStatus.Accepted}::"WorkspaceMemberStatus"`} AND wm.state = 'active'
ORDER BY u.created_at ASC ORDER BY u.created_at ASC, wm.id ASC
LIMIT 1 LIMIT 1
) o ON TRUE ) o ON TRUE
${featureJoin} ${featureJoin}
@@ -519,20 +673,20 @@ export class WorkspaceModel extends BaseModel {
) { ) {
switch (order) { switch (order) {
case 'snapshotSize': case 'snapshotSize':
return `"snapshotSize" DESC NULLS LAST, "createdAt" DESC`; return `"snapshotSize" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
case 'blobCount': case 'blobCount':
return `"blobCount" DESC NULLS LAST, "createdAt" DESC`; return `"blobCount" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
case 'blobSize': case 'blobSize':
return `"blobSize" DESC NULLS LAST, "createdAt" DESC`; return `"blobSize" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
case 'snapshotCount': case 'snapshotCount':
return `"snapshotCount" DESC NULLS LAST, "createdAt" DESC`; return `"snapshotCount" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
case 'memberCount': case 'memberCount':
return `"memberCount" DESC NULLS LAST, "createdAt" DESC`; return `"memberCount" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
case 'publicPageCount': case 'publicPageCount':
return `"publicPageCount" DESC NULLS LAST, "createdAt" DESC`; return `"publicPageCount" DESC NULLS LAST, "createdAt" DESC, "id" ASC`;
case 'createdAt': case 'createdAt':
default: default:
return `"createdAt" DESC`; return `"createdAt" DESC, "id" ASC`;
} }
} }
// #endregion // #endregion

View File

@@ -163,10 +163,16 @@ type AdminWorkspaceMember {
email: String! email: String!
id: String! id: String!
name: String! name: String!
role: Permission! role: AdminWorkspaceMemberRole!
status: WorkspaceMemberStatus! status: WorkspaceMemberStatus!
} }
enum AdminWorkspaceMemberRole {
Admin
Collaborator
Owner
}
type AdminWorkspaceSharedLink { type AdminWorkspaceSharedLink {
docId: String! docId: String!
publishedAt: DateTime publishedAt: DateTime

View File

@@ -213,10 +213,16 @@ export interface AdminWorkspaceMember {
email: Scalars['String']['output']; email: Scalars['String']['output'];
id: Scalars['String']['output']; id: Scalars['String']['output'];
name: Scalars['String']['output']; name: Scalars['String']['output'];
role: Permission; role: AdminWorkspaceMemberRole;
status: WorkspaceMemberStatus; status: WorkspaceMemberStatus;
} }
export enum AdminWorkspaceMemberRole {
Admin = 'Admin',
Collaborator = 'Collaborator',
Owner = 'Owner',
}
export interface AdminWorkspaceSharedLink { export interface AdminWorkspaceSharedLink {
__typename?: 'AdminWorkspaceSharedLink'; __typename?: 'AdminWorkspaceSharedLink';
docId: Scalars['String']['output']; docId: Scalars['String']['output'];
@@ -4013,7 +4019,7 @@ export type AdminWorkspaceQuery = {
name: string; name: string;
email: string; email: string;
avatarUrl: string | null; avatarUrl: string | null;
role: Permission; role: AdminWorkspaceMemberRole;
status: WorkspaceMemberStatus; status: WorkspaceMemberStatus;
}>; }>;
} | null; } | null;

View File

@@ -102,14 +102,14 @@ export const useColumns = () => {
}, },
{ {
accessorKey: 'members', accessorKey: 'members',
header: () => <div className="text-xs font-medium">Members</div>, header: () => <div className="text-xs font-medium">Active Members</div>,
cell: ({ row }) => { cell: ({ row }) => {
const ws = row.original; const ws = row.original;
return ( return (
<div className="flex flex-col text-xs gap-1"> <div className="flex flex-col text-xs gap-1">
<div className="flex gap-2"> <div className="flex gap-2">
<span className="font-medium">{ws.memberCount}</span> <span className="font-medium">{ws.memberCount}</span>
<span className="text-muted-foreground">members</span> <span className="text-muted-foreground">active members</span>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<span className="font-medium">{ws.publicPageCount}</span> <span className="font-medium">{ws.publicPageCount}</span>

View File

@@ -235,7 +235,10 @@ function WorkspacePanelContent({
value={formatBytes(workspace.blobSize)} value={formatBytes(workspace.blobSize)}
/> />
<MetricCard label="Blob Count" value={`${workspace.blobCount}`} /> <MetricCard label="Blob Count" value={`${workspace.blobCount}`} />
<MetricCard label="Members" value={`${workspace.memberCount}`} /> <MetricCard
label="Active Members"
value={`${workspace.memberCount}`}
/>
<MetricCard <MetricCard
label="Shared Pages" label="Shared Pages"
value={`${workspace.publicPageCount}`} value={`${workspace.publicPageCount}`}
@@ -243,12 +246,14 @@ function WorkspacePanelContent({
</div> </div>
<div className="rounded-xl border border-border/60 bg-card shadow-sm"> <div className="rounded-xl border border-border/60 bg-card shadow-sm">
<div className="px-3 py-2 text-sm font-medium">Members</div> <div className="px-3 py-2 text-sm font-medium">
Members and Invitations
</div>
<Separator /> <Separator />
<div className="flex flex-col divide-y"> <div className="flex flex-col divide-y">
{memberList.length === 0 ? ( {memberList.length === 0 ? (
<div className="px-3 py-3 text-xs text-muted-foreground"> <div className="px-3 py-3 text-xs text-muted-foreground">
No members. No members or invitations.
</div> </div>
) : ( ) : (
memberList.map(member => ( memberList.map(member => (
@@ -270,9 +275,16 @@ function WorkspacePanelContent({
{member.email} {member.email}
</div> </div>
</div> </div>
<div className="ml-auto text-xs px-2 py-1 rounded border"> <div className="ml-auto flex flex-col items-end gap-1 text-xs">
<div className="rounded border px-2 py-1">
{member.role} {member.role}
</div> </div>
{member.status !== 'Accepted' ? (
<div className="text-muted-foreground">
{member.status}
</div>
) : null}
</div>
</div> </div>
)) ))
)} )}