- openai provider: use .chat() (Chat Completions) instead of the default callable (Responses API), which gateways reject on multi-turn -> 400. - updateAiProviderSettings: assemble settings.ai.provider via jsonb_build_object with ::text-cast bound params + jsonb_typeof self-heal (postgres.js was double-encoding it into an array; the ::text cast avoids 'could not determine data type of parameter'). - chat agent: drop the hard maxOutputTokens cap (truncated complex tool calls); keep a tiny cap only on the test-connection ping. - testConnection + chat stream: surface the real provider error (statusCode+message) to logs and the UI instead of generic masks; never log the API key. - chat UI: typing indicator, incremental streaming render, tool 'running' status, Stop. Also bundled (prior uncommitted ai-chat work): - history 'AI agent' provenance badge; vector RAG (pgvector image + page_embeddings + AI_QUEUE indexer + space-scoped semanticSearch); external MCP servers backend (@ai-sdk/mcp client, SSRF IP-pinning, encrypted headers, admin CRUD/Test); yjs duplicate-instance fix via pnpm patch (single CJS instance server-side).
309 lines
9.3 KiB
TypeScript
309 lines
9.3 KiB
TypeScript
import { Injectable } from '@nestjs/common';
|
|
import { InjectKysely } from 'nestjs-kysely';
|
|
import { KyselyDB, KyselyTransaction } from '../../types/kysely.types';
|
|
import { dbOrTx } from '../../utils';
|
|
import {
|
|
InsertableWorkspace,
|
|
UpdatableWorkspace,
|
|
Workspace,
|
|
} from '@docmost/db/types/entity.types';
|
|
import { ExpressionBuilder, sql } from 'kysely';
|
|
import { DB, Workspaces } from '@docmost/db/types/db';
|
|
|
|
@Injectable()
|
|
export class WorkspaceRepo {
|
|
public baseFields: Array<keyof Workspaces> = [
|
|
'id',
|
|
'name',
|
|
'description',
|
|
'logo',
|
|
'hostname',
|
|
'customDomain',
|
|
'settings',
|
|
'defaultRole',
|
|
'emailDomains',
|
|
'defaultSpaceId',
|
|
'createdAt',
|
|
'updatedAt',
|
|
'deletedAt',
|
|
'stripeCustomerId',
|
|
'status',
|
|
'billingEmail',
|
|
'trialEndAt',
|
|
'enforceSso',
|
|
'plan',
|
|
'enforceMfa',
|
|
'trashRetentionDays',
|
|
'isScimEnabled',
|
|
];
|
|
constructor(@InjectKysely() private readonly db: KyselyDB) {}
|
|
|
|
async findById(
|
|
workspaceId: string,
|
|
opts?: {
|
|
withLock?: boolean;
|
|
withMemberCount?: boolean;
|
|
withLicenseKey?: boolean;
|
|
trx?: KyselyTransaction;
|
|
},
|
|
): Promise<Workspace> {
|
|
const db = dbOrTx(this.db, opts?.trx);
|
|
|
|
let query = db
|
|
.selectFrom('workspaces')
|
|
.select(this.baseFields)
|
|
.where('id', '=', workspaceId);
|
|
|
|
if (opts?.withMemberCount) {
|
|
query = query.select(this.withMemberCount);
|
|
}
|
|
|
|
if (opts?.withLicenseKey) {
|
|
query = query.select('licenseKey');
|
|
}
|
|
|
|
if (opts?.withLock && opts?.trx) {
|
|
query = query.forUpdate();
|
|
}
|
|
|
|
return query.executeTakeFirst();
|
|
}
|
|
|
|
async findLicenseKeyById(
|
|
workspaceId: string,
|
|
): Promise<string | undefined> {
|
|
const row = await this.db
|
|
.selectFrom('workspaces')
|
|
.select('licenseKey')
|
|
.where('id', '=', workspaceId)
|
|
.executeTakeFirst();
|
|
return row?.licenseKey;
|
|
}
|
|
|
|
async findFirst(): Promise<Workspace> {
|
|
return await this.db
|
|
.selectFrom('workspaces')
|
|
.selectAll()
|
|
.orderBy('createdAt', 'asc')
|
|
.limit(1)
|
|
.executeTakeFirst();
|
|
}
|
|
|
|
async findByHostname(hostname: string): Promise<Workspace> {
|
|
return await this.db
|
|
.selectFrom('workspaces')
|
|
.selectAll()
|
|
.where(sql`LOWER(hostname)`, '=', sql`LOWER(${hostname})`)
|
|
.executeTakeFirst();
|
|
}
|
|
|
|
async hostnameExists(
|
|
hostname: string,
|
|
trx?: KyselyTransaction,
|
|
): Promise<boolean> {
|
|
if (hostname?.length < 1) return false;
|
|
|
|
const db = dbOrTx(this.db, trx);
|
|
let { count } = await db
|
|
.selectFrom('workspaces')
|
|
.select((eb) => eb.fn.count('id').as('count'))
|
|
.where(sql`LOWER(hostname)`, '=', sql`LOWER(${hostname})`)
|
|
.executeTakeFirst();
|
|
count = count as number;
|
|
return count != 0;
|
|
}
|
|
|
|
async updateWorkspace(
|
|
updatableWorkspace: UpdatableWorkspace,
|
|
workspaceId: string,
|
|
trx?: KyselyTransaction,
|
|
): Promise<Workspace> {
|
|
const db = dbOrTx(this.db, trx);
|
|
return db
|
|
.updateTable('workspaces')
|
|
.set({ ...updatableWorkspace, updatedAt: new Date() })
|
|
.where('id', '=', workspaceId)
|
|
.returning(this.baseFields)
|
|
.executeTakeFirst();
|
|
}
|
|
|
|
async insertWorkspace(
|
|
insertableWorkspace: InsertableWorkspace,
|
|
trx?: KyselyTransaction,
|
|
): Promise<Workspace> {
|
|
const db = dbOrTx(this.db, trx);
|
|
return db
|
|
.insertInto('workspaces')
|
|
.values(insertableWorkspace)
|
|
.returning(this.baseFields)
|
|
.executeTakeFirst();
|
|
}
|
|
|
|
async count(): Promise<number> {
|
|
const { count } = await this.db
|
|
.selectFrom('workspaces')
|
|
.select((eb) => eb.fn.count('id').as('count'))
|
|
.executeTakeFirst();
|
|
return count as number;
|
|
}
|
|
|
|
withMemberCount(eb: ExpressionBuilder<DB, 'workspaces'>) {
|
|
return eb
|
|
.selectFrom('users')
|
|
.select((eb) => eb.fn.countAll().as('count'))
|
|
.where('users.deactivatedAt', 'is', null)
|
|
.where('users.deletedAt', 'is', null)
|
|
.whereRef('users.workspaceId', '=', 'workspaces.id')
|
|
.as('memberCount');
|
|
}
|
|
|
|
async getActiveUserCount(workspaceId: string): Promise<number> {
|
|
const users = await this.db
|
|
.selectFrom('users')
|
|
.select(['id', 'deactivatedAt', 'deletedAt'])
|
|
.where('workspaceId', '=', workspaceId)
|
|
.execute();
|
|
|
|
const activeUsers = users.filter(
|
|
(user) => user.deletedAt === null && user.deactivatedAt === null,
|
|
);
|
|
|
|
return activeUsers.length;
|
|
}
|
|
|
|
async updateApiSettings(
|
|
workspaceId: string,
|
|
prefKey: string,
|
|
prefValue: string | boolean,
|
|
trx?: KyselyTransaction,
|
|
) {
|
|
const db = dbOrTx(this.db, trx);
|
|
return db
|
|
.updateTable('workspaces')
|
|
.set({
|
|
settings: sql`COALESCE(settings, '{}'::jsonb)
|
|
|| jsonb_build_object('api', COALESCE(settings->'api', '{}'::jsonb)
|
|
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where('id', '=', workspaceId)
|
|
.returning(this.baseFields)
|
|
.executeTakeFirst();
|
|
}
|
|
|
|
async updateAiSettings(
|
|
workspaceId: string,
|
|
prefKey: string,
|
|
prefValue: string | boolean,
|
|
trx?: KyselyTransaction,
|
|
) {
|
|
const db = dbOrTx(this.db, trx);
|
|
return db
|
|
.updateTable('workspaces')
|
|
.set({
|
|
settings: sql`COALESCE(settings, '{}'::jsonb)
|
|
|| jsonb_build_object('ai', COALESCE(settings->'ai', '{}'::jsonb)
|
|
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where('id', '=', workspaceId)
|
|
.returning(this.baseFields)
|
|
.executeTakeFirst();
|
|
}
|
|
|
|
/**
|
|
* Deep-merge a partial provider config into the fixed path
|
|
* `settings.ai.provider`. Unlike `updateAiSettings` (single scalar key under
|
|
* `settings.ai`), this stores a nested object. The provider object is assembled
|
|
* IN SQL via `jsonb_build_object`: keys come from a fixed allowlist (inlined
|
|
* via `sql.lit`, so no injection) and values are bound params, so the result is
|
|
* a real jsonb object and never a double-encoded string (postgres.js would
|
|
* otherwise re-serialize a `JSON.stringify`'d string, yielding a jsonb string
|
|
* that `||` turns into an array). A `jsonb_typeof = 'object'` CASE self-heals
|
|
* workspaces whose `settings.ai.provider` was previously corrupted into an
|
|
* array/string. Sibling `settings.ai.*` keys (search / generative / chat / mcp
|
|
* / systemPrompt) and provider fields absent from the partial are preserved via
|
|
* jsonb `||` merge.
|
|
*/
|
|
async updateAiProviderSettings(
|
|
workspaceId: string,
|
|
provider: Record<string, unknown>,
|
|
trx?: KyselyTransaction,
|
|
): Promise<Workspace> {
|
|
const db = dbOrTx(this.db, trx);
|
|
// Assemble the provider object IN SQL. Keys are fixed provider field names
|
|
// (sql.lit -> inlined literals, no injection); values are bound params cast
|
|
// to ::text — postgres.js sends bound params untyped, and jsonb_build_object's
|
|
// value args are polymorphic ("any"), so without the explicit ::text cast
|
|
// Postgres throws "could not determine data type of parameter $1". The result
|
|
// is a real jsonb object, never a double-encoded string. The CASE self-heals
|
|
// workspaces whose settings.ai.provider was previously corrupted into an
|
|
// array/string.
|
|
const ALLOWED = ['driver', 'chatModel', 'embeddingModel', 'baseUrl', 'systemPrompt'];
|
|
const entries = Object.entries(provider).filter(
|
|
([k, v]) => v !== undefined && ALLOWED.includes(k),
|
|
);
|
|
const patch = entries.length
|
|
? sql`jsonb_build_object(${sql.join(
|
|
entries.flatMap(([k, v]) => [sql.lit(k), sql`${v}::text`]),
|
|
)})`
|
|
: sql`'{}'::jsonb`;
|
|
return db
|
|
.updateTable('workspaces')
|
|
.set({
|
|
settings: sql`COALESCE(settings, '{}'::jsonb) || jsonb_build_object(
|
|
'ai', COALESCE(settings->'ai', '{}'::jsonb) || jsonb_build_object(
|
|
'provider',
|
|
(CASE WHEN jsonb_typeof(settings->'ai'->'provider') = 'object'
|
|
THEN settings->'ai'->'provider' ELSE '{}'::jsonb END)
|
|
|| ${patch}
|
|
))`,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where('id', '=', workspaceId)
|
|
.returning(this.baseFields)
|
|
.executeTakeFirst();
|
|
}
|
|
|
|
async updateSharingSettings(
|
|
workspaceId: string,
|
|
prefKey: string,
|
|
prefValue: string | boolean,
|
|
trx?: KyselyTransaction,
|
|
) {
|
|
const db = dbOrTx(this.db, trx);
|
|
return db
|
|
.updateTable('workspaces')
|
|
.set({
|
|
settings: sql`COALESCE(settings, '{}'::jsonb)
|
|
|| jsonb_build_object('sharing', COALESCE(settings->'sharing', '{}'::jsonb)
|
|
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where('id', '=', workspaceId)
|
|
.returning(this.baseFields)
|
|
.executeTakeFirst();
|
|
}
|
|
|
|
async updateTemplateSettings(
|
|
workspaceId: string,
|
|
prefKey: string,
|
|
prefValue: string | boolean,
|
|
trx?: KyselyTransaction,
|
|
) {
|
|
const db = dbOrTx(this.db, trx);
|
|
return db
|
|
.updateTable('workspaces')
|
|
.set({
|
|
settings: sql`COALESCE(settings, '{}'::jsonb)
|
|
|| jsonb_build_object('templates', COALESCE(settings->'templates', '{}'::jsonb)
|
|
|| jsonb_build_object('${sql.raw(prefKey)}', ${sql.lit(prefValue)}))`,
|
|
updatedAt: new Date(),
|
|
})
|
|
.where('id', '=', workspaceId)
|
|
.returning(this.baseFields)
|
|
.executeTakeFirst();
|
|
}
|
|
|
|
}
|