mirror of
https://github.com/hcengineering/platform.git
synced 2025-06-09 09:20:54 +00:00
uberf-10455: merge accounts for merged persons
Signed-off-by: Alexey Zinoviev <alexey.zinoviev@xored.com>
This commit is contained in:
parent
6dd9fbfb33
commit
050b0fe433
@ -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
|
||||
|
@ -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)
|
||||
|
@ -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',
|
||||
|
@ -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)
|
||||
}
|
||||
|
@ -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' })
|
||||
|
@ -95,6 +95,7 @@ export interface Integration {
|
||||
|
||||
export interface SocialId extends SocialIdBase {
|
||||
personUuid: PersonUuid
|
||||
isDeleted?: boolean
|
||||
}
|
||||
|
||||
export type IntegrationKey = Omit<Integration, 'data'>
|
||||
|
@ -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
|
||||
}
|
||||
|
||||
|
@ -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 {
|
||||
|
@ -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())
|
||||
|
343
server/account/src/collections/postgres/migrations.ts
Normal file
343
server/account/src/collections/postgres/migrations.ts
Normal 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);
|
||||
`
|
||||
]
|
||||
}
|
@ -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)
|
||||
}
|
||||
}
|
@ -1827,7 +1827,6 @@ export type AccountMethods =
|
||||
| 'findPersonBySocialKey'
|
||||
| 'findPersonBySocialId'
|
||||
| 'findSocialIdBySocialKey'
|
||||
| 'findFullSocialIdBySocialKey'
|
||||
| 'ensurePerson'
|
||||
| 'exchangeGuestToken'
|
||||
| 'getMailboxOptions'
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
@ -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> & {
|
||||
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user