feat: import v4 jsons (settings wip)

This commit is contained in:
diced
2025-12-08 01:07:15 -08:00
parent 9da74054ff
commit 5d27c14b77
8 changed files with 831 additions and 40 deletions

View File

@@ -1,10 +1,31 @@
import { ModalsProvider } from '@mantine/modals';
import { ContextModalProps, ModalsProvider } from '@mantine/modals';
import { Notifications } from '@mantine/notifications';
import { Outlet } from 'react-router-dom';
import { SWRConfig } from 'swr';
import ThemeProvider from '@/components/ThemeProvider';
import { type ZiplineTheme } from '@/lib/theme';
import { type Config } from '@/lib/config/validate';
import { Button, Text } from '@mantine/core';
const AlertModal = ({ context, id, innerProps }: ContextModalProps<{ modalBody: string }>) => (
<>
<Text size='sm'>{innerProps.modalBody}</Text>
<Button fullWidth mt='md' onClick={() => context.closeModal(id)}>
OK
</Button>
</>
);
const contextModals = {
alert: AlertModal,
};
declare module '@mantine/modals' {
export interface MantineModalsOverride {
modals: typeof contextModals;
}
}
export default function Root({
themes,
@@ -37,6 +58,7 @@ export default function Root({
},
centered: true,
}}
modals={contextModals}
>
<Notifications zIndex={10000000} />
<Outlet />

View File

@@ -21,7 +21,6 @@ import {
IconFolder,
IconGraphFilled,
IconLink,
IconSettings,
IconTag,
IconTagPlus,
IconTarget,
@@ -66,18 +65,9 @@ export default function Export3Details({ export4 }: { export4: Export4 }) {
</Table.Tr>
));
const settingsRows = Object.entries(export4.data.settings)
.filter(([key]) => !['id', 'createdAt', 'updatedAt'].includes(key))
.map(([key, value]) => (
<Table.Tr key={key}>
<Table.Td ff='monospace'>{key}</Table.Td>
<Table.Td ff='monospace'>{String(value)}</Table.Td>
</Table.Tr>
));
const userRows = export4.data.users.map((user, i) => (
<Table.Tr key={i}>
<Table.Td>{user.avatar ? <Avatar src={user.avatar} size={24} /> : ''}</Table.Td>
<Table.Td>{user.avatar ? <Avatar src={user.avatar} size={24} radius='sm' /> : ''}</Table.Td>
<Table.Td>{user.id}</Table.Td>
<Table.Td>{user.username}</Table.Td>
<Table.Td>{user.password ? <IconCheck size='1rem' /> : <IconX size='1rem' />}</Table.Td>
@@ -467,23 +457,6 @@ export default function Export3Details({ export4 }: { export4: Export4 }) {
</Stack>
</Accordion.Panel>
</Accordion.Item>
<Accordion.Item value='settings'>
<Accordion.Control icon={<IconSettings size='1rem' />}>Settings</Accordion.Control>
<Accordion.Panel>
<Paper withBorder>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th>Key</Table.Th>
<Table.Th>Value</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>{settingsRows}</Table.Tbody>
</Table>
</Paper>
</Accordion.Panel>
</Accordion.Item>
</Accordion>
</>
);

View File

@@ -0,0 +1,77 @@
import { Export4 } from '@/lib/import/version4/validateExport';
import { Box, Button, Checkbox, Code, Collapse, Group, Paper, Table, Text } from '@mantine/core';
import { useDisclosure } from '@mantine/hooks';
export default function Export4ImportSettings({
export4,
setImportSettings,
importSettings,
}: {
export4: Export4;
setImportSettings: (importSettings: boolean) => void;
importSettings: boolean;
}) {
const [showSettings, { toggle: toggleSettings }] = useDisclosure(false);
const filteredSettings = Object.fromEntries(
Object.entries(export4.data.settings).filter(
([key, _value]) => !['createdAt', 'updatedAt', 'id'].includes(key),
),
);
return (
<Box my='lg'>
<Text size='md'>Import settings?</Text>
<Text size='sm' c='dimmed'>
Import all settings from your previous instance into this v4 instance.
<br />
After importing, it is recommended to restart Zipline for all settings to take full effect.
</Text>
<Button my='xs' onClick={toggleSettings} size='compact-xs'>
{showSettings ? 'Hide' : 'Show'} Settings to be Imported
</Button>
<Collapse in={showSettings}>
<Paper withBorder>
<Table>
<Table.Thead>
<Table.Tr>
<Table.Th w={300}>Key</Table.Th>
<Table.Th>Value</Table.Th>
</Table.Tr>
</Table.Thead>
<Table.Tbody>
{Object.entries(filteredSettings).map(([key, value]) => (
<Table.Tr key={key}>
<Table.Td ff='monospace'>{key}</Table.Td>
<Table.Td>
<Text c='dimmed' fz='xs' ff='monospace'>
{JSON.stringify(value)}
</Text>
</Table.Td>
</Table.Tr>
))}
</Table.Tbody>
</Table>
</Paper>
<Button my='xs' onClick={toggleSettings} size='compact-xs'>
{showSettings ? 'Hide' : 'Show'} Settings to be Imported
</Button>
</Collapse>
<Checkbox.Card
checked={importSettings}
onClick={() => setImportSettings(!importSettings)}
radius='md'
my='sm'
>
<Group wrap='nowrap' align='flex-start'>
<Checkbox.Indicator m='md' />
<Text my='sm'>Import {Object.keys(filteredSettings).length} settings</Text>
</Group>
</Checkbox.Card>
</Box>
);
}

View File

@@ -0,0 +1,59 @@
import { Export4 } from '@/lib/import/version4/validateExport';
import { Avatar, Box, Group, Radio, Stack, Text } from '@mantine/core';
export default function Export4UserChoose({
export4,
setImportFrom,
importFrom,
}: {
export4: Export4;
setImportFrom: (importFrom: string) => void;
importFrom: string;
}) {
return (
<Box my='lg'>
<Text size='md'>Select a user to import data from into the current user.</Text>
<Text size='sm' c='dimmed'>
This option allows you to import data from a user in your export into the currently logged-in user,
even if both have the same username. Normally, the system skips importing users with usernames that
already exist in the system. <br /> <br /> <b>However</b>, if you&apos;ve just set up your instance
and reused the same username as your old instance, this option enables you to merge data from that
user into your logged-in account without needing to delete or replace it.{' '}
<b>It is recommended to select a user with super-administrator permissions for this operation.</b>
</Text>
<Radio.Group value={importFrom} onChange={(value) => setImportFrom(value)} name='importFrom'>
{export4.data.users.map((user, i) => (
<Radio.Card key={i} value={user.id} my='sm'>
<Group wrap='nowrap' align='flex-start'>
<Radio.Indicator m='md' />
{user.avatar && <Avatar my='md' src={user.avatar} alt={user.username} radius='sm' />}
<Stack gap={0}>
<Text my='sm'>
{user.username} ({user.id})
</Text>{' '}
{user.role === 'SUPERADMIN' && (
<Text c='red' size='xs' mb='xs'>
Super Administrator
</Text>
)}
</Stack>
</Group>
</Radio.Card>
))}
<Radio.Card value='' my='sm'>
<Group wrap='nowrap' align='flex-start'>
<Radio.Indicator m='md' />
<Stack gap={0}>
<Text my='sm'>Do not merge data</Text>{' '}
<Text c='dimmed' size='xs' mb='xs'>
Select this option if you do not want to merge data from any user into the current user.
</Text>
</Stack>
</Group>
</Radio.Card>
</Radio.Group>
</Box>
);
}

View File

@@ -0,0 +1,51 @@
import { Export4 } from '@/lib/import/version4/validateExport';
import { useUserStore } from '@/lib/store/user';
import { Box, Checkbox, Group, Text } from '@mantine/core';
export function detectSameInstance(export4?: Export4 | null, currentUserId?: string) {
if (!export4) return false;
if (!currentUserId) return false;
const idInExport = export4.data.users.find((user) => user.id === currentUserId);
return !!idInExport;
}
export default function Export4WarningSameInstance({
export4,
sameInstanceAgree,
setSameInstanceAgree,
}: {
export4: Export4;
sameInstanceAgree: boolean;
setSameInstanceAgree: (sameInstanceAgree: boolean) => void;
}) {
const currentUserId = useUserStore((state) => state.user?.id);
const isSameInstance = detectSameInstance(export4, currentUserId);
if (!isSameInstance) return null;
return (
<Box my='lg'>
<Text size='md' c='red'>
Same Instance Detected
</Text>
<Text size='sm' c='dimmed'>
Detected that you are importing data from the same instance as the current running one. Proceeding
with this import may lead to data conflicts or overwriting existing data. Please ensure that you
understand the implications before continuing.
</Text>
<Checkbox.Card
checked={sameInstanceAgree}
onClick={() => setSameInstanceAgree(!sameInstanceAgree)}
radius='md'
my='sm'
>
<Group wrap='nowrap' align='flex-start'>
<Checkbox.Indicator m='md' />
<Text my='sm'>I agree, and understand the implications.</Text>
</Group>
</Checkbox.Card>
</Box>
);
}

View File

@@ -1,14 +1,27 @@
import { Export4, validateExport } from '@/lib/import/version4/validateExport';
import { Button, FileButton, Modal, Pill } from '@mantine/core';
import { showNotification } from '@mantine/notifications';
import { Button, FileButton, Modal, Pill, Text } from '@mantine/core';
import { showNotification, updateNotification } from '@mantine/notifications';
import { IconDatabaseImport, IconDatabaseOff, IconUpload, IconX } from '@tabler/icons-react';
import { useEffect, useState } from 'react';
import Export4Details from './Export4Details';
import Export4ImportSettings from './Export4ImportSettings';
import Export4WarningSameInstance, { detectSameInstance } from './Export4WarningSameInstance';
import Export4UserChoose from './Export4UserChoose';
import { useUserStore } from '@/lib/store/user';
import { modals } from '@mantine/modals';
import { fetchApi } from '@/lib/fetchApi';
import { Response } from '@/lib/api/response';
export default function ImportV4Button() {
const [open, setOpen] = useState(false);
const [file, setFile] = useState<File | null>(null);
const [export4, setExport4] = useState<Export4 | null>(null);
const [importSettings, setImportSettings] = useState(true);
const [sameInstanceAgree, setSameInstanceAgree] = useState(false);
const [importFrom, setImportFrom] = useState('');
const currentUserId = useUserStore((state) => state.user?.id);
const isSameInstance = detectSameInstance(export4, currentUserId);
const onContent = (content: string) => {
if (!content) return console.error('no content');
@@ -39,6 +52,118 @@ export default function ImportV4Button() {
setExport4(validated.data);
};
const handleImport = async () => {
if (!export4) return;
if (isSameInstance && !sameInstanceAgree) {
modals.openContextModal({
modal: 'alert',
title: 'Same Instance Detected',
innerProps: {
modalBody:
'Detected that you are importing data from the same instance as the current running one. You must agree to the warning before proceeding with the import.',
},
});
return;
}
modals.openConfirmModal({
title: 'Are you sure?',
children:
'This process will NOT overwrite existing data but will append to it. In case of conflicts, the imported data will be skipped and logged.',
labels: {
confirm: 'Yes, import data.',
cancel: 'Cancel',
},
onConfirm: async () => {
showNotification({
title: 'Importing data...',
message:
'The export file will be uploaded. This amy take a few moments. The import is running in the background and is logged, so you can close this browser tab if you want.',
color: 'blue',
autoClose: 5000,
id: 'importing-data',
loading: true,
});
setOpen(false);
const { error, data } = await fetchApi<Response['/api/server/import/v4']>(
'/api/server/import/v4',
'POST',
{
export4,
config: {
settings: importSettings,
mergeCurrentUser: importFrom === '' ? undefined : importFrom,
},
},
);
if (error) {
updateNotification({
title: 'Failed to import data...',
message:
error.error ?? 'An error occurred while importing data. Check the logs for more details.',
color: 'red',
icon: <IconDatabaseOff size='1rem' />,
id: 'importing-data',
autoClose: 10000,
});
} else {
if (!data) return;
modals.open({
title: 'Import Completed.',
children: (
<Text size='md'>
The import has been completed. To make sure files are properly viewable, make sure that you
have configured the datasource correctly to match your previous instance. For example, if you
were using local storage before, make sure to set it to the same directory (or same backed up
directory) as before. If you are using S3, make sure you are using the same bucket. <br />{' '}
<br />
Additionally, it is recommended to restart Zipline to ensure all settings take full effect.
<br /> <br />
<b>Users: </b>
{data.imported.users} imported.
<br />
<b>OAuth Providers: </b>
{data.imported.oauthProviders} imported.
<br />
<b>Quotas: </b>
{data.imported.quotas} imported.
<br />
<b>Passkeys: </b>
{data.imported.passkeys} imported.
<br />
<b>Folders: </b>
{data.imported.folders} imported.
<br />
<b>Files: </b>
{data.imported.files} imported.
<br />
<b>Tags: </b>
{data.imported.tags} imported.
<br />
<b>URLs: </b>
{data.imported.urls} imported.
<br />
<b>Invites: </b>
{data.imported.invites} imported.
<br />
<b>Metrics: </b>
{data.imported.metrics} imported.
</Text>
),
});
}
},
});
setFile(null);
setExport4(null);
};
useEffect(() => {
if (!open) return;
if (!file) return;
@@ -90,11 +215,22 @@ export default function ImportV4Button() {
{file && export4 && (
<>
<Export4Details export4={export4} />
<Export4ImportSettings
export4={export4}
importSettings={importSettings}
setImportSettings={setImportSettings}
/>
<Export4UserChoose export4={export4} importFrom={importFrom} setImportFrom={setImportFrom} />
<Export4WarningSameInstance
export4={export4}
sameInstanceAgree={sameInstanceAgree}
setSameInstanceAgree={setSameInstanceAgree}
/>
</>
)}
{export4 && (
<Button fullWidth leftSection={<IconDatabaseImport size='1rem' />} mt='xs'>
<Button onClick={handleImport} fullWidth leftSection={<IconDatabaseImport size='1rem' />} mt='xs'>
Import Data
</Button>
)}

View File

@@ -11,6 +11,7 @@ import { ApiServerClearTempResponse } from '@/server/routes/api/server/clear_tem
import { ApiServerClearZerosResponse } from '@/server/routes/api/server/clear_zeros';
import { ApiServerFolderResponse } from '@/server/routes/api/server/folder';
import { ApiServerImportV3 } from '@/server/routes/api/server/import/v3';
import { ApiServerImportV4 } from '@/server/routes/api/server/import/v4';
import { ApiServerPublicResponse } from '@/server/routes/api/server/public';
import { ApiServerRequerySizeResponse } from '@/server/routes/api/server/requery_size';
import { ApiServerSettingsResponse, ApiServerSettingsWebResponse } from '@/server/routes/api/server/settings';
@@ -83,6 +84,7 @@ export type Response = {
'/api/server/themes': ApiServerThemesResponse;
'/api/server/thumbnails': ApiServerThumbnailsResponse;
'/api/server/import/v3': ApiServerImportV3;
'/api/server/import/v4': ApiServerImportV4;
'/api/healthcheck': ApiHealthcheckResponse;
'/api/setup': ApiSetupResponse;
'/api/upload': ApiUploadResponse;

View File

@@ -1,3 +1,5 @@
import { createToken } from '@/lib/crypto';
import { prisma } from '@/lib/db';
import { Export4, validateExport } from '@/lib/import/version4/validateExport';
import { log } from '@/lib/logger';
import { secondlyRatelimit } from '@/lib/ratelimits';
@@ -6,17 +8,27 @@ import { userMiddleware } from '@/server/middleware/user';
import fastifyPlugin from 'fastify-plugin';
export type ApiServerImportV4 = {
users: Record<string, string>;
files: Record<string, string>;
folders: Record<string, string>;
urls: Record<string, string>;
settings: string[];
imported: {
users: number;
oauthProviders: number;
quotas: number;
passkeys: number;
folders: number;
files: number;
tags: number;
urls: number;
invites: number;
metrics: number;
};
};
type Body = {
export4: Export4;
importFromUser?: string;
config: {
settings: boolean;
mergeCurrentUser: string | null;
};
};
const logger = log('api').c('server').c('import').c('v4');
@@ -35,7 +47,7 @@ export default fastifyPlugin(
async (req, res) => {
if (req.user.role !== 'SUPERADMIN') return res.forbidden('not super admin');
const { export4 } = req.body;
const { export4, config: importConfig } = req.body;
if (!export4) return res.badRequest('missing export4 in request body');
const validated = validateExport(export4);
@@ -49,7 +61,466 @@ export default fastifyPlugin(
});
}
return res.send({ message: 'Import v4 is not yet implemented' });
// users
const importedUsers: Record<string, string> = {};
for (const user of export4.data.users) {
let mergeCurrent = false;
if (importConfig.mergeCurrentUser && user.id === importConfig.mergeCurrentUser) {
logger.info('importing to current user', {
from: user.id,
to: req.user.id,
});
mergeCurrent = true;
}
const existing = await prisma.user.findFirst({
where: {
OR: [{ username: user.username }, { id: user.id }],
},
});
if (!mergeCurrent && existing) {
logger.warn('user already exists with a username or id, skipping importing', {
id: user.id,
conflict: existing.id,
});
continue;
}
if (mergeCurrent) {
const updated = await prisma.user.update({
where: {
id: req.user.id,
},
data: {
avatar: user.avatar ?? null,
totpSecret: user.totpSecret ?? null,
view: user.view as any,
},
});
importedUsers[user.id] = updated.id;
continue;
}
const created = await prisma.user.create({
data: {
username: user.username,
password: user.password ?? null,
avatar: user.avatar ?? null,
role: user.role,
view: user.view as any,
totpSecret: user.totpSecret ?? null,
token: createToken(),
createdAt: new Date(user.createdAt),
},
});
importedUsers[user.id] = created.id;
}
logger.debug('imported users', { users: importedUsers });
// oauth providers from users
const importedOauthProviders: Record<string, string> = {};
for (const oauthProvider of export4.data.userOauthProviders) {
const userId = importedUsers[oauthProvider.userId];
if (!userId) {
logger.warn('failed to find user for oauth provider, skipping', {
provider: oauthProvider.id,
user: oauthProvider.userId,
});
continue;
}
const existing = await prisma.oAuthProvider.findFirst({
where: {
provider: oauthProvider.provider,
oauthId: oauthProvider.oauthId,
},
});
if (existing) {
logger.warn('oauth provider already exists, skipping importing', {
id: oauthProvider.id,
conflict: existing.id,
});
continue;
}
const created = await prisma.oAuthProvider.create({
data: {
provider: oauthProvider.provider,
oauthId: oauthProvider.oauthId,
username: oauthProvider.username,
accessToken: oauthProvider.accessToken,
refreshToken: oauthProvider.refreshToken ?? null,
userId,
},
});
importedOauthProviders[oauthProvider.id] = created.id;
}
logger.debug('imported oauth providers', { oauthProviders: importedOauthProviders });
// quotas from users
const importedQuotas: Record<string, string> = {};
for (const quota of export4.data.userQuotas) {
const userId = importedUsers[quota.userId ?? ''];
if (!userId) {
logger.warn('failed to find user for quota, skipping', {
quota: quota.id,
user: quota.userId,
});
continue;
}
const existing = await prisma.userQuota.findFirst({
where: {
userId,
},
});
if (existing) {
logger.warn('quota already exists for user, skipping importing', {
id: quota.id,
conflict: existing.id,
});
continue;
}
const created = await prisma.userQuota.create({
data: {
filesQuota: quota.filesQuota,
maxBytes: quota.maxBytes ?? null,
maxFiles: quota.maxFiles ?? null,
maxUrls: quota.maxUrls ?? null,
userId,
createdAt: new Date(quota.createdAt),
},
});
importedQuotas[quota.id] = created.id;
}
logger.debug('imported quotas', { quotas: importedQuotas });
const importedPasskeys: Record<string, string> = {};
for (const passkey of export4.data.userPasskeys) {
const userId = importedUsers[passkey.userId];
if (!userId) {
logger.warn('failed to find user for passkey, skipping', {
passkey: passkey.id,
user: passkey.userId,
});
continue;
}
const existing = await prisma.userPasskey.findFirst({
where: {
name: passkey.name,
userId,
},
});
if (existing) {
logger.warn('passkey already exists for user, skipping importing', {
id: passkey.id,
conflict: existing.id,
});
continue;
}
const created = await prisma.userPasskey.create({
data: {
name: passkey.name,
reg: passkey.reg as any,
userId,
},
});
importedPasskeys[passkey.id] = created.id;
}
logger.debug('imported passkeys', { passkeys: importedPasskeys });
// folders
const importedFolders: Record<string, string> = {};
for (const folder of export4.data.folders) {
const userId = importedUsers[folder.userId ?? ''];
if (!userId) {
logger.warn('failed to find user for folder, skipping', {
folder: folder.id,
user: folder.userId,
});
continue;
}
const existing = await prisma.folder.findFirst({
where: {
name: folder.name,
userId,
},
});
if (existing) {
logger.warn('folder already exists, skipping importing', {
id: folder.id,
conflict: existing.id,
});
continue;
}
const created = await prisma.folder.create({
data: {
userId,
name: folder.name,
allowUploads: folder.allowUploads,
public: folder.public,
createdAt: new Date(folder.createdAt),
},
});
importedFolders[folder.id] = created.id;
}
// files
const importedFiles: Record<string, string> = {};
for (const file of export4.data.files) {
const userId = importedUsers[file.userId ?? ''];
if (!userId) {
logger.warn('failed to find user for file, skipping', {
file: file.id,
user: file.userId,
});
continue;
}
const existing = await prisma.file.findFirst({
where: {
name: file.name,
},
});
if (existing) {
logger.warn('file already exists, skipping importing', {
id: file.id,
conflict: existing.id,
});
continue;
}
const folderId = file.folderId ? importedFolders[file.folderId] : null;
const created = await prisma.file.create({
data: {
userId,
name: file.name,
size: file.size,
type: file.type,
folderId,
originalName: file.originalName ?? null,
maxViews: file.maxViews ?? null,
views: file.views ?? 0,
deletesAt: file.deletesAt ? new Date(file.deletesAt) : null,
createdAt: new Date(file.createdAt),
favorite: file.favorite ?? false,
password: file.password ?? null,
},
});
importedFiles[file.id] = created.id;
}
logger.debug('imported files', { files: importedFiles });
// tags, mapped to files and users
const importedTags: Record<string, string> = {};
for (const tag of export4.data.userTags) {
const userId = tag.userId ? importedUsers[tag.userId] : null;
const existing = await prisma.tag.findFirst({
where: {
name: tag.name,
userId: userId ?? null,
createdAt: new Date(tag.createdAt),
},
});
if (existing) {
logger.warn('tag already exists, skipping importing', {
id: tag.id,
conflict: existing.id,
});
continue;
}
if (!userId) {
logger.warn('tag has no user, skipping', { id: tag.id });
continue;
}
const created = await prisma.tag.create({
data: {
name: tag.name,
color: tag.color ?? '#000000',
files: {
connect: tag.files.map((fileId) => ({ id: importedFiles[fileId] })),
},
userId,
},
});
importedTags[tag.id] = created.id;
}
logger.debug('imported tags', { tags: importedTags });
// urls
const importedUrls: Record<string, string> = {};
for (const url of export4.data.urls) {
const userId = url.userId ? importedUsers[url.userId] : null;
if (!userId) {
logger.warn('failed to find user for url, skipping', {
url: url.id,
user: url.userId,
});
continue;
}
const existing = await prisma.url.findFirst({
where: {
code: url.code,
userId,
},
});
if (existing) {
logger.warn('url already exists, skipping importing', {
id: url.id,
conflict: existing.id,
});
continue;
}
const created = await prisma.url.create({
data: {
userId,
destination: url.destination,
vanity: url.vanity ?? null,
code: url.code,
maxViews: url.maxViews ?? null,
views: url.views,
enabled: url.enabled,
createdAt: new Date(url.createdAt),
password: url.password ?? null,
},
});
importedUrls[url.id] = created.id;
}
logger.debug('imported urls', { urls: importedUrls });
// invites
const importedInvites: Record<string, string> = {};
for (const invite of export4.data.invites) {
const inviterId = importedUsers[invite.inviterId];
if (!inviterId) {
logger.warn('failed to find inviter for invite, skipping', {
invite: invite.id,
inviter: invite.inviterId,
});
continue;
}
const existing = await prisma.invite.findFirst({
where: {
code: invite.code,
inviterId,
},
});
if (existing) {
logger.warn('invite already exists, skipping importing', {
id: invite.id,
conflict: existing.id,
});
continue;
}
const created = await prisma.invite.create({
data: {
code: invite.code,
uses: invite.uses,
maxUses: invite.maxUses ?? null,
inviterId,
createdAt: new Date(invite.createdAt),
expiresAt: invite.expiresAt ? new Date(invite.expiresAt) : null,
},
});
importedInvites[invite.id] = created.id;
}
logger.debug('imported invites', { invites: importedInvites });
const metricRes = await prisma.metric.createMany({
data: export4.data.metrics.map((metric) => ({
createdAt: new Date(metric.createdAt),
data: metric.data as any,
})),
});
// metrics, through batch
logger.debug('imported metrics', { count: metricRes.count });
const response = {
imported: {
users: Object.keys(importedUsers).length,
oauthProviders: Object.keys(importedOauthProviders).length,
quotas: Object.keys(importedQuotas).length,
passkeys: Object.keys(importedPasskeys).length,
folders: Object.keys(importedFolders).length,
files: Object.keys(importedFiles).length,
tags: Object.keys(importedTags).length,
urls: Object.keys(importedUrls).length,
invites: Object.keys(importedInvites).length,
metrics: metricRes.count,
},
};
return res.send(response);
},
);