feat(server): cleanup legacy compatibility (#15239)

This commit is contained in:
DarkSky
2026-07-15 03:01:51 +08:00
committed by GitHub
parent 00d4ab10a1
commit e145d87d56
104 changed files with 3184 additions and 8883 deletions

View File

@@ -19,13 +19,4 @@ export class BaseModel {
// See https://papooch.github.io/nestjs-cls/plugins/available-plugins/transactional#using-the-injecttransaction-decorator
return this.txHost.tx;
}
protected async withPermissionProjectionMetric<T>(operation: Promise<T>) {
try {
return await operation;
} catch (err) {
this.models.permissionProjection.recordTriggerErrorMetric(err);
throw err;
}
}
}

View File

@@ -1,39 +1,18 @@
import { z } from 'zod';
import { OneDay, OneGB, OneMB } from '../../base';
export interface UserQuota {
name: string;
blobLimit: number;
businessBlobLimit?: number;
storageQuota: number;
historyPeriod: number;
memberLimit: number;
copilotActionLimit?: number;
}
const UserPlanQuotaConfig = z.object({
// quota name
name: z.string(),
// single blob limit
blobLimit: z.number(),
// server limit will larger then client to handle a edge case:
// when a user downgrades from pro to free, he can still continue
// to upload previously added files that exceed the free limit
// NOTE: this is a product decision, may change in future
businessBlobLimit: z.number().optional(),
// total blob limit
storageQuota: z.number(),
// history period of validity
historyPeriod: z.number(),
// member limit
memberLimit: z.number(),
// copilot action limit
copilotActionLimit: z.number().optional(),
});
export type UserQuota = z.infer<typeof UserPlanQuotaConfig>;
const WorkspaceQuotaConfig = UserPlanQuotaConfig.extend({
// seat quota
seatQuota: z.number(),
}).omit({
copilotActionLimit: true,
});
export type WorkspaceQuota = z.infer<typeof WorkspaceQuotaConfig>;
const EMPTY_CONFIG = z.object({});
export interface WorkspaceQuota extends UserQuota {
seatQuota: number;
}
export enum FeatureType {
Feature,
@@ -41,120 +20,25 @@ export enum FeatureType {
}
export enum Feature {
// user
Admin = 'administrator',
UnlimitedCopilot = 'unlimited_copilot',
FreePlan = 'free_plan_v1',
ProPlan = 'pro_plan_v1',
LifetimeProPlan = 'lifetime_pro_plan_v1',
// workspace
UnlimitedWorkspace = 'unlimited_workspace',
TeamPlan = 'team_plan_v1',
QuotaExceededReadonlyWorkspace = 'quota_exceeded_readonly_workspace_v1',
}
// TODO(@forehalo): may merge `FeatureShapes` and `FeatureConfigs`?
export const FeaturesShapes = {
unlimited_workspace: EMPTY_CONFIG,
unlimited_copilot: EMPTY_CONFIG,
administrator: EMPTY_CONFIG,
free_plan_v1: UserPlanQuotaConfig,
pro_plan_v1: UserPlanQuotaConfig,
lifetime_pro_plan_v1: UserPlanQuotaConfig,
team_plan_v1: WorkspaceQuotaConfig,
quota_exceeded_readonly_workspace_v1: EMPTY_CONFIG,
} satisfies Record<Feature, z.ZodObject<any>>;
administrator: z.object({}),
};
export type UserFeatureName = keyof Pick<
typeof FeaturesShapes,
| 'unlimited_copilot'
| 'administrator'
| 'free_plan_v1'
| 'pro_plan_v1'
| 'lifetime_pro_plan_v1'
>;
export type WorkspaceFeatureName = keyof Pick<
typeof FeaturesShapes,
| 'unlimited_workspace'
| 'team_plan_v1'
| 'quota_exceeded_readonly_workspace_v1'
>;
export type FeatureName = UserFeatureName | WorkspaceFeatureName;
export type UserFeatureName = 'administrator';
export type FeatureName = UserFeatureName;
export type FeatureConfig<T extends FeatureName> = z.infer<
(typeof FeaturesShapes)[T]
>;
const FreeFeature = {
type: FeatureType.Quota,
configs: {
// quota name
name: 'Free',
blobLimit: 10 * OneMB,
businessBlobLimit: 100 * OneMB,
storageQuota: 10 * OneGB,
historyPeriod: 7 * OneDay,
memberLimit: 3,
copilotActionLimit: 10,
export const FeatureConfigs = {
administrator: {
type: FeatureType.Feature,
configs: {},
},
} as const;
const ProFeature = {
type: FeatureType.Quota,
configs: {
name: 'Pro',
blobLimit: 100 * OneMB,
storageQuota: 100 * OneGB,
historyPeriod: 30 * OneDay,
memberLimit: 10,
copilotActionLimit: 10,
},
} as const;
const LifetimeProFeature = {
type: FeatureType.Quota,
configs: {
name: 'Lifetime Pro',
blobLimit: 100 * OneMB,
storageQuota: 1024 * OneGB,
historyPeriod: 30 * OneDay,
memberLimit: 10,
copilotActionLimit: 10,
},
} as const;
const TeamFeature = {
type: FeatureType.Quota,
configs: {
name: 'Team Workspace',
blobLimit: 500 * OneMB,
storageQuota: 100 * OneGB,
seatQuota: 20 * OneGB,
historyPeriod: 30 * OneDay,
memberLimit: 1,
},
} as const;
const EmptyFeature = {
type: FeatureType.Feature,
configs: {},
} as const;
export const FeatureConfigs: {
[K in FeatureName]: {
type: FeatureType;
configs: FeatureConfig<K>;
};
} = {
get free_plan_v1() {
return env.selfhosted ? ProFeature : FreeFeature;
},
pro_plan_v1: ProFeature,
lifetime_pro_plan_v1: LifetimeProFeature,
team_plan_v1: TeamFeature,
unlimited_workspace: EmptyFeature,
quota_exceeded_readonly_workspace_v1: EmptyFeature,
unlimited_copilot: EmptyFeature,
administrator: EmptyFeature,
};
} satisfies Record<
FeatureName,
{ type: FeatureType; configs: FeatureConfig<FeatureName> }
>;

View File

@@ -3,7 +3,7 @@ import assert from 'node:assert';
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import type { TransactionalAdapterPrisma } from '@nestjs-cls/transactional-adapter-prisma';
import { DocGrant, WorkspaceDocUserRole } from '@prisma/client';
import { DocGrant } from '@prisma/client';
import { CanNotBatchGrantDocOwnerPermissions, PaginationInput } from '../base';
import { BaseModel } from './base';
@@ -82,20 +82,12 @@ export class DocUserModel extends BaseModel {
@Transactional()
async deleteByUserId(userId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
await this.db.docGrant.deleteMany({
where: {
principalType: 'user',
principalId: userId,
},
});
await this.withPermissionProjectionMetric(
this.db.workspaceDocUserRole.deleteMany({
where: {
userId,
},
})
);
}
async getOwner(workspaceId: string, docId: string) {
@@ -160,7 +152,7 @@ export class DocUserModel extends BaseModel {
workspaceId: string,
docId: string,
pagination: PaginationInput
): Promise<[WorkspaceDocUserRole[], number]> {
): Promise<[DocUserCompat[], number]> {
const [grants, total] = await Promise.all([
this.db.docGrant.findMany({
where: {
@@ -184,7 +176,7 @@ export class DocUserModel extends BaseModel {
return [grants.map(grant => this.docGrantToCompat(grant)), total];
}
private docGrantToCompat(grant: DocGrant): WorkspaceDocUserRole {
private docGrantToCompat(grant: DocGrant): DocUserCompat {
return {
workspaceId: grant.workspaceId,
docId: grant.docId,
@@ -194,3 +186,11 @@ export class DocUserModel extends BaseModel {
};
}
}
type DocUserCompat = {
workspaceId: string;
docId: string;
userId: string;
type: DocRole;
createdAt: Date;
};

View File

@@ -1,6 +1,6 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import type { Update } from '@prisma/client';
import type { Update, WorkspaceDoc } from '@prisma/client';
import { Prisma } from '@prisma/client';
import { EventBus, PaginationInput } from '../base';
@@ -33,7 +33,10 @@ declare global {
export type DocMetaUpsertInput = Omit<
Prisma.WorkspaceDocUncheckedCreateInput,
'workspaceId' | 'docId'
>;
> & {
public?: boolean;
defaultRole?: DocRole;
};
/**
* Workspace Doc Model
@@ -50,6 +53,24 @@ export class DocModel extends BaseModel {
super();
}
private docRoleFromPolicy(role: string | null | undefined) {
switch (role) {
case 'none':
return DocRole.None;
case 'reader':
return DocRole.Reader;
case 'commenter':
return DocRole.Commenter;
case 'editor':
return DocRole.Editor;
case 'owner':
return DocRole.Owner;
case 'manager':
default:
return DocRole.Manager;
}
}
// #region Update
private updateToDocRecord(row: Update): Doc {
@@ -365,56 +386,64 @@ export class DocModel extends BaseModel {
docId: string,
data?: DocMetaUpsertInput
) {
if (
data &&
('public' in data || 'defaultRole' in data || 'publishedAt' in data)
) {
const { public: isPublic, defaultRole, ...meta } = data ?? {};
if (data && ('public' in data || 'defaultRole' in data)) {
await this.models.docAccessPolicy.upsert(workspaceId, docId, {
public: data.public,
defaultRole: data.defaultRole,
publishedAt:
typeof data.publishedAt === 'string'
? new Date(data.publishedAt)
: data.publishedAt,
public: isPublic,
defaultRole,
});
}
const doc = await this.withPermissionProjectionMetric(
this.db.workspaceDoc.upsert({
where: {
workspaceId_docId: {
workspaceId,
docId,
},
},
update: {
...data,
},
create: {
...data,
const doc = await this.db.workspaceDoc.upsert({
where: {
workspaceId_docId: {
workspaceId,
docId,
},
})
);
},
update: {
...meta,
},
create: {
...meta,
workspaceId,
docId,
},
});
this.event.emit('doc.updated', {
workspaceId,
docId,
});
return doc;
const policy = await this.db.docAccessPolicy.findUnique({
where: { workspaceId_docId: { workspaceId, docId } },
});
return {
...doc,
public: policy?.visibility === 'public',
defaultRole: this.docRoleFromPolicy(policy?.memberDefaultRole),
};
}
/**
* Get the doc meta.
*/
async getMeta(
workspaceId: string,
docId: string
): Promise<(WorkspaceDoc & { public: boolean; defaultRole: DocRole }) | null>;
async getMeta<Select extends Prisma.WorkspaceDocSelect>(
workspaceId: string,
docId: string,
options?: {
select?: Select;
options: {
select: Select;
}
) {
return (await this.db.workspaceDoc.findUnique({
): Promise<Prisma.WorkspaceDocGetPayload<{ select: Select }> | null>;
async getMeta(
workspaceId: string,
docId: string,
options?: { select: Prisma.WorkspaceDocSelect }
): Promise<unknown> {
const doc = await this.db.workspaceDoc.findUnique({
where: {
workspaceId_docId: {
workspaceId,
@@ -422,7 +451,18 @@ export class DocModel extends BaseModel {
},
},
select: options?.select,
})) as Prisma.WorkspaceDocGetPayload<{ select: Select }> | null;
});
if (!doc || options?.select) {
return doc;
}
const policy = await this.db.docAccessPolicy.findUnique({
where: { workspaceId_docId: { workspaceId, docId } },
});
return {
...doc,
public: policy?.visibility === 'public',
defaultRole: this.docRoleFromPolicy(policy?.memberDefaultRole),
};
}
async setDefaultRole(workspaceId: string, docId: string, role: DocRole) {
@@ -432,22 +472,15 @@ export class DocModel extends BaseModel {
}
async findDefaultRoles(workspaceId: string, docIds: string[]) {
const docs = await this.findMetas(
docIds.map(docId => ({
workspaceId,
docId,
})),
{
select: {
defaultRole: true,
public: true,
},
}
);
const policies = await this.db.docAccessPolicy.findMany({
where: { workspaceId, docId: { in: docIds } },
});
const byDocId = new Map(policies.map(policy => [policy.docId, policy]));
return docs.map(doc => ({
external: doc?.public ? DocRole.External : null,
workspace: doc?.defaultRole ?? DocRole.Manager,
return docIds.map(docId => ({
external:
byDocId.get(docId)?.publicRole === 'external' ? DocRole.External : null,
workspace: this.docRoleFromPolicy(byDocId.get(docId)?.memberDefaultRole),
}));
}
@@ -509,21 +542,29 @@ export class DocModel extends BaseModel {
* Find the workspace public doc metas.
*/
async findPublics(workspaceId: string, order: 'asc' | 'desc' = 'asc') {
return await this.db.workspaceDoc.findMany({
where: { workspaceId, public: true },
const policies = await this.db.docAccessPolicy.findMany({
where: { workspaceId, visibility: 'public', publicRole: 'external' },
});
const byDocId = new Map(policies.map(policy => [policy.docId, policy]));
const metas = await this.db.workspaceDoc.findMany({
where: { workspaceId, docId: { in: [...byDocId.keys()] } },
orderBy: { publishedAt: order },
});
return metas.map(meta => ({
...meta,
public: true,
defaultRole: this.docRoleFromPolicy(
byDocId.get(meta.docId)?.memberDefaultRole
),
}));
}
/**
* Get the workspace public docs count.
*/
async getPublicsCount(workspaceId: string) {
return await this.db.workspaceDoc.count({
where: {
workspaceId,
public: true,
},
return await this.db.docAccessPolicy.count({
where: { workspaceId, visibility: 'public', publicRole: 'external' },
});
}
@@ -552,8 +593,7 @@ export class DocModel extends BaseModel {
@Transactional()
async unpublish(workspaceId: string, docId: string) {
const docMeta = await this.getMeta(workspaceId, docId);
if (!docMeta?.public) {
if (!(await this.isPublic(workspaceId, docId))) {
throw new DocIsNotPublic();
}
@@ -567,12 +607,11 @@ export class DocModel extends BaseModel {
* Check if the doc is public.
*/
async isPublic(workspaceId: string, docId: string) {
const docMeta = await this.getMeta(workspaceId, docId, {
select: {
public: true,
},
const policy = await this.db.docAccessPolicy.findUnique({
where: { workspaceId_docId: { workspaceId, docId } },
select: { visibility: true, publicRole: true },
});
return docMeta?.public ?? false;
return policy?.visibility === 'public' && policy.publicRole === 'external';
}
async getDocInfo(workspaceId: string, docId: string) {
@@ -582,7 +621,7 @@ export class DocModel extends BaseModel {
docId: string;
mode: PublicDocMode;
public: boolean;
defaultRole: DocRole;
defaultRolePolicy: string;
title: string | null;
summary: string | null;
createdAt: Date;
@@ -595,8 +634,8 @@ export class DocModel extends BaseModel {
"workspace_pages"."workspace_id" as "workspaceId",
"workspace_pages"."page_id" as "docId",
"workspace_pages"."mode" as "mode",
"workspace_pages"."public" as "public",
"workspace_pages"."defaultRole" as "defaultRole",
(dap.visibility = 'public' AND dap.public_role = 'external') as "public",
COALESCE(dap.member_default_role, 'manager') as "defaultRolePolicy",
"workspace_pages"."title" as "title",
"workspace_pages"."summary" as "summary",
"snapshots"."created_at" as "createdAt",
@@ -607,13 +646,24 @@ export class DocModel extends BaseModel {
INNER JOIN "snapshots"
ON "workspace_pages"."workspace_id" = "snapshots"."workspace_id"
AND "workspace_pages"."page_id" = "snapshots"."guid"
LEFT JOIN "doc_access_policies" dap
ON "workspace_pages"."workspace_id" = dap.workspace_id
AND "workspace_pages"."page_id" = dap.doc_id
WHERE
"workspace_pages"."workspace_id" = ${workspaceId}
AND "workspace_pages"."page_id" = ${docId}
LIMIT 1;
`;
return rows.at(0) ?? null;
const row = rows.at(0);
if (!row) {
return null;
}
const { defaultRolePolicy, ...doc } = row;
return {
...doc,
defaultRole: this.docRoleFromPolicy(defaultRolePolicy),
};
}
async paginateDocInfo(workspaceId: string, pagination: PaginationInput) {
@@ -633,7 +683,7 @@ export class DocModel extends BaseModel {
docId: string;
mode: PublicDocMode;
public: boolean;
defaultRole: DocRole;
defaultRolePolicy: string;
createdAt: Date;
updatedAt: Date;
creatorId?: string;
@@ -644,8 +694,8 @@ export class DocModel extends BaseModel {
"workspace_pages"."workspace_id" as "workspaceId",
"workspace_pages"."page_id" as "docId",
"workspace_pages"."mode" as "mode",
"workspace_pages"."public" as "public",
"workspace_pages"."defaultRole" as "defaultRole",
(dap.visibility = 'public' AND dap.public_role = 'external') as "public",
COALESCE(dap.member_default_role, 'manager') as "defaultRolePolicy",
"snapshots"."created_at" as "createdAt",
"snapshots"."updated_at" as "updatedAt",
"snapshots"."created_by" as "creatorId",
@@ -654,6 +704,9 @@ export class DocModel extends BaseModel {
INNER JOIN "snapshots"
ON "workspace_pages"."workspace_id" = "snapshots"."workspace_id"
AND "workspace_pages"."page_id" = "snapshots"."guid"
LEFT JOIN "doc_access_policies" dap
ON "workspace_pages"."workspace_id" = dap.workspace_id
AND "workspace_pages"."page_id" = dap.doc_id
WHERE
"workspace_pages"."workspace_id" = ${workspaceId}
${after}
@@ -663,7 +716,13 @@ export class DocModel extends BaseModel {
OFFSET ${pagination.offset}
`;
return [count, rows] as const;
return [
count,
rows.map(({ defaultRolePolicy, ...doc }) => ({
...doc,
defaultRole: this.docRoleFromPolicy(defaultRolePolicy),
})),
] as const;
}
async paginateDocInfoByUpdatedAt(
@@ -690,7 +749,7 @@ export class DocModel extends BaseModel {
docId: string;
mode: PublicDocMode;
public: boolean;
defaultRole: DocRole;
defaultRolePolicy: string;
title: string | null;
createdAt: Date;
updatedAt: Date;
@@ -702,8 +761,8 @@ export class DocModel extends BaseModel {
"workspace_pages"."workspace_id" as "workspaceId",
"workspace_pages"."page_id" as "docId",
"workspace_pages"."mode" as "mode",
"workspace_pages"."public" as "public",
"workspace_pages"."defaultRole" as "defaultRole",
(dap.visibility = 'public' AND dap.public_role = 'external') as "public",
COALESCE(dap.member_default_role, 'manager') as "defaultRolePolicy",
"workspace_pages"."title" as "title",
"snapshots"."created_at" as "createdAt",
"snapshots"."updated_at" as "updatedAt",
@@ -713,6 +772,9 @@ export class DocModel extends BaseModel {
INNER JOIN "snapshots"
ON "workspace_pages"."workspace_id" = "snapshots"."workspace_id"
AND "workspace_pages"."page_id" = "snapshots"."guid"
LEFT JOIN "doc_access_policies" dap
ON "workspace_pages"."workspace_id" = dap.workspace_id
AND "workspace_pages"."page_id" = dap.doc_id
WHERE
"workspace_pages"."workspace_id" = ${workspaceId}
AND ${readablePredicate}
@@ -723,7 +785,13 @@ export class DocModel extends BaseModel {
OFFSET ${pagination.offset}
`;
return [count, rows] as const;
return [
count,
rows.map(({ defaultRolePolicy, ...doc }) => ({
...doc,
defaultRole: this.docRoleFromPolicy(defaultRolePolicy),
})),
] as const;
}
async findEmptySummaryDocIds(workspaceId: string) {

View File

@@ -32,7 +32,6 @@ import { MagicLinkOtpModel } from './magic-link-otp';
import { MailDeliveryModel } from './mail-delivery';
import { McpCredentialModel } from './mcp-credential';
import { NotificationModel } from './notification';
import { PermissionProjectionModel } from './permission-projection';
import {
DocAccessPolicyModel,
DocGrantModel,
@@ -50,8 +49,6 @@ import { VerificationTokenModel } from './verification-token';
import { WorkspaceModel } from './workspace';
import { WorkspaceAnalyticsModel } from './workspace-analytics';
import { WorkspaceCalendarModel } from './workspace-calendar';
import { WorkspaceFeatureModel } from './workspace-feature';
import { WorkspaceRuntimeStateModel } from './workspace-runtime-state';
import { WorkspaceUserModel } from './workspace-user';
const MODELS = {
@@ -64,15 +61,12 @@ const MODELS = {
feature: FeatureModel,
workspace: WorkspaceModel,
userFeature: UserFeatureModel,
workspaceFeature: WorkspaceFeatureModel,
workspaceRuntimeState: WorkspaceRuntimeStateModel,
doc: DocModel,
userDoc: UserDocModel,
workspaceUser: WorkspaceUserModel,
docUser: DocUserModel,
history: HistoryModel,
notification: NotificationModel,
permissionProjection: PermissionProjectionModel,
workspaceMember: WorkspaceMemberModel,
workspaceInvitation: WorkspaceInvitationModel,
workspaceAccessPolicy: WorkspaceAccessPolicyModel,
@@ -172,7 +166,6 @@ export * from './history';
export * from './magic-link-otp';
export * from './mail-delivery';
export * from './notification';
export * from './permission-projection';
export * from './permission-write';
export * from './session';
export * from './user';
@@ -183,6 +176,5 @@ export * from './verification-token';
export * from './workspace';
export * from './workspace-analytics';
export * from './workspace-calendar';
export * from './workspace-feature';
export * from './workspace-runtime-state';
export * from './workspace-user';
export type { WorkspaceUserCompat } from './workspace-user-compat';

View File

@@ -1,581 +0,0 @@
import { Injectable, Optional } from '@nestjs/common';
import { Prisma, PrismaClient } from '@prisma/client';
import { metrics } from '../base';
import { BaseModel } from './base';
type CountRow = { count: bigint };
type ProjectionIssueRow = {
category: string;
count: bigint;
};
type ProjectionBackfillDb = {
$transaction: (
callback: (
tx: Pick<Prisma.TransactionClient, '$executeRaw'>
) => Promise<void>,
options?: { timeout?: number }
) => Promise<void>;
};
export type PermissionProjectionCheckReport = {
oldWorkspacePolicyMismatch: number;
oldAcceptedMemberMismatch: number;
extraProjectedMember: number;
oldInvitationMismatch: number;
extraProjectedInvitation: number;
oldDocGrantMismatch: number;
extraProjectedDocGrant: number;
oldDocPolicyMismatch: number;
extraProjectedDocPolicy: number;
runtimeStateMissing: number;
runtimeStateMismatch: number;
ownerConflict: number;
oldNewDecisionMismatch: number;
invalidLegacyRows: Record<string, number>;
};
export const PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES = [
'owner_conflict',
'invalid_legacy_role',
'foreign_key_missing',
'projection_recursion_guard_missing',
'unknown',
] as const;
export function permissionProjectionTriggerErrorCategory(error: unknown) {
const message =
error instanceof Error ? error.message : String(error ?? 'unknown');
const match = message.match(/permission_projection_error:([^:]+):/);
const category = match?.[1];
if (!category) {
return null;
}
return PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES.includes(
category as (typeof PERMISSION_PROJECTION_TRIGGER_ERROR_CATEGORIES)[number]
)
? category
: 'unknown';
}
async function count(first: Promise<CountRow[]>) {
const rows = await first;
return Number(rows[0]?.count ?? 0);
}
@Injectable()
export class PermissionProjectionModel extends BaseModel {
constructor(@Optional() private readonly prisma?: PrismaClient) {
super();
}
async backfillLegacyProjection() {
const db = (this.prisma ?? this.db) as unknown as ProjectionBackfillDb;
await db.$transaction(
async tx => {
await tx.$executeRaw`
SELECT set_config('affine.permission_sync_origin', 'legacy', true)
`;
await tx.$executeRaw`
DELETE FROM workspace_members projected
WHERE projected.legacy_permission_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM workspace_user_permissions old
WHERE old.id = projected.legacy_permission_id
AND old.status = 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_legacy_workspace_role(old.type) IS NOT NULL
)
`;
await tx.$executeRaw`
DELETE FROM workspace_invitations projected
WHERE projected.legacy_permission_id IS NOT NULL
AND NOT EXISTS (
SELECT 1
FROM workspace_user_permissions old
WHERE old.id = projected.legacy_permission_id
AND old.status <> 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_workspace_invitation_state(old.status) IS NOT NULL
AND affine_permission_legacy_workspace_role(old.type) IS NOT NULL
)
`;
await tx.$executeRaw`
DELETE FROM doc_grants projected
WHERE projected.principal_type = 'user'
AND NOT EXISTS (
SELECT 1
FROM workspace_page_user_permissions old
WHERE old.workspace_id = projected.workspace_id
AND old.page_id = projected.doc_id
AND old.user_id = projected.principal_id
AND affine_permission_legacy_doc_role(old.type) IS NOT NULL
)
`;
await tx.$executeRaw`
DELETE FROM doc_access_policies projected
WHERE NOT EXISTS (
SELECT 1
FROM workspace_pages old
WHERE old.workspace_id = projected.workspace_id
AND old.page_id = projected.doc_id
AND affine_permission_legacy_default_doc_role(old."defaultRole") IS NOT NULL
)
`;
await tx.$executeRaw`
DELETE FROM workspace_access_policies projected
WHERE NOT EXISTS (
SELECT 1
FROM workspaces old
WHERE old.id = projected.workspace_id
)
`;
await tx.$executeRaw`
INSERT INTO workspace_access_policies (
workspace_id,
visibility,
sharing_enabled,
url_preview_enabled,
updated_at
)
SELECT
id,
CASE WHEN public THEN 'public' ELSE 'private' END,
enable_sharing,
enable_url_preview,
now()
FROM workspaces
ON CONFLICT (workspace_id)
DO UPDATE SET
visibility = EXCLUDED.visibility,
sharing_enabled = EXCLUDED.sharing_enabled,
url_preview_enabled = EXCLUDED.url_preview_enabled,
updated_at = now()
`;
await tx.$executeRaw`
INSERT INTO workspace_members (
workspace_id,
user_id,
role,
state,
source,
legacy_permission_id,
created_at,
updated_at
)
SELECT
workspace_id,
user_id,
affine_permission_legacy_workspace_role(type),
'active',
CASE source
WHEN 'Email'::"WorkspaceMemberSource" THEN 'email'
WHEN 'Link'::"WorkspaceMemberSource" THEN 'link'
ELSE 'legacy'
END,
id,
created_at,
updated_at
FROM workspace_user_permissions
WHERE status = 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_legacy_workspace_role(type) IS NOT NULL
ON CONFLICT ("legacy_permission_id") WHERE "legacy_permission_id" IS NOT NULL
DO UPDATE SET
user_id = EXCLUDED.user_id,
role = EXCLUDED.role,
state = EXCLUDED.state,
source = EXCLUDED.source,
updated_at = EXCLUDED.updated_at
`;
await tx.$executeRaw`
INSERT INTO workspace_invitations (
workspace_id,
invitee_user_id,
inviter_user_id,
requested_role,
status,
kind,
legacy_permission_id,
created_at,
updated_at
)
SELECT
workspace_id,
user_id,
inviter_id,
CASE WHEN affine_permission_legacy_workspace_role(type) = 'admin' THEN 'admin' ELSE 'member' END,
affine_permission_workspace_invitation_state(status),
CASE source
WHEN 'Link'::"WorkspaceMemberSource" THEN 'link'
ELSE 'email'
END,
id,
created_at,
updated_at
FROM workspace_user_permissions
WHERE status <> 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_workspace_invitation_state(status) IS NOT NULL
AND affine_permission_legacy_workspace_role(type) IS NOT NULL
ON CONFLICT ("legacy_permission_id") WHERE "legacy_permission_id" IS NOT NULL
DO UPDATE SET
invitee_user_id = EXCLUDED.invitee_user_id,
inviter_user_id = EXCLUDED.inviter_user_id,
requested_role = EXCLUDED.requested_role,
status = EXCLUDED.status,
kind = EXCLUDED.kind,
updated_at = EXCLUDED.updated_at
`;
await tx.$executeRaw`
INSERT INTO doc_access_policies (
workspace_id,
doc_id,
visibility,
public_role,
member_default_role,
published_at,
updated_at
)
SELECT
workspace_id,
page_id,
CASE WHEN public THEN 'public' ELSE 'private' END,
CASE WHEN public THEN 'external' ELSE NULL END,
affine_permission_legacy_default_doc_role("defaultRole"),
published_at,
now()
FROM workspace_pages
WHERE affine_permission_legacy_default_doc_role("defaultRole") IS NOT NULL
ON CONFLICT (workspace_id, doc_id)
DO UPDATE SET
visibility = EXCLUDED.visibility,
public_role = EXCLUDED.public_role,
member_default_role = EXCLUDED.member_default_role,
published_at = EXCLUDED.published_at,
updated_at = now()
`;
await tx.$executeRaw`
WITH legacy_doc_grants AS (
SELECT
workspace_id,
page_id,
user_id,
type,
created_at,
row_number() OVER (
PARTITION BY workspace_id, page_id, affine_permission_legacy_doc_role(type)
ORDER BY created_at ASC, user_id ASC
) AS role_rank
FROM workspace_page_user_permissions
WHERE affine_permission_legacy_doc_role(type) IS NOT NULL
)
INSERT INTO doc_grants (
workspace_id,
doc_id,
principal_type,
principal_id,
role,
legacy_workspace_id,
legacy_doc_id,
legacy_user_id,
created_at,
updated_at
)
SELECT
workspace_id,
page_id,
'user',
user_id,
affine_permission_legacy_doc_role(type),
workspace_id,
page_id,
user_id,
created_at,
now()
FROM legacy_doc_grants
WHERE affine_permission_legacy_doc_role(type) IS NOT NULL
AND (
affine_permission_legacy_doc_role(type) <> 'owner'
OR role_rank = 1
)
ON CONFLICT (workspace_id, doc_id, principal_type, principal_id)
DO UPDATE SET
role = EXCLUDED.role,
updated_at = now()
`;
},
{ timeout: 10 * 60 * 1000 }
);
}
recordTriggerErrorMetric(error: unknown) {
const category = permissionProjectionTriggerErrorCategory(error);
if (!category) {
return null;
}
metrics.permission
.counter('projection_trigger_errors', {
description: 'Permission projection trigger error count',
})
.add(1, { category });
return category;
}
async checkLegacyProjection(): Promise<PermissionProjectionCheckReport> {
const [
oldWorkspacePolicyMismatch,
oldAcceptedMemberMismatch,
extraProjectedMember,
oldInvitationMismatch,
extraProjectedInvitation,
oldDocGrantMismatch,
extraProjectedDocGrant,
oldDocPolicyMismatch,
extraProjectedDocPolicy,
ownerConflict,
invalidLegacyRows,
] = await Promise.all([
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspaces old
LEFT JOIN workspace_access_policies projected
ON projected.workspace_id = old.id
WHERE projected.workspace_id IS NULL
OR projected.visibility <> CASE WHEN old.public THEN 'public' ELSE 'private' END
OR projected.sharing_enabled <> old.enable_sharing
OR projected.url_preview_enabled <> old.enable_url_preview
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_user_permissions old
LEFT JOIN workspace_members projected
ON projected.legacy_permission_id = old.id
OR (
projected.legacy_permission_id IS NULL
AND projected.workspace_id = old.workspace_id
AND projected.user_id = old.user_id
AND projected.state = 'active'
)
WHERE old.status = 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_legacy_workspace_role(old.type) IS NOT NULL
AND (
projected.id IS NULL OR
projected.workspace_id <> old.workspace_id OR
projected.user_id <> old.user_id OR
projected.role <> affine_permission_legacy_workspace_role(old.type) OR
projected.state <> 'active'
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_members projected
LEFT JOIN workspace_user_permissions old
ON old.id = projected.legacy_permission_id
OR (
projected.legacy_permission_id IS NULL
AND old.workspace_id = projected.workspace_id
AND old.user_id = projected.user_id
AND old.status = 'Accepted'::"WorkspaceMemberStatus"
)
WHERE
projected.state = 'active'
AND (
old.id IS NULL OR
old.status <> 'Accepted'::"WorkspaceMemberStatus" OR
affine_permission_legacy_workspace_role(old.type) IS NULL
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_user_permissions old
LEFT JOIN workspace_invitations projected
ON projected.legacy_permission_id = old.id
OR (
projected.legacy_permission_id IS NULL
AND projected.workspace_id = old.workspace_id
AND projected.invitee_user_id = old.user_id
)
WHERE old.status <> 'Accepted'::"WorkspaceMemberStatus"
AND affine_permission_workspace_invitation_state(old.status) IS NOT NULL
AND affine_permission_legacy_workspace_role(old.type) IS NOT NULL
AND (
projected.id IS NULL OR
projected.workspace_id <> old.workspace_id OR
projected.invitee_user_id <> old.user_id OR
projected.requested_role <> CASE WHEN affine_permission_legacy_workspace_role(old.type) = 'admin' THEN 'admin' ELSE 'member' END OR
projected.status <> affine_permission_workspace_invitation_state(old.status)
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_invitations projected
LEFT JOIN workspace_user_permissions old
ON old.id = projected.legacy_permission_id
OR (
projected.legacy_permission_id IS NULL
AND old.workspace_id = projected.workspace_id
AND old.user_id = projected.invitee_user_id
AND old.status <> 'Accepted'::"WorkspaceMemberStatus"
)
WHERE projected.invitee_user_id IS NOT NULL
AND (
old.id IS NULL OR
old.status = 'Accepted'::"WorkspaceMemberStatus" OR
affine_permission_workspace_invitation_state(old.status) IS NULL OR
affine_permission_legacy_workspace_role(old.type) IS NULL
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_page_user_permissions old
LEFT JOIN doc_grants projected
ON projected.workspace_id = old.workspace_id
AND projected.doc_id = old.page_id
AND projected.principal_type = 'user'
AND projected.principal_id = old.user_id
WHERE affine_permission_legacy_doc_role(old.type) IS NOT NULL
AND (
projected.workspace_id IS NULL OR
projected.role <> affine_permission_legacy_doc_role(old.type)
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM doc_grants projected
LEFT JOIN workspace_page_user_permissions old
ON old.workspace_id = projected.workspace_id
AND old.page_id = projected.doc_id
AND old.user_id = projected.principal_id
WHERE projected.principal_type = 'user'
AND (
old.workspace_id IS NULL OR
affine_permission_legacy_doc_role(old.type) IS NULL
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM workspace_pages old
LEFT JOIN doc_access_policies projected
ON projected.workspace_id = old.workspace_id
AND projected.doc_id = old.page_id
WHERE affine_permission_legacy_default_doc_role(old."defaultRole") IS NOT NULL
AND (
projected.workspace_id IS NULL OR
projected.visibility <> CASE WHEN old.public THEN 'public' ELSE 'private' END OR
projected.public_role IS DISTINCT FROM CASE WHEN old.public THEN 'external' ELSE NULL END OR
projected.member_default_role IS DISTINCT FROM affine_permission_legacy_default_doc_role(old."defaultRole")
)
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COUNT(*)::bigint AS count
FROM doc_access_policies projected
LEFT JOIN workspace_pages old
ON old.workspace_id = projected.workspace_id
AND old.page_id = projected.doc_id
WHERE old.workspace_id IS NULL
`),
count(this.db.$queryRaw<CountRow[]>`
SELECT COALESCE(SUM(conflicts.count - 1), 0)::bigint AS count
FROM (
SELECT workspace_id, COUNT(*)::bigint AS count
FROM workspace_members
WHERE state = 'active'
AND role = 'owner'
GROUP BY workspace_id
HAVING COUNT(*) > 1
UNION ALL
SELECT workspace_id || ':' || doc_id AS workspace_id, COUNT(*)::bigint AS count
FROM doc_grants
WHERE principal_type = 'user'
AND role = 'owner'
GROUP BY workspace_id, doc_id
HAVING COUNT(*) > 1
) conflicts
`),
this.db.$queryRaw<ProjectionIssueRow[]>`
SELECT category, COUNT(*)::bigint AS count
FROM (
SELECT 'unknown_workspace_role' AS category
FROM workspace_user_permissions
WHERE affine_permission_legacy_workspace_role(type) IS NULL
AND type <> -99
UNION ALL
SELECT 'unknown_doc_role' AS category
FROM workspace_page_user_permissions
WHERE affine_permission_legacy_doc_role(type) IS NULL
AND type NOT IN (0, -32768)
UNION ALL
SELECT 'legacy_doc_external_row' AS category
FROM workspace_page_user_permissions
WHERE type = 0
UNION ALL
SELECT 'legacy_doc_none_row' AS category
FROM workspace_page_user_permissions
WHERE type = -32768
UNION ALL
SELECT 'doc_default_owner' AS category
FROM workspace_pages
WHERE "defaultRole" = 99
) issues
GROUP BY category
`,
]);
return {
oldWorkspacePolicyMismatch,
oldAcceptedMemberMismatch,
extraProjectedMember,
oldInvitationMismatch,
extraProjectedInvitation,
oldDocGrantMismatch,
extraProjectedDocGrant,
oldDocPolicyMismatch,
extraProjectedDocPolicy,
runtimeStateMissing: 0,
runtimeStateMismatch: 0,
ownerConflict,
oldNewDecisionMismatch: 0,
invalidLegacyRows: Object.fromEntries(
invalidLegacyRows.map(row => [row.category, Number(row.count)])
),
};
}
async lockWorkspaceOwnerTransfer(workspaceId: string) {
await this.db.$executeRaw`
SELECT pg_advisory_xact_lock(hashtextextended(${workspaceId}, 16))
`;
}
async lockDocOwnerTransfer(workspaceId: string, docId: string) {
await this.db.$executeRaw`
SELECT pg_advisory_xact_lock(hashtextextended(${`${workspaceId}:${docId}`}, 16))
`;
}
async markNewWriteOrigin() {
await this.db.$executeRaw`
SELECT set_config('affine.permission_sync_origin', 'new', true)
`;
}
async markLegacyWriteOrigin() {
await this.db.$executeRaw`
SELECT set_config('affine.permission_sync_origin', 'legacy', true)
`;
}
}

View File

@@ -55,19 +55,6 @@ export function workspaceStatusFromNew(
}
}
export function workspaceSourceToNew(
source?: WorkspaceMemberSource
): PermissionSource {
switch (source) {
case WorkspaceMemberSource.Email:
return 'email';
case WorkspaceMemberSource.Link:
return 'link';
default:
return 'legacy';
}
}
export function workspaceSourceFromNew(
source?: PermissionSource | WorkspaceInvitationKind
): WorkspaceMemberSource {
@@ -141,10 +128,8 @@ export class WorkspaceMemberModel extends BaseModel {
userId: string,
fallbackRole: WorkspaceRole
) {
await this.models.permissionProjection.markNewWriteOrigin();
await this.models.permissionProjection.lockWorkspaceOwnerTransfer(
workspaceId
);
await this.db
.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`permission:workspace-owner:${workspaceId}`}, 0))`;
const ownerCount = await this.db.workspaceMember.count({
where: { workspaceId, role: 'owner', state: 'active' },
});
@@ -197,13 +182,20 @@ export class WorkspaceMemberModel extends BaseModel {
workspaceId: string,
userId: string,
role: WorkspaceRole,
data: { legacyPermissionId?: string | null; source?: PermissionSource } = {}
data: { source?: PermissionSource } = {}
) {
await this.models.permissionProjection.markNewWriteOrigin();
if (role === WorkspaceRole.Owner) {
throw new Error('Cannot grant Owner role of a workspace to a user.');
}
const invitation = await this.db.workspaceInvitation.findUnique({
where: {
workspaceId_inviteeUserId: {
workspaceId,
inviteeUserId: userId,
},
},
});
await this.db.workspaceInvitation.deleteMany({
where: { workspaceId, inviteeUserId: userId },
});
@@ -218,23 +210,21 @@ export class WorkspaceMemberModel extends BaseModel {
},
update: {
role: workspaceRoleToNew(role),
legacyPermissionId: data.legacyPermissionId,
source: data.source,
},
create: {
id: invitation?.id,
workspaceId,
userId,
role: workspaceRoleToNew(role),
state: 'active',
source: data.source ?? 'legacy',
legacyPermissionId: data.legacyPermissionId,
source: data.source ?? invitation?.kind ?? 'legacy',
},
});
}
@Transactional()
async delete(workspaceId: string, userId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
await this.db.$queryRaw`
SELECT id
FROM workspace_members
@@ -272,8 +262,6 @@ export class WorkspaceMemberModel extends BaseModel {
@Injectable()
export class WorkspaceInvitationModel extends BaseModel {
private hasCurrentColumns?: Promise<boolean>;
@Transactional()
async set(
workspaceId: string,
@@ -285,7 +273,6 @@ export class WorkspaceInvitationModel extends BaseModel {
inviterId?: string;
} = {}
): Promise<void> {
await this.models.permissionProjection.markNewWriteOrigin();
if (role === WorkspaceRole.Owner) {
throw new Error('Cannot grant Owner role of a workspace to a user.');
}
@@ -307,7 +294,6 @@ export class WorkspaceInvitationModel extends BaseModel {
requestedRole: role === WorkspaceRole.Admin ? 'admin' : 'member',
status: invitationStatus,
kind: workspaceInvitationKindToNew(data.source),
source: workspaceSourceToNew(data.source),
});
}
@@ -320,7 +306,6 @@ export class WorkspaceInvitationModel extends BaseModel {
inviterId?: string;
} = {}
) {
await this.models.permissionProjection.markNewWriteOrigin();
const invitationStatus = workspaceStatusToInvitationState(status);
if (!invitationStatus) {
const invitation = await this.findInvitation(workspaceId, userId);
@@ -335,10 +320,7 @@ export class WorkspaceInvitationModel extends BaseModel {
workspaceId,
userId,
role,
{
legacyPermissionId: invitation.legacyPermissionId,
source: invitation.source,
}
{ source: invitation.source }
);
}
@@ -352,7 +334,6 @@ export class WorkspaceInvitationModel extends BaseModel {
@Transactional()
async deleteNonAccepted(workspaceId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
return await this.db.workspaceInvitation.deleteMany({
where: { workspaceId },
});
@@ -360,7 +341,6 @@ export class WorkspaceInvitationModel extends BaseModel {
@Transactional()
async cancelPendingByActor(actorUserId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
return await this.db.workspaceInvitation.deleteMany({
where: {
inviterUserId: actorUserId,
@@ -373,7 +353,6 @@ export class WorkspaceInvitationModel extends BaseModel {
@Transactional()
async cancelPendingByWorkspace(workspaceId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
return await this.db.workspaceInvitation.deleteMany({
where: {
workspaceId,
@@ -384,18 +363,6 @@ export class WorkspaceInvitationModel extends BaseModel {
});
}
private async supportsCurrentInvitationColumns() {
this.hasCurrentColumns ??= this.db.$queryRaw<Array<{ exists: boolean }>>`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'workspace_invitations'
AND column_name = 'requested_role'
) AS "exists"
`.then(rows => rows[0]?.exists ?? false);
return await this.hasCurrentColumns;
}
private async upsertInvitation(input: {
workspaceId: string;
userId: string;
@@ -403,99 +370,41 @@ export class WorkspaceInvitationModel extends BaseModel {
requestedRole: 'admin' | 'member';
status: WorkspaceInvitationStatus;
kind: WorkspaceInvitationKind;
source: PermissionSource;
}) {
if (await this.supportsCurrentInvitationColumns()) {
return await this.db.$executeRaw`
INSERT INTO workspace_invitations (
workspace_id,
invitee_user_id,
inviter_user_id,
requested_role,
status,
kind,
updated_at
)
VALUES (
${input.workspaceId},
${input.userId},
${input.inviterId ?? null},
${input.requestedRole},
${input.status},
${input.kind},
now()
)
ON CONFLICT (workspace_id, invitee_user_id)
DO UPDATE SET
inviter_user_id = EXCLUDED.inviter_user_id,
requested_role = EXCLUDED.requested_role,
status = EXCLUDED.status,
kind = EXCLUDED.kind,
updated_at = now()
`;
}
return await this.db.$executeRaw`
INSERT INTO workspace_invitations (
workspace_id,
invitee_user_id,
inviter_id,
role,
state,
source,
updated_at
)
VALUES (
${input.workspaceId},
${input.userId},
${input.inviterId ?? null},
${input.requestedRole},
${input.status},
${input.source},
now()
)
ON CONFLICT (workspace_id, invitee_user_id)
DO UPDATE SET
inviter_id = EXCLUDED.inviter_id,
role = EXCLUDED.role,
state = EXCLUDED.state,
source = EXCLUDED.source,
updated_at = now()
`;
return await this.db.workspaceInvitation.upsert({
where: {
workspaceId_inviteeUserId: {
workspaceId: input.workspaceId,
inviteeUserId: input.userId,
},
},
update: {
inviterUserId: input.inviterId,
requestedRole: input.requestedRole,
status: input.status,
kind: input.kind,
},
create: {
workspaceId: input.workspaceId,
inviteeUserId: input.userId,
inviterUserId: input.inviterId,
requestedRole: input.requestedRole,
status: input.status,
kind: input.kind,
},
});
}
private async findInvitation(workspaceId: string, userId: string) {
if (await this.supportsCurrentInvitationColumns()) {
const rows = await this.db.$queryRaw<
Array<{
requestedRole: 'admin' | 'member';
legacyPermissionId: string | null;
source: PermissionSource;
}>
>`
SELECT
requested_role AS "requestedRole",
legacy_permission_id AS "legacyPermissionId",
kind AS source
FROM workspace_invitations
WHERE workspace_id = ${workspaceId}
AND invitee_user_id = ${userId}
LIMIT 1
`;
return rows[0] ?? null;
}
const rows = await this.db.$queryRaw<
Array<{
requestedRole: 'admin' | 'member';
legacyPermissionId: string | null;
source: PermissionSource;
}>
>`
SELECT
role AS "requestedRole",
legacy_permission_id AS "legacyPermissionId",
source
requested_role AS "requestedRole",
kind AS source
FROM workspace_invitations
WHERE workspace_id = ${workspaceId}
AND invitee_user_id = ${userId}
@@ -510,27 +419,16 @@ export class WorkspaceInvitationModel extends BaseModel {
status: WorkspaceInvitationStatus;
inviterId?: string;
}) {
if (await this.supportsCurrentInvitationColumns()) {
return await this.db.$executeRaw`
UPDATE workspace_invitations
SET
status = ${input.status},
inviter_user_id = ${input.inviterId ?? null},
updated_at = now()
WHERE workspace_id = ${input.workspaceId}
AND invitee_user_id = ${input.userId}
`;
}
return await this.db.$executeRaw`
UPDATE workspace_invitations
SET
state = ${input.status},
inviter_id = ${input.inviterId ?? null},
updated_at = now()
WHERE workspace_id = ${input.workspaceId}
AND invitee_user_id = ${input.userId}
`;
return await this.db.workspaceInvitation.updateMany({
where: {
workspaceId: input.workspaceId,
inviteeUserId: input.userId,
},
data: {
status: input.status,
inviterUserId: input.inviterId,
},
});
}
}
@@ -545,7 +443,6 @@ export class WorkspaceAccessPolicyModel extends BaseModel {
enableUrlPreview?: boolean;
}
) {
await this.models.permissionProjection.markNewWriteOrigin();
return await this.db.workspaceAccessPolicy.upsert({
where: { workspaceId },
update: {
@@ -592,7 +489,6 @@ export class DocAccessPolicyModel extends BaseModel {
urlPreviewEnabled?: boolean;
}
) {
await this.models.permissionProjection.markNewWriteOrigin();
const publicRole = policy.public ? 'external' : null;
return await this.db.docAccessPolicy.upsert({
where: { workspaceId_docId: { workspaceId, docId } },
@@ -635,11 +531,8 @@ export class DocAccessPolicyModel extends BaseModel {
export class DocGrantModel extends BaseModel {
@Transactional()
async setOwner(workspaceId: string, docId: string, userId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
await this.models.permissionProjection.lockDocOwnerTransfer(
workspaceId,
docId
);
await this.db
.$executeRaw`SELECT pg_advisory_xact_lock(hashtextextended(${`permission:doc-owner:${workspaceId}:${docId}`}, 0))`;
await this.db.docGrant.updateMany({
where: {
workspaceId,
@@ -656,7 +549,6 @@ export class DocGrantModel extends BaseModel {
@Transactional()
async set(workspaceId: string, docId: string, userId: string, role: DocRole) {
await this.models.permissionProjection.markNewWriteOrigin();
assert(role !== DocRole.None && role !== DocRole.External);
return await this.db.docGrant.upsert({
@@ -688,7 +580,6 @@ export class DocGrantModel extends BaseModel {
userIds: string[],
role: DocRole
) {
await this.models.permissionProjection.markNewWriteOrigin();
if (role === DocRole.Owner) {
throw new CanNotBatchGrantDocOwnerPermissions();
}
@@ -724,7 +615,6 @@ export class DocGrantModel extends BaseModel {
@Transactional()
async delete(workspaceId: string, docId: string, userId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
await this.db.$queryRaw`
SELECT 1
FROM doc_grants

View File

@@ -1,5 +1,4 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { Prisma } from '@prisma/client';
import { BaseModel } from './base';
@@ -23,22 +22,6 @@ export class UserFeatureModel extends BaseModel {
return await this.models.feature.get(name);
}
async getQuota(userId: string) {
const quota = await this.db.userFeature.findFirst({
where: {
userId,
type: FeatureType.Quota,
activated: true,
},
});
if (!quota) {
return null;
}
return await this.models.feature.get<'free_plan_v1'>(quota.name as any);
}
async has(userId: string, name: UserFeatureName) {
const count = await this.db.userFeature.count({
where: {
@@ -51,18 +34,13 @@ export class UserFeatureModel extends BaseModel {
return count > 0;
}
async list(userId: string, type?: FeatureType) {
const filter: Prisma.UserFeatureWhereInput =
type === undefined
? {
userId,
activated: true,
}
: {
userId,
activated: true,
type,
};
async list(userId: string, type?: FeatureType, names?: UserFeatureName[]) {
const filter: Prisma.UserFeatureWhereInput = {
userId,
activated: true,
type,
name: names ? { in: names } : undefined,
};
const userFeatures = await this.db.userFeature.findMany({
where: filter,
@@ -123,29 +101,4 @@ export class UserFeatureModel extends BaseModel {
);
}
}
@Transactional()
async switchQuota(userId: string, to: UserFeatureName, reason: string) {
const quotas = await this.list(userId, FeatureType.Quota);
// deactivate the previous quota
if (quotas.length) {
// db state error
if (quotas.length > 1) {
this.logger.error(
`User ${userId} has multiple quotas, please check the database state.`
);
}
const from = quotas.at(-1) as UserFeatureName;
if (from === to) {
return;
}
await this.remove(userId, from);
}
await this.add(userId, to, reason);
}
}

View File

@@ -326,6 +326,8 @@ export class WorkspaceAnalyticsModel extends BaseModel {
COALESCE(v.guest_views, 0) AS "guestViews",
v.last_accessed_at AS "lastAccessedAt"
FROM workspace_pages wp
INNER JOIN doc_access_policies dap
ON dap.workspace_id = wp.workspace_id AND dap.doc_id = wp.page_id
LEFT JOIN snapshots sn
ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id
LEFT JOIN view_agg v
@@ -339,7 +341,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
ORDER BY created_at ASC, id ASC
LIMIT 1
) owner ON TRUE
WHERE wp.public = TRUE
WHERE dap.visibility = 'public' AND dap.public_role = 'external'
ORDER BY views DESC, "uniqueViews" DESC, "workspaceId" ASC, "docId" ASC
LIMIT 10
`
@@ -642,6 +644,8 @@ export class WorkspaceAnalyticsModel extends BaseModel {
COALESCE(wp.published_at, to_timestamp(0)) AS "sortValueDatePublishedAt",
COALESCE(v.views, 0) AS "sortValueViews"
FROM workspace_pages wp
INNER JOIN doc_access_policies dap
ON dap.workspace_id = wp.workspace_id AND dap.doc_id = wp.page_id
LEFT JOIN snapshots sn
ON sn.workspace_id = wp.workspace_id AND sn.guid = wp.page_id
LEFT JOIN view_agg v
@@ -655,7 +659,7 @@ export class WorkspaceAnalyticsModel extends BaseModel {
ORDER BY created_at ASC, id ASC
LIMIT 1
) owner ON TRUE
WHERE wp.public = TRUE
WHERE dap.visibility = 'public' AND dap.public_role = 'external'
${keywordCondition}
${workspaceCondition}
${updatedAfterCondition}
@@ -1101,7 +1105,9 @@ export class WorkspaceAnalyticsModel extends BaseModel {
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
SELECT COUNT(*) AS total
FROM workspace_pages wp
WHERE wp.public = TRUE
INNER JOIN doc_access_policies dap
ON dap.workspace_id = wp.workspace_id AND dap.doc_id = wp.page_id
WHERE dap.visibility = 'public' AND dap.public_role = 'external'
${keywordCondition}
${workspaceCondition}
${updatedAfterCondition}

View File

@@ -1,210 +0,0 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { Prisma } from '@prisma/client';
import { BaseModel } from './base';
import {
type FeatureConfig,
FeatureType,
type WorkspaceFeatureName,
} from './common';
@Injectable()
export class WorkspaceFeatureModel extends BaseModel {
async get<T extends WorkspaceFeatureName>(workspaceId: string, name: T) {
const workspaceFeature = await this.db.workspaceFeature.findFirst({
where: {
workspaceId,
name,
activated: true,
},
});
if (!workspaceFeature) {
return null;
}
const feature = await this.models.feature.get_unchecked(name);
return {
...feature,
configs: this.models.feature.check(name, {
...feature.configs,
...(workspaceFeature?.configs as {}),
}),
};
}
async getQuota(workspaceId: string) {
const quota = await this.db.workspaceFeature.findFirst({
where: {
workspaceId,
type: FeatureType.Quota,
activated: true,
},
orderBy: {
createdAt: 'desc',
},
});
if (!quota) {
return null;
}
const rawFeature = await this.models.feature.get_unchecked(
quota.name as WorkspaceFeatureName
);
const feature = {
...rawFeature,
configs: this.models.feature.check(quota.name as 'team_plan_v1', {
...rawFeature.configs,
...(quota?.configs as {}),
}),
};
// workspace's storage quota is the sum of base quota and seats * quota per seat
feature.configs.storageQuota =
feature.configs.seatQuota * feature.configs.memberLimit +
feature.configs.storageQuota;
return feature;
}
async has(workspaceId: string, name: WorkspaceFeatureName) {
const count = await this.db.workspaceFeature.count({
where: {
workspaceId,
name,
activated: true,
},
});
return count > 0;
}
/**
* helper function to check if a list of workspaces have a standalone quota feature when calculating owner's quota usage
*/
async batchHasQuota(workspaceIds: string[]) {
const workspaceFeatures = await this.db.workspaceFeature.findMany({
select: {
workspaceId: true,
},
where: {
workspaceId: { in: workspaceIds },
type: FeatureType.Quota,
activated: true,
},
});
return workspaceFeatures.map(feature => feature.workspaceId);
}
async list(workspaceId: string, type?: FeatureType) {
const filter: Prisma.WorkspaceFeatureWhereInput =
type === undefined
? {
workspaceId,
activated: true,
}
: {
workspaceId,
activated: true,
type,
};
const workspaceFeatures = await this.db.workspaceFeature.findMany({
select: {
name: true,
},
where: filter,
});
return workspaceFeatures.map(
workspaceFeature => workspaceFeature.name
) as WorkspaceFeatureName[];
}
@Transactional()
async add<T extends WorkspaceFeatureName>(
workspaceId: string,
name: T,
reason: string,
overrides?: Partial<FeatureConfig<T>>
) {
// ensure feature exists
await this.models.feature.get_unchecked(name);
const existing = await this.db.workspaceFeature.findFirst({
where: {
workspaceId,
name: name,
activated: true,
},
});
if (existing && !overrides) {
return existing;
}
const configs = {
...(existing?.configs as {}),
...overrides,
};
const parseResult = this.models.feature
.getConfigShape(name)
.partial()
.safeParse(configs);
if (!parseResult.success) {
throw new Error(`Invalid feature config for ${name}`, {
cause: parseResult.error,
});
}
let workspaceFeature;
if (existing) {
workspaceFeature = await this.db.workspaceFeature.update({
where: {
id: existing.id,
},
data: {
configs: parseResult.data,
reason,
},
});
} else {
workspaceFeature = await this.db.workspaceFeature.create({
data: {
workspaceId,
name,
type: this.models.feature.getFeatureType(name),
activated: true,
reason,
configs: parseResult.data,
},
});
}
this.logger.verbose(`Feature ${name} added to workspace ${workspaceId}`);
return workspaceFeature;
}
async remove(workspaceId: string, featureName: WorkspaceFeatureName) {
const { count } = await this.db.workspaceFeature.deleteMany({
where: {
workspaceId,
name: featureName,
},
});
if (count > 0) {
this.logger.verbose(
`Feature ${featureName} removed from workspace ${workspaceId}`
);
}
}
}

View File

@@ -1,223 +0,0 @@
import { Injectable } from '@nestjs/common';
import { BaseModel } from './base';
export type WorkspaceRuntimeState = {
workspaceId: string;
known: boolean;
stale: boolean;
readonly: boolean;
readonlyReasons: string[];
updatedAt: Date | null;
lastReconciledAt: Date | null;
staleAfter: Date | null;
};
type WorkspaceRuntimeStateRow = {
workspaceId: string;
known: boolean;
readonly: boolean;
readonlyReasons: string[];
updatedAt: Date;
lastReconciledAt: Date | null;
staleAfter: Date | null;
};
type LegacyWorkspaceRuntimeStateRow = {
workspaceId: string;
readonly: boolean;
readonlyReasons: string[];
updatedAt: Date;
staleAt: Date | null;
};
function isMissingRuntimeStateColumn(error: unknown) {
const meta = (error as { meta?: { code?: string } })?.meta;
return meta?.code === '42703';
}
@Injectable()
export class WorkspaceRuntimeStateModel extends BaseModel {
private hasCurrentColumns?: Promise<boolean>;
async get(workspaceId: string): Promise<WorkspaceRuntimeState> {
const rows = await this.loadRows(workspaceId);
const row = rows[0];
if (!row) {
return {
workspaceId,
known: false,
stale: true,
readonly: false,
readonlyReasons: [],
updatedAt: null,
lastReconciledAt: null,
staleAfter: null,
};
}
return {
workspaceId,
known: row.known,
stale:
!row.known || (row.staleAfter !== null && row.staleAfter <= new Date()),
readonly: row.readonly,
readonlyReasons: row.readonlyReasons,
updatedAt: row.updatedAt,
lastReconciledAt: row.lastReconciledAt,
staleAfter: row.staleAfter,
};
}
async upsert(
workspaceId: string,
state: {
readonly: boolean;
readonlyReasons: string[];
known?: boolean;
lastReconciledAt?: Date | null;
staleAfter?: Date | null;
}
) {
if (await this.supportsCurrentRuntimeStateColumns()) {
await this.upsertCurrent(workspaceId, state);
} else {
await this.upsertLegacy(workspaceId, state);
}
}
private async loadRows(workspaceId: string) {
if (!(await this.supportsCurrentRuntimeStateColumns())) {
return await this.loadLegacyRows(workspaceId);
}
try {
return await this.db.$queryRaw<WorkspaceRuntimeStateRow[]>`
SELECT
workspace_id AS "workspaceId",
known,
readonly,
readonly_reasons AS "readonlyReasons",
updated_at AS "updatedAt",
last_reconciled_at AS "lastReconciledAt",
stale_after AS "staleAfter"
FROM workspace_runtime_states
WHERE workspace_id = ${workspaceId}
LIMIT 1
`;
} catch (error) {
if (!isMissingRuntimeStateColumn(error)) {
throw error;
}
return await this.loadLegacyRows(workspaceId);
}
}
private async supportsCurrentRuntimeStateColumns() {
this.hasCurrentColumns ??= this.db.$queryRaw<Array<{ exists: boolean }>>`
SELECT EXISTS (
SELECT 1
FROM information_schema.columns
WHERE table_name = 'workspace_runtime_states'
AND column_name = 'known'
) AS "exists"
`.then(rows => rows[0]?.exists ?? false);
return await this.hasCurrentColumns;
}
private async loadLegacyRows(workspaceId: string) {
const rows = await this.db.$queryRaw<LegacyWorkspaceRuntimeStateRow[]>`
SELECT
workspace_id AS "workspaceId",
readonly,
readonly_reasons AS "readonlyReasons",
updated_at AS "updatedAt",
stale_at AS "staleAt"
FROM workspace_runtime_states
WHERE workspace_id = ${workspaceId}
LIMIT 1
`;
return rows.map(row => ({
workspaceId: row.workspaceId,
known: true,
readonly: row.readonly,
readonlyReasons: row.readonlyReasons,
updatedAt: row.updatedAt,
lastReconciledAt: row.updatedAt,
staleAfter: row.staleAt,
}));
}
private async upsertCurrent(
workspaceId: string,
state: {
readonly: boolean;
readonlyReasons: string[];
known?: boolean;
lastReconciledAt?: Date | null;
staleAfter?: Date | null;
}
) {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
known,
readonly,
readonly_reasons,
last_reconciled_at,
stale_after,
updated_at
)
VALUES (
${workspaceId},
${state.known ?? true},
${state.readonly},
${state.readonlyReasons},
${state.lastReconciledAt ?? new Date()},
${state.staleAfter ?? null},
now()
)
ON CONFLICT (workspace_id)
DO UPDATE SET
known = EXCLUDED.known,
readonly = EXCLUDED.readonly,
readonly_reasons = EXCLUDED.readonly_reasons,
last_reconciled_at = EXCLUDED.last_reconciled_at,
stale_after = EXCLUDED.stale_after,
updated_at = now()
`;
}
private async upsertLegacy(
workspaceId: string,
state: {
readonly: boolean;
readonlyReasons: string[];
staleAfter?: Date | null;
}
) {
await this.db.$executeRaw`
INSERT INTO workspace_runtime_states (
workspace_id,
readonly,
readonly_reasons,
stale_at,
updated_at
)
VALUES (
${workspaceId},
${state.readonly},
${state.readonlyReasons},
${state.staleAfter ?? null},
now()
)
ON CONFLICT (workspace_id)
DO UPDATE SET
readonly = EXCLUDED.readonly,
readonly_reasons = EXCLUDED.readonly_reasons,
stale_at = EXCLUDED.stale_at,
updated_at = now()
`;
}
}

View File

@@ -6,7 +6,6 @@ import {
WorkspaceMember,
WorkspaceMemberSource,
WorkspaceMemberStatus,
WorkspaceUserRole,
} from '@prisma/client';
import { groupBy } from 'lodash-es';
@@ -17,7 +16,16 @@ import {
workspaceStatusFromNew,
} from './permission-write';
export type WorkspaceUserCompat = WorkspaceUserRole & {
export type WorkspaceUserCompat = {
id: string;
workspaceId: string;
userId: string;
type: WorkspaceRole;
status: WorkspaceMemberStatus;
source: WorkspaceMemberSource;
inviterId: string | null;
createdAt: Date;
updatedAt: Date;
user?: Pick<User, keyof typeof workspaceUserSelect>;
};
@@ -53,7 +61,7 @@ export function workspaceMemberToCompat(
member: WorkspaceMemberWithUser
): WorkspaceUserCompat {
return {
id: member.legacyPermissionId ?? member.id,
id: member.id,
workspaceId: member.workspaceId,
userId: member.userId,
type: workspaceRoleFromNew(member.role as never),
@@ -70,7 +78,7 @@ export function workspaceInvitationToCompat(
invitation: WorkspaceInvitationWithUser
): WorkspaceUserCompat {
return {
id: invitation.legacyPermissionId ?? invitation.id,
id: invitation.id,
workspaceId: invitation.workspaceId,
userId: invitation.inviteeUserId ?? '',
type: workspaceRoleFromNew(invitation.requestedRole as never),
@@ -117,7 +125,7 @@ export async function queryCompatRows(
SELECT *
FROM (
SELECT
COALESCE(wm.legacy_permission_id, wm.id) AS id,
wm.id AS id,
wm.workspace_id AS "workspaceId",
wm.user_id AS "userId",
CASE wm.role
@@ -143,7 +151,7 @@ export async function queryCompatRows(
AND wm.state = 'active'
UNION ALL
SELECT
COALESCE(wi.legacy_permission_id, wi.id) AS id,
wi.id AS id,
wi.workspace_id AS "workspaceId",
wi.invitee_user_id AS "userId",
CASE wi.requested_role
@@ -192,7 +200,7 @@ export async function searchCompatRows(
SELECT *
FROM (
SELECT
COALESCE(wm.legacy_permission_id, wm.id) AS id,
wm.id AS id,
wm.workspace_id AS "workspaceId",
wm.user_id AS "userId",
CASE wm.role
@@ -218,7 +226,7 @@ export async function searchCompatRows(
AND wm.state = 'active'
UNION ALL
SELECT
COALESCE(wi.legacy_permission_id, wi.id) AS id,
wi.id AS id,
wi.workspace_id AS "workspaceId",
wi.invitee_user_id AS "userId",
CASE wi.requested_role
@@ -329,7 +337,6 @@ export async function hasSharedWorkspace(
export async function allocateWorkspaceSeats(
db: WorkspaceUserCompatDb,
models: {
permissionProjection: { markNewWriteOrigin(): Promise<void> };
workspaceMember: {
setActive(
workspaceId: string,
@@ -341,7 +348,6 @@ export async function allocateWorkspaceSeats(
workspaceId: string,
limit: number
) {
await models.permissionProjection.markNewWriteOrigin();
const [activeCount, pendingCount] = await Promise.all([
db.workspaceMember.count({
where: {

View File

@@ -4,7 +4,6 @@ import {
Prisma,
WorkspaceMemberSource,
WorkspaceMemberStatus,
WorkspaceUserRole,
} from '@prisma/client';
import { EventBus, NewOwnerIsNotActiveMember, PaginationInput } from '../base';
@@ -20,6 +19,7 @@ import {
searchCompatRows,
workspaceInvitationToCompat,
workspaceMemberToCompat,
type WorkspaceUserCompat,
} from './workspace-user-compat';
export { WorkspaceMemberStatus };
@@ -190,39 +190,22 @@ export class WorkspaceUserModel extends BaseModel {
private async setExternal(
workspaceId: string,
userId: string,
oldRole: WorkspaceUserRole | null
oldRole: WorkspaceUserCompat | null
) {
await this.models.permissionProjection.markLegacyWriteOrigin();
if (!oldRole) {
throw new Error(`Workspace member ${workspaceId}/${userId} not found.`);
}
await this.db.workspaceMember.deleteMany({
where: { workspaceId, userId, state: 'active' },
});
await this.db.workspaceInvitation.deleteMany({
where: { workspaceId, inviteeUserId: userId },
});
await this.models.permissionProjection.markNewWriteOrigin();
if (oldRole) {
return await this.withPermissionProjectionMetric(
this.db.workspaceUserRole.update({
where: { id: oldRole.id },
data: {
type: WorkspaceRole.External,
status: WorkspaceMemberStatus.Accepted,
},
})
);
}
return await this.withPermissionProjectionMetric(
this.db.workspaceUserRole.create({
data: {
workspaceId,
userId,
type: WorkspaceRole.External,
status: WorkspaceMemberStatus.Accepted,
},
})
);
return {
...oldRole,
type: WorkspaceRole.External,
status: WorkspaceMemberStatus.Accepted,
};
}
async setStatus(
@@ -261,32 +244,16 @@ export class WorkspaceUserModel extends BaseModel {
await this.db.workspaceInvitation.deleteMany({
where: { workspaceId, inviteeUserId: userId },
});
await this.withPermissionProjectionMetric(
this.db.workspaceUserRole.deleteMany({
where: {
workspaceId,
userId,
},
})
);
}
@Transactional()
async deleteByUserId(userId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
await this.db.workspaceMember.deleteMany({
where: { userId },
});
await this.db.workspaceInvitation.deleteMany({
where: { inviteeUserId: userId },
});
await this.withPermissionProjectionMetric(
this.db.workspaceUserRole.deleteMany({
where: {
userId,
},
})
);
}
async deleteNonAccepted(workspaceId: string) {
@@ -295,7 +262,6 @@ export class WorkspaceUserModel extends BaseModel {
@Transactional()
async demoteAcceptedAdmins(workspaceId: string) {
await this.models.permissionProjection.markNewWriteOrigin();
return await this.db.workspaceMember.updateMany({
where: { workspaceId, role: 'admin', state: 'active' },
data: { role: 'member' },
@@ -326,20 +292,12 @@ export class WorkspaceUserModel extends BaseModel {
return workspaceInvitationToCompat(invitation);
}
return await this.db.workspaceUserRole.findFirst({
where: {
workspaceId,
userId,
type: WorkspaceRole.External,
},
});
return null;
}
async getById(id: string) {
const member = await this.db.workspaceMember.findFirst({
where: {
OR: [{ id }, { legacyPermissionId: id }],
},
where: { id },
});
if (member) {
return workspaceMemberToCompat(member);
@@ -347,7 +305,7 @@ export class WorkspaceUserModel extends BaseModel {
const invitation = await this.db.workspaceInvitation.findFirst({
where: {
OR: [{ id }, { legacyPermissionId: id }],
id,
inviteeUserId: {
not: null,
},
@@ -357,9 +315,7 @@ export class WorkspaceUserModel extends BaseModel {
return workspaceInvitationToCompat(invitation);
}
return await this.db.workspaceUserRole.findUnique({
where: { id },
});
return null;
}
/**

View File

@@ -1,10 +1,9 @@
import { Injectable } from '@nestjs/common';
import { Transactional } from '@nestjs-cls/transactional';
import { Prisma, type Workspace } from '@prisma/client';
import { Prisma, type Workspace as WorkspaceRecord } from '@prisma/client';
import { EventBus } from '../base';
import { BaseModel } from './base';
import type { WorkspaceFeatureName } from './common';
type RawWorkspaceSummary = {
id: string;
@@ -22,7 +21,6 @@ type RawWorkspaceSummary = {
snapshotSize: bigint | number | null;
blobCount: bigint | number | null;
blobSize: bigint | number | null;
features: WorkspaceFeatureName[] | null;
ownerId: string | null;
ownerName: string | null;
ownerEmail: string | null;
@@ -45,7 +43,6 @@ export type AdminWorkspaceSummary = {
snapshotSize: number;
blobCount: number;
blobSize: number;
features: WorkspaceFeatureName[];
owner: {
id: string;
name: string;
@@ -63,19 +60,24 @@ declare global {
}
}
export type { Workspace };
export type Workspace = WorkspaceRecord & {
public: boolean;
enableSharing: boolean;
enableUrlPreview: boolean;
};
export type UpdateWorkspaceInput = Pick<
Partial<Workspace>,
| 'public'
Partial<WorkspaceRecord>,
| 'enableAi'
| 'enableSharing'
| 'enableUrlPreview'
| 'enableDocEmbedding'
| 'name'
| 'avatarKey'
| 'indexed'
| 'lastCheckEmbeddings'
>;
> & {
public?: boolean;
enableSharing?: boolean;
enableUrlPreview?: boolean;
};
@Injectable()
export class WorkspaceModel extends BaseModel {
@@ -89,44 +91,60 @@ export class WorkspaceModel extends BaseModel {
*/
@Transactional()
async create(userId: string) {
const workspace = await this.withPermissionProjectionMetric(
this.db.workspace.create({
data: { public: false },
})
);
const workspace = await this.db.workspace.create({
data: {
accessPolicy: {
create: {
visibility: 'private',
sharingEnabled: true,
urlPreviewEnabled: false,
},
},
},
include: { accessPolicy: true },
});
this.logger.log(`Workspace created with id ${workspace.id}`);
await this.models.workspaceUser.setOwner(workspace.id, userId);
return workspace;
return this.withAccessPolicy(workspace);
}
/**
* Update the workspace with the given data.
*/
@Transactional()
async update(
workspaceId: string,
data: UpdateWorkspaceInput,
notifyUpdate = true
) {
const {
public: isPublic,
enableSharing,
enableUrlPreview,
...workspaceData
} = data;
if (
data.public !== undefined ||
data.enableSharing !== undefined ||
data.enableUrlPreview !== undefined
isPublic !== undefined ||
enableSharing !== undefined ||
enableUrlPreview !== undefined
) {
await this.models.workspaceAccessPolicy.upsert(workspaceId, {
public: data.public,
enableSharing: data.enableSharing,
enableUrlPreview: data.enableUrlPreview,
public: isPublic,
enableSharing,
enableUrlPreview,
});
}
const workspace = await this.withPermissionProjectionMetric(
this.db.workspace.update({
where: {
id: workspaceId,
},
data,
})
);
await this.db.workspace.update({
where: {
id: workspaceId,
},
data: workspaceData,
});
const workspace = await this.get(workspaceId);
if (!workspace) {
throw new Error(`Workspace ${workspaceId} not found after update`);
}
this.logger.debug(
`Updated workspace ${workspaceId} with data ${JSON.stringify(data)}`
);
@@ -139,19 +157,23 @@ export class WorkspaceModel extends BaseModel {
}
async get(workspaceId: string) {
return await this.db.workspace.findUnique({
const workspace = await this.db.workspace.findUnique({
where: {
id: workspaceId,
},
include: { accessPolicy: true },
});
return workspace ? this.withAccessPolicy(workspace) : null;
}
async findMany(ids: string[]) {
return await this.db.workspace.findMany({
const workspaces = await this.db.workspace.findMany({
where: {
id: { in: ids },
},
include: { accessPolicy: true },
});
return workspaces.map(workspace => this.withAccessPolicy(workspace));
}
async list<S extends Prisma.WorkspaceSelect>(
@@ -169,14 +191,13 @@ export class WorkspaceModel extends BaseModel {
})) as Prisma.WorkspaceGetPayload<{ select: S }>[];
}
@Transactional()
async delete(workspaceId: string) {
const rawResult = await this.withPermissionProjectionMetric(
this.db.workspace.deleteMany({
where: {
id: workspaceId,
},
})
);
const rawResult = await this.db.workspace.deleteMany({
where: {
id: workspaceId,
},
});
if (rawResult.count > 0) {
this.event.emit('workspace.deleted', { id: workspaceId });
@@ -185,13 +206,19 @@ export class WorkspaceModel extends BaseModel {
}
async allowUrlPreview(workspaceId: string) {
const workspace = await this.get(workspaceId);
return workspace?.enableUrlPreview ?? false;
const policy = await this.db.workspaceAccessPolicy.findUnique({
where: { workspaceId },
select: { urlPreviewEnabled: true },
});
return policy?.urlPreviewEnabled ?? false;
}
async allowSharing(workspaceId: string) {
const workspace = await this.get(workspaceId);
return workspace?.enableSharing ?? true;
const policy = await this.db.workspaceAccessPolicy.findUnique({
where: { workspaceId },
select: { sharingEnabled: true },
});
return policy?.sharingEnabled ?? true;
}
async allowEmbedding(workspaceId: string) {
@@ -218,6 +245,20 @@ export class WorkspaceModel extends BaseModel {
return count > 0;
}
private withAccessPolicy(
workspace: Prisma.WorkspaceGetPayload<{
include: { accessPolicy: true };
}>
): Workspace {
const { accessPolicy, ...data } = workspace;
return {
...data,
public: accessPolicy?.visibility === 'public',
enableSharing: accessPolicy?.sharingEnabled ?? true,
enableUrlPreview: accessPolicy?.urlPreviewEnabled ?? false,
};
}
// #endregion
// #region admin
@@ -225,7 +266,6 @@ export class WorkspaceModel extends BaseModel {
skip: number;
first: number;
keyword?: string | null;
features?: WorkspaceFeatureName[] | null;
flags?: {
public?: boolean;
enableAi?: boolean;
@@ -244,89 +284,34 @@ export class WorkspaceModel extends BaseModel {
includeTotal?: boolean;
}): Promise<{ rows: AdminWorkspaceSummary[]; total: number }> {
const keyword = options.keyword?.trim();
const features = options.features ?? [];
const flags = options.flags ?? {};
const includeTotal = options.includeTotal ?? true;
const total = includeTotal
? await this.adminCountWorkspaces({ keyword, features, flags })
? await this.adminCountWorkspaces({ keyword, flags })
: 0;
if (includeTotal && total === 0) {
return { rows: [], total: 0 };
}
const featuresHaving =
features.length > 0
? Prisma.sql`
HAVING COUNT(
DISTINCT CASE
WHEN wf.name = ANY(${Prisma.sql`${features}::text[]`}) THEN wf.name
END
) = ${features.length}
`
: 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;
const groupAndHaving =
features.length > 0
? Prisma.sql`
GROUP BY w.id,
w.public,
w.created_at,
w.name,
w.avatar_key,
w.enable_ai,
w.enable_url_preview,
w.enable_doc_embedding,
o.owner_id,
o.owner_name,
o.owner_email,
o.owner_avatar_url
${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,
(wap.visibility = 'public') AS 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",
wap.sharing_enabled AS "enableSharing",
wap.url_preview_enabled AS "enableUrlPreview",
w.enable_doc_embedding AS "enableDocEmbedding"
FROM workspaces w
${featureJoin}
JOIN workspace_access_policies wap ON wap.workspace_id = w.id
WHERE ${
this.buildAdminFlagWhere(flags).length
? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ')
: Prisma.sql`TRUE`
}
${workspaceOnlyGroupAndHaving}
),
page AS (
SELECT f.*,
@@ -335,8 +320,7 @@ export class WorkspaceModel extends BaseModel {
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
COALESCE(s.public_page_count, 0) AS "publicPageCount"
FROM filtered f
LEFT JOIN workspace_admin_stats s ON s.workspace_id = f.id
ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))}
@@ -371,19 +355,20 @@ export class WorkspaceModel extends BaseModel {
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
WITH filtered AS (
SELECT w.id,
w.public,
(wap.visibility = 'public') AS 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",
wap.sharing_enabled AS "enableSharing",
wap.url_preview_enabled 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"
FROM workspaces w
JOIN workspace_access_policies wap ON wap.workspace_id = w.id
LEFT JOIN LATERAL (
SELECT u.id AS owner_id,
u.name AS owner_name,
@@ -397,7 +382,6 @@ export class WorkspaceModel extends BaseModel {
ORDER BY u.created_at ASC, wm.id ASC
LIMIT 1
) o ON TRUE
${featureJoin}
WHERE ${
keyword
? Prisma.sql`
@@ -417,7 +401,6 @@ export class WorkspaceModel extends BaseModel {
)}`
: Prisma.empty
}
${groupAndHaving}
)
SELECT f.*,
COALESCE(s.snapshot_count, 0) AS "snapshotCount",
@@ -425,8 +408,7 @@ export class WorkspaceModel extends BaseModel {
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
COALESCE(s.public_page_count, 0) AS "publicPageCount"
FROM filtered f
LEFT JOIN workspace_admin_stats s ON s.workspace_id = f.id
ORDER BY ${Prisma.raw(this.buildAdminOrder(options.order))}
@@ -454,7 +436,6 @@ export class WorkspaceModel extends BaseModel {
snapshotSize: Number(row.snapshotSize ?? 0),
blobCount: Number(row.blobCount ?? 0),
blobSize: Number(row.blobSize ?? 0),
features: (row.features ?? []) as WorkspaceFeatureName[],
owner: row.ownerId
? {
id: row.ownerId,
@@ -469,13 +450,13 @@ export class WorkspaceModel extends BaseModel {
async adminGetWorkspace(id: string) {
const rows = await this.db.$queryRaw<RawWorkspaceSummary[]>`
SELECT w.id,
w.public,
(wap.visibility = 'public') AS 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",
wap.sharing_enabled AS "enableSharing",
wap.url_preview_enabled AS "enableUrlPreview",
w.enable_doc_embedding AS "enableDocEmbedding",
o.owner_id AS "ownerId",
o.owner_name AS "ownerName",
@@ -486,9 +467,9 @@ export class WorkspaceModel extends BaseModel {
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
COALESCE(s.public_page_count, 0) AS "publicPageCount"
FROM workspaces w
JOIN workspace_access_policies wap ON wap.workspace_id = w.id
LEFT JOIN LATERAL (
SELECT u.id AS owner_id,
u.name AS owner_name,
@@ -512,7 +493,6 @@ export class WorkspaceModel extends BaseModel {
async adminCountWorkspaces(options: {
keyword?: string | null;
features?: WorkspaceFeatureName[] | null;
flags?: {
public?: boolean;
enableAi?: boolean;
@@ -522,13 +502,13 @@ export class WorkspaceModel extends BaseModel {
};
}) {
const keyword = options.keyword?.trim();
const features = options.features ?? [];
const flags = options.flags ?? {};
if (!keyword && features.length === 0) {
if (!keyword) {
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
SELECT COUNT(*) AS total
FROM workspaces w
JOIN workspace_access_policies wap ON wap.workspace_id = w.id
WHERE ${
this.buildAdminFlagWhere(flags).length
? Prisma.join(this.buildAdminFlagWhere(flags), ' AND ')
@@ -539,58 +519,13 @@ export class WorkspaceModel extends BaseModel {
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`
HAVING COUNT(
DISTINCT CASE
WHEN wf.name = ANY(${Prisma.sql`${features}::text[]`}) THEN wf.name
END
) = ${features.length}
`
: 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
? Prisma.sql`
GROUP BY w.id, o.owner_id, o.owner_email
${featuresHaving}
`
: Prisma.empty;
const [row] = await this.db.$queryRaw<{ total: bigint | number }[]>`
WITH filtered AS (
SELECT w.id,
o.owner_id AS "ownerId",
o.owner_email AS "ownerEmail"
FROM workspaces w
JOIN workspace_access_policies wap ON wap.workspace_id = w.id
LEFT JOIN LATERAL (
SELECT wm.workspace_id,
u.id AS owner_id,
@@ -603,7 +538,6 @@ export class WorkspaceModel extends BaseModel {
ORDER BY u.created_at ASC, wm.id ASC
LIMIT 1
) o ON TRUE
${featureJoin}
WHERE ${
keyword
? Prisma.sql`
@@ -623,7 +557,6 @@ export class WorkspaceModel extends BaseModel {
)}`
: Prisma.empty
}
${groupAndHaving}
)
SELECT COUNT(*) AS total FROM filtered
`;
@@ -640,17 +573,19 @@ export class WorkspaceModel extends BaseModel {
}) {
const conditions: Prisma.Sql[] = [];
if (flags.public !== undefined) {
conditions.push(Prisma.sql`w.public = ${flags.public}`);
conditions.push(
Prisma.sql`(wap.visibility = 'public') = ${flags.public}`
);
}
if (flags.enableAi !== undefined) {
conditions.push(Prisma.sql`w.enable_ai = ${flags.enableAi}`);
}
if (flags.enableSharing !== undefined) {
conditions.push(Prisma.sql`w.enable_sharing = ${flags.enableSharing}`);
conditions.push(Prisma.sql`wap.sharing_enabled = ${flags.enableSharing}`);
}
if (flags.enableUrlPreview !== undefined) {
conditions.push(
Prisma.sql`w.enable_url_preview = ${flags.enableUrlPreview}`
Prisma.sql`wap.url_preview_enabled = ${flags.enableUrlPreview}`
);
}
if (flags.enableDocEmbedding !== undefined) {