metamigo-db: build, fmt, lint

This commit is contained in:
Abel Luck 2023-03-13 11:28:50 +00:00
parent 2ffb15b1f9
commit 2a1ced5383
17 changed files with 160 additions and 125 deletions

View file

@ -0,0 +1,67 @@
import process from "node:process";
import { existsSync } from "node:fs";
import { exec } from "node:child_process";
import type { IAppConfig } from "@digiresilience/metamigo-config";
/**
* We use graphile-migrate for managing database migrations.
*
* However we also use convict as the sole source of truth for our app's configuration. We do not want to have to configure
* separate env files or config files for graphile-migrate and yet again others for convict.
*
* So we wrap the graphile-migrate cli tool here. We parse our convict config, set necessary env vars, and then shell out to
* graphile-migrate.
*
* Commander eats all args starting with --, so you must use the -- escape to indicate the arguments have finished
*
* Example:
* ./cli db -- --help // will show graphile migrate help
* ./cli db -- watch // will watch the current sql for changes
* ./cli db -- watch --once // will apply the current sql once
*/
export const migrateWrapper = async (
commands: string[],
config: IAppConfig,
silent = false
): Promise<void> => {
const env = {
DATABASE_URL: config.db.connection,
SHADOW_DATABASE_URL: config.dev.shadowConnection,
ROOT_DATABASE_URL: config.dev.rootConnection,
DATABASE_NAME: config.db.name,
DATABASE_OWNER: config.db.owner,
DATABASE_AUTHENTICATOR: config.postgraphile.auth,
DATABASE_VISITOR: config.postgraphile.visitor,
};
const cmd = `npx --no-install graphile-migrate ${commands.join(" ")}`;
const dbDir = `../../db`;
const gmrc = `${dbDir}/.gmrc`;
if (!existsSync(gmrc)) {
throw new Error(`graphile migrate config not found at ${gmrc}`);
}
if (!silent) console.log("executing:", cmd);
return new Promise((resolve, reject) => {
const proc = exec(cmd, {
env: { ...process.env, ...env },
cwd: dbDir,
});
proc.stdout.on("data", (data) => {
if (!silent) console.log("MIGRATE:", data);
});
proc.stderr.on("data", (data) => {
console.error("MIGRATE", data);
});
proc.on("close", (code) => {
if (code !== 0) {
reject(new Error(`graphile-migrate exited with code ${code}`));
return;
}
resolve();
});
});
};

View file

@ -0,0 +1,89 @@
import type { IAppConfig } from "@digiresilience/metamigo-config";
import camelcaseKeys from "camelcase-keys";
import PgSimplifyInflectorPlugin from "@graphile-contrib/pg-simplify-inflector";
// import PgManyToManyPlugin from "@graphile-contrib/pg-many-to-many";
import * as ConnectionFilterPlugin from "postgraphile-plugin-connection-filter";
import type { PostGraphileCoreOptions } from "postgraphile-core";
import {
UserRecordRepository,
AccountRecordRepository,
SessionRecordRepository,
} from "@digiresilience/metamigo-common";
import {
SettingRecordRepository,
VoiceProviderRecordRepository,
VoiceLineRecordRepository,
WebhookRecordRepository,
WhatsappBotRecordRepository,
WhatsappMessageRecordRepository,
WhatsappAttachmentRecordRepository,
SignalBotRecordRepository,
} from "./records";
import type { IInitOptions, IDatabase } from "pg-promise";
export interface IRepositories {
users: UserRecordRepository;
sessions: SessionRecordRepository;
accounts: AccountRecordRepository;
settings: SettingRecordRepository;
voiceLines: VoiceLineRecordRepository;
voiceProviders: VoiceProviderRecordRepository;
webhooks: WebhookRecordRepository;
whatsappBots: WhatsappBotRecordRepository;
whatsappMessages: WhatsappMessageRecordRepository;
whatsappAttachments: WhatsappAttachmentRecordRepository;
signalBots: SignalBotRecordRepository;
}
export type AppDatabase = IDatabase<IRepositories> & IRepositories;
export const dbInitOptions = (
_config: IAppConfig
): IInitOptions<IRepositories> => ({
noWarnings: true,
receive(e) {
const { data, result } = e;
if (result) result.rows = camelcaseKeys(data);
},
// Extending the database protocol with our custom repositories;
// API: http://vitaly-t.github.io/pg-promise/global.html#event:extend
// eslint-disable-next-line @typescript-eslint/no-explicit-any
extend(obj: any, _dc) {
// AppDatase was obj type
// Database Context (_dc) is mainly needed for extending multiple databases with different access API.
// NOTE:
// This event occurs for every task and transaction being executed (which could be every request!)
// so it should be as fast as possible. Do not use 'require()' or do any other heavy lifting.
obj.users = new UserRecordRepository(obj);
obj.sessions = new SessionRecordRepository(obj);
obj.accounts = new AccountRecordRepository(obj);
obj.settings = new SettingRecordRepository(obj);
obj.voiceLines = new VoiceLineRecordRepository(obj);
obj.voiceProviders = new VoiceProviderRecordRepository(obj);
obj.webhooks = new WebhookRecordRepository(obj);
obj.whatsappBots = new WhatsappBotRecordRepository(obj);
obj.whatsappMessages = new WhatsappMessageRecordRepository(obj);
obj.whatsappAttachments = new WhatsappAttachmentRecordRepository(obj);
obj.signalBots = new SignalBotRecordRepository(obj);
},
});
export const getPostGraphileOptions = (): PostGraphileCoreOptions => ({
ignoreRBAC: false,
dynamicJson: true,
ignoreIndexes: false,
appendPlugins: [
PgSimplifyInflectorPlugin,
// PgManyToManyPlugin,
// eslint-disable-next-line @typescript-eslint/no-explicit-any
ConnectionFilterPlugin as any,
],
});
export * from "./helpers";
export * from "./records";

