diff --git a/lib/index.ts b/lib/index.ts index 6f66b9d..651def7 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -6,3 +6,4 @@ export * from './src/d4h' export * from './src/emergencyContacts' export * from './src/group' export * from './src/member' +export * from './src/events' diff --git a/lib/package.json b/lib/package.json index dcfc74a..536aa58 100644 --- a/lib/package.json +++ b/lib/package.json @@ -14,7 +14,12 @@ "typescript": "^4.8.4", "@typescript-eslint/eslint-plugin": "^5.55.0", "@typescript-eslint/parser": "^5.55.0", - "eslint": "^8.36.0" + "@types/tough-cookie": "^4.0.5", + "axios": "^1.6.7", + "axios-cookiejar-support": "^5.0.0", + "eslint": "^8.36.0", + "node-html-parser": "^6.1.12", + "tough-cookie": "^4.1.3" }, "dependencies": { } diff --git a/lib/src/d4hRequest.ts b/lib/src/d4hRequest.ts index d3ac31a..e700b1e 100644 --- a/lib/src/d4hRequest.ts +++ b/lib/src/d4hRequest.ts @@ -22,7 +22,7 @@ export default class D4HRequest { if (!token) { throw new Error('Token cannot be empty') } - + this._fetchLimit = fetchLimit this._token = token } @@ -32,8 +32,8 @@ export default class D4HRequest { 'Authorization': `Bearer ${this._token}`, 'Content-Type': 'application/json' } - - console.log(`${method}: ${url.toString()}\n${JSON.stringify(body)}`) + + //console.log(`${method}: ${url.toString()}\n${JSON.stringify(body)}`) const options: RequestInit = { method, @@ -43,42 +43,42 @@ export default class D4HRequest { if (body) { options.body = JSON.stringify(body) } - + const rawResponse = await fetch(url.toString(), options) const response = await rawResponse.json() as D4HResponse & D4HError - + if (response.statusCode !== 200) { const d4hError = response as D4HError throw new Error(`${d4hError.statusCode}: ${d4hError.error}: ${d4hError.message}`) } - + return response.data } async getAsync(url: URL): Promise { return this.requestAsync(url, HttpMethod.Get) } - + async getManyAsync(url: URL): Promise { let results: DataType[] = [] - + let offset = 0 - + // eslint-disable-next-line @typescript-eslint/no-unnecessary-condition, no-constant-condition while (true) { const urlWithOffset = new URL(url) urlWithOffset.searchParams.append('offset', offset.toString()) urlWithOffset.searchParams.append('limit', this._fetchLimit.toString()) - + const newResults = await this.getAsync(urlWithOffset) results = results.concat(newResults) offset += this._fetchLimit - + if (newResults.length < this._fetchLimit) { break } } - + return results } diff --git a/lib/src/d4hWebClient.ts b/lib/src/d4hWebClient.ts new file mode 100644 index 0000000..3904395 --- /dev/null +++ b/lib/src/d4hWebClient.ts @@ -0,0 +1,99 @@ +import Axios, { AxiosInstance } from 'axios' +import { wrapper } from 'axios-cookiejar-support' +import { CookieJar } from 'tough-cookie' +import { parse as parseHtml, HTMLElement } from 'node-html-parser' + +const MONTHS = [ '', 'Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug', 'Sep', 'Oct', 'Nov', 'Dec' ] + +function d4hWebTimeToDate(d4hTime: string) :Date { + const parts = d4hTime.trim().split(' ') + if (parts.length < 3 || !parts[1] || !parts[2]) throw new Error('unrecognized web date format: ' + d4hTime) + + return new Date(`${parts[3]}-${MONTHS.indexOf(parts[2])}-${/^\d+/.exec(parts[1])?.[0]} ${parts[0]}`) +} + +export class MissionPage { + id: string + root: HTMLElement + + constructor(id: string|number, body: string) { + this.id = id+'' + this.root = parseHtml(body) + } + + getTimeline() { + const headers = this.root.querySelectorAll('#timestamps th') + + return headers.reduce( + (a, c) => { + const t = d4hWebTimeToDate(c.parentNode.querySelector('.timestamp_title')?.text ?? '') + if (!isNaN(t.getTime())) { + a[c.text.trim()] = t + } + return a + }, + {} as Record + ) + } +} + +export class D4HWebClient { + jar: CookieJar = new CookieJar() + axios: AxiosInstance = Axios.create() + + async setup() { + if (!this.axios.defaults.baseURL) { + if (!process.env['D4H_DOMAIN'] || !process.env['D4H_USER'] || !process.env['D4H_PASSWORD']) throw new Error('Missing D4H web config') + + this.axios = wrapper(Axios.create({ jar: this.jar, baseURL: `https://${process.env['D4H_DOMAIN']}.team-manager.us.d4h.com` })) + await this.axios.get('/') + + const crumb = (await this.jar.getCookies('https://accounts.us.d4h.com/')).find(f => f.key == 'crumb')?.value + if (!crumb) throw new Error('did not find crumb during login') + + + const params = new URLSearchParams() + params.append('crumb', crumb) + params.append('email', process.env['D4H_USER']) + params.append('password', process.env['D4H_PASSWORD']) + await this.axios.post('https://accounts.us.d4h.com/password', + params, + { headers: { 'content-type': 'application/x-www-form-urlencoded'}} + ) + + await this.axios.get('/team') + } + } + + async getMissionPage(id: string) { + await this.setup() + const response = await this.axios.get(`/team/incidents/view/${id}`) + return new MissionPage(id, response.data) + } + + async getMissionReportContributorNames(missionId: string|number) { + await this.setup() + const response = await this.axios.get(`/audit/embed?entity_type=incident&entity_id=${missionId}`) + const root = parseHtml(response.data) + const rows = root.querySelectorAll('tr') + + const uniqueList = Array.from(new Set(rows.map(tr => tr.querySelectorAll('td')?.[1]?.text.trim())).values()).sort() + return uniqueList + } + + async getMileage(activityId: string|number): Promise> { + // The costings table can get out of sync with the attendance table, which breaks the ability to export the CSV + // and match rows in that file to rows from the API. If you load the costings edit page, the site shows you which rows + // are out of date, and you have to click an icon to sync each row. + // The data for both the stale and current values is in the page HTML as JSON, so we grab the JSON from the page source + // and parse out the entity_id (matches the attendance row id from the API) and the distance in miles. + await this.setup() + const response = await this.axios.get(`/team/incidents/edit/${activityId}/costing`) + const match = /existing_data *?= *?(.*?original_members.*?);/.exec(response.data) + if (match) { + const rawData = JSON.parse(match[1] ?? '{}') as { member: { entity_id: number, distance: number }[] } + return rawData.member.reduce((a,c) => ({ ...a, [c.entity_id]: c.distance }), {} as Record) + } + return {} + } +} diff --git a/lib/src/events.ts b/lib/src/events.ts new file mode 100644 index 0000000..c793bd3 --- /dev/null +++ b/lib/src/events.ts @@ -0,0 +1,23 @@ +export interface Incident { + id: number, + ref_desc: string, + date: string, + enddate?: string, + description?: string, + lat?: number, + lng?: number, + tags?: string[], +} + +export interface IncidentRoster { + id: number, + status: string, + date: string, + enddate: string, + role: { + id: number, + title: string, + bundle: string, + }, + member: { id: number, name: string } +} \ No newline at end of file