Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: entity files #20

Merged
merged 3 commits into from
Jul 29, 2024
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ examples, etc.
- ❌ Interactions
- ❌ Relationship Strengths
- ❌ Notes
- Entity Files
- ✅ [Entity Files](src/v1/entity_files.ts)
- ❌ Reminders
- ❌ Webhooks
- ✅ [Whoami](src/v1/auth.ts)
Expand Down
4 changes: 3 additions & 1 deletion deno.jsonc
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
"@std/assert": "jsr:@std/assert@^0.226.0",
"@std/fs": "jsr:@std/fs@^0.229.1",
"@std/path": "jsr:@std/path@^0.225.2",
"@std/streams": "jsr:@std/streams@^1.0.0",
"@std/testing": "jsr:@std/testing@^0.225.0",
"axios": "npm:axios@^1.7.2",
"axios-mock-adapter": "https://esm.sh/[email protected]",
Expand All @@ -12,7 +13,8 @@
"tasks": {
"build": "deno run -A scripts/build_npm.ts",
"check": "deno check src/**/*.ts",
"test": "deno test --allow-env=API_KEY,NODE_EXTRA_CA_CERTS --allow-net=api.affinity.co --doc --allow-read=./src/v1/tests/ src/**/tests/",
"test": "deno test --allow-env=API_KEY,NODE_EXTRA_CA_CERTS --allow-net --doc --allow-read=./src/v1/tests/ src/**/tests/",
"test:single": "deno test --allow-env=API_KEY,NODE_EXTRA_CA_CERTS --allow-net --doc --allow-read=./src/v1/tests/",
"test:coverage": "deno task test --coverage=cov_profile && deno coverage cov_profile --html",
"watch": "deno task test --no-clear-screen --watch --shuffle --parallel",
"snapshot-update": "deno task test --allow-write=./src/v1/tests/__snapshots__ -- --update",
Expand Down
54 changes: 52 additions & 2 deletions deno.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions flake.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

231 changes: 231 additions & 0 deletions src/v1/entity_files.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,231 @@
import type { AxiosInstance } from 'axios'
import { defaultTransformers } from './axios_default_transformers.ts'
import { FieldBase } from './fields.ts'
import { FieldValueType } from './lists.ts'
import type { DateTime, Replace, RequireOnlyOne } from './types.ts'
import { entityFilesUrl, fieldsUrl } from './urls.ts'
import type { PagedRequest } from './paged_request.ts'
import type { PagedResponse } from './paged_response.ts'
export type { DateTime } from './types.ts'
import { Readable } from 'node:stream'
import { EntityRequestFilter } from './field_value_changes.ts'
import { assert } from '@std/assert'
import { createSearchIteratorFn } from './create_search_iterator_fn.ts'

type EntityFileRaw = {
/** The unique identifier of the entity file object. */
id: number
/** The name of the file. */
name: string
/** The size of the file in bytes. */
size: string
/** The unique identifier of the person corresponding to the entity file. */
person_id: number | null
/** The unique identifier of the organization corresponding to the entity file. */
organization_id: number | null
/** The unique identifier of the opportunity corresponding to the entity file. */
opportunity_id: number | null
/** The unique identifier of the user who created the entity file. */
uploader_id: number
/** The time when the entity file was created. */
created_at: DateTime
}
joscha marked this conversation as resolved.
Show resolved Hide resolved

type EntityFile = Replace<EntityFileRaw, {
created_at: Date
}>

/**
* Represents the request parameters for retrieving entity files.
*/
export type AllEntityFileRequest =
& {
/**
* A unique ID that represents a Person whose associated files should be retrieved.
*/
person_id?: number
/**
* A unique ID that represents an Organization whose associated files should be retrieved.
*/
organization_id?: number
/**
* A unique ID that represents an Opportunity whose associated files should be retrieved.
*/
opportunity_id?: number
}
& PagedRequest

type PagedEntityFileResponseRaw =
& {
entity_files: EntityFileRaw[]
}
& PagedResponse

type PagedEntityFileResponse = Replace<PagedEntityFileResponseRaw, {
entity_files: EntityFile[]
}>

type UploadEntityFileRequest =
& {
files: File[]
}
& RequireOnlyOne<EntityRequestFilter, keyof EntityRequestFilter>

/**
* Entity files are files uploaded to a relevant entity.
* Possible files, for example, would be a pitch deck for an opportunity or a physical mail correspondence for a person.
*/
export class EntityFiles {
/** @hidden */
constructor(private readonly axios: AxiosInstance) {
}

private static transformEntityFile(file: EntityFileRaw): EntityFile {
return {
...file,
created_at: new Date(file.created_at),
}
}

/**
* Returns all entity files within your organization.
*/
async all(params?: AllEntityFileRequest): Promise<PagedEntityFileResponse> {
const response = await this.axios.get<PagedEntityFileResponse>(
entityFilesUrl(),
{
params,
transformResponse: [
...defaultTransformers(),
(json: PagedEntityFileResponseRaw) => {
return {
...json,
entity_files: json.entity_files.map(
EntityFiles.transformEntityFile,
),
}
},
],
},
)
return response.data
}

/**
* Returns an async iterator that yields all entity files matching the given request
* Each yielded array contains up to the number specified in {@link AllEntityFileRequest.page_size} of entity files.
* Use this method if you want to process the entity files in a streaming fashion.
*
* *Please note:* the yielded entity files array may be empty on the last page.
*
* @example
* ```typescript
* let page = 0
* for await (const entries of affinity.entityFiles.pagedIterator({
* person_id: 123,
* page_size: 10
* })) {
* console.log(`Page ${++page} of entries:`, entries)
* }
* ```
*/
pagedIterator = createSearchIteratorFn(
this.all.bind(this),
'entity_files',
)

/**
* Fetches an entity with a specified `entity_file_id`.
*/
async get(entity_file_id: EntityFile['id']): Promise<EntityFile> {
const response = await this.axios.get<EntityFile>(
entityFilesUrl(entity_file_id),
{
transformResponse: [
...defaultTransformers(),
EntityFiles.transformEntityFile,
],
},
)
return response.data
}

/**
* Downloads the entity file with the specified `entity_file_id`.
*
* @example
* ```typescript
* import { promises as fsPromises } from 'node:fs';
* const fileResponseStream = affinity.entityFiles.download(123);
* await fsPromises.writeFile(filePath, fileResponseStream);
* ```
*/
async download(entity_file_id: EntityFile['id']): Promise<Readable> {
const response = await this.axios.get(
entityFilesUrl(entity_file_id, true),
{
responseType: 'stream',
// The download location of entity files is provided via a redirect from Affinity
maxRedirects: 5,
},
)
return response.data
}

/**
* Uploads files attached to the entity with the given id.
*
* The file will display on the entity's profile, provided that the entity is not a person internal to the user's organization.
*
* @example
* ```typescript
* const file = fs.createReadStream('example.pdf');
* const entityFile = await affinity.entityFiles.upload({
* files: [file],
* person_id: 123,
* });
* ```
*/
async upload(params: UploadEntityFileRequest): Promise<boolean> {
const formData = new FormData()
const { files } = params
assert(files.length, 'At least one file must be provided')
if (files.length === 1) {
// Append the file as 'file' if only one file is provided
// it's a bit odd that the Affinity API expects the file to be sent in a different
// parameter, but maybe there is an implementation detail that treats multiple files
// differently to a single one, so we're complying with the API here
const [file] = files
formData.append('file', file, file.name)
} else {
files.forEach((file) => {
formData.append('files[]', file, file.name)
})
}
if (params.person_id) {
formData.append('person_id', params.person_id.toString())
} else if (params.organization_id) {
formData.append(
'organization_id',
params.organization_id.toString(),
)
} else if (params.opportunity_id) {
formData.append('opportunity_id', params.opportunity_id.toString())
} else {
throw new Error(
'One of person_id, organization_id or opportunity_id must be provided',
)
}

const response = await this.axios.post<{ success: boolean }>(
entityFilesUrl(),
formData,
{
headers: {
'Content-Type': 'multipart/form-data',
},
},
)
return response.data.success === true
}
}
Loading
Loading