Last active
January 8, 2023 17:08
-
-
Save jahands/caabc600a1ef3f9aa388db2d9876d32a to your computer and use it in GitHub Desktop.
Repeat.dev to Notion Dashboard https://repeat.dev
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
import { Client as NotionClient } from '@notionhq/client'; | |
import formatISO from 'date-fns/formatISO' | |
import { utcToZonedTime } from 'date-fns-tz' | |
import { ThrottledQueue, auth } from '@jahands/msc-utils'; | |
let notion: NotionClient; | |
function setup(env: Repeat.Env) { | |
if (!notion) { | |
notion = new NotionClient({ | |
auth: env.variables.NOTION_API_KEY, | |
}); | |
} | |
} | |
export default { | |
async webhook(request: Request, env: Repeat.Env) { | |
const unauthed = auth(request, env.variables.WEBHOOK_KEY); | |
if (unauthed) return unauthed; | |
setup(env) | |
await env.unstable.tasks.add({ note: 'syncing repeats' }, { id: 'sync-repeats' }) | |
}, | |
async cron(cron: Repeat.Cron, env: Repeat.Env) { | |
setup(env) | |
return syncRecentRepeatsToNotionDashboard(env) | |
}, | |
async task(task: Repeat.Task, env: Repeat.Env) { | |
setup(env) | |
return syncRepeatsToNotionDashboard(env) | |
} | |
}; | |
const queue = new ThrottledQueue({ concurrency: 6, interval: 333, limit: 1 }); | |
async function syncRepeatsToNotionDashboard(env: Repeat.Env): Promise<Response | void> { | |
let response: NotionListResponse | |
await queue.add(async () => env.tracing | |
.span('notion', 'db_list') | |
.tag('database_id', env.variables.NOTION_DB_ID) | |
.measure(async () => { | |
response = (await notion.databases.query({ | |
page_size: 1000, | |
database_id: env.variables.NOTION_DB_ID | |
})) as NotionListResponse | |
})) | |
// String is the Repeat ID | |
const notionMap = new Map<string, NotionRow | null>() | |
response.results.forEach((r) => | |
notionMap.set(r.properties.RepeatID.select.name, r)) | |
const repeats = await env.unstable.repeats.list() as RepeatResponse[] | |
for (const repeat of repeats) { | |
const properties = getPropertiesForRepeat(repeat) | |
// TODO: Only do API call if anything actually needs changing | |
if (notionMap.has(repeat.id)) { | |
// Update row | |
const updateFn = async () => { | |
const notionRow = notionMap.get(repeat.id) | |
await notion.pages.update({ | |
page_id: notionRow.id, | |
properties, | |
}); | |
} | |
queue.add(async () => await env.tracing | |
.span('notion', 'update_page') | |
.tag('repeat_name', repeat.name) | |
.tag('repeat_id', repeat.id) | |
.tag('notion_id', notionMap.get(repeat.id).id) | |
.measure(updateFn)) | |
} else { | |
// Create new row | |
const createFn = async () => { | |
notionMap.set(repeat.id, null) | |
await notion.pages.create({ | |
parent: { | |
'type': 'database_id', | |
database_id: env.variables.NOTION_DB_ID, | |
}, | |
properties, | |
}); | |
} | |
queue.add(async () => await env.tracing | |
.span('notion', 'create_page') | |
.tag('repeat_name', repeat.name) | |
.tag('repeat_id', repeat.id) | |
.measure(createFn)) | |
} | |
} | |
await queue.onIdle() | |
await markDeleted(repeats, notionMap, env) | |
} | |
/** Only syncs recently modified repeats */ | |
async function syncRecentRepeatsToNotionDashboard(env: Repeat.Env): Promise<Response | void> { | |
const allRepeats = await env.unstable.repeats.list() as RepeatResponse[] | |
const maxAge = 1000 * 60 * 60 // 1 hour | |
const repeats = allRepeats.filter((r) => Date.now() - new Date(r.updated_at).getTime() < maxAge) | |
if (repeats.length === 0) { | |
console.log('Nothing to update! Stopping...') | |
return | |
} | |
let response: NotionListResponse | |
await queue.add(async () => env.tracing | |
.span('notion', 'db_list') | |
.tag('database_id', env.variables.NOTION_DB_ID) | |
.measure(async () => { | |
response = (await notion.databases.query({ | |
page_size: 1000, | |
database_id: env.variables.NOTION_DB_ID | |
})) as NotionListResponse | |
})) | |
// String is the Repeat ID | |
const notionMap = new Map<string, NotionRow | null>() | |
response.results.forEach((r) => | |
notionMap.set(r.properties.RepeatID.select.name, r)) | |
console.log(`Syncing ${repeats.length} out of ${allRepeats.length}`) | |
for (const repeat of repeats) { | |
const properties = getPropertiesForRepeat(repeat) | |
// TODO: Only do API call if anything actually needs changing | |
if (notionMap.has(repeat.id)) { | |
// Update row | |
const updateFn = async () => { | |
const notionRow = notionMap.get(repeat.id) | |
await notion.pages.update({ | |
page_id: notionRow.id, | |
properties, | |
}); | |
} | |
queue.add(async () => await env.tracing | |
.span('notion', 'update_page') | |
.tag('repeat_name', repeat.name) | |
.tag('repeat_id', repeat.id) | |
.tag('notion_id', notionMap.get(repeat.id).id) | |
.measure(updateFn)) | |
} else { | |
// Create new row | |
const createFn = async () => { | |
notionMap.set(repeat.id, null) | |
await notion.pages.create({ | |
parent: { | |
'type': 'database_id', | |
database_id: env.variables.NOTION_DB_ID, | |
}, | |
properties, | |
}); | |
} | |
queue.add(async () => await env.tracing | |
.span('notion', 'create_page') | |
.tag('repeat_name', repeat.name) | |
.tag('repeat_id', repeat.id) | |
.measure(createFn)) | |
} | |
} | |
await queue.onIdle() | |
await markDeleted(allRepeats, notionMap, env) | |
} | |
async function markDeleted(repeats: RepeatResponse[], notionMap: Map<string, NotionRow>, env: Repeat.Env): Promise<void> { | |
const repeatMap = new Map<string, boolean>() | |
repeats.forEach((r) => repeatMap.set(r.id, true)) | |
// Set deleted Repeats as inactive in Notion | |
for (const [repeatID, notionRow] of notionMap) { | |
if (!repeatMap.has(repeatID)) { | |
console.log(`setting ${repeatID} as deleted`) | |
// Set it as Deleted | |
const updateFn = async () => { | |
await notion.pages.update({ | |
page_id: notionRow.id, | |
properties: { | |
Status: { | |
select: { | |
name: 'Deleted' | |
} | |
} | |
} as Properties | |
}); | |
} | |
queue.add(async () => await env.tracing | |
.span('notion', 'update_page') | |
.tag('repeat_status', 'deleted') | |
.tag('repeat_name', notionMap.get(repeatID).properties.Name.title[0].text.content) | |
.tag('repeat_id', repeatID) | |
.tag('notion_id', notionRow.id) | |
.measure(updateFn)) | |
} | |
} | |
await queue.onIdle() | |
} | |
function getPropertiesForRepeat(repeat: RepeatResponse): Properties { | |
const timeZone = 'America/Chicago' | |
const zonedDate = utcToZonedTime(new Date(repeat.updated_at).getTime(), timeZone) | |
const dateString = formatISO(zonedDate) | |
const properties = { | |
RepeatID: { | |
select: { | |
name: repeat.id | |
} | |
}, | |
Name: { | |
title: [ | |
{ | |
text: { | |
content: repeat.name, | |
link: { url: getRepeatUrl(repeat.id) } | |
} | |
} | |
] | |
}, | |
Status: { | |
select: { | |
name: 'Active' | |
} | |
}, | |
URL: { | |
url: getRepeatUrl(repeat.id) | |
}, | |
Updated: { | |
date: { | |
start: dateString, | |
time_zone: timeZone | |
} | |
} | |
} as Properties | |
return properties | |
} | |
function getRepeatUrl(repeatId: string): string { | |
return `https://dash.repeat.dev/repeats/${repeatId}` | |
} | |
/** ========== TYPES ========== */ | |
interface RepeatResponse { | |
id: string; | |
name: string; | |
script: string; | |
created_at: Date; | |
updated_at: Date; | |
repeat_version_id: string; | |
last_trace: LastTrace; | |
repeat_events: RepeatEvent[]; | |
} | |
interface LastTrace { | |
outcome: Outcome; | |
stats: Stats; | |
time: Date; | |
} | |
enum Outcome { | |
Exception = "exception", | |
Ok = "ok", | |
} | |
interface Stats { | |
logs: number; | |
errors: number; | |
duration: number; | |
warnings: number; | |
} | |
interface RepeatEvent { | |
id: string; | |
type: RepeatType; | |
} | |
enum RepeatType { | |
Cron = "cron", | |
Email = "email", | |
Webhook = "webhook", | |
} | |
// ======= Notion List Response ======== // | |
export interface NotionListResponse { | |
object: string; | |
results: NotionRow[]; | |
next_cursor: null; | |
has_more: boolean; | |
type: string; | |
page: Page; | |
} | |
export interface Page { | |
} | |
export interface NotionRow { | |
object: string; | |
id: string; | |
created_time: Date; | |
last_edited_time: Date; | |
created_by: TedBy; | |
last_edited_by: TedBy; | |
cover: null; | |
icon: null; | |
parent: Parent; | |
archived: boolean; | |
properties: Properties; | |
url: string; | |
} | |
export interface TedBy { | |
object: string; | |
id: string; | |
} | |
export interface Parent { | |
type: string; | |
database_id: string; | |
} | |
export interface Properties { | |
URL: URL; | |
Status: RepeatID; | |
Updated: Updated; | |
RepeatID: RepeatID; | |
Tags: Tags; | |
Name: Name; | |
} | |
export interface Name { | |
id: string; | |
type: string; | |
title: Title[]; | |
} | |
export interface Title { | |
type: string; | |
text: Text; | |
annotations: Annotations; | |
plain_text: string; | |
href: null; | |
} | |
export interface Annotations { | |
bold: boolean; | |
italic: boolean; | |
strikethrough: boolean; | |
underline: boolean; | |
code: boolean; | |
color: string; | |
} | |
export interface Text { | |
content: string; | |
link: Link; | |
} | |
export interface Link { | |
url: string; | |
} | |
export interface RepeatID { | |
id: string; | |
type: string; | |
select: Select; | |
} | |
export interface Select { | |
id: string; | |
name: string; | |
color: string; | |
} | |
export interface Tags { | |
id: string; | |
type: string; | |
multi_select: Select[]; | |
} | |
export interface URL { | |
id: string; | |
type: string; | |
url: string; | |
} | |
export interface Updated { | |
id: UpdatedID; | |
type: UpdatedType; | |
date: DateClass | null; | |
} | |
export interface DateClass { | |
start: Date | string; | |
end: null; | |
time_zone: null; | |
} | |
export enum UpdatedID { | |
The5BQzw = "%5BQzw", | |
} | |
export enum UpdatedType { | |
Date = "date", | |
} | |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment