uberf-10455: merge accounts for merged persons

Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
This commit is contained in:
Alexey Zinoviev 2025-05-15 13:31:59 +04:00
parent 6dd9fbfb33
commit 050b0fe433
No known key found for this signature in database
15 changed files with 899 additions and 406 deletions

View File

@ -1,5 +1,15 @@
/* eslint-disable @typescript-eslint/no-unused-vars */
import { type AccountDB, type MongoAccountDB, type Workspace, ensurePerson } from '@hcengineering/account'
import {
type AccountDB,
type MongoAccountDB,
type Workspace,
addSocialIdToPerson,
ensurePerson,
findFullSocialIdBySocialKey,
findPersonBySocialKey,
mergeSpecifiedPersons,
mergeSpecifiedAccounts
} from '@hcengineering/account'
import { getFirstName, getLastName } from '@hcengineering/contact'
import {
systemAccountUuid,
@ -7,7 +17,12 @@ import {
type Client,
type Doc,
MeasureMetricsContext,
SocialIdType
SocialIdType,
type PersonUuid,
type SocialKey,
type AccountUuid,
parseSocialIdString,
DOMAIN_SPACE
} from '@hcengineering/core'
import { getMongoClient, getWorkspaceMongoDB } from '@hcengineering/mongo'
import {
@ -486,6 +501,262 @@ export async function migrateCreatedModifiedBy (ctx: MeasureMetricsContext, dbUr
}
}
async function fillAccountSocialKeyMapping (ctx: MeasureMetricsContext, pgClient: postgres.Sql): Promise<void> {
ctx.info('Creating account to social key mapping table...')
// Create schema
await pgClient`CREATE SCHEMA IF NOT EXISTS temp_data`
// Create mapping table
await pgClient`
CREATE TABLE IF NOT EXISTS temp_data.account_socialkey_mapping (
workspace_id text,
old_account_id text,
new_social_key text,
person_ref text,
person_name text,
INDEX idx_account_mapping_old_id (workspace_id, old_account_id)
)
`
const [res] = await pgClient`SELECT COUNT(*) FROM temp_data.account_socialkey_mapping`
if (res.count === '0') {
// Populate mapping table
await pgClient`
INSERT INTO temp_data.account_socialkey_mapping
WITH accounts AS (
SELECT
tx."workspaceId",
tx."objectId",
COALESCE(
-- Get the latest email from updates
(
SELECT tx2.data->'operations'->>'email'
FROM model_tx tx2
WHERE tx2."objectId" = tx."objectId"
AND tx2."workspaceId" = tx."workspaceId"
AND tx2.data->>'objectClass' = 'contact:class:PersonAccount'
AND tx2.data->'operations'->>'email' IS NOT NULL
ORDER BY tx2."createdOn" DESC
LIMIT 1
),
-- If no updates with email, get from create transaction
tx.data->'attributes'->>'email'
) as latest_email,
COALESCE(
-- Get the latest person from updates
(
SELECT (tx2.data->'operations'->>'person')::text
FROM model_tx tx2
WHERE tx2."objectId" = tx."objectId"
AND tx2."workspaceId" = tx."workspaceId"
AND tx2.data->>'objectClass' = 'contact:class:PersonAccount'
AND tx2.data->'operations'->>'person' IS NOT NULL
ORDER BY tx2."createdOn" DESC
LIMIT 1
),
-- If no updates, get from create transaction
(tx.data->'attributes'->>'person')::text
) as person_ref
FROM model_tx tx
WHERE tx."_class" = 'core:class:TxCreateDoc'
AND tx.data->>'objectClass' = 'contact:class:PersonAccount'
AND tx."objectId" NOT IN ('core:account:System', 'core:account:ConfigUser')
)
SELECT
a."workspaceId" as workspace_id,
a."objectId" as old_account_id,
CASE
WHEN a.latest_email LIKE 'github:%' THEN lower(a.latest_email)
WHEN a.latest_email LIKE 'openid:%' THEN 'oidc:' || lower(substring(a.latest_email from 8))
ELSE 'email:' || lower(a.latest_email)
END as new_social_key,
a.person_ref,
c.data->>'name' as person_name
FROM accounts as a
LEFT JOIN public.contact c ON c."_id" = a.person_ref AND c."workspaceId" = a."workspaceId"
WHERE a.latest_email IS NOT NULL
AND a.latest_email != ''
`
}
}
export async function migrateMergedAccounts (
ctx: MeasureMetricsContext,
dbUrl: string,
accountDb: AccountDB
): Promise<void> {
ctx.info('Migrating merged person accounts... ', {})
if (!dbUrl.startsWith('postgresql')) {
throw new Error('Only CockroachDB is supported')
}
const pg = getDBClient(sharedPipelineContextVars, dbUrl)
const pgClient = await pg.getClient()
const token = getToolToken()
try {
await fillAccountSocialKeyMapping(ctx, pgClient)
const personsAccounts = await pgClient`
SELECT workspace_id, person_ref, array_agg(new_social_key) as social_keys
FROM temp_data.account_socialkey_mapping
WHERE new_social_key != 'email:huly.ai.bot@hc.engineering'
GROUP BY workspace_id, person_ref
HAVING count(*) > 1
`
ctx.info('Processing persons with merged accounts ', { count: personsAccounts.length })
let processed = 0
let errors = 0
for (const personAccounts of personsAccounts) {
try {
const socialKeys = personAccounts.social_keys
// Every social id in the old account might either be already in the new account or not in the accounts at all
// So we want to
// 1. Take the first social id with the existing account
// 2. Merge all other accounts into the first one
// 3. Create social ids for the first account which haven't had their own accounts
const toAdd: Array<SocialKey> = []
const toMergePersons = new Set<PersonUuid>()
const toMergeAccounts = new Set<AccountUuid>()
for (const socialKey of socialKeys) {
const socialIdKey = parseSocialIdString(socialKey)
const socialId = await findFullSocialIdBySocialKey(ctx, accountDb, null, token, { socialKey })
const personUuid = socialId?.personUuid
const accountUuid = (await findPersonBySocialKey(ctx, accountDb, null, token, {
socialString: socialKey,
requireAccount: true
})) as AccountUuid
if (personUuid == null) {
toAdd.push(socialIdKey)
// Means not attached to any account yet, simply add the social id to the primary account
} else if (accountUuid == null) {
toMergePersons.add(personUuid)
} else {
// This is the case when the social id is already attached to an account. Merge the accounts.
toMergeAccounts.add(accountUuid)
}
}
if (toMergeAccounts.size === 0) {
// No existing accounts for the person's social ids. Normally this should never be the case.
ctx.info('No existing accounts for person', personAccounts)
continue
}
const toMergeAccountsArray = Array.from(toMergeAccounts)
const primaryAccount = toMergeAccountsArray[0]
for (let i = 1; i < toMergeAccountsArray.length; i++) {
const accountToMerge = toMergeAccountsArray[i]
await mergeSpecifiedAccounts(ctx, accountDb, null, token, {
primaryAccount,
secondaryAccount: accountToMerge
})
}
const toMergePersonsArray = Array.from(toMergePersons)
for (const personToMerge of toMergePersonsArray) {
await mergeSpecifiedPersons(ctx, accountDb, null, token, {
primaryPerson: primaryAccount,
secondaryPerson: personToMerge
})
}
for (const addTarget of toAdd) {
await addSocialIdToPerson(ctx, accountDb, null, token, {
person: primaryAccount,
...addTarget,
confirmed: false
})
}
processed++
if (processed % 10 === 0) {
ctx.info(`Processed ${processed} of ${personsAccounts.length} persons`)
}
} catch (err: any) {
errors++
ctx.error('Failed to merge accounts for person', { mergedGroup: personAccounts, err })
}
}
ctx.info('Finished processing persons with merged accounts', { processed, of: personsAccounts.length, errors })
} catch (err: any) {
ctx.error('Failed to migrate merged accounts', { err })
} finally {
pg.close()
}
}
export async function filterMergedAccountsInMembers (
ctx: MeasureMetricsContext,
dbUrl: string,
accountDb: AccountDB
): Promise<void> {
ctx.info('Filtering merged accounts in members... ', {})
if (!dbUrl.startsWith('postgresql')) {
throw new Error('Only CockroachDB is supported')
}
const pg = getDBClient(sharedPipelineContextVars, dbUrl)
const pgClient = await pg.getClient()
try {
const mergedPersons = await accountDb.person.find({ migratedTo: { $ne: null } })
if (mergedPersons.length === 0) {
ctx.info('No merged persons to migrate')
return
}
ctx.info('Merged persons found', { count: mergedPersons.length })
const migrationMap = new Map<PersonUuid, PersonUuid>()
for (const person of mergedPersons) {
if (person.migratedTo == null) {
continue
}
migrationMap.set(person.uuid, person.migratedTo)
}
const spacesToUpdate = await pgClient`
SELECT "workspaceId", _id, members FROM ${pgClient(DOMAIN_SPACE)} WHERE members && ${pgClient.array(Array.from(migrationMap.keys()))}
`
ctx.info('Spaces to update', { count: spacesToUpdate.length })
let processed = 0
let errors = 0
for (const space of spacesToUpdate) {
try {
const newMembers = new Set<PersonUuid>(space.members.map((it: PersonUuid) => migrationMap.get(it) ?? it))
await pgClient`
UPDATE ${pgClient(DOMAIN_SPACE)} SET members = ${pgClient.array(Array.from(newMembers))}
WHERE "workspaceId" = ${space.workspaceId}
AND "_id" = ${space._id}
`
processed++
} catch (err: any) {
errors++
ctx.error('Failed to update space members', { space, err })
}
}
ctx.info('Finished updating spaces', { processed, of: spacesToUpdate.length, errors })
} finally {
pg.close()
}
}
export async function ensureGlobalPersonsForLocalAccounts (
ctx: MeasureMetricsContext,
dbUrl: string,
@ -502,69 +773,7 @@ export async function ensureGlobalPersonsForLocalAccounts (
const token = getToolToken()
try {
ctx.info('Creating account to social key mapping table...')
// Create schema
await pgClient`CREATE SCHEMA IF NOT EXISTS temp_data`
// Create mapping table
await pgClient`
CREATE TABLE IF NOT EXISTS temp_data.account_socialkey_mapping (
workspace_id text,
old_account_id text,
new_social_key text,
person_ref text,
person_name text,
INDEX idx_account_mapping_old_id (workspace_id, old_account_id)
)
`
const [res] = await pgClient`SELECT COUNT(*) FROM temp_data.account_socialkey_mapping`
if (res.count === '0') {
// Populate mapping table
await pgClient`
INSERT INTO temp_data.account_socialkey_mapping
WITH person_refs AS (
SELECT
tx."workspaceId" as workspace_id,
tx."objectId" as account_id,
CASE
WHEN tx.data->'attributes'->>'email' LIKE 'github:%' THEN lower(tx.data->'attributes'->>'email')
WHEN tx.data->'attributes'->>'email' LIKE 'openid:%' THEN 'oidc:' || lower(substring(tx.data->'attributes'->>'email' from 8))
ELSE 'email:' || lower(tx.data->'attributes'->>'email')
END as new_social_key,
COALESCE(
-- Try to get person from most recent update
(
SELECT (tx2.data->'operations'->>'person')::text
FROM model_tx tx2
WHERE tx2."objectId" = tx."objectId"
AND tx2."workspaceId" = tx."workspaceId"
AND tx2.data->>'objectClass' = 'contact:class:PersonAccount'
AND tx2.data->'operations'->>'person' IS NOT NULL
ORDER BY tx2."createdOn" DESC
LIMIT 1
),
-- If no updates, get from create transaction
(tx.data->'attributes'->>'person')::text
) as person_ref
FROM model_tx tx
WHERE tx."_class" = 'core:class:TxCreateDoc'
AND tx.data->>'objectClass' = 'contact:class:PersonAccount'
AND tx.data->'attributes'->>'email' IS NOT NULL
AND tx.data->'attributes'->>'email' != ''
AND tx."objectId" NOT IN ('core:account:System', 'core:account:ConfigUser')
)
SELECT
p.workspace_id,
p.account_id as old_account_id,
p.new_social_key,
p.person_ref,
c.data->>'name' as person_name
FROM person_refs p
LEFT JOIN public.contact c ON c."_id" = p.person_ref
`
}
await fillAccountSocialKeyMapping(ctx, pgClient)
let count = 0
let failed = 0

View File

@ -94,7 +94,13 @@ import { getAccountDBUrl, getKvsUrl, getMongoDBUrl } from './__start'
import { changeConfiguration } from './configuration'
import { performGithubAccountMigrations } from './github'
import { migrateCreatedModifiedBy, ensureGlobalPersonsForLocalAccounts, moveAccountDbFromMongoToPG } from './db'
import {
migrateCreatedModifiedBy,
ensureGlobalPersonsForLocalAccounts,
moveAccountDbFromMongoToPG,
migrateMergedAccounts,
filterMergedAccountsInMembers
} from './db'
import { getToolToken, getWorkspace, getWorkspaceTransactorEndpoint } from './utils'
import { performGmailAccountMigrations } from './gmail'
import { performCalendarAccountMigrations } from './calendar'
@ -2314,6 +2320,22 @@ export function devTool (
}, dbUrl)
})
program.command('migrate-merged-accounts').action(async () => {
const { dbUrl } = prepareTools()
await withAccountDatabase(async (accDb) => {
await migrateMergedAccounts(toolCtx, dbUrl, accDb)
}, dbUrl)
})
program.command('filter-merged-accounts-in-members').action(async () => {
const { dbUrl } = prepareTools()
await withAccountDatabase(async (accDb) => {
await filterMergedAccountsInMembers(toolCtx, dbUrl, accDb)
}, dbUrl)
})
// program
// .command('perfomance')
// .option('-p, --parallel', '', false)

View File

@ -13,6 +13,7 @@ import {
} from '@hcengineering/contact'
import {
AccountRole,
type AccountUuid,
buildSocialIdString,
type Class,
type Doc,
@ -23,7 +24,9 @@ import {
type MarkupBlobRef,
MeasureMetricsContext,
type PersonId,
type PersonUuid,
type Ref,
type SocialKey,
SortingOrder,
type Space,
type TxCUD
@ -283,6 +286,88 @@ async function createSocialIdentities (client: MigrationClient): Promise<void> {
}
}
async function migrateMergedAccounts (client: MigrationClient): Promise<void> {
const ctx = new MeasureMetricsContext('migrateMergedAccounts', {})
ctx.info('migrating merged person accounts ', {})
const accountsByPerson = new Map<string, any[]>()
const personAccountsTxes: any[] = await client.find<TxCUD<Doc>>(DOMAIN_MODEL_TX, {
objectClass: 'contact:class:PersonAccount' as Ref<Class<Doc>>
})
const personAccounts = getAccountsFromTxes(personAccountsTxes)
for (const account of personAccounts) {
if (!accountsByPerson.has(account.person)) {
accountsByPerson.set(account.person, [])
}
// exclude empty emails
// also exclude Hulia account
if (account.email === '' || account.email === 'huly.ai.bot@hc.engineering') {
continue
}
accountsByPerson.get(account.person)?.push(account)
}
for (const [person, oldAccounts] of accountsByPerson.entries()) {
try {
if (oldAccounts.length < 2) continue
// Every social id in the old account might either be already in the new account or not in the accounts at all
// So we want to
// 1. Take the first social id with the existing account
// 2. Merge all other accounts into the first one
// 3. Create social ids for the first account which haven't had their own accounts
const toAdd: Array<SocialKey> = []
const toMergePersons = new Set<PersonUuid>()
const toMerge = new Set<AccountUuid>()
for (const oldAccount of oldAccounts) {
const socialIdKeyObj = getSocialKeyByOldEmail(oldAccount.email)
const socialIdKey = buildSocialIdString(socialIdKeyObj)
const socialId = await client.accountClient.findFullSocialIdBySocialKey(socialIdKey)
const personUuid = socialId?.personUuid
const accountUuid = (await client.accountClient.findPersonBySocialKey(socialIdKey, true)) as AccountUuid
if (personUuid == null) {
toAdd.push(socialIdKeyObj)
// Means not attached to any account yet, simply add the social id to the primary account
} else if (accountUuid == null) {
// Attached to a person without an account. Should not be the case if being run before the global accounts migration.
// Merge the person into the primary account.
toMergePersons.add(personUuid)
} else {
// This is the case when the social id is already attached to an account. Merge the accounts.
toMerge.add(accountUuid)
}
}
if (toMerge.size === 0) {
// No existing accounts for the person's social ids. Normally this should never be the case.
continue
}
const toMergeAccountsArray = Array.from(toMerge)
const primaryAccount = toMergeAccountsArray[0]
for (let i = 1; i < toMergeAccountsArray.length; i++) {
const accountToMerge = toMergeAccountsArray[i]
await client.accountClient.mergeSpecifiedAccounts(primaryAccount, accountToMerge)
}
const toMergePersonsArray = Array.from(toMergePersons)
for (const personToMerge of toMergePersonsArray) {
await client.accountClient.mergeSpecifiedPersons(primaryAccount, personToMerge)
}
for (const addTarget of toAdd) {
await client.accountClient.addSocialIdToPerson(primaryAccount, addTarget.type, addTarget.value, false)
}
} catch (err: any) {
ctx.error('Failed to merge accounts for person', { person, oldAccounts, err })
}
}
}
async function ensureGlobalPersonsForLocalAccounts (client: MigrationClient): Promise<void> {
const ctx = new MeasureMetricsContext('contact ensureGlobalPersonsForLocalAccounts', {})
ctx.info('ensuring global persons for local accounts ', {})
@ -377,6 +462,11 @@ async function createUserProfiles (client: MigrationClient): Promise<void> {
export const contactOperation: MigrateOperation = {
async preMigrate (client: MigrationClient, logger: ModelLogger, mode): Promise<void> {
await tryMigrate(mode, client, contactId, [
{
state: 'migrate-merged-accounts',
mode: 'upgrade',
func: (client) => migrateMergedAccounts(client)
},
{
state: 'ensure-accounts-global-persons-v2',
mode: 'upgrade',

View File

@ -668,13 +668,13 @@ export async function getAccountUuidByOldAccount (
const cached = accountUuidByOldAccount.has(oldAccount)
if (!cached) {
const socialId = socialKeyByOldAccount[oldAccount]
if (socialId == null) {
const socialKey = socialKeyByOldAccount[oldAccount]
if (socialKey == null) {
accountUuidByOldAccount.set(oldAccount, null)
return null
}
const personUuid = await client.accountClient.findPersonBySocialKey(socialId)
const personUuid = await client.accountClient.findPersonBySocialKey(socialKey)
accountUuidByOldAccount.set(oldAccount, (personUuid as AccountUuid | undefined) ?? null)
}

View File

@ -169,6 +169,8 @@ export interface AccountClient {
getIntegrationSecret: (integrationSecretKey: IntegrationSecretKey) => Promise<IntegrationSecret | null>
listIntegrationsSecrets: (filter: Partial<IntegrationSecretKey>) => Promise<IntegrationSecret[]>
getAccountInfo: (uuid: AccountUuid) => Promise<AccountInfo>
mergeSpecifiedPersons: (primaryPerson: PersonUuid, secondaryPerson: PersonUuid) => Promise<void>
mergeSpecifiedAccounts: (primaryAccount: AccountUuid, secondaryAccount: AccountUuid) => Promise<void>
setCookie: () => Promise<void>
deleteCookie: () => Promise<void>
@ -904,6 +906,24 @@ class AccountClientImpl implements AccountClient {
return await this.rpc(request)
}
async mergeSpecifiedPersons (primaryPerson: PersonUuid, secondaryPerson: PersonUuid): Promise<void> {
const request = {
method: 'mergeSpecifiedPersons' as const,
params: { primaryPerson, secondaryPerson }
}
await this.rpc(request)
}
async mergeSpecifiedAccounts (primaryAccount: AccountUuid, secondaryAccount: AccountUuid): Promise<void> {
const request = {
method: 'mergeSpecifiedAccounts' as const,
params: { primaryAccount, secondaryAccount }
}
await this.rpc(request)
}
async setCookie (): Promise<void> {
const url = concatLink(this.url, '/cookie')
const response = await fetch(url, { ...this.request, method: 'PUT' })

View File

@ -95,6 +95,7 @@ export interface Integration {
export interface SocialId extends SocialIdBase {
personUuid: PersonUuid
isDeleted?: boolean
}
export type IntegrationKey = Omit<Integration, 'data'>

View File

@ -539,7 +539,7 @@ export const roleOrder: Record<AccountRole, number> = {
* @public
*/
export interface Person {
uuid: string
uuid: PersonUuid
firstName: string
lastName: string
country?: string
@ -857,10 +857,6 @@ export interface SocialId {
key: string // Calculated from type and value. Just for convenience.
displayValue?: string
// To be used later when person detaches social id from his account by any means
// There should always be only one ACTIVE social id with the same key every time
// active: boolean
verifiedOn?: number
}

View File

@ -20,7 +20,7 @@ import {
type WorkspaceMode,
type WorkspaceUuid
} from '@hcengineering/core'
import { AccountPostgresDbCollection, PostgresAccountDB, PostgresDbCollection } from '../collections/postgres'
import { AccountPostgresDbCollection, PostgresAccountDB, PostgresDbCollection } from '../collections/postgres/postgres'
import { Sql } from 'postgres'
interface TestWorkspace {

View File

@ -716,12 +716,12 @@ export class MongoAccountDB implements AccountDB {
return assignment?.role ?? null
}
async getWorkspaceRoles (accountId: AccountUuid): Promise<Map<WorkspaceUuid, AccountRole | null>> {
async getWorkspaceRoles (accountId: AccountUuid): Promise<Map<WorkspaceUuid, AccountRole>> {
const assignment = await this.workspaceMembers.find({
accountUuid: accountId
})
return assignment.reduce<Map<WorkspaceUuid, AccountRole | null>>((acc, it) => {
return assignment.reduce<Map<WorkspaceUuid, AccountRole>>((acc, it) => {
acc.set(it.workspaceUuid, it.role)
return acc
}, new Map())

View File

@ -0,0 +1,343 @@
//
// Copyright © 2025 Hardcore Engineering Inc.
//
// Licensed under the Eclipse Public License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License. You may
// obtain a copy of the License at https://www.eclipse.org/legal/epl-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
//
// See the License for the specific language governing permissions and
// limitations under the License.
//
export function getMigrations (ns: string): [string, string][] {
return [
getV1Migration(ns),
getV2Migration1(ns),
getV2Migration2(ns),
getV2Migration3(ns),
getV3Migration(ns),
getV4Migration(ns),
getV4Migration1(ns),
getV5Migration(ns),
getV6Migration(ns),
getV7Migration(ns),
getV8Migration(ns),
getV9Migration(ns)
]
}
// NOTE: NEVER MODIFY EXISTING MIGRATIONS. IF YOU NEED TO ADJUST THE SCHEMA, ADD A NEW MIGRATION.
function getV1Migration (ns: string): [string, string] {
return [
'account_db_v1_global_init',
`
/* ======= FUNCTIONS ======= */
CREATE OR REPLACE FUNCTION current_epoch_ms()
RETURNS BIGINT AS $$
SELECT (extract(epoch from current_timestamp) * 1000)::bigint;
$$ LANGUAGE SQL;
/* ======= T Y P E S ======= */
CREATE TYPE IF NOT EXISTS ${ns}.social_id_type AS ENUM ('email', 'github', 'google', 'phone', 'oidc', 'huly', 'telegram');
CREATE TYPE IF NOT EXISTS ${ns}.location AS ENUM ('kv', 'weur', 'eeur', 'wnam', 'enam', 'apac');
CREATE TYPE IF NOT EXISTS ${ns}.workspace_role AS ENUM ('OWNER', 'MAINTAINER', 'USER', 'GUEST', 'DOCGUEST');
/* ======= P E R S O N ======= */
CREATE TABLE IF NOT EXISTS ${ns}.person (
uuid UUID NOT NULL DEFAULT gen_random_uuid(),
first_name STRING NOT NULL,
last_name STRING NOT NULL,
country STRING,
city STRING,
CONSTRAINT person_pk PRIMARY KEY (uuid)
);
/* ======= A C C O U N T ======= */
CREATE TABLE IF NOT EXISTS ${ns}.account (
uuid UUID NOT NULL,
timezone STRING,
locale STRING,
CONSTRAINT account_pk PRIMARY KEY (uuid),
CONSTRAINT account_person_fk FOREIGN KEY (uuid) REFERENCES ${ns}.person(uuid)
);
CREATE TABLE IF NOT EXISTS ${ns}.account_passwords (
account_uuid UUID NOT NULL,
hash BYTES NOT NULL,
salt BYTES NOT NULL,
CONSTRAINT account_auth_pk PRIMARY KEY (account_uuid),
CONSTRAINT account_passwords_account_fk FOREIGN KEY (account_uuid) REFERENCES ${ns}.account(uuid)
);
CREATE TABLE IF NOT EXISTS ${ns}.account_events (
account_uuid UUID NOT NULL,
event_type STRING NOT NULL,
time BIGINT NOT NULL DEFAULT current_epoch_ms(),
data JSONB,
CONSTRAINT account_events_pk PRIMARY KEY (account_uuid, event_type, time),
CONSTRAINT account_events_account_fk FOREIGN KEY (account_uuid) REFERENCES ${ns}.account(uuid)
);
/* ======= S O C I A L I D S ======= */
CREATE TABLE IF NOT EXISTS ${ns}.social_id (
type ${ns}.social_id_type NOT NULL,
value STRING NOT NULL,
key STRING AS (CONCAT(type::STRING, ':', value)) STORED,
person_uuid UUID NOT NULL,
created_on BIGINT NOT NULL DEFAULT current_epoch_ms(),
verified_on BIGINT,
CONSTRAINT social_id_pk PRIMARY KEY (type, value),
CONSTRAINT social_id_key_unique UNIQUE (key),
INDEX social_id_account_idx (person_uuid),
CONSTRAINT social_id_person_fk FOREIGN KEY (person_uuid) REFERENCES ${ns}.person(uuid)
);
/* ======= W O R K S P A C E ======= */
CREATE TABLE IF NOT EXISTS ${ns}.workspace (
uuid UUID NOT NULL DEFAULT gen_random_uuid(),
name STRING NOT NULL,
url STRING NOT NULL,
data_id STRING,
branding STRING,
location ${ns}.location,
region STRING,
created_by UUID, -- account uuid
created_on BIGINT NOT NULL DEFAULT current_epoch_ms(),
billing_account UUID,
CONSTRAINT workspace_pk PRIMARY KEY (uuid),
CONSTRAINT workspace_url_unique UNIQUE (url),
CONSTRAINT workspace_created_by_fk FOREIGN KEY (created_by) REFERENCES ${ns}.account(uuid),
CONSTRAINT workspace_billing_account_fk FOREIGN KEY (billing_account) REFERENCES ${ns}.account(uuid)
);
CREATE TABLE IF NOT EXISTS ${ns}.workspace_status (
workspace_uuid UUID NOT NULL,
mode STRING,
processing_progress INT2 DEFAULT 0,
version_major INT2 NOT NULL DEFAULT 0,
version_minor INT2 NOT NULL DEFAULT 0,
version_patch INT4 NOT NULL DEFAULT 0,
last_processing_time BIGINT DEFAULT 0,
last_visit BIGINT,
is_disabled BOOL DEFAULT FALSE,
processing_attempts INT2 DEFAULT 0,
processing_message STRING,
backup_info JSONB,
CONSTRAINT workspace_status_pk PRIMARY KEY (workspace_uuid),
CONSTRAINT workspace_status_workspace_fk FOREIGN KEY (workspace_uuid) REFERENCES ${ns}.workspace(uuid)
);
CREATE TABLE IF NOT EXISTS ${ns}.workspace_members (
workspace_uuid UUID NOT NULL,
account_uuid UUID NOT NULL,
role ${ns}.workspace_role NOT NULL DEFAULT 'USER',
CONSTRAINT workspace_assignment_pk PRIMARY KEY (workspace_uuid, account_uuid),
CONSTRAINT members_workspace_fk FOREIGN KEY (workspace_uuid) REFERENCES ${ns}.workspace(uuid),
CONSTRAINT members_account_fk FOREIGN KEY (account_uuid) REFERENCES ${ns}.account(uuid)
);
/* ========================================================================================== */
/* MAIN SCHEMA ENDS HERE */
/* ===================== */
/* ======= O T P ======= */
CREATE TABLE IF NOT EXISTS ${ns}.otp (
social_id STRING NOT NULL,
code STRING NOT NULL,
expires_on BIGINT NOT NULL,
created_on BIGINT NOT NULL DEFAULT current_epoch_ms(),
CONSTRAINT otp_pk PRIMARY KEY (social_id, code),
CONSTRAINT otp_social_id_fk FOREIGN KEY (social_id) REFERENCES ${ns}.social_id(key)
);
/* ======= I N V I T E ======= */
CREATE TABLE IF NOT EXISTS ${ns}.invite (
id INT8 NOT NULL DEFAULT unique_rowid(),
workspace_uuid UUID NOT NULL,
expires_on BIGINT NOT NULL,
email_pattern STRING,
remaining_uses INT2,
role ${ns}.workspace_role NOT NULL DEFAULT 'USER',
migrated_from STRING,
CONSTRAINT invite_pk PRIMARY KEY (id),
INDEX workspace_invite_idx (workspace_uuid),
INDEX migrated_from_idx (migrated_from),
CONSTRAINT invite_workspace_fk FOREIGN KEY (workspace_uuid) REFERENCES ${ns}.workspace(uuid)
);
`
]
}
function getV2Migration1 (ns: string): [string, string] {
return [
'account_db_v2_social_id_id_add',
`
-- Add _id column to social_id table
ALTER TABLE ${ns}.social_id
ADD COLUMN IF NOT EXISTS _id INT8 NOT NULL DEFAULT unique_rowid();
`
]
}
function getV2Migration2 (ns: string): [string, string] {
return [
'account_db_v2_social_id_pk_change',
`
-- Drop existing otp foreign key constraint
ALTER TABLE ${ns}.otp
DROP CONSTRAINT IF EXISTS otp_social_id_fk;
-- Drop existing primary key on social_id
ALTER TABLE ${ns}.social_id
DROP CONSTRAINT IF EXISTS social_id_pk;
-- Add new primary key on _id
ALTER TABLE ${ns}.social_id
ADD CONSTRAINT social_id_pk PRIMARY KEY (_id);
`
]
}
function getV2Migration3 (ns: string): [string, string] {
return [
'account_db_v2_social_id_constraints',
`
-- Add unique constraint on type, value
ALTER TABLE ${ns}.social_id
ADD CONSTRAINT social_id_tv_key_unique UNIQUE (type, value);
-- Drop old table
DROP TABLE ${ns}.otp;
-- Create new OTP table with correct column type
CREATE TABLE ${ns}.otp (
social_id INT8 NOT NULL,
code STRING NOT NULL,
expires_on BIGINT NOT NULL,
created_on BIGINT NOT NULL DEFAULT current_epoch_ms(),
CONSTRAINT otp_new_pk PRIMARY KEY (social_id, code),
CONSTRAINT otp_new_social_id_fk FOREIGN KEY (social_id) REFERENCES ${ns}.social_id(_id)
);
`
]
}
function getV3Migration (ns: string): [string, string] {
return [
'account_db_v3_add_invite_auto_join_final',
`
ALTER TABLE ${ns}.invite
ADD COLUMN IF NOT EXISTS email STRING,
ADD COLUMN IF NOT EXISTS auto_join BOOL DEFAULT FALSE;
ALTER TABLE ${ns}.account
ADD COLUMN IF NOT EXISTS automatic BOOL;
`
]
}
function getV4Migration (ns: string): [string, string] {
return [
'account_db_v4_mailbox',
`
CREATE TABLE IF NOT EXISTS ${ns}.mailbox (
account_uuid UUID NOT NULL,
mailbox STRING NOT NULL,
CONSTRAINT mailbox_pk PRIMARY KEY (mailbox),
CONSTRAINT mailbox_account_fk FOREIGN KEY (account_uuid) REFERENCES ${ns}.account(uuid)
);
CREATE TABLE IF NOT EXISTS ${ns}.mailbox_secrets (
mailbox STRING NOT NULL,
app STRING,
secret STRING NOT NULL,
CONSTRAINT mailbox_secret_mailbox_fk FOREIGN KEY (mailbox) REFERENCES ${ns}.mailbox(mailbox)
);
`
]
}
function getV4Migration1 (ns: string): [string, string] {
return [
'account_db_v4_remove_mailbox_account_fk',
`
ALTER TABLE ${ns}.mailbox
DROP CONSTRAINT IF EXISTS mailbox_account_fk;
`
]
}
function getV5Migration (ns: string): [string, string] {
return [
'account_db_v5_social_id_is_deleted',
`
ALTER TABLE ${ns}.social_id
ADD COLUMN IF NOT EXISTS is_deleted BOOL NOT NULL DEFAULT FALSE;
`
]
}
function getV6Migration (ns: string): [string, string] {
return [
'account_db_v6_add_social_id_integrations',
`
CREATE TABLE IF NOT EXISTS ${ns}.integrations (
social_id INT8 NOT NULL,
kind STRING NOT NULL,
workspace_uuid UUID,
_def_ws_uuid UUID NOT NULL GENERATED ALWAYS AS (COALESCE(workspace_uuid, '00000000-0000-0000-0000-000000000000')) STORED NOT VISIBLE,
data JSONB,
CONSTRAINT integrations_pk PRIMARY KEY (social_id, kind, _def_ws_uuid),
INDEX integrations_kind_idx (kind),
CONSTRAINT integrations_social_id_fk FOREIGN KEY (social_id) REFERENCES ${ns}.social_id(_id),
CONSTRAINT integrations_workspace_fk FOREIGN KEY (workspace_uuid) REFERENCES ${ns}.workspace(uuid)
);
CREATE TABLE IF NOT EXISTS ${ns}.integration_secrets (
social_id INT8 NOT NULL,
kind STRING NOT NULL,
workspace_uuid UUID,
_def_ws_uuid UUID NOT NULL GENERATED ALWAYS AS (COALESCE(workspace_uuid, '00000000-0000-0000-0000-000000000000')) STORED NOT VISIBLE,
key STRING,
secret STRING NOT NULL,
CONSTRAINT integration_secrets_pk PRIMARY KEY (social_id, kind, _def_ws_uuid, key)
);
`
]
}
function getV7Migration (ns: string): [string, string] {
return [
'account_db_v7_add_display_value',
`
ALTER TABLE ${ns}.social_id
ADD COLUMN IF NOT EXISTS display_value TEXT;
`
]
}
function getV8Migration (ns: string): [string, string] {
return [
'account_db_v8_add_account_max_workspaces',
`
ALTER TABLE ${ns}.account
ADD COLUMN IF NOT EXISTS max_workspaces SMALLINT;
`
]
}
function getV9Migration (ns: string): [string, string] {
return [
'account_db_v9_add_migrated_to_person',
`
ALTER TABLE ${ns}.person
ADD COLUMN IF NOT EXISTS migrated_to UUID,
ADD CONSTRAINT person_migrated_to_fk FOREIGN KEY (migrated_to) REFERENCES ${ns}.person(uuid);
`
]
}

View File

@ -23,6 +23,7 @@ import {
type AccountUuid
} from '@hcengineering/core'
import { getMigrations } from './migrations'
import type {
DbCollection,
Query,
@ -44,7 +45,7 @@ import type {
MailboxSecret,
Integration,
IntegrationSecret
} from '../types'
} from '../../types'
function toSnakeCase (str: string): string {
// Preserve leading underscore
@ -201,9 +202,14 @@ implements DbCollection<T> {
break
}
case '$ne': {
currIdx++
whereChunks.push(`"${snakeKey}" != ${formatVar(currIdx, castType)}`)
values.push(Object.values(qKey as object)[0])
const val = Object.values(qKey as object)[0]
if (val === null) {
whereChunks.push(`"${snakeKey}" IS NOT NULL`)
} else {
currIdx++
whereChunks.push(`"${snakeKey}" != ${formatVar(currIdx, castType)}`)
values.push(val)
}
break
}
default: {
@ -661,7 +667,7 @@ export class PostgresAccountDB implements AccountDB {
return res[0]?.role ?? null
}
async getWorkspaceRoles (accountUuid: AccountUuid): Promise<Map<WorkspaceUuid, AccountRole | null>> {
async getWorkspaceRoles (accountUuid: AccountUuid): Promise<Map<WorkspaceUuid, AccountRole>> {
const res = await this
.client`SELECT workspace_uuid, role FROM ${this.client(this.getWsMembersTableName())} WHERE account_uuid = ${accountUuid}`
@ -842,319 +848,6 @@ export class PostgresAccountDB implements AccountDB {
}
protected getMigrations (): [string, string][] {
return [
this.getV1Migration(),
this.getV2Migration1(),
this.getV2Migration2(),
this.getV2Migration3(),
this.getV3Migration(),
this.getV4Migration(),
this.getV4Migration1(),
this.getV5Migration(),
this.getV6Migration(),
this.getV7Migration(),
this.getV8Migration()
]
}
// NOTE: NEVER MODIFY EXISTING MIGRATIONS. IF YOU NEED TO ADJUST THE SCHEMA, ADD A NEW MIGRATION.
private getV1Migration (): [string, string] {
return [
'account_db_v1_global_init',
`
/* ======= FUNCTIONS ======= */
CREATE OR REPLACE FUNCTION current_epoch_ms()
RETURNS BIGINT AS $$
SELECT (extract(epoch from current_timestamp) * 1000)::bigint;
$$ LANGUAGE SQL;
/* ======= T Y P E S ======= */
CREATE TYPE IF NOT EXISTS ${this.ns}.social_id_type AS ENUM ('email', 'github', 'google', 'phone', 'oidc', 'huly', 'telegram');
CREATE TYPE IF NOT EXISTS ${this.ns}.location AS ENUM ('kv', 'weur', 'eeur', 'wnam', 'enam', 'apac');
CREATE TYPE IF NOT EXISTS ${this.ns}.workspace_role AS ENUM ('OWNER', 'MAINTAINER', 'USER', 'GUEST', 'DOCGUEST');
/* ======= P E R S O N ======= */
CREATE TABLE IF NOT EXISTS ${this.ns}.person (
uuid UUID NOT NULL DEFAULT gen_random_uuid(),
first_name STRING NOT NULL,
last_name STRING NOT NULL,
country STRING,
city STRING,
CONSTRAINT person_pk PRIMARY KEY (uuid)
);
/* ======= A C C O U N T ======= */
CREATE TABLE IF NOT EXISTS ${this.ns}.account (
uuid UUID NOT NULL,
timezone STRING,
locale STRING,
CONSTRAINT account_pk PRIMARY KEY (uuid),
CONSTRAINT account_person_fk FOREIGN KEY (uuid) REFERENCES ${this.ns}.person(uuid)
);
CREATE TABLE IF NOT EXISTS ${this.ns}.account_passwords (
account_uuid UUID NOT NULL,
hash BYTES NOT NULL,
salt BYTES NOT NULL,
CONSTRAINT account_auth_pk PRIMARY KEY (account_uuid),
CONSTRAINT account_passwords_account_fk FOREIGN KEY (account_uuid) REFERENCES ${this.ns}.account(uuid)
);
CREATE TABLE IF NOT EXISTS ${this.ns}.account_events (
account_uuid UUID NOT NULL,
event_type STRING NOT NULL,
time BIGINT NOT NULL DEFAULT current_epoch_ms(),
data JSONB,
CONSTRAINT account_events_pk PRIMARY KEY (account_uuid, event_type, time),
CONSTRAINT account_events_account_fk FOREIGN KEY (account_uuid) REFERENCES ${this.ns}.account(uuid)
);
/* ======= S O C I A L I D S ======= */
CREATE TABLE IF NOT EXISTS ${this.ns}.social_id (
type ${this.ns}.social_id_type NOT NULL,
value STRING NOT NULL,
key STRING AS (CONCAT(type::STRING, ':', value)) STORED,
person_uuid UUID NOT NULL,
created_on BIGINT NOT NULL DEFAULT current_epoch_ms(),
verified_on BIGINT,
CONSTRAINT social_id_pk PRIMARY KEY (type, value),
CONSTRAINT social_id_key_unique UNIQUE (key),
INDEX social_id_account_idx (person_uuid),
CONSTRAINT social_id_person_fk FOREIGN KEY (person_uuid) REFERENCES ${this.ns}.person(uuid)
);
/* ======= W O R K S P A C E ======= */
CREATE TABLE IF NOT EXISTS ${this.ns}.workspace (
uuid UUID NOT NULL DEFAULT gen_random_uuid(),
name STRING NOT NULL,
url STRING NOT NULL,
data_id STRING,
branding STRING,
location ${this.ns}.location,
region STRING,
created_by UUID, -- account uuid
created_on BIGINT NOT NULL DEFAULT current_epoch_ms(),
billing_account UUID,
CONSTRAINT workspace_pk PRIMARY KEY (uuid),
CONSTRAINT workspace_url_unique UNIQUE (url),
CONSTRAINT workspace_created_by_fk FOREIGN KEY (created_by) REFERENCES ${this.ns}.account(uuid),
CONSTRAINT workspace_billing_account_fk FOREIGN KEY (billing_account) REFERENCES ${this.ns}.account(uuid)
);
CREATE TABLE IF NOT EXISTS ${this.ns}.workspace_status (
workspace_uuid UUID NOT NULL,
mode STRING,
processing_progress INT2 DEFAULT 0,
version_major INT2 NOT NULL DEFAULT 0,
version_minor INT2 NOT NULL DEFAULT 0,
version_patch INT4 NOT NULL DEFAULT 0,
last_processing_time BIGINT DEFAULT 0,
last_visit BIGINT,
is_disabled BOOL DEFAULT FALSE,
processing_attempts INT2 DEFAULT 0,
processing_message STRING,
backup_info JSONB,
CONSTRAINT workspace_status_pk PRIMARY KEY (workspace_uuid),
CONSTRAINT workspace_status_workspace_fk FOREIGN KEY (workspace_uuid) REFERENCES ${this.ns}.workspace(uuid)
);
CREATE TABLE IF NOT EXISTS ${this.ns}.workspace_members (
workspace_uuid UUID NOT NULL,
account_uuid UUID NOT NULL,
role ${this.ns}.workspace_role NOT NULL DEFAULT 'USER',
CONSTRAINT workspace_assignment_pk PRIMARY KEY (workspace_uuid, account_uuid),
CONSTRAINT members_workspace_fk FOREIGN KEY (workspace_uuid) REFERENCES ${this.ns}.workspace(uuid),
CONSTRAINT members_account_fk FOREIGN KEY (account_uuid) REFERENCES ${this.ns}.account(uuid)
);
/* ========================================================================================== */
/* MAIN SCHEMA ENDS HERE */
/* ===================== */
/* ======= O T P ======= */
CREATE TABLE IF NOT EXISTS ${this.ns}.otp (
social_id STRING NOT NULL,
code STRING NOT NULL,
expires_on BIGINT NOT NULL,
created_on BIGINT NOT NULL DEFAULT current_epoch_ms(),
CONSTRAINT otp_pk PRIMARY KEY (social_id, code),
CONSTRAINT otp_social_id_fk FOREIGN KEY (social_id) REFERENCES ${this.ns}.social_id(key)
);
/* ======= I N V I T E ======= */
CREATE TABLE IF NOT EXISTS ${this.ns}.invite (
id INT8 NOT NULL DEFAULT unique_rowid(),
workspace_uuid UUID NOT NULL,
expires_on BIGINT NOT NULL,
email_pattern STRING,
remaining_uses INT2,
role ${this.ns}.workspace_role NOT NULL DEFAULT 'USER',
migrated_from STRING,
CONSTRAINT invite_pk PRIMARY KEY (id),
INDEX workspace_invite_idx (workspace_uuid),
INDEX migrated_from_idx (migrated_from),
CONSTRAINT invite_workspace_fk FOREIGN KEY (workspace_uuid) REFERENCES ${this.ns}.workspace(uuid)
);
`
]
}
private getV2Migration1 (): [string, string] {
return [
'account_db_v2_social_id_id_add',
`
-- Add _id column to social_id table
ALTER TABLE ${this.ns}.social_id
ADD COLUMN IF NOT EXISTS _id INT8 NOT NULL DEFAULT unique_rowid();
`
]
}
private getV2Migration2 (): [string, string] {
return [
'account_db_v2_social_id_pk_change',
`
-- Drop existing otp foreign key constraint
ALTER TABLE ${this.ns}.otp
DROP CONSTRAINT IF EXISTS otp_social_id_fk;
-- Drop existing primary key on social_id
ALTER TABLE ${this.ns}.social_id
DROP CONSTRAINT IF EXISTS social_id_pk;
-- Add new primary key on _id
ALTER TABLE ${this.ns}.social_id
ADD CONSTRAINT social_id_pk PRIMARY KEY (_id);
`
]
}
private getV2Migration3 (): [string, string] {
return [
'account_db_v2_social_id_constraints',
`
-- Add unique constraint on type, value
ALTER TABLE ${this.ns}.social_id
ADD CONSTRAINT social_id_tv_key_unique UNIQUE (type, value);
-- Drop old table
DROP TABLE ${this.ns}.otp;
-- Create new OTP table with correct column type
CREATE TABLE ${this.ns}.otp (
social_id INT8 NOT NULL,
code STRING NOT NULL,
expires_on BIGINT NOT NULL,
created_on BIGINT NOT NULL DEFAULT current_epoch_ms(),
CONSTRAINT otp_new_pk PRIMARY KEY (social_id, code),
CONSTRAINT otp_new_social_id_fk FOREIGN KEY (social_id) REFERENCES ${this.ns}.social_id(_id)
);
`
]
}
private getV3Migration (): [string, string] {
return [
'account_db_v3_add_invite_auto_join_final',
`
ALTER TABLE ${this.ns}.invite
ADD COLUMN IF NOT EXISTS email STRING,
ADD COLUMN IF NOT EXISTS auto_join BOOL DEFAULT FALSE;
ALTER TABLE ${this.ns}.account
ADD COLUMN IF NOT EXISTS automatic BOOL;
`
]
}
private getV4Migration (): [string, string] {
return [
'account_db_v4_mailbox',
`
CREATE TABLE IF NOT EXISTS ${this.ns}.mailbox (
account_uuid UUID NOT NULL,
mailbox STRING NOT NULL,
CONSTRAINT mailbox_pk PRIMARY KEY (mailbox),
CONSTRAINT mailbox_account_fk FOREIGN KEY (account_uuid) REFERENCES ${this.ns}.account(uuid)
);
CREATE TABLE IF NOT EXISTS ${this.ns}.mailbox_secrets (
mailbox STRING NOT NULL,
app STRING,
secret STRING NOT NULL,
CONSTRAINT mailbox_secret_mailbox_fk FOREIGN KEY (mailbox) REFERENCES ${this.ns}.mailbox(mailbox)
);
`
]
}
private getV4Migration1 (): [string, string] {
return [
'account_db_v4_remove_mailbox_account_fk',
`
ALTER TABLE ${this.ns}.mailbox
DROP CONSTRAINT IF EXISTS mailbox_account_fk;
`
]
}
private getV5Migration (): [string, string] {
return [
'account_db_v5_social_id_is_deleted',
`
ALTER TABLE ${this.ns}.social_id
ADD COLUMN IF NOT EXISTS is_deleted BOOL NOT NULL DEFAULT FALSE;
`
]
}
private getV6Migration (): [string, string] {
return [
'account_db_v6_add_social_id_integrations',
`
CREATE TABLE IF NOT EXISTS ${this.ns}.integrations (
social_id INT8 NOT NULL,
kind STRING NOT NULL,
workspace_uuid UUID,
_def_ws_uuid UUID NOT NULL GENERATED ALWAYS AS (COALESCE(workspace_uuid, '00000000-0000-0000-0000-000000000000')) STORED NOT VISIBLE,
data JSONB,
CONSTRAINT integrations_pk PRIMARY KEY (social_id, kind, _def_ws_uuid),
INDEX integrations_kind_idx (kind),
CONSTRAINT integrations_social_id_fk FOREIGN KEY (social_id) REFERENCES ${this.ns}.social_id(_id),
CONSTRAINT integrations_workspace_fk FOREIGN KEY (workspace_uuid) REFERENCES ${this.ns}.workspace(uuid)
);
CREATE TABLE IF NOT EXISTS ${this.ns}.integration_secrets (
social_id INT8 NOT NULL,
kind STRING NOT NULL,
workspace_uuid UUID,
_def_ws_uuid UUID NOT NULL GENERATED ALWAYS AS (COALESCE(workspace_uuid, '00000000-0000-0000-0000-000000000000')) STORED NOT VISIBLE,
key STRING,
secret STRING NOT NULL,
CONSTRAINT integration_secrets_pk PRIMARY KEY (social_id, kind, _def_ws_uuid, key)
);
`
]
}
private getV7Migration (): [string, string] {
return [
'account_db_v7_add_display_value',
`
ALTER TABLE ${this.ns}.social_id
ADD COLUMN IF NOT EXISTS display_value TEXT;
`
]
}
private getV8Migration (): [string, string] {
return [
'account_db_v8_add_account_max_workspaces',
`
ALTER TABLE ${this.ns}.account
ADD COLUMN IF NOT EXISTS max_workspaces SMALLINT;
`
]
return getMigrations(this.ns)
}
}

View File

@ -1827,7 +1827,6 @@ export type AccountMethods =
| 'findPersonBySocialKey'
| 'findPersonBySocialId'
| 'findSocialIdBySocialKey'
| 'findFullSocialIdBySocialKey'
| 'ensurePerson'
| 'exchangeGuestToken'
| 'getMailboxOptions'

View File

@ -64,7 +64,9 @@ import {
getWorkspaces,
updateWorkspaceRole,
getPersonName,
doReleaseSocialId
doReleaseSocialId,
doMergeAccounts,
doMergePersons
} from './utils'
// Note: it is IMPORTANT to always destructure params passed here to avoid sending extra params
@ -528,7 +530,7 @@ export async function releaseSocialId (
): Promise<void> {
const { extra } = decodeTokenVerbose(ctx, token)
verifyAllowedServices(['github'], extra)
verifyAllowedServices(['github', 'tool', 'workspace'], extra)
const { personUuid, type, value } = params
@ -549,7 +551,7 @@ export async function addSocialIdToPerson (
const { person, type, value, confirmed, displayValue } = params
const { extra } = decodeTokenVerbose(ctx, token)
verifyAllowedServices(['github', 'telegram-bot', 'gmail'], extra)
verifyAllowedServices(['github', 'telegram-bot', 'gmail', 'tool', 'workspace'], extra)
return await addSocialId(db, person, type, value, confirmed, displayValue)
}
@ -815,13 +817,47 @@ export async function findFullSocialIdBySocialKey (
params: { socialKey: string }
): Promise<SocialId | null> {
const { extra } = decodeTokenVerbose(ctx, token)
verifyAllowedServices(['telegram-bot', 'gmail'], extra)
verifyAllowedServices(['telegram-bot', 'gmail', 'tool', 'workspace'], extra)
const { socialKey } = params
return await db.socialId.findOne({ key: socialKey })
}
export async function mergeSpecifiedPersons (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
primaryPerson: PersonUuid
secondaryPerson: PersonUuid
}
): Promise<void> {
const { extra } = decodeTokenVerbose(ctx, token)
verifyAllowedServices(['tool', 'workspace'], extra)
const { primaryPerson, secondaryPerson } = params
await doMergePersons(db, primaryPerson, secondaryPerson)
}
export async function mergeSpecifiedAccounts (
ctx: MeasureContext,
db: AccountDB,
branding: Branding | null,
token: string,
params: {
primaryAccount: AccountUuid
secondaryAccount: AccountUuid
}
): Promise<void> {
const { extra } = decodeTokenVerbose(ctx, token)
verifyAllowedServices(['tool', 'workspace'], extra)
const { primaryAccount, secondaryAccount } = params
await doMergeAccounts(db, primaryAccount, secondaryAccount)
}
export type AccountServiceMethods =
| 'getPendingWorkspace'
| 'updateWorkspaceInfo'
@ -846,6 +882,8 @@ export type AccountServiceMethods =
| 'getIntegrationSecret'
| 'listIntegrationsSecrets'
| 'findFullSocialIdBySocialKey'
| 'mergeSpecifiedPersons'
| 'mergeSpecifiedAccounts'
/**
* @public
@ -874,6 +912,8 @@ export function getServiceMethods (): Partial<Record<AccountServiceMethods, Acco
deleteIntegrationSecret: wrap(deleteIntegrationSecret),
getIntegrationSecret: wrap(getIntegrationSecret),
listIntegrationsSecrets: wrap(listIntegrationsSecrets),
findFullSocialIdBySocialKey: wrap(findFullSocialIdBySocialKey)
findFullSocialIdBySocialKey: wrap(findFullSocialIdBySocialKey),
mergeSpecifiedPersons: wrap(mergeSpecifiedPersons),
mergeSpecifiedAccounts: wrap(mergeSpecifiedAccounts)
}
}

View File

@ -23,7 +23,7 @@ import {
WorkspaceMemberInfo,
WorkspaceMode,
type AccountUuid,
type Person,
type Person as BasePerson,
type PersonId,
type PersonUuid,
type SocialId as SocialIdBase,
@ -42,7 +42,10 @@ export enum Location {
}
// AccountRole in core
// Person in core
export interface Person extends BasePerson {
migratedTo?: PersonUuid
}
export interface SocialId extends SocialIdBase {
personUuid: PersonUuid
@ -201,7 +204,7 @@ export interface AccountDB {
updateWorkspaceRole: (accountId: AccountUuid, workspaceId: WorkspaceUuid, role: AccountRole) => Promise<void>
unassignWorkspace: (accountId: AccountUuid, workspaceId: WorkspaceUuid) => Promise<void>
getWorkspaceRole: (accountId: AccountUuid, workspaceId: WorkspaceUuid) => Promise<AccountRole | null>
getWorkspaceRoles: (accountId: AccountUuid) => Promise<Map<WorkspaceUuid, AccountRole | null>>
getWorkspaceRoles: (accountId: AccountUuid) => Promise<Map<WorkspaceUuid, AccountRole>>
getWorkspaceMembers: (workspaceId: WorkspaceUuid) => Promise<WorkspaceMemberInfo[]>
getAccountWorkspaces: (accountId: AccountUuid) => Promise<WorkspaceInfoWithStatus[]>
getPendingWorkspace: (
@ -237,6 +240,7 @@ export interface QueryOperator<T> {
$lte?: T
$gt?: T
$gte?: T
$ne?: T | null
}
export type Operations<T> = Partial<T> & {

View File

@ -42,7 +42,7 @@ import { Analytics } from '@hcengineering/analytics'
import { sharedPipelineContextVars } from '@hcengineering/server-pipeline'
import { decodeTokenVerbose, generateToken, TokenError } from '@hcengineering/server-token'
import { MongoAccountDB } from './collections/mongo'
import { PostgresAccountDB } from './collections/postgres'
import { PostgresAccountDB } from './collections/postgres/postgres'
import { accountPlugin } from './plugin'
import {
AccountEventType,
@ -1521,3 +1521,79 @@ export async function findExistingIntegration (
return await db.integration.findOne({ socialId, kind, workspaceUuid })
}
export async function doMergePersons (
db: AccountDB,
primaryPerson: PersonUuid,
secondaryPerson: PersonUuid
): Promise<void> {
if (primaryPerson === secondaryPerson) {
// Nothing to do
return
}
const primaryPersonObj = await db.person.findOne({ uuid: primaryPerson })
if (primaryPersonObj == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.PersonNotFound, { person: primaryPerson }))
}
const secondaryPersonObj = await db.person.findOne({ uuid: secondaryPerson })
if (secondaryPersonObj == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.PersonNotFound, { person: secondaryPerson }))
}
// Merge social ids. Re-wire the secondary account social ids to the primary account.
// Keep their ids. This way all PersonIds inside the workspaces will remain the same.
const secondarySocialIds = await db.socialId.find({ personUuid: secondaryPerson })
for (const secondarySocialId of secondarySocialIds) {
await db.socialId.updateOne(
{ _id: secondarySocialId._id, personUuid: secondaryPerson },
{ personUuid: primaryPerson }
)
}
await db.person.updateOne({ uuid: secondaryPerson }, { migratedTo: primaryPerson })
}
export async function doMergeAccounts (
db: AccountDB,
primaryAccount: AccountUuid,
secondaryAccount: AccountUuid
): Promise<void> {
if (primaryAccount === secondaryAccount) {
// Nothing to do
return
}
const primaryAccountObj = await db.account.findOne({ uuid: primaryAccount })
if (primaryAccountObj == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: primaryAccount }))
}
const secondaryAccountObj = await db.account.findOne({ uuid: secondaryAccount })
if (secondaryAccountObj == null) {
throw new PlatformError(new Status(Severity.ERROR, platform.status.AccountNotFound, { account: secondaryAccount }))
}
await doMergePersons(db, primaryAccount, secondaryAccount)
// Workspace assignments. Assign primary account to all workspaces of the secondary account.
const secondaryWorkspacesRoles = await db.getWorkspaceRoles(secondaryAccount)
for (const [workspaceUuid, role] of secondaryWorkspacesRoles) {
await db.unassignWorkspace(secondaryAccount, workspaceUuid)
const primaryRole = await db.getWorkspaceRole(primaryAccount, workspaceUuid)
if (primaryRole != null) {
if (getRolePower(primaryRole) < getRolePower(role)) {
await db.updateWorkspaceRole(primaryAccount, workspaceUuid, role)
}
} else {
await db.assignWorkspace(primaryAccount, workspaceUuid, role)
}
}
// Merge passwords. Keep the primary account password if exists, otherwise take the secondary account password.
if (primaryAccountObj.hash == null && secondaryAccountObj.hash != null && secondaryAccountObj.salt != null) {
await db.setPassword(primaryAccount, secondaryAccountObj.hash, secondaryAccountObj.salt)
}
}