♻️ Remove @typebot.io/schemas from @typebot.io/lib
This commit is contained in:
92
packages/results/archiveResults.ts
Normal file
92
packages/results/archiveResults.ts
Normal file
@ -0,0 +1,92 @@
|
||||
import { Prisma, PrismaClient } from '@typebot.io/prisma'
|
||||
import { Block, Typebot } from '@typebot.io/schemas'
|
||||
import { deleteFilesFromBucket } from '@typebot.io/lib/s3/deleteFilesFromBucket'
|
||||
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
|
||||
|
||||
type ArchiveResultsProps = {
|
||||
typebot: Pick<Typebot, 'groups'>
|
||||
resultsFilter?: Omit<Prisma.ResultWhereInput, 'typebotId'> & {
|
||||
typebotId: string
|
||||
}
|
||||
}
|
||||
|
||||
export const archiveResults =
|
||||
(prisma: PrismaClient) =>
|
||||
async ({ typebot, resultsFilter }: ArchiveResultsProps) => {
|
||||
const batchSize = 100
|
||||
const fileUploadBlockIds = typebot.groups
|
||||
.flatMap<Block>((group) => group.blocks)
|
||||
.filter((block) => block.type === InputBlockType.FILE)
|
||||
.map((block) => block.id)
|
||||
|
||||
let currentTotalResults = 0
|
||||
|
||||
const resultsCount = await prisma.result.count({
|
||||
where: {
|
||||
...resultsFilter,
|
||||
OR: [{ isArchived: false }, { isArchived: null }],
|
||||
},
|
||||
})
|
||||
|
||||
if (resultsCount === 0) return { success: true }
|
||||
|
||||
let progress = 0
|
||||
|
||||
do {
|
||||
progress += batchSize
|
||||
console.log(`Archiving ${progress} / ${resultsCount} results...`)
|
||||
const resultsToDelete = await prisma.result.findMany({
|
||||
where: {
|
||||
...resultsFilter,
|
||||
OR: [{ isArchived: false }, { isArchived: null }],
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
},
|
||||
take: batchSize,
|
||||
})
|
||||
|
||||
if (resultsToDelete.length === 0) break
|
||||
|
||||
currentTotalResults = resultsToDelete.length
|
||||
|
||||
const resultIds = resultsToDelete.map((result) => result.id)
|
||||
|
||||
if (fileUploadBlockIds.length > 0) {
|
||||
const filesToDelete = await prisma.answer.findMany({
|
||||
where: {
|
||||
resultId: { in: resultIds },
|
||||
blockId: { in: fileUploadBlockIds },
|
||||
},
|
||||
})
|
||||
if (filesToDelete.length > 0)
|
||||
await deleteFilesFromBucket({
|
||||
urls: filesToDelete.flatMap((a) => a.content.split(', ')),
|
||||
})
|
||||
}
|
||||
|
||||
await prisma.$transaction([
|
||||
prisma.log.deleteMany({
|
||||
where: {
|
||||
resultId: { in: resultIds },
|
||||
},
|
||||
}),
|
||||
prisma.answer.deleteMany({
|
||||
where: {
|
||||
resultId: { in: resultIds },
|
||||
},
|
||||
}),
|
||||
prisma.result.updateMany({
|
||||
where: {
|
||||
id: { in: resultIds },
|
||||
},
|
||||
data: {
|
||||
isArchived: true,
|
||||
variables: [],
|
||||
},
|
||||
}),
|
||||
])
|
||||
} while (currentTotalResults >= batchSize)
|
||||
|
||||
return { success: true }
|
||||
}
|
88
packages/results/convertResultsToTableData.ts
Normal file
88
packages/results/convertResultsToTableData.ts
Normal file
@ -0,0 +1,88 @@
|
||||
import {
|
||||
ResultWithAnswers,
|
||||
ResultHeaderCell,
|
||||
VariableWithValue,
|
||||
Answer,
|
||||
TableData,
|
||||
} from '@typebot.io/schemas'
|
||||
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
|
||||
import { isDefined } from '../lib/utils'
|
||||
|
||||
type CellParser = (
|
||||
content: VariableWithValue['value'],
|
||||
blockType?: InputBlockType
|
||||
) => { element?: React.JSX.Element; plainText: string }
|
||||
|
||||
const defaultCellParser: CellParser = (content, blockType) => {
|
||||
if (!content) return { plainText: '' }
|
||||
if (Array.isArray(content))
|
||||
return {
|
||||
plainText: content.join(', '),
|
||||
}
|
||||
return blockType === InputBlockType.FILE
|
||||
? { plainText: content }
|
||||
: { plainText: content.toString() }
|
||||
}
|
||||
|
||||
export const convertResultsToTableData = (
|
||||
results: ResultWithAnswers[] | undefined,
|
||||
headerCells: ResultHeaderCell[],
|
||||
cellParser: CellParser = defaultCellParser
|
||||
): TableData[] =>
|
||||
(results ?? []).map((result) => ({
|
||||
id: { plainText: result.id },
|
||||
date: {
|
||||
plainText: convertDateToReadable(result.createdAt),
|
||||
},
|
||||
...[...result.answers, ...result.variables].reduce<{
|
||||
[key: string]: { element?: JSX.Element; plainText: string }
|
||||
}>((tableData, answerOrVariable) => {
|
||||
if ('groupId' in answerOrVariable) {
|
||||
const answer = answerOrVariable satisfies Answer
|
||||
const header = answer.variableId
|
||||
? headerCells.find((headerCell) =>
|
||||
headerCell.variableIds?.includes(answer.variableId as string)
|
||||
)
|
||||
: headerCells.find((headerCell) =>
|
||||
headerCell.blocks?.some((block) => block.id === answer.blockId)
|
||||
)
|
||||
if (!header || !header.blocks || !header.blockType) return tableData
|
||||
const variableValue = result.variables.find(
|
||||
(variable) => variable.id === answer.variableId
|
||||
)?.value
|
||||
const content = variableValue ?? answer.content
|
||||
return {
|
||||
...tableData,
|
||||
[header.id]: cellParser(content, header.blockType),
|
||||
}
|
||||
}
|
||||
const variable = answerOrVariable satisfies VariableWithValue
|
||||
if (variable.value === null) return tableData
|
||||
const headerId = headerCells.find((headerCell) =>
|
||||
headerCell.variableIds?.includes(variable.id)
|
||||
)?.id
|
||||
if (!headerId) return tableData
|
||||
if (isDefined(tableData[headerId])) return tableData
|
||||
return {
|
||||
...tableData,
|
||||
[headerId]: cellParser(variable.value),
|
||||
}
|
||||
}, {}),
|
||||
}))
|
||||
|
||||
const convertDateToReadable = (date: Date): string => {
|
||||
const isThisYear = new Date().getFullYear() === date.getFullYear()
|
||||
|
||||
const dateString = date.toLocaleDateString('default', {
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
year: isThisYear ? undefined : 'numeric', // Only show the year if it's not the current year
|
||||
})
|
||||
|
||||
const timeString = date.toLocaleTimeString('default', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
})
|
||||
|
||||
return `${dateString}, ${timeString}`
|
||||
}
|
17
packages/results/package.json
Normal file
17
packages/results/package.json
Normal file
@ -0,0 +1,17 @@
|
||||
{
|
||||
"name": "@typebot.io/results",
|
||||
"version": "1.0.0",
|
||||
"description": "",
|
||||
"scripts": {},
|
||||
"keywords": [],
|
||||
"author": "Baptiste Arnaud",
|
||||
"license": "ISC",
|
||||
"dependencies": {
|
||||
"@typebot.io/schemas": "workspace:*",
|
||||
"@typebot.io/lib": "workspace:*",
|
||||
"@typebot.io/prisma": "workspace:*"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@typebot.io/tsconfig": "workspace:*"
|
||||
}
|
||||
}
|
39
packages/results/parseAnswers.ts
Normal file
39
packages/results/parseAnswers.ts
Normal file
@ -0,0 +1,39 @@
|
||||
import {
|
||||
AnswerInSessionState,
|
||||
Variable,
|
||||
VariableWithValue,
|
||||
} from '@typebot.io/schemas'
|
||||
import { isDefined, isEmpty } from '../lib/utils'
|
||||
|
||||
export const parseAnswers = ({
|
||||
answers,
|
||||
variables: resultVariables,
|
||||
}: {
|
||||
answers: AnswerInSessionState[]
|
||||
variables: Variable[]
|
||||
}): {
|
||||
[key: string]: string
|
||||
} => {
|
||||
const variablesWithValues = resultVariables.filter((variable) =>
|
||||
isDefined(variable.value)
|
||||
) as VariableWithValue[]
|
||||
|
||||
return {
|
||||
submittedAt: new Date().toISOString(),
|
||||
...[...answers, ...variablesWithValues].reduce<{
|
||||
[key: string]: string
|
||||
}>((o, answerOrVariable) => {
|
||||
if ('id' in answerOrVariable) {
|
||||
const variable = answerOrVariable
|
||||
if (variable.value === null) return o
|
||||
return { ...o, [variable.name]: variable.value.toString() }
|
||||
}
|
||||
const answer = answerOrVariable as AnswerInSessionState
|
||||
if (isEmpty(answer.key)) return o
|
||||
return {
|
||||
...o,
|
||||
[answer.key]: answer.value,
|
||||
}
|
||||
}, {}),
|
||||
}
|
||||
}
|
15
packages/results/parseColumnsOrder.ts
Normal file
15
packages/results/parseColumnsOrder.ts
Normal file
@ -0,0 +1,15 @@
|
||||
import { ResultHeaderCell } from '@typebot.io/schemas'
|
||||
|
||||
export const parseColumnsOrder = (
|
||||
existingOrder: string[] | undefined,
|
||||
resultHeader: ResultHeaderCell[]
|
||||
) =>
|
||||
existingOrder
|
||||
? [
|
||||
...existingOrder.slice(0, -1),
|
||||
...resultHeader
|
||||
.filter((header) => !existingOrder.includes(header.id))
|
||||
.map((h) => h.id),
|
||||
'logs',
|
||||
]
|
||||
: ['select', ...resultHeader.map((h) => h.id), 'logs']
|
213
packages/results/parseResultHeader.ts
Normal file
213
packages/results/parseResultHeader.ts
Normal file
@ -0,0 +1,213 @@
|
||||
import {
|
||||
ResultWithAnswers,
|
||||
ResultHeaderCell,
|
||||
Group,
|
||||
Variable,
|
||||
InputBlock,
|
||||
Typebot,
|
||||
} from '@typebot.io/schemas'
|
||||
import { isInputBlock } from '@typebot.io/schemas/helpers'
|
||||
import { InputBlockType } from '@typebot.io/schemas/features/blocks/inputs/constants'
|
||||
import { byId, isNotEmpty } from '@typebot.io/lib/utils'
|
||||
|
||||
export const parseResultHeader = (
|
||||
typebot: Pick<Typebot, 'groups' | 'variables'>,
|
||||
linkedTypebots: Pick<Typebot, 'groups' | 'variables'>[] | undefined,
|
||||
results?: ResultWithAnswers[]
|
||||
): ResultHeaderCell[] => {
|
||||
const parsedGroups = [
|
||||
...typebot.groups,
|
||||
...(linkedTypebots ?? []).flatMap(
|
||||
(linkedTypebot) => linkedTypebot.groups as Group[]
|
||||
),
|
||||
]
|
||||
const parsedVariables = [
|
||||
...typebot.variables,
|
||||
...(linkedTypebots ?? []).flatMap(
|
||||
(linkedTypebot) => linkedTypebot.variables
|
||||
),
|
||||
]
|
||||
const inputsResultHeader = parseInputsResultHeader({
|
||||
groups: parsedGroups,
|
||||
variables: parsedVariables,
|
||||
})
|
||||
return [
|
||||
{ label: 'Submitted at', id: 'date' },
|
||||
...inputsResultHeader,
|
||||
...parseVariablesHeaders(parsedVariables, inputsResultHeader),
|
||||
...parseResultsFromPreviousBotVersions(results ?? [], inputsResultHeader),
|
||||
]
|
||||
}
|
||||
|
||||
type ResultHeaderCellWithBlock = Omit<ResultHeaderCell, 'blocks'> & {
|
||||
blocks: NonNullable<ResultHeaderCell['blocks']>
|
||||
}
|
||||
|
||||
const parseInputsResultHeader = ({
|
||||
groups,
|
||||
variables,
|
||||
}: {
|
||||
groups: Group[]
|
||||
variables: Variable[]
|
||||
}): ResultHeaderCellWithBlock[] =>
|
||||
(
|
||||
groups
|
||||
.flatMap((group) =>
|
||||
group.blocks.map((block) => ({
|
||||
...block,
|
||||
groupTitle: group.title,
|
||||
groupId: group.id,
|
||||
}))
|
||||
)
|
||||
.filter((block) => isInputBlock(block)) as (InputBlock & {
|
||||
groupId: string
|
||||
groupTitle: string
|
||||
})[]
|
||||
).reduce<ResultHeaderCellWithBlock[]>((existingHeaders, inputBlock) => {
|
||||
if (
|
||||
existingHeaders.some(
|
||||
(existingHeader) =>
|
||||
inputBlock.options?.variableId &&
|
||||
existingHeader.variableIds?.includes(inputBlock.options.variableId)
|
||||
)
|
||||
)
|
||||
return existingHeaders
|
||||
const matchedVariableName =
|
||||
inputBlock.options?.variableId &&
|
||||
variables.find(byId(inputBlock.options.variableId))?.name
|
||||
|
||||
let label = matchedVariableName ?? inputBlock.groupTitle
|
||||
const headerWithSameLabel = existingHeaders.find((h) => h.label === label)
|
||||
if (headerWithSameLabel) {
|
||||
const shouldMerge = headerWithSameLabel.blocks?.some(
|
||||
(block) => block.id === inputBlock.id
|
||||
)
|
||||
if (shouldMerge) {
|
||||
const updatedHeaderCell: ResultHeaderCellWithBlock = {
|
||||
...headerWithSameLabel,
|
||||
variableIds:
|
||||
headerWithSameLabel.variableIds && inputBlock.options?.variableId
|
||||
? headerWithSameLabel.variableIds.concat([
|
||||
inputBlock.options.variableId,
|
||||
])
|
||||
: undefined,
|
||||
blocks: headerWithSameLabel.blocks.concat({
|
||||
id: inputBlock.id,
|
||||
groupId: inputBlock.groupId,
|
||||
}),
|
||||
}
|
||||
return [
|
||||
...existingHeaders.filter(
|
||||
(existingHeader) => existingHeader.label !== label
|
||||
),
|
||||
updatedHeaderCell,
|
||||
]
|
||||
}
|
||||
const totalPrevious = existingHeaders.filter((h) =>
|
||||
h.label.includes(label)
|
||||
).length
|
||||
const newHeaderCell: ResultHeaderCellWithBlock = {
|
||||
id: inputBlock.id,
|
||||
label: label + ` (${totalPrevious})`,
|
||||
blocks: [
|
||||
{
|
||||
id: inputBlock.id,
|
||||
groupId: inputBlock.groupId,
|
||||
},
|
||||
],
|
||||
blockType: inputBlock.type,
|
||||
variableIds: inputBlock.options?.variableId
|
||||
? [inputBlock.options.variableId]
|
||||
: undefined,
|
||||
}
|
||||
return [...existingHeaders, newHeaderCell]
|
||||
}
|
||||
|
||||
const newHeaderCell: ResultHeaderCellWithBlock = {
|
||||
id: inputBlock.id,
|
||||
label,
|
||||
blocks: [
|
||||
{
|
||||
id: inputBlock.id,
|
||||
groupId: inputBlock.groupId,
|
||||
},
|
||||
],
|
||||
blockType: inputBlock.type,
|
||||
variableIds: inputBlock.options?.variableId
|
||||
? [inputBlock.options.variableId]
|
||||
: undefined,
|
||||
}
|
||||
|
||||
return [...existingHeaders, newHeaderCell]
|
||||
}, [])
|
||||
|
||||
const parseVariablesHeaders = (
|
||||
variables: Variable[],
|
||||
existingInputResultHeaders: ResultHeaderCell[]
|
||||
) =>
|
||||
variables.reduce<ResultHeaderCell[]>((existingHeaders, variable) => {
|
||||
if (
|
||||
existingInputResultHeaders.some((existingInputResultHeader) =>
|
||||
existingInputResultHeader.variableIds?.includes(variable.id)
|
||||
)
|
||||
)
|
||||
return existingHeaders
|
||||
|
||||
const headerCellWithSameLabel = existingHeaders.find(
|
||||
(existingHeader) => existingHeader.label === variable.name
|
||||
)
|
||||
if (headerCellWithSameLabel) {
|
||||
const updatedHeaderCell: ResultHeaderCell = {
|
||||
...headerCellWithSameLabel,
|
||||
variableIds: headerCellWithSameLabel.variableIds?.concat([variable.id]),
|
||||
}
|
||||
return [
|
||||
...existingHeaders.filter((h) => h.label !== variable.name),
|
||||
updatedHeaderCell,
|
||||
]
|
||||
}
|
||||
const newHeaderCell: ResultHeaderCell = {
|
||||
id: variable.id,
|
||||
label: variable.name,
|
||||
variableIds: [variable.id],
|
||||
}
|
||||
|
||||
return [...existingHeaders, newHeaderCell]
|
||||
}, [])
|
||||
|
||||
const parseResultsFromPreviousBotVersions = (
|
||||
results: ResultWithAnswers[],
|
||||
existingInputResultHeaders: ResultHeaderCell[]
|
||||
): ResultHeaderCell[] =>
|
||||
results
|
||||
.flatMap((result) => result.answers)
|
||||
.filter(
|
||||
(answer) =>
|
||||
!answer.variableId &&
|
||||
existingInputResultHeaders.every(
|
||||
(header) => header.id !== answer.blockId
|
||||
) &&
|
||||
isNotEmpty(answer.content)
|
||||
)
|
||||
.reduce<ResultHeaderCell[]>((existingHeaders, answer) => {
|
||||
if (
|
||||
existingHeaders.some(
|
||||
(existingHeader) => existingHeader.id === answer.blockId
|
||||
)
|
||||
)
|
||||
return existingHeaders
|
||||
return [
|
||||
...existingHeaders,
|
||||
{
|
||||
id: answer.blockId,
|
||||
label: `${answer.blockId} (deleted block)`,
|
||||
blocks: [
|
||||
{
|
||||
id: answer.blockId,
|
||||
groupId: answer.groupId,
|
||||
},
|
||||
],
|
||||
blockType: InputBlockType.TEXT,
|
||||
},
|
||||
]
|
||||
}, [])
|
8
packages/results/tsconfig.json
Normal file
8
packages/results/tsconfig.json
Normal file
@ -0,0 +1,8 @@
|
||||
{
|
||||
"extends": "@typebot.io/tsconfig/base.json",
|
||||
"include": ["**/*.ts"],
|
||||
"exclude": ["node_modules"],
|
||||
"compilerOptions": {
|
||||
"lib": ["ES2021", "DOM"]
|
||||
}
|
||||
}
|
Reference in New Issue
Block a user