Skip to content

Commit 7301910

Browse files
committed
feat: export contact
1 parent efdfb1e commit 7301910

26 files changed

Lines changed: 2020 additions & 308 deletions

File tree

apps/builder/messages/en.json

Lines changed: 10 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1883,7 +1883,16 @@
18831883
"description": "Generate a token to allow your workspace to access ChatbotX APIs. Keep this token secret and do not share it with anyone."
18841884
},
18851885
"contacts": {
1886-
"title": "Contacts"
1886+
"title": "Contacts",
1887+
"exportPreparing": "Preparing your export…",
1888+
"exportPreparingDescription": "We're generating your CSV file. This may take a moment.",
1889+
"exportReadyTitle": "Your export is ready",
1890+
"exportReadyDescription": "Copy the link below or download the file directly.",
1891+
"exportDownloadCount": "Download ({count})",
1892+
"exportFailed": "Export failed. Please try again.",
1893+
"copyLink": "Copy link",
1894+
"linkCopied": "Link copied to clipboard",
1895+
"exportAllNotice": "All contacts matching the current filter will be exported."
18871896
},
18881897
"auditLogs": {
18891898
"title": "Audit Logs"

apps/builder/src/app/space/[workspaceId]/contacts/page.tsx

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,14 @@ export default async function ContactsPage(props: {
4040
<TagStoreProvider workspaceId={workspaceId}>
4141
<CustomFieldStoreProvider workspaceId={workspaceId}>
4242
<InboxStoreProvider workspaceId={workspaceId}>
43-
<ContactsTable promises={promises} workspaceId={workspaceId} />
43+
<ContactsTable
44+
filter={{
45+
keyword: search?.keyword,
46+
contactFilter: search?.contactFilter,
47+
}}
48+
promises={promises}
49+
workspaceId={workspaceId}
50+
/>
4451
</InboxStoreProvider>
4552
</CustomFieldStoreProvider>
4653
</TagStoreProvider>
Lines changed: 41 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,24 @@
11
"use server"
22

3-
import { and, db, eq, inArray } from "@chatbotx.io/database/client"
4-
import { contactModel } from "@chatbotx.io/database/schema"
3+
import { db } from "@chatbotx.io/database/client"
4+
import { fileContextTypes, fileStatuses } from "@chatbotx.io/database/partials"
5+
import { fileModel } from "@chatbotx.io/database/schema"
56
import type { UserModel } from "@chatbotx.io/database/types"
7+
import { createId } from "@chatbotx.io/utils"
68
import { DefaultJobAction, defaultQueue } from "@chatbotx.io/worker-config"
7-
import { returnValidationErrors } from "next-safe-action"
89
import {
910
type WorkspaceIdRequestParams,
1011
workspaceIdrequestParams,
1112
} from "@/features/common/schemas"
1213
import { workspaceActionClient } from "@/lib/safe-action"
1314
import {
1415
type ExportContactsRequest,
16+
type ExportContactsResponse,
1517
exportContactsRequest,
1618
} from "../schemas/action"
1719

20+
const exportFileSubType = "export-contacts"
21+
1822
export const exportContactsAction = workspaceActionClient
1923
.bindArgsSchemas(workspaceIdrequestParams)
2024
.inputSchema(exportContactsRequest)
@@ -27,36 +31,48 @@ export const exportContactsAction = workspaceActionClient
2731
ctx: { user: UserModel }
2832
bindArgsParsedInputs: WorkspaceIdRequestParams
2933
parsedInput: ExportContactsRequest
30-
}) => {
31-
const { contactIds, fields } = parsedInput
34+
}): Promise<ExportContactsResponse> => {
35+
const { fields } = parsedInput
3236

33-
// Make sure the contacts exist
34-
const contactsCount = await db.$count(
35-
contactModel,
36-
and(
37-
eq(contactModel.workspaceId, workspaceId),
38-
inArray(contactModel.id, contactIds),
39-
),
40-
)
41-
if (contactsCount === 0) {
42-
return returnValidationErrors(exportContactsRequest, {
43-
_errors: ["Validation Exception"],
44-
fields: {
45-
_errors: ["No contacts found"],
46-
},
47-
})
48-
}
37+
// The worker resolves the filter and counts records. The action only
38+
// records the export request and enqueues the job.
39+
const filter = parsedInput.exportAll
40+
? {
41+
keyword: parsedInput.filter?.keyword,
42+
contactFilter: parsedInput.filter?.contactFilter,
43+
}
44+
: undefined
45+
const contactIds = parsedInput.exportAll
46+
? undefined
47+
: (parsedInput.contactIds ?? [])
48+
49+
const fileId = createId()
50+
const fileName = `contacts-${new Date().toISOString().slice(0, 10)}.csv`
51+
const outputPath = `workspaces/${workspaceId}/exports/contacts/${fileId}.csv`
52+
53+
await db.insert(fileModel).values({
54+
id: fileId,
55+
workspaceId,
56+
userId: user.id,
57+
contextType: fileContextTypes.enum.generic,
58+
subType: exportFileSubType,
59+
path: outputPath,
60+
fileName,
61+
mimeType: "text/csv",
62+
status: fileStatuses.enum.pending,
63+
})
4964

5065
await Promise.all([
5166
defaultQueue.add(DefaultJobAction.exportContacts, {
5267
type: DefaultJobAction.exportContacts,
5368
data: {
5469
workspaceId,
5570
requestedUserId: user.id,
56-
contactIds,
71+
fileId,
5772
fields,
58-
outputPath: `/tmp/contacts-list-${Date.now()}.csv`,
73+
outputPath,
5974
outputFormat: "csv",
75+
...(filter ? { filter } : { contactIds: contactIds ?? [] }),
6076
},
6177
}),
6278
defaultQueue.add(DefaultJobAction.sendAuditLog, {
@@ -69,5 +85,7 @@ export const exportContactsAction = workspaceActionClient
6985
},
7086
}),
7187
])
88+
89+
return { fileId }
7290
},
7391
)

