mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-07-15 09:06:19 +08:00
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:
@@ -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();
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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<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()
|
||||
async allocateSeats(workspaceId: string, limit: number) {
|
||||
return await allocateWorkspaceSeats(
|
||||
|
||||
@@ -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<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[]>`
|
||||
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<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: {
|
||||
@@ -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
|
||||
|
||||
@@ -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!
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -102,14 +102,14 @@ export const useColumns = () => {
|
||||
},
|
||||
{
|
||||
accessorKey: 'members',
|
||||
header: () => <div className="text-xs font-medium">Members</div>,
|
||||
header: () => <div className="text-xs font-medium">Active Members</div>,
|
||||
cell: ({ row }) => {
|
||||
const ws = row.original;
|
||||
return (
|
||||
<div className="flex flex-col text-xs gap-1">
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">{ws.memberCount}</span>
|
||||
<span className="text-muted-foreground">members</span>
|
||||
<span className="text-muted-foreground">active members</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<span className="font-medium">{ws.publicPageCount}</span>
|
||||
|
||||
@@ -235,7 +235,10 @@ function WorkspacePanelContent({
|
||||
value={formatBytes(workspace.blobSize)}
|
||||
/>
|
||||
<MetricCard label="Blob Count" value={`${workspace.blobCount}`} />
|
||||
<MetricCard label="Members" value={`${workspace.memberCount}`} />
|
||||
<MetricCard
|
||||
label="Active Members"
|
||||
value={`${workspace.memberCount}`}
|
||||
/>
|
||||
<MetricCard
|
||||
label="Shared Pages"
|
||||
value={`${workspace.publicPageCount}`}
|
||||
@@ -243,12 +246,14 @@ function WorkspacePanelContent({
|
||||
</div>
|
||||
|
||||
<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 />
|
||||
<div className="flex flex-col divide-y">
|
||||
{memberList.length === 0 ? (
|
||||
<div className="px-3 py-3 text-xs text-muted-foreground">
|
||||
No members.
|
||||
No members or invitations.
|
||||
</div>
|
||||
) : (
|
||||
memberList.map(member => (
|
||||
@@ -270,8 +275,15 @@ function WorkspacePanelContent({
|
||||
{member.email}
|
||||
</div>
|
||||
</div>
|
||||
<div className="ml-auto text-xs px-2 py-1 rounded border">
|
||||
{member.role}
|
||||
<div className="ml-auto flex flex-col items-end gap-1 text-xs">
|
||||
<div className="rounded border px-2 py-1">
|
||||
{member.role}
|
||||
</div>
|
||||
{member.status !== 'Accepted' ? (
|
||||
<div className="text-muted-foreground">
|
||||
{member.status}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
|
||||
Reference in New Issue
Block a user