From 0aa815072fc9a59605c13363ca759dfae74af75b Mon Sep 17 00:00:00 2001 From: Sasha Sorokin Date: Sat, 29 Sep 2018 00:20:52 +0700 Subject: [PATCH 1/2] streamNotifications: revamp databases structure --- .../streamNotifications/db/BaseDBManager.ts | 27 ++++ .../Notifications/NotificationController.ts | 123 ++++++++++++++++ .../db/Notifications/NotificationData.ts | 29 ++++ .../db/Notifications/NotificationsDB.ts | 106 ++++++++++++++ .../NotificationsSettingsController.ts | 94 ++++++++++++ .../db/Settings/NotificationsSettingsDB.ts | 134 ++++++++++++++++++ .../db/Settings/NotificationsSettingsData.ts | 24 ++++ .../db/SubscriptionBasedController.ts | 83 +++++++++++ .../db/Subscriptions/SubscriptionData.ts | 98 +++++++++++++ .../db/Subscriptions/SubscriptionsDB.ts | 74 ++++++++++ 10 files changed, 792 insertions(+) create mode 100644 src/cogs/streamNotifications/db/BaseDBManager.ts create mode 100644 src/cogs/streamNotifications/db/Notifications/NotificationController.ts create mode 100644 src/cogs/streamNotifications/db/Notifications/NotificationData.ts create mode 100644 src/cogs/streamNotifications/db/Notifications/NotificationsDB.ts create mode 100644 src/cogs/streamNotifications/db/Settings/NotificationsSettingsController.ts create mode 100644 src/cogs/streamNotifications/db/Settings/NotificationsSettingsDB.ts create mode 100644 src/cogs/streamNotifications/db/Settings/NotificationsSettingsData.ts create mode 100644 src/cogs/streamNotifications/db/SubscriptionBasedController.ts create mode 100644 src/cogs/streamNotifications/db/Subscriptions/SubscriptionData.ts create mode 100644 src/cogs/streamNotifications/db/Subscriptions/SubscriptionsDB.ts diff --git a/src/cogs/streamNotifications/db/BaseDBManager.ts b/src/cogs/streamNotifications/db/BaseDBManager.ts new file mode 100644 index 0000000..861ff69 --- /dev/null +++ b/src/cogs/streamNotifications/db/BaseDBManager.ts @@ -0,0 +1,27 @@ +import { getDB } from "@utils/db"; +import * as Knex from "knex"; + +export class BaseDBManager { + private constructor() { + throw new Error("This class is not initializable"); + } + + public static readonly DB = getDB(); + + public static async isTableExists(tableName: string) { + return BaseDBManager.DB.schema.hasTable(tableName); + } + + public static async createTableIfNotExists(tableName: string, callback: (tableBuilder: Knex.TableBuilder) => void) { + if (BaseDBManager.isTableExists(tableName)) { + return; + } + + return BaseDBManager.DB.schema.createTableIfNotExists( + tableName, + callback + ); + } +} + +export default BaseDBManager; diff --git a/src/cogs/streamNotifications/db/Notifications/NotificationController.ts b/src/cogs/streamNotifications/db/Notifications/NotificationController.ts new file mode 100644 index 0000000..d52a8eb --- /dev/null +++ b/src/cogs/streamNotifications/db/Notifications/NotificationController.ts @@ -0,0 +1,123 @@ +import SharedSubscriptionData from "./Subscriptions/SubscriptionData"; +import NotificationsDB from "./NotificationsDB"; +import { NotificationData, fulfillmentCheck } from "./NotificationData"; +import SubscriptionBasedController from "./SubscriptionBasedController"; + +type D = NotificationData; +type P = NotificationsDB; + +export class NotificationController extends SubscriptionBasedController { + constructor(subscription: SharedSubscriptionData, parent: NotificationsDB) { + super(subscription, parent); + } + + private _oldStreamId?: string; + + public async fetch() { + const currentData = this._data; + + const availableData = await this._getData(); + + if (!availableData) { return false; } + + this._data = { + ...currentData, + ...availableData + }; + + this._markCreated(true); + + return true; + } + + public async post() { + const data = this._data; + + if (!fulfillmentCheck(data)) { + return false; + } + + const currentData = await this._getData(); + + if (currentData) { + await this._parent.updateNotification( + data, + this._oldStreamId + ); + + this._oldStreamId = undefined; + } else { + await this._parent.saveNotification(data); + + this._markCreated(true); + } + + return true; + } + + public getMessageId() { + // ideally it never should be null + // TODO: check if `messageId` is not null + return this._data.messageId; + } + + public setMessageId(value: string) { + const currentMessageId = this._data.messageId; + + if (currentMessageId && this.isCreated()) { + throw new Error("Cannot set message ID when it is already set"); + } + + // We cannot use spread because it will make default object + // with such things such as `toString` that we don't really need + + // tslint:disable-next-line:prefer-object-spread + this._data = Object.assign( + this._data, { + messageId: value + } + ); + + return this; + } + + public getStreamId() { + return this._data.streamerId; + } + + public setStreamId(value: string) { + if (this.isCreated()) { + const currentStreamId = this._data.streamId; + + if (currentStreamId && !this._oldStreamId) { + this._oldStreamId = currentStreamId; + } + } + + this._data.streamId = value; + + return this; + } + + public getPayload() { + return this._data.platformPayload; + } + + public setPayload(value: string) { + this._data.platformPayload = value; + + return this; + } + + protected async _getData() { + return this._parent.getNotification( + this._subscription + ); + } + + public fulfillmentCheck() { + return fulfillmentCheck(this._data); + } +} + +export default NotificationController; diff --git a/src/cogs/streamNotifications/db/Notifications/NotificationData.ts b/src/cogs/streamNotifications/db/Notifications/NotificationData.ts new file mode 100644 index 0000000..c516240 --- /dev/null +++ b/src/cogs/streamNotifications/db/Notifications/NotificationData.ts @@ -0,0 +1,29 @@ +import { SharedSubscriptionData, fulfillmentCheck as subFulfillmentCheck } from "./Subscriptions/SubscriptionData"; +import { TableBuilder } from "knex"; +import { MissingPropertyError } from "./SubscriptionBasedController"; + +export type NotificationData = SharedSubscriptionData & { + readonly messageId: string; + streamId?: string; + platformPayload?: string; +}; + +export function addNotificationColumns(tableBuilder: TableBuilder) { + tableBuilder.string("messageId").notNullable(); + tableBuilder.string("streamId").nullable(); + tableBuilder.string("platformPayload").nullable(); +} + +export function fulfillmentCheck(data: Partial) : data is NotificationData { + if (!data.messageId) { + throw new MissingPropertyError("streamerId"); + } + + if (!subFulfillmentCheck(data)) { + return false; + } + + return true; +} + +export default NotificationData; diff --git a/src/cogs/streamNotifications/db/Notifications/NotificationsDB.ts b/src/cogs/streamNotifications/db/Notifications/NotificationsDB.ts new file mode 100644 index 0000000..eec4566 --- /dev/null +++ b/src/cogs/streamNotifications/db/Notifications/NotificationsDB.ts @@ -0,0 +1,106 @@ +import BaseDBManager from "./BaseDBManager"; +import { SharedSubscriptionData, addSharedSubscriptionColumns, getSelection } from "./Subscriptions/SubscriptionData"; +import { NotificationData, addNotificationColumns } from "./NotificationData"; +import * as db from "@utils/db"; + +// Notifications + +// Notification modification +// - [x] saveNotification +// - [x] updateNotification +// - [x] deleteNotification + +// Searching notifications +// - [ ] getAllNotifications +// - [ ] findNotification + +const INIT_MAP = new WeakMap(); + +export class NotificationsDB { + private readonly _tableName: string; + private readonly _db = db.getDB(); + + constructor(tableName: string) { + if (!tableName) { + throw new Error("No table name specified"); + } + + this._tableName = tableName; + } + + /** + * Initializes and checks the database + */ + public async init() { + await BaseDBManager.createTableIfNotExists( + this._tableName, + (tb) => { + addSharedSubscriptionColumns(tb); + addNotificationColumns(tb); + } + ); + + INIT_MAP.set(this, true); + } + + /** + * Gets a notification for the stream + * @param subscription Subscription details + * @param streamId Stream ID if mandatory + */ + public async getNotification(subscription: SharedSubscriptionData, streamId?: string) : Notification { + NotificationsDB._checkInitDone(this); + + return this._db(this._tableName) + .where(subscription) + .where({ streamId }) + .first(); + } + + /** + * Creates the notification in the database + * @param data Notification data + */ + public async saveNotification(data: NotificationData) : Promise { + NotificationsDB._checkInitDone(this); + + return this._db(this._tableName) + .insert(data); + } + + /** + * Updates the notification + * @param data Notification data + * @param oldStreamId Old Stream ID if mandatory + */ + public async updateNotification(data: NotificationData, oldStreamId?: string) { + NotificationsDB._checkInitDone(this); + + return this._db(this._tableName) + .where(getSelection(data)) + .where({ streamId: oldStreamId }) + .update(data); + } + + /** + * Deletes the notification + * @param data Notification data + */ + public async deleteNotification(data: NotificationData) { + NotificationsDB._checkInitDone(this); + + return this._db(this._tableName) + .where(data) + .delete(); + } + + private static _checkInitDone(dbController: NotificationsDB) { + if (!INIT_MAP.has(dbController)) { + throw new Error("DB controller must be initialized first"); + } + } +} + +type Notification = Promise; + +export default NotificationsDB; diff --git a/src/cogs/streamNotifications/db/Settings/NotificationsSettingsController.ts b/src/cogs/streamNotifications/db/Settings/NotificationsSettingsController.ts new file mode 100644 index 0000000..7d3f318 --- /dev/null +++ b/src/cogs/streamNotifications/db/Settings/NotificationsSettingsController.ts @@ -0,0 +1,94 @@ +import NotificationsSettingsDB from "./NotificationsSettingsDB"; +import NotificationsSettingsData, { fulfillmentCheck } from "./NotificationsSettingsData"; +import SharedSubscriptionData from "./Subscriptions/SubscriptionData"; +import SubscriptionBasedController from "./SubscriptionBasedController"; + +type P = NotificationsSettingsDB; +type D = NotificationsSettingsData; + +export class NotificationsSettingsController extends SubscriptionBasedController { + constructor(subscription: SharedSubscriptionData, parent: NotificationsSettingsDB) { + super(subscription, parent); + } + + public async fetch() { + const currentData = this._data; + + const availableData = await this._getData(); + + if (!availableData) { return false; } + + this._data = { + ...currentData, + ...availableData + }; + + return true; + } + + public async post() { + const data = this._data; + + if (!fulfillmentCheck(data)) { + return false; + } + + const currentData = await this._getData(); + + if (currentData) { + await this._parent.updateSettings(data); + } else { + await this._parent.createSettings(data); + } + + return true; + } + + public fulfillmentCheck() { + return fulfillmentCheck(this._data); + } + + protected async _getData() { + return this._parent.getSettings( + this._subscription + ); + } + + /** + * Gets text to use in messages when notification is being sent + */ + public getMessageText() : OptionalString { + return this._data.messageText; + } + + /** + * Sest text used in messages when notification is being sent + * @param text Text to use in messages + */ + public setMessageText(text: OptionalString) { + if (text && text.length === 0) { + throw new Error("Empty text"); + } + + this._data.messageText = text; + } + + /** + * Sets the platform data + * @param data Platform data + */ + public setPlatformData(data: OptionalString) { + this._data.platformData = data; + } + + /** + * The platform data + */ + public getMessageData() : OptionalString { + return this._data.platformData; + } +} + +type OptionalString = string | null | undefined; + +export default NotificationsSettingsController; diff --git a/src/cogs/streamNotifications/db/Settings/NotificationsSettingsDB.ts b/src/cogs/streamNotifications/db/Settings/NotificationsSettingsDB.ts new file mode 100644 index 0000000..184c60b --- /dev/null +++ b/src/cogs/streamNotifications/db/Settings/NotificationsSettingsDB.ts @@ -0,0 +1,134 @@ +import BaseDBManager from "@cogs/streamNotifications/db/BaseDBManager"; +import { SharedSubscriptionData, addSharedSubscriptionColumns } from "@cogs/streamNotifications/db/Subscriptions/SubscriptionData"; +import { NotificationsSettingsData, addNotificationSettingsColumns } from "@cogs/streamNotifications/db/NotificationsSettingsData"; +import * as db from "@utils/db"; + +// Settings + +// Settings modifications +// - [x] createSettings +// - [x] updateSettings +// - [x] deleteSettings + +// Searching settings: +// - [x] getSettings + +const INIT_MAP = new WeakMap(); + +export class NotificationsSettingsDB { + private readonly _tableName: string; + private readonly _db = db.getDB(); + + constructor(tableName: string) { + if (!tableName) { + throw new Error("No table name specified"); + } + + this._tableName = tableName; + } + + /** + * Initializes and checks the database + */ + public async init() { + await BaseDBManager.createTableIfNotExists( + this._tableName, + (tb) => { + addSharedSubscriptionColumns(tb); + addNotificationSettingsColumns(tb); + } + ); + + INIT_MAP.set(this, true); + } + + /** + * Inserts settings into the database + * @param data Settings + */ + public async createSettings(data: NotificationsSettingsData) : Promise { + NotificationsSettingsDB._checkInitDone(this); + + return this._db(this._tableName) + .insert(data); + } + + /** + * Finds the settings for guild + * @param guildId Guild ID of settings + * @param platform Platform signature + * @param streamerId Streamer ID + */ + public async getSettings(subscription: SharedSubscriptionData) : OptionalSettings { + NotificationsSettingsDB._checkInitDone(this); + + const { + guildId, + platform, + streamerId + } = subscription; + + return this._db(this._tableName) + .where({ + guildId, + platform, + streamerId + }) + .first(); + } + + /** + * Updates settings for guild + * @param settings New settings + */ + public async updateSettings(settings: NotificationsSettingsData) { + NotificationsSettingsDB._checkInitDone(this); + + return this._db(this._tableName) + .where( + NotificationsSettingsDB._getSelect( + settings + ) + ) + .update(settings); + } + + /** + * Deletes settings from the table + * @param settings Settings to delet + */ + public async deleteSettings(settings: NotificationsSettingsData) { + NotificationsSettingsDB._checkInitDone(this); + + return this._db(this._tableName) + .where( + NotificationsSettingsDB._getSelect( + settings + ) + ) + .delete(); + } + + private static _getSelect(obj: NotificationsSettingsData) { + const { + guildId, + alternativeChannel, + platform, + streamerId + } = obj; + + return { guildId, alternativeChannel, platform, streamerId }; + } + + private static _checkInitDone(dbController: NotificationsSettingsDB) { + if (!INIT_MAP.has(dbController)) { + throw new Error("DB controller must be initialized first"); + } + + return true; + } +} + +type OptionalSettings = Promise; + +export default NotificationsSettingsDB; diff --git a/src/cogs/streamNotifications/db/Settings/NotificationsSettingsData.ts b/src/cogs/streamNotifications/db/Settings/NotificationsSettingsData.ts new file mode 100644 index 0000000..c91d908 --- /dev/null +++ b/src/cogs/streamNotifications/db/Settings/NotificationsSettingsData.ts @@ -0,0 +1,24 @@ +import { SharedSubscriptionData, fulfillmentCheck as subFulfillmentCheck } from "@cogs/streamNotifications/db/Subscriptions/SubscriptionData"; +import { TableBuilder } from "knex"; + +export type NotificationsSettingsData = SharedSubscriptionData & { + /** + * Text for the message with embed + */ + messageText?: string | null; + /** + * The platform data + */ + platformData?: string | null; +}; + +export function addNotificationSettingsColumns(tableBuilder: TableBuilder) { + tableBuilder.string("messageText").nullable(); + tableBuilder.string("platformData").nullable(); +} + +export function fulfillmentCheck(data: Partial) : data is NotificationsSettingsData { + return subFulfillmentCheck(data); +} + +export default NotificationsSettingsData; diff --git a/src/cogs/streamNotifications/db/SubscriptionBasedController.ts b/src/cogs/streamNotifications/db/SubscriptionBasedController.ts new file mode 100644 index 0000000..34b93ca --- /dev/null +++ b/src/cogs/streamNotifications/db/SubscriptionBasedController.ts @@ -0,0 +1,83 @@ +import SharedSubscriptionData from "./Subscriptions/SubscriptionData"; + +const CREATE_STATES = new WeakMap, boolean>(); + +export abstract class SubscriptionBasedController { + protected readonly _parent: P; + protected readonly _subscription: SharedSubscriptionData; + protected _data: Partial; + + constructor(subscription: SharedSubscriptionData, parent: P) { + this._subscription = subscription; + this._parent = parent; + this._data = Object.create(null); + } + + /** + * Fetches data from the database, it's the first thing you must do + * + * Wasn't data posted to DB before fetch, it will override the current data + */ + public abstract async fetch() : Promise; + + /** + * Posts data to the database + */ + public abstract async post() : Promise; + + /** + * Gets data from the database + */ + protected abstract _getData() : Promise; + + /** + * Checks if the current data meets the requirements to be posted + * + * If data didn't meet this requirements it cannot be posted + * + * @throws + */ + public abstract fulfillmentCheck() : boolean; + + protected _markCreated(state: boolean) { + CREATE_STATES.set(this, state); + } + + public isCreated() { + const fetchState = CREATE_STATES.get(this); + + if (fetchState == null) { + return false; + } + + return fetchState; + } +} + +export class MissingPropertyError extends Error { + private readonly _prop: keyof T; + + public get property() { + return this._prop; + } + + constructor(missedProp: keyof T) { + super(`The data is missing "${missedProp}"`); + this._prop = missedProp; + } +} + +export class ValueError extends Error { + private readonly _prop: keyof T; + + public get property() { + return this._prop; + } + + constructor(prop: keyof T, reason: string) { + super(`The data has invalid type for "${prop}": ${reason}`); + this._prop = prop; + } +} + +export default SubscriptionBasedController; diff --git a/src/cogs/streamNotifications/db/Subscriptions/SubscriptionData.ts b/src/cogs/streamNotifications/db/Subscriptions/SubscriptionData.ts new file mode 100644 index 0000000..c560c5d --- /dev/null +++ b/src/cogs/streamNotifications/db/Subscriptions/SubscriptionData.ts @@ -0,0 +1,98 @@ +import { TableBuilder } from "knex"; +import { MissingPropertyError, ValueError } from "../SubscriptionBasedController"; +import { canBeSnowflake } from "@utils/text"; + +export type SharedSubscriptionData = { + /** + * Guild ID or "user_sub" + */ + readonly guildId: string | "user_sub"; + /** + * Alternative Channel ID for notifications or User ID + */ + readonly alternativeChannel?: string; + /** + * Platform of the streams + */ + readonly platform: string; + /** + * Streamer ID for the platform + */ + readonly streamerId: string; +}; + +export type SubscriptionData = SharedSubscriptionData & { + /** + * Display name for the streamer set by platform + */ + displayName?: string; +}; + +export function addSharedSubscriptionColumns(tableBuilder: TableBuilder) { + tableBuilder.string("guildId").notNullable(); + tableBuilder.string("alternativeChannel").nullable(); + tableBuilder.string("platform").notNullable(); + tableBuilder.string("streamerId").notNullable(); +} + +export function addSubscriptionColumns(tableBuilder: TableBuilder) { + addSharedSubscriptionColumns(tableBuilder); + + tableBuilder.string("displayName").nullable(); +} + +export function getSelection(data: SharedSubscriptionData) { + const { + guildId, + alternativeChannel, + platform, + streamerId + } = data; + + return { + guildId, + alternativeChannel, + platform, + streamerId + }; +} + +export function fulfillmentCheck(data: Partial) : data is SharedSubscriptionData { + type D = SharedSubscriptionData; + + if (!data.platform) { + throw new MissingPropertyError("platform"); + } + + if (!data.streamerId) { + throw new MissingPropertyError("streamerId"); + } + + const { guildId, alternativeChannel } = data; + + if (!guildId) { + throw new MissingPropertyError("guildId"); + } else if (guildId === "user_sub" && !alternativeChannel) { + throw new MissingPropertyError("alternativeChannel"); + } + + if (!canBeSnowflake(guildId)) { + throw new ValueError( + "guildId", + "guild ID must be valid Snowflake" + ); + } + + if (alternativeChannel) { + if (!canBeSnowflake(alternativeChannel)) { + throw new ValueError( + "alternativeChannel", + "for user subscription `alternativeChannel` must be their ID" + ); + } + } + + return true; +} + +export default SharedSubscriptionData; diff --git a/src/cogs/streamNotifications/db/Subscriptions/SubscriptionsDB.ts b/src/cogs/streamNotifications/db/Subscriptions/SubscriptionsDB.ts new file mode 100644 index 0000000..357cea7 --- /dev/null +++ b/src/cogs/streamNotifications/db/Subscriptions/SubscriptionsDB.ts @@ -0,0 +1,74 @@ +import * as db from "@utils/db"; +import BaseDBManager from "../BaseDBManager"; +import { SubscriptionData, addSubscriptionColumns, getSelection } from "./SubscriptionData"; + +// Subscriptions + +// Subscription modification +// - [x] createSubscription +// - [x] updateSubscription +// - [x] deleteSubscription + +// Searching subscriptions: +// - [ ] findSubscriptionsByFilter +// - [ ] findSubscription + +const INIT_MAP = new WeakMap(); + +function checkInitDone(instance: SubscriptionsDB) { + if (!INIT_MAP.has(instance)) { + throw new Error("DB controller must be initialized first"); + } +} + +export class SubscriptionsDB { + private readonly _tableName: string; + private readonly _db = db.getDB(); + + constructor(tableName: string) { + if (!tableName) { + throw new Error("No table name specified"); + } + + this._tableName = tableName; + } + + public async init() { + BaseDBManager.createTableIfNotExists( + this._tableName, + (tb) => { + addSubscriptionColumns(tb); + } + ); + + INIT_MAP.set(this, true); + } + + public async getSubscription(subscription: SubscriptionData) : OptionalSubscription { + checkInitDone(this); + + return this._db(this._tableName) + .where(getSelection(subscription)) + .first(); + } + + public async updateSubscription(data: SubscriptionData) { + checkInitDone(this); + + return this._db(this._tableName) + .where(getSelection(data)) + .update(data); + } + + public async deleteSubscription(data: SubscriptionData) { + checkInitDone(this); + + return this._db(this._tableName) + .where(getSelection(data)) + .delete(); + } +} + +type OptionalSubscription = Promise; + +export default SubscriptionsDB; From 979b4761dc03defbab42162ea48ddf8bc51a0615 Mon Sep 17 00:00:00 2001 From: Sasha Sorokin Date: Tue, 2 Oct 2018 23:39:28 +0700 Subject: [PATCH 2/2] streamNotifications: add more db controllers --- .../GuildSettings/GuildSettingsController.ts | 129 +++++++++++++++ .../db/GuildSettings/GuildSettingsDB.ts | 87 ++++++++++ .../db/GuildSettings/GuildSettingsData.ts | 66 ++++++++ .../Notifications/NotificationController.ts | 4 +- .../db/Notifications/NotificationData.ts | 6 +- .../db/Notifications/NotificationsDB.ts | 6 +- .../NotificationsSettingsController.ts | 94 +++++++++++ .../db/NotificationsSettingsController.ts | 94 +++++++++++ .../NotificationsSettingsController.ts | 6 +- .../db/Settings/NotificationsSettingsDB.ts | 4 +- .../db/Settings/NotificationsSettingsData.ts | 1 - .../db/StreamingDBController.test.ts | 56 +++++++ .../db/StreamingDBController.ts | 150 ++++++++++++++++++ .../db/SubscriptionBasedController.ts | 20 ++- .../db/Subscriptions/SubscriptionsDB.ts | 13 ++ 15 files changed, 715 insertions(+), 21 deletions(-) create mode 100644 src/cogs/streamNotifications/db/GuildSettings/GuildSettingsController.ts create mode 100644 src/cogs/streamNotifications/db/GuildSettings/GuildSettingsDB.ts create mode 100644 src/cogs/streamNotifications/db/GuildSettings/GuildSettingsData.ts create mode 100644 src/cogs/streamNotifications/db/Notifications/NotificationsSettingsController.ts create mode 100644 src/cogs/streamNotifications/db/NotificationsSettingsController.ts create mode 100644 src/cogs/streamNotifications/db/StreamingDBController.test.ts create mode 100644 src/cogs/streamNotifications/db/StreamingDBController.ts diff --git a/src/cogs/streamNotifications/db/GuildSettings/GuildSettingsController.ts b/src/cogs/streamNotifications/db/GuildSettings/GuildSettingsController.ts new file mode 100644 index 0000000..aee5fef --- /dev/null +++ b/src/cogs/streamNotifications/db/GuildSettings/GuildSettingsController.ts @@ -0,0 +1,129 @@ +import { BaseController } from "../SubscriptionBasedController"; +import { GuildSettingsDB } from "./GuildSettingsDB"; +import { GuildSettingsData, fulfillmentCheck, MatureStreamBehavior } from "./GuildSettingsData"; + +export class GuildSettingsController extends BaseController { + private readonly _guildId: string; + + constructor(guildId: string, parent: GuildSettingsDB) { + super(parent); + + this._guildId = guildId; + + // tslint:disable-next-line:prefer-object-spread + this._data = Object.assign( + this._data, { + guildId + } + ); + } + + public async fetch() { + const currentData = this._data; + + const availableData = await this._getData(); + + if (!availableData) { + this._markCreated(false); + + return false; + } + + this._data = { + ...currentData, + ...availableData + }; + + this._markCreated(true); + + return true; + } + + public async post() { + const data = this._data; + + if (!fulfillmentCheck(data)) { + return false; + } + + const currentData = await this._getData(); + + if (currentData) { + await this._parent.updateSettings(data); + } else { + await this._parent.createSettings(data); + + this._markCreated(true); + } + + return true; + } + + public fulfillmentCheck() { + return fulfillmentCheck( + this._data + ); + } + + protected async _getData() { + return this._parent.getSettings( + this._guildId + ); + } + + public resolveGuild() { + const { guildId } = this._data; + + if (!guildId) { + return undefined; + } + + return $discordBot.guilds.get( + guildId + ); + } + + public getGuildId() { + return this._data.guildId!; + } + + public getMatureBehavior() { + return this._data.matureBehavior; + } + + public setMatureBehavior(behavior?: MatureStreamBehavior) { + this._data.matureBehavior = behavior; + + return this; + } + + public resolveDefaultChannel() { + const { defaultChannelId } = this._data; + + if (!defaultChannelId) { + return undefined; + } + + const guild = this.resolveGuild(); + + if (!guild) { + return undefined; + } + + return guild.channels.get(defaultChannelId); + } + + public getDefaultChannelId() { + return this._data.defaultChannelId; + } + + public setDefaultChannelId(id: string) { + if (!id) { + throw new Error("ID cannot be set to null"); + } + + this._data.defaultChannelId = id; + + return this; + } +} diff --git a/src/cogs/streamNotifications/db/GuildSettings/GuildSettingsDB.ts b/src/cogs/streamNotifications/db/GuildSettings/GuildSettingsDB.ts new file mode 100644 index 0000000..0a7907c --- /dev/null +++ b/src/cogs/streamNotifications/db/GuildSettings/GuildSettingsDB.ts @@ -0,0 +1,87 @@ +import * as db from "@utils/db"; +import BaseDBManager from "../BaseDBManager"; +import { addGuildSettingsColumns, GuildSettingsData } from "./GuildSettingsData"; + +const INIT_MAP = new WeakMap(); + +export class GuildSettingsDB { + private readonly _tableName: string; + private readonly _db = db.getDB(); + + constructor(tableName: string) { + if (!tableName) { + throw new Error("No table name specified"); + } + + this._tableName = tableName; + } + + /** + * Initializes and checks the database + */ + public async init() { + await BaseDBManager.createTableIfNotExists( + this._tableName, + (tb) => { + addGuildSettingsColumns(tb); + } + ); + + INIT_MAP.set(this, true); + } + + public async createSettings(data: GuildSettingsData) { + GuildSettingsDB._checkInitDone(this); + + return this._db(this._tableName) + .insert(data); + } + + public async getSettings(guildId: string) : OptionalSettings { + GuildSettingsDB._checkInitDone(this); + + return this._db(this._tableName) + .where({ guildId }) + .first(); + } + + public async updateSettings(data: GuildSettingsData) { + GuildSettingsDB._checkInitDone(this); + + return this._db(this._tableName) + .where( + GuildSettingsDB._getSelect( + data + ) + ) + .update(data); + } + + public async deleteSettings(data: GuildSettingsData) { + GuildSettingsDB._checkInitDone(this); + + return this._db(this._tableName) + .where( + GuildSettingsDB._getSelect( + data + ) + ) + .delete(); + } + + private static _getSelect(data: GuildSettingsData) { + const { guildId } = data; + + return { guildId }; + } + + private static _checkInitDone(instance: GuildSettingsDB) { + if (!INIT_MAP.has(instance)) { + throw new Error("DB controller must be initlized first"); + } + + return true; + } +} + +type OptionalSettings = Promise; diff --git a/src/cogs/streamNotifications/db/GuildSettings/GuildSettingsData.ts b/src/cogs/streamNotifications/db/GuildSettings/GuildSettingsData.ts new file mode 100644 index 0000000..e3576dd --- /dev/null +++ b/src/cogs/streamNotifications/db/GuildSettings/GuildSettingsData.ts @@ -0,0 +1,66 @@ +import { TableBuilder } from "knex"; +import { MissingPropertyError, ValueError } from "../SubscriptionBasedController"; +import { canBeSnowflake } from "@utils/text"; + +export type MatureStreamBehavior = "Nothing" | "Ignore" | "Banner"; + +const MATURE_BEHAVIOR_VALUES: MatureStreamBehavior[] = [ + "Nothing", + "Ignore", + "Banner" +]; + +export type GuildSettingsData = { + /** + * Where notifications will be sent + */ + readonly guildId: string; + /** + * Behavior when sending notification about the mature stream + */ + matureBehavior?: MatureStreamBehavior; + /** + * Discord ID of the default channel for the notifications + */ + defaultChannelId: string; +}; + +export function addGuildSettingsColumns(tableBuilder: TableBuilder) { + tableBuilder.string("guildId").notNullable(); + tableBuilder + .enum( + "matureBehavior", + ["Nothing", "Banner", "Ignore"] + ) + .nullable(); +} + +export function fulfillmentCheck(data: Partial): data is GuildSettingsData { + type D = GuildSettingsData; + + const { guildId } = data; + + if (!guildId) { + throw new MissingPropertyError("guildId"); + } + + if (!canBeSnowflake(guildId)) { + throw new ValueError( + "guildId", + "must be a valid guild ID" + ); + } + + const { matureBehavior } = data; + + if (matureBehavior != null) { + if (!MATURE_BEHAVIOR_VALUES.includes(matureBehavior)) { + throw new ValueError( + "matureBehavior", + `"${matureBehavior}" is not valid value, valid values are ${MATURE_BEHAVIOR_VALUES.join("/")}` + ); + } + } + + return true; +} diff --git a/src/cogs/streamNotifications/db/Notifications/NotificationController.ts b/src/cogs/streamNotifications/db/Notifications/NotificationController.ts index d52a8eb..82f4b86 100644 --- a/src/cogs/streamNotifications/db/Notifications/NotificationController.ts +++ b/src/cogs/streamNotifications/db/Notifications/NotificationController.ts @@ -1,7 +1,7 @@ -import SharedSubscriptionData from "./Subscriptions/SubscriptionData"; +import SharedSubscriptionData from "../Subscriptions/SubscriptionData"; import NotificationsDB from "./NotificationsDB"; import { NotificationData, fulfillmentCheck } from "./NotificationData"; -import SubscriptionBasedController from "./SubscriptionBasedController"; +import SubscriptionBasedController from "../SubscriptionBasedController"; type D = NotificationData; type P = NotificationsDB; diff --git a/src/cogs/streamNotifications/db/Notifications/NotificationData.ts b/src/cogs/streamNotifications/db/Notifications/NotificationData.ts index c516240..2bda1f1 100644 --- a/src/cogs/streamNotifications/db/Notifications/NotificationData.ts +++ b/src/cogs/streamNotifications/db/Notifications/NotificationData.ts @@ -1,6 +1,6 @@ -import { SharedSubscriptionData, fulfillmentCheck as subFulfillmentCheck } from "./Subscriptions/SubscriptionData"; +import { SharedSubscriptionData, fulfillmentCheck as subFulfillmentCheck } from "../Subscriptions/SubscriptionData"; import { TableBuilder } from "knex"; -import { MissingPropertyError } from "./SubscriptionBasedController"; +import { MissingPropertyError } from "../SubscriptionBasedController"; export type NotificationData = SharedSubscriptionData & { readonly messageId: string; @@ -25,5 +25,3 @@ export function fulfillmentCheck(data: Partial) : data is Noti return true; } - -export default NotificationData; diff --git a/src/cogs/streamNotifications/db/Notifications/NotificationsDB.ts b/src/cogs/streamNotifications/db/Notifications/NotificationsDB.ts index eec4566..b1bf449 100644 --- a/src/cogs/streamNotifications/db/Notifications/NotificationsDB.ts +++ b/src/cogs/streamNotifications/db/Notifications/NotificationsDB.ts @@ -1,5 +1,5 @@ -import BaseDBManager from "./BaseDBManager"; -import { SharedSubscriptionData, addSharedSubscriptionColumns, getSelection } from "./Subscriptions/SubscriptionData"; +import BaseDBManager from "../BaseDBManager"; +import { SharedSubscriptionData, addSharedSubscriptionColumns, getSelection } from "../Subscriptions/SubscriptionData"; import { NotificationData, addNotificationColumns } from "./NotificationData"; import * as db from "@utils/db"; @@ -101,6 +101,6 @@ export class NotificationsDB { } } -type Notification = Promise; +type Notification = Promise; export default NotificationsDB; diff --git a/src/cogs/streamNotifications/db/Notifications/NotificationsSettingsController.ts b/src/cogs/streamNotifications/db/Notifications/NotificationsSettingsController.ts new file mode 100644 index 0000000..8ba9313 --- /dev/null +++ b/src/cogs/streamNotifications/db/Notifications/NotificationsSettingsController.ts @@ -0,0 +1,94 @@ +import NotificationsSettingsDB from "../Settings/NotificationsSettingsDB"; +import { NotificationsSettingsData, fulfillmentCheck } from "../Settings/NotificationsSettingsData"; +import SharedSubscriptionData from "../Subscriptions/SubscriptionData"; +import SubscriptionBasedController from "../SubscriptionBasedController"; + +type P = NotificationsSettingsDB; +type D = NotificationsSettingsData; + +export class NotificationsSettingsController extends SubscriptionBasedController { + constructor(subscription: SharedSubscriptionData, parent: NotificationsSettingsDB) { + super(subscription, parent); + } + + public async fetch() { + const currentData = this._data; + + const availableData = await this._getData(); + + if (!availableData) { return false; } + + this._data = { + ...currentData, + ...availableData + }; + + return true; + } + + public async post() { + const data = this._data; + + if (!fulfillmentCheck(data)) { + return false; + } + + const currentData = await this._getData(); + + if (currentData) { + await this._parent.updateSettings(data); + } else { + await this._parent.createSettings(data); + } + + return true; + } + + public fulfillmentCheck() { + return fulfillmentCheck(this._data); + } + + protected async _getData() { + return this._parent.getSettings( + this._subscription + ); + } + + /** + * Gets text to use in messages when notification is being sent + */ + public getMessageText() : OptionalString { + return this._data.messageText; + } + + /** + * Sest text used in messages when notification is being sent + * @param text Text to use in messages + */ + public setMessageText(text: OptionalString) { + if (text && text.length === 0) { + throw new Error("Empty text"); + } + + this._data.messageText = text; + } + + /** + * Sets the platform data + * @param data Platform data + */ + public setPlatformData(data: OptionalString) { + this._data.platformData = data; + } + + /** + * The platform data + */ + public getMessageData() : OptionalString { + return this._data.platformData; + } +} + +type OptionalString = string | null | undefined; + +export default NotificationsSettingsController; diff --git a/src/cogs/streamNotifications/db/NotificationsSettingsController.ts b/src/cogs/streamNotifications/db/NotificationsSettingsController.ts new file mode 100644 index 0000000..79727ee --- /dev/null +++ b/src/cogs/streamNotifications/db/NotificationsSettingsController.ts @@ -0,0 +1,94 @@ +import NotificationsSettingsDB from "./Settings/NotificationsSettingsDB"; +import { NotificationsSettingsData, fulfillmentCheck } from "./Settings/NotificationsSettingsData"; +import SharedSubscriptionData from "./Subscriptions/SubscriptionData"; +import SubscriptionBasedController from "./SubscriptionBasedController"; + +type P = NotificationsSettingsDB; +type D = NotificationsSettingsData; + +export class NotificationsSettingsController extends SubscriptionBasedController { + constructor(subscription: SharedSubscriptionData, parent: NotificationsSettingsDB) { + super(subscription, parent); + } + + public async fetch() { + const currentData = this._data; + + const availableData = await this._getData(); + + if (!availableData) { return false; } + + this._data = { + ...currentData, + ...availableData + }; + + return true; + } + + public async post() { + const data = this._data; + + if (!fulfillmentCheck(data)) { + return false; + } + + const currentData = await this._getData(); + + if (currentData) { + await this._parent.updateSettings(data); + } else { + await this._parent.createSettings(data); + } + + return true; + } + + public fulfillmentCheck() { + return fulfillmentCheck(this._data); + } + + protected async _getData() { + return this._parent.getSettings( + this._subscription + ); + } + + /** + * Gets text to use in messages when notification is being sent + */ + public getMessageText() : OptionalString { + return this._data.messageText; + } + + /** + * Sest text used in messages when notification is being sent + * @param text Text to use in messages + */ + public setMessageText(text: OptionalString) { + if (text && text.length === 0) { + throw new Error("Empty text"); + } + + this._data.messageText = text; + } + + /** + * Sets the platform data + * @param data Platform data + */ + public setPlatformData(data: OptionalString) { + this._data.platformData = data; + } + + /** + * The platform data + */ + public getMessageData() : OptionalString { + return this._data.platformData; + } +} + +type OptionalString = string | null | undefined; + +export default NotificationsSettingsController; diff --git a/src/cogs/streamNotifications/db/Settings/NotificationsSettingsController.ts b/src/cogs/streamNotifications/db/Settings/NotificationsSettingsController.ts index 7d3f318..bec4055 100644 --- a/src/cogs/streamNotifications/db/Settings/NotificationsSettingsController.ts +++ b/src/cogs/streamNotifications/db/Settings/NotificationsSettingsController.ts @@ -1,7 +1,7 @@ import NotificationsSettingsDB from "./NotificationsSettingsDB"; -import NotificationsSettingsData, { fulfillmentCheck } from "./NotificationsSettingsData"; -import SharedSubscriptionData from "./Subscriptions/SubscriptionData"; -import SubscriptionBasedController from "./SubscriptionBasedController"; +import { NotificationsSettingsData, fulfillmentCheck } from "./NotificationsSettingsData"; +import SharedSubscriptionData from "../Subscriptions/SubscriptionData"; +import SubscriptionBasedController from "../SubscriptionBasedController"; type P = NotificationsSettingsDB; type D = NotificationsSettingsData; diff --git a/src/cogs/streamNotifications/db/Settings/NotificationsSettingsDB.ts b/src/cogs/streamNotifications/db/Settings/NotificationsSettingsDB.ts index 184c60b..6e99e62 100644 --- a/src/cogs/streamNotifications/db/Settings/NotificationsSettingsDB.ts +++ b/src/cogs/streamNotifications/db/Settings/NotificationsSettingsDB.ts @@ -1,6 +1,6 @@ import BaseDBManager from "@cogs/streamNotifications/db/BaseDBManager"; import { SharedSubscriptionData, addSharedSubscriptionColumns } from "@cogs/streamNotifications/db/Subscriptions/SubscriptionData"; -import { NotificationsSettingsData, addNotificationSettingsColumns } from "@cogs/streamNotifications/db/NotificationsSettingsData"; +import { NotificationsSettingsData, addNotificationSettingsColumns } from "@cogs/streamNotifications/db/Settings/NotificationsSettingsData"; import * as db from "@utils/db"; // Settings @@ -129,6 +129,6 @@ export class NotificationsSettingsDB { } } -type OptionalSettings = Promise; +type OptionalSettings = Promise; export default NotificationsSettingsDB; diff --git a/src/cogs/streamNotifications/db/Settings/NotificationsSettingsData.ts b/src/cogs/streamNotifications/db/Settings/NotificationsSettingsData.ts index c91d908..ef9eb86 100644 --- a/src/cogs/streamNotifications/db/Settings/NotificationsSettingsData.ts +++ b/src/cogs/streamNotifications/db/Settings/NotificationsSettingsData.ts @@ -21,4 +21,3 @@ export function fulfillmentCheck(data: Partial) : dat return subFulfillmentCheck(data); } -export default NotificationsSettingsData; diff --git a/src/cogs/streamNotifications/db/StreamingDBController.test.ts b/src/cogs/streamNotifications/db/StreamingDBController.test.ts new file mode 100644 index 0000000..d9c69ee --- /dev/null +++ b/src/cogs/streamNotifications/db/StreamingDBController.test.ts @@ -0,0 +1,56 @@ +import StreamingDBController from "./StreamingDBController"; +import SharedSubscriptionData from "./Subscriptions/SubscriptionData"; + +const sharedController = new StreamingDBController("streamNotifications"); + +const subscription: SharedSubscriptionData = { + guildId: "user_sub", + alternativeChannel: "133145125122605057", + platform: "my_best_streaming_platform", + streamerId: "1234567980" +}; + +(async () => { + const guild = await sharedController.getGuildController("417734993398464513"); + + guild.getGuildId(); // "417734993398464513" + guild.resolveDefaultChannel(); // undefined + + guild.setMatureBehavior("Banner"); + guild.setDefaultChannelId("417735852341592074"); + + guild.post(); + + await sharedController.subscriptions.createSubscription(subscription); + + // Modifying settings + + const settings = await sharedController.getSettingsController(subscription); + + settings.setMessageText("Heya {everyone}, {username} has started the stream!"); + + settings.setPlatformData( + JSON.stringify({ + displayGame: false + }) + ); + + await settings.post(); + + const notifications = await sharedController.getNotificationController( + subscription + ); + + notifications.isCreated(); // false + + notifications + .setMessageId("495223776934363136") + .setPayload( + JSON.stringify({ + startedAt: Date.now() + }) + ) + .setStreamId("1234567890"); + + notifications.post(); // true +})(); diff --git a/src/cogs/streamNotifications/db/StreamingDBController.ts b/src/cogs/streamNotifications/db/StreamingDBController.ts new file mode 100644 index 0000000..cbf7e54 --- /dev/null +++ b/src/cogs/streamNotifications/db/StreamingDBController.ts @@ -0,0 +1,150 @@ +import NotificationsDB from "./Notifications/NotificationsDB"; +import NotificationsSettingsDB from "./Settings/NotificationsSettingsDB"; +import SubscriptionsDB from "./Subscriptions/SubscriptionsDB"; +import SharedSubscriptionData from "./Subscriptions/SubscriptionData"; +import NotificationsSettingsController from "./Settings/NotificationsSettingsController"; +import NotificationController from "./Notifications/NotificationController"; +import { GuildSettingsController } from "./GuildSettings/GuildSettingsController"; +import { GuildSettingsDB } from "./GuildSettings/GuildSettingsDB"; +import { INullableHashMap } from "@sb-types/Types"; + +const INIT_MAP = new WeakMap(); + +type GuildSettingsActuals = INullableHashMap; +type NotificationsSettingsActuals = INullableHashMap; +type NotificationsActuals = INullableHashMap; + +const ACTUALS_GUILD_SETTINGS: GuildSettingsActuals = Object.create(null); +const ACTUALS_NOTIFICATIONS_SETTINGS: NotificationsSettingsActuals = Object.create(null); +const ACTUALS_NOTIFICATIONS: NotificationsActuals = Object.create(null); + +export class StreamingDBController { + public readonly notifications: NotificationsDB; + public readonly settings: NotificationsSettingsDB; + public readonly subscriptions: SubscriptionsDB; + public readonly guildSettings: GuildSettingsDB; + + /** + * Controls all the tables at once + * @param baseName Base name for the tables + */ + constructor(baseName: string) { + this.notifications = new NotificationsDB(`${baseName}_notifications`); + this.settings = new NotificationsSettingsDB(`${baseName}_settings`); + this.subscriptions = new SubscriptionsDB(`${baseName}_subscriptions`); + this.guildSettings = new GuildSettingsDB(`${baseName}_guild-settings`); + } + + public async init() { + if (INIT_MAP.has(this)) { + throw new Error("Controller is already initalized"); + } + + INIT_MAP.set(this, true); + } + + // #region Settings + + private _notificationsSettingsController(subscription: SharedSubscriptionData) { + const lookup = StreamingDBController._squashSubscription( + subscription + ); + + const actual = ACTUALS_NOTIFICATIONS_SETTINGS[lookup]; + + if (actual) { + return actual; + } + + return ACTUALS_NOTIFICATIONS_SETTINGS[lookup] = + new NotificationsSettingsController( + subscription, + this.settings + ); + } + + public async getSettingsController(subscription: SharedSubscriptionData) { + const controller = this._notificationsSettingsController( + subscription + ); + + await controller.fetch(); + + return controller; + } + + // #endregion + + // #region Notifications + + private _notificationsController(subscription: SharedSubscriptionData) { + const lookup = StreamingDBController._squashSubscription( + subscription + ); + + const actual = ACTUALS_NOTIFICATIONS[lookup]; + + if (actual) { + return actual; + } + + return ACTUALS_NOTIFICATIONS[lookup] = + new NotificationController( + subscription, + this.notifications + ); + } + + public async getNotificationController(subscription: SharedSubscriptionData) { + const controller = this._notificationsController(subscription); + + await controller.fetch(); + + return controller; + } + + // #endregion + + // #region Guild Settings + + private _guildSettingsController(guildId: string) { + const actual = ACTUALS_GUILD_SETTINGS[guildId]; + + if (actual) { + return actual; + } + + return ACTUALS_GUILD_SETTINGS[guildId] = + new GuildSettingsController( + guildId, + this.guildSettings + ); + } + + public async getGuildController(guildId: string) { + const controller = this._guildSettingsController(guildId); + + await controller.fetch(); + + return controller; + } + + // #endregion + + private static _squashSubscription(subscription: SharedSubscriptionData) { + let squashed = ""; + + squashed += `${subscription.platform}-`; + squashed += `${subscription.streamerId}::`; + + squashed += `${subscription.guildId}`; + + if (subscription.alternativeChannel) { + squashed += `-${subscription.alternativeChannel}`; + } + + return squashed; + } +} + +export default StreamingDBController; diff --git a/src/cogs/streamNotifications/db/SubscriptionBasedController.ts b/src/cogs/streamNotifications/db/SubscriptionBasedController.ts index 34b93ca..7913109 100644 --- a/src/cogs/streamNotifications/db/SubscriptionBasedController.ts +++ b/src/cogs/streamNotifications/db/SubscriptionBasedController.ts @@ -1,14 +1,12 @@ import SharedSubscriptionData from "./Subscriptions/SubscriptionData"; -const CREATE_STATES = new WeakMap, boolean>(); +const CREATE_STATES = new WeakMap, boolean>(); -export abstract class SubscriptionBasedController { +export abstract class BaseController { protected readonly _parent: P; - protected readonly _subscription: SharedSubscriptionData; protected _data: Partial; - constructor(subscription: SharedSubscriptionData, parent: P) { - this._subscription = subscription; + constructor(parent: P) { this._parent = parent; this._data = Object.create(null); } @@ -28,7 +26,7 @@ export abstract class SubscriptionBasedController { /** * Gets data from the database */ - protected abstract _getData() : Promise; + protected abstract _getData() : Promise; /** * Checks if the current data meets the requirements to be posted @@ -54,6 +52,16 @@ export abstract class SubscriptionBasedController { } } +export abstract class SubscriptionBasedController extends BaseController { + protected readonly _subscription: SharedSubscriptionData; + + constructor(subscription: SharedSubscriptionData, parent: P) { + super(parent); + + this._subscription = subscription; + } +} + export class MissingPropertyError extends Error { private readonly _prop: keyof T; diff --git a/src/cogs/streamNotifications/db/Subscriptions/SubscriptionsDB.ts b/src/cogs/streamNotifications/db/Subscriptions/SubscriptionsDB.ts index 357cea7..f3fba35 100644 --- a/src/cogs/streamNotifications/db/Subscriptions/SubscriptionsDB.ts +++ b/src/cogs/streamNotifications/db/Subscriptions/SubscriptionsDB.ts @@ -52,6 +52,19 @@ export class SubscriptionsDB { .first(); } + public async createSubscription(subscription: SubscriptionData) { + checkInitDone(this); + + // Check if there such subscription + + if ((await this.getSubscription(subscription)) != null) { + throw new Error("Subscription exists"); + } + + return this._db(this._tableName) + .insert(subscription); + } + public async updateSubscription(data: SubscriptionData) { checkInitDone(this);