View file

@ -0,0 +1,9 @@
export * from "./settings";
export * from "./signal/bots";
export * from "./whatsapp/bots";
export * from "./whatsapp/messages";
export * from "./whatsapp/attachments";
export * from "./settings";
export * from "./voice/voice-line";
export * from "./voice/voice-provider";
export * from "./webhooks";

View file

@ -0,0 +1,109 @@
/* eslint-disable @typescript-eslint/no-unused-vars,@typescript-eslint/no-explicit-any,prefer-destructuring */
import {
RepositoryBase,
recordInfo,
UUID,
Flavor,
} from "@digiresilience/metamigo-common";
export type SettingId = Flavor<UUID, "Setting Id">;
export interface UnsavedSetting<T> {
name: string;
value: T;
}
export interface SavedSetting<T> extends UnsavedSetting<T> {
id: SettingId;
createdAt: Date;
updatedAt: Date;
}
export const SettingRecord = recordInfo<UnsavedSetting<any>, SavedSetting<any>>(
"app_public",
"settings"
);
export class SettingRecordRepository extends RepositoryBase(SettingRecord) {
async findByName<T>(name: string): Promise<SavedSetting<T> | null> {
return this.db.oneOrNone("SELECT * FROM $1 $2:raw LIMIT 1", [
this.schemaTable,
this.where({ name }),
]);
}
async upsert<T>(name: string, value: T): Promise<SavedSetting<T>> {
return this.db.one(
`INSERT INTO $1 ($2:name) VALUES ($2:csv)
ON CONFLICT (name)
DO UPDATE SET value = EXCLUDED.value RETURNING *`,
[this.schemaTable, this.columnize({ name, value })]
);
}
}
// these helpers let us create type safe setting constants
export interface SettingType<T = any> {
_type: T;
}
export interface SettingInfo<T = any> extends SettingType<T> {
name: string;
}
export function castToSettingInfo(
runtimeData: Omit<SettingInfo, "_type">
): SettingInfo {
return runtimeData as SettingInfo;
}
export function settingInfo<T>(name: string): SettingInfo<T>;
// don't use this signature, use the explicit typed signature
export function settingInfo(name: string) {
return castToSettingInfo({
name,
});
}
export interface ISettingsService {
name: string;
lookup<T>(settingInfo: SettingInfo<T>): Promise<T>;
save<T>(settingInfo: SettingInfo<T>, value: T): Promise<T>;
}
export const SettingsService = (
repo: SettingRecordRepository
): ISettingsService => ({
name: "settingService",
async lookup<T>(settingInfo: SettingInfo<T>): Promise<T> {
const s = await repo.findByName<T>(settingInfo.name);
return s.value;
},
async save<T>(settingInfo: SettingInfo<T>, value: T): Promise<T> {
const s = await repo.upsert(settingInfo.name, value);
return s.value;
},
});
const _test = async () => {
// here is an example of how to use this module
// it also serves as a compile-time test case
const repo = new SettingRecordRepository({} as any);
// create your own custom setting types!
// the value is serialized as json in the database
type Custom = { foo: string; bar: string };
type CustomUnsavedSetting = UnsavedSetting<Custom>;
type CustomSetting = SavedSetting<Custom>;
const s3: CustomSetting = await repo.findByName("test");
const customValue = { foo: "monkeys", bar: "eggplants" };
let customSetting = { name: "custom", value: customValue };
customSetting = await repo.insert(customSetting);
const value: Custom = customSetting.value;
const MySetting = settingInfo<string>("my-setting");
};