apps/builder/src/features/contacts/api/authenticated.ts

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,11 +8,17 @@ import { createContact } from "../actions/create-contact.action"
88
import { deleteContactCustomFields } from "../actions/delete-contact-custom-field.action"
99
import { removeContactTags } from "../actions/remove-contact-tag.action"
1010
import { getContact } from "../queries/get-contact.query"
11+
import { getExportFile } from "../queries/get-export-file.query"
1112
import { listContactCustomFields } from "../queries/list-contact-fields.query"
1213
import { countContactInboxes } from "../queries/list-contact-inboxes.queries"
1314
import { listContactTags } from "../queries/list-contact-tags.query"
1415
import { countContacts, listContacts } from "../queries/list-contacts.queries"
15-
import { createContactRequest, createContactResponse } from "../schemas/action"
16+
import {
17+
createContactRequest,
18+
createContactResponse,
19+
getExportFileRequest,
20+
getExportFileResponse,
21+
} from "../schemas/action"
1622
import {
1723
deleteContactCustomFieldRequest,
1824
listContactCustomFieldsRequest,
@@ -102,6 +108,18 @@ export const contactsAuthenticatedAPI = {
102108
return await createContact({ workspaceId, parsedInput })
103109
}),
104110

111+
getExportFileAuthenticatedAPI: authorizedAPI
112+
.route({
113+
method: "GET",
114+
path: "/workspaces/{workspaceId}/contacts/export-files/{fileId}",
115+
summary: "Get contact export file status",
116+
tags: ["Contacts"],
117+
})
118+
.input(getExportFileRequest)
119+
.output(getExportFileResponse)
120+
.use(workspaceAuthorizedMidddleware, (input) => input.workspaceId)
121+
.handler(async ({ input }) => await getExportFile(input)),
122+
105123
listContactTagsAuthenticatedAPI: authorizedAPI
106124
.route({
107125
method: "GET",

apps/builder/src/features/contacts/contacts-list-action.tsx

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,21 +42,25 @@ import DeleteContactDialog from "./components/remove-contact-dialog"
4242
import RemoveContactSequenceDialog from "./components/remove-contact-sequence-dialog"
4343
import RemoveContactTagDialog from "./components/remove-contact-tag-dialog"
4444
import { ExportContactDialog } from "./export-contact-dialog"
45+
import type { ExportContactsFilter } from "./schemas/action"
4546
import type { ListContactsItem } from "./schemas/query"
4647

4748
type ContactListActionProps = {
4849
workspaceId: string
4950
table: Table<ListContactsItem>
51+
filter?: ExportContactsFilter
5052
}
5153

5254
export function ContactListAction({
5355
workspaceId,
5456
table,
57+
filter,
5558
}: ContactListActionProps) {
5659
const t = useTranslations()
5760
const router = useRouter()
5861

5962
const rows = table.getFilteredSelectedRowModel().rows
63+
const exportAll = table.getIsAllPageRowsSelected()
6064

6165
return (
6266
<DropdownMenu>
@@ -139,6 +143,8 @@ export function ContactListAction({
139143

140144
<ExportContactDialog
141145
contactIds={rows.map((r) => r.original.id)}
146+
exportAll={exportAll}
147+
filter={filter}
142148
trigger={
143149
<DropdownMenuItem
144150
disabled={rows.length === 0}

apps/builder/src/features/contacts/contacts-table.tsx

Lines changed: 12 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -21,15 +21,21 @@ import { getUserName } from "../users/schemas/resource"
2121
import { ContactListAction } from "./contacts-list-action"
2222
import { CreateContactDialog } from "./create-contact-dialog"
2323
import type { listContacts } from "./queries/list-contacts.queries"
24+
import type { ExportContactsFilter } from "./schemas/action"
2425
import type { ListContactsItem } from "./schemas/query"
2526
import type { ContactResource } from "./schemas/resource"
2627

2728
type ContactsTableProps = {
2829
workspaceId: string
2930
promises: Promise<[Awaited<ReturnType<typeof listContacts>>]>
31+
filter?: ExportContactsFilter
3032
}
3133

32-
export function ContactsTable({ workspaceId, promises }: ContactsTableProps) {
34+
export function ContactsTable({
35+
workspaceId,
36+
promises,
37+
filter,
38+
}: ContactsTableProps) {
3339
const t = useTranslations()
3440
const [{ data, pageCount }] = use(promises)
3541

@@ -216,7 +222,11 @@ export function ContactsTable({ workspaceId, promises }: ContactsTableProps) {
216222
<DataTable table={table}>
217223
<DataTableToolbar table={table}>
218224
<CreateContactDialog workspaceId={workspaceId} />
219-
<ContactListAction table={table} workspaceId={workspaceId} />
225+
<ContactListAction
226+
filter={filter}
227+
table={table}
228+
workspaceId={workspaceId}
229+
/>
220230
</DataTableToolbar>
221231
</DataTable>
222232
)

0 commit comments

Comments
 (0)