feat(server): cleanup legacy compatibility (#15239)
This commit is contained in:
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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> }
|
||||
>;
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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)
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
|
||||
@@ -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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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()
|
||||
`;
|
||||
}
|
||||
}
|
||||
@@ -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: {
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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) {
|
||||
|
||||
Reference in New Issue
Block a user