View file

@ -0,0 +1,40 @@
import {
RepositoryBase,
recordInfo,
UUID,
Flavor,
} from "@digiresilience/metamigo-common";
export type SignalBotId = Flavor<UUID, "Signal Bot Id">;
export interface UnsavedSignalBot {
phoneNumber: string;
userId: string;
description: string;
}
export interface SavedSignalBot extends UnsavedSignalBot {
id: SignalBotId;
createdAt: Date;
updatedAt: Date;
token: string;
authInfo: string;
isVerified: boolean;
}
export const SignalBotRecord = recordInfo<UnsavedSignalBot, SavedSignalBot>(
"app_public",
"signal_bots"
);
export class SignalBotRecordRepository extends RepositoryBase(SignalBotRecord) {
async updateAuthInfo(
bot: SavedSignalBot,
authInfo: string | undefined
): Promise<SavedSignalBot> {
return this.db.one(
"UPDATE $1 SET (auth_info, is_verified) = ROW($2, true) WHERE id = $3 RETURNING *",
[this.schemaTable, authInfo, bot.id]
);
}
}

View file

@ -0,0 +1,62 @@
import {
RepositoryBase,
recordInfo,
UUID,
Flavor,
} from "@digiresilience/metamigo-common";
import type {} from "pg-promise";
export type VoiceLineId = Flavor<UUID, "VoiceLine Id">;
export type VoiceLineAudio = {
"audio/webm": string;
"audio/mpeg"?: string;
checksum?: string;
};
export interface UnsavedVoiceLine {
providerId: string;
providerLineSid: string;
number: string;
language: string;
voice: string;
promptText?: string;
promptAudio?: VoiceLineAudio;
audioPromptEnabled: boolean;
audioConvertedAt?: Date;
}
export interface SavedVoiceLine extends UnsavedVoiceLine {
id: VoiceLineId;
createdAt: Date;
updatedAt: Date;
}
export const VoiceLineRecord = recordInfo<UnsavedVoiceLine, SavedVoiceLine>(
"app_public",
"voice_lines"
);
export class VoiceLineRecordRepository extends RepositoryBase(VoiceLineRecord) {
/**
* Fetch all voice lines given the numbers
* @param numbers
*/
async findAllByNumbers(numbers: string[]): Promise<SavedVoiceLine[]> {
return this.db.any(
"SELECT id,provider_id,provider_line_sid,number FROM $1 WHERE number in ($2:csv)",
[this.schemaTable, numbers]
);
}
/**
* Fetch all voice lines given a list of provider line ids
* @param ids
*/
async findAllByProviderLineSids(ids: string[]): Promise<SavedVoiceLine[]> {
return this.db.any(
"SELECT id,provider_id,provider_line_sid,number FROM $1 WHERE provider_line_sid in ($2:csv)",
[this.schemaTable, ids]
);
}
}

View file

@ -0,0 +1,57 @@
import {
RepositoryBase,
recordInfo,
UUID,
Flavor,
} from "@digiresilience/metamigo-common";
/*
* VoiceProvider
*
* A provider is a company that provides incoming voice call services
*/
export type VoiceProviderId = Flavor<UUID, "VoiceProvider Id">;
export enum VoiceProviderKinds {
TWILIO = "TWILIO",
}
export type TwilioCredentials = {
accountSid: string;
apiKeySid: string;
apiKeySecret: string;
};
// expand this type later when we support more providers
export type VoiceProviderCredentials = TwilioCredentials;
export interface UnsavedVoiceProvider {
kind: VoiceProviderKinds;
name: string;
credentials: VoiceProviderCredentials;
}
export interface SavedVoiceProvider extends UnsavedVoiceProvider {
id: VoiceProviderId;
createdAt: Date;
updatedAt: Date;
}
export const VoiceProviderRecord = recordInfo<
UnsavedVoiceProvider,
SavedVoiceProvider
>("app_public", "voice_providers");
export class VoiceProviderRecordRepository extends RepositoryBase(
VoiceProviderRecord
) {
async findByTwilioAccountSid(
accountSid: string
): Promise<SavedVoiceProvider | null> {
return this.db.oneOrNone(
"select * from $1 where credentials->>'accountSid' = $2",
[this.schemaTable, accountSid]
);
}
}

View file

@ -0,0 +1,50 @@
import {
RepositoryBase,
recordInfo,
UUID,
Flavor,
} from "@digiresilience/metamigo-common";
/*
* Webhook
*
* A webhook allows external services to be notified when a recorded call is available
*/
export type WebhookId = Flavor<UUID, "Webhook Id">;
export interface HttpHeaders {
header: string;
value: string;
}
export interface UnsavedWebhook {
name: string;
voiceLineId: string;
endpointUrl: string;
httpMethod: "post" | "put";
headers?: HttpHeaders[];
}
export interface SavedWebhook extends UnsavedWebhook {
id: WebhookId;
createdAt: Date;
updatedAt: Date;
}
export const WebhookRecord = recordInfo<UnsavedWebhook, SavedWebhook>(
"app_public",
"webhooks"
);
export class WebhookRecordRepository extends RepositoryBase(WebhookRecord) {
async findAllByBackendId(
backendType: string,
backendId: string
): Promise<SavedWebhook[]> {
return this.db.any(
"select * from $1 where backend_type = $2 and backend_id = $3",
[this.schemaTable, backendType, backendId]
);
}
}

View file

@ -0,0 +1,29 @@
import {
RepositoryBase,
recordInfo,
UUID,
Flavor,
} from "@digiresilience/metamigo-common";
export type WhatsappAttachmentId = Flavor<UUID, "Whatsapp Attachment Id">;
export interface UnsavedWhatsappAttachment {
whatsappBotId: string;
whatsappMessageId: string;
attachment: Buffer;
}
export interface SavedWhatsappAttachment extends UnsavedWhatsappAttachment {
id: WhatsappAttachmentId;
createdAt: Date;
updatedAt: Date;
}
export const WhatsappAttachmentRecord = recordInfo<
UnsavedWhatsappAttachment,
SavedWhatsappAttachment
>("app_public", "whatsapp_attachments");
export class WhatsappAttachmentRecordRepository extends RepositoryBase(
WhatsappAttachmentRecord
) {}

View file

@ -0,0 +1,53 @@
import {
RepositoryBase,
recordInfo,
UUID,
Flavor,
} from "@digiresilience/metamigo-common";
export type WhatsappBotId = Flavor<UUID, "Whatsapp Bot Id">;
export interface UnsavedWhatsappBot {
phoneNumber: string;
userId: string;
description: string;
}
export interface SavedWhatsappBot extends UnsavedWhatsappBot {
id: WhatsappBotId;
createdAt: Date;
updatedAt: Date;
token: string;
authInfo: string;
qrCode: string;
isVerified: boolean;
}
export const WhatsappBotRecord = recordInfo<
UnsavedWhatsappBot,
SavedWhatsappBot
>("app_public", "whatsapp_bots");
export class WhatsappBotRecordRepository extends RepositoryBase(
WhatsappBotRecord
) {
async updateQR(
bot: SavedWhatsappBot,
qrCode: string | undefined
): Promise<SavedWhatsappBot> {
return this.db.one(
"UPDATE $1 SET (qr_code) = ROW($2) WHERE id = $3 RETURNING *",
[this.schemaTable, qrCode, bot.id]
);
}
async updateAuthInfo(
bot: SavedWhatsappBot,
authInfo: string | undefined
): Promise<SavedWhatsappBot> {
return this.db.one(
"UPDATE $1 SET (auth_info, is_verified) = ROW($2, true) WHERE id = $3 RETURNING *",
[this.schemaTable, authInfo, bot.id]
);
}
}

View file

@ -0,0 +1,31 @@
import {
RepositoryBase,
recordInfo,
UUID,
Flavor,
} from "@digiresilience/metamigo-common";
export type WhatsappMessageId = Flavor<UUID, "Whatsapp Message Id">;
export interface UnsavedWhatsappMessage {
whatsappBotId: string;
waMessageId: string;
waTimestamp: Date;
waMessage: string;
attachments?: string[];
}
export interface SavedWhatsappMessage extends UnsavedWhatsappMessage {
id: WhatsappMessageId;
createdAt: Date;
updatedAt: Date;
}
export const WhatsappMessageRecord = recordInfo<
UnsavedWhatsappMessage,
SavedWhatsappMessage
>("app_public", "whatsapp_messages");
export class WhatsappMessageRecordRepository extends RepositoryBase(
WhatsappMessageRecord
) {}