From eba5034c8ed4cc11a611fbf96b78e449f873e24b Mon Sep 17 00:00:00 2001 From: "Zed A. Shaw" Date: Sun, 18 Dec 2022 22:09:38 -0500 Subject: [PATCH] More documentation. --- lib/models.js | 223 +++++++++++++++++++++++++++++++++++++++++++++++--- lib/ormish.js | 22 +++-- 2 files changed, 226 insertions(+), 19 deletions(-) diff --git a/lib/models.js b/lib/models.js index 99a4e75..438eeb2 100644 --- a/lib/models.js +++ b/lib/models.js @@ -7,11 +7,49 @@ import crypto from "crypto"; const log = logging.create("lib/models.js"); -export const RESET_CODE_SIZE = 8; // how big to make the unique email reset code -export const UNSUB_CODE_SIZE = 16; // how big to make the unique email unsubscribe code +/* Number of bytes in the reset code sent. */ +export const RESET_CODE_SIZE = 8; +/* Bytes in the unique unsubscribe code. */ +export const UNSUB_CODE_SIZE = 16; + +/* + The `User` model contains the majority of settings for the users for the site. + It should contain almost everything you need, and won't change much. If you want + to add more options and settings I recommend either create a `knex` migration to + change it, or add a `Model.has_one` relation that uses a second table for additional + features. + + + + [user](/admin/#/table/user) - admin this table + */ export class User extends Model.from_table('user') { + /* + Authenticates the user based on their `username` and `password`. + The `username` can be anything, but I've chosen email since that seems + to be the most common unique username method. + It first finds that user, then it uses the [bcryptjs](https://www.npmjs.com/package/bcryptjs) + or [bcrypt](https://www.npmjs.com/package/bcrypt) modules. + + ### bcrypt vs bcryptjs + + By default this code uses [bcryptjs](https://www.npmjs.com/package/bcryptjs) as that has + lower installation requirements. The big problem with all C++ based modules is the + fagility of `node-gyp` and `node-pre-gyp`. If you need the speed then change the import + above to use `bcrypt` instead of `bcryptjs`, but be prepared to have possible errors + randomly when you install it. + + ### Usage + + ```javascript + const authenticated = await User.auth("help@learnjsthehardway.com", "notmypassword"); + ``` + + + `username String` -- The "username" which is currently the `user.email` field. + + `password String` -- The password as the user types it. _DO NOT STORE THIS._ + + ___return___ `User` or `undefined` if the user is not found or not authenticated + */ static async auth(username, password) { try { const uname = username.toLowerCase(); @@ -38,13 +76,13 @@ export class User extends Model.from_table('user') { } } - /** - * Performs all the cleanup and checks needed for a registration. It will - * ensure that the password and password_repeat are the same, lowercase the email, - * clean out unwanted attrbiutes with User.clean, generate required keys, etc. - * - * @param {Object} attr - attributes usually from a web form - * @return {Object} undefined or the new user on success + /* + Performs all the cleanup and checks needed for a registration. It will + ensure that the password and password_repeat are the same, lowercase the email, + clean out unwanted attrbiutes with User.clean, generate required keys, etc. + + + `attr Object` - attributes usually from a web form + + ___return___ `Object`, `undefined` or the new user on success */ static async register(attr) { let user = undefined; @@ -76,17 +114,42 @@ export class User extends Model.from_table('user') { } } + /* + Uses the [crypt](https://nodejs.org/docs/latest-v18.x/api/crypto.html) module to generate a random hex string of + size `bytes`. + + + `size Number` -- size of bytes, then converted to hex. + + ___return___ String + */ static random_hex(size) { assert(size, `random_hex size can't be falsy ${ size }`); return crypto.randomBytes(size).toString("hex"); } + /* + Given a string for a password this will use the `bcrypt.hashSync` function + to encrypt it. It generates the salt for each password it encrypts, which + I believe is the correct way to do it. If that's not then someone should + update the `bcrypt` docs. + + + `password String` -- A string to password encrypt. + + ___return___ String` + */ static encrypt_password(password) { assert(password, `Password cannot be falsy: ${ password }`); let salt = bcrypt.genSaltSync(10); return bcrypt.hashSync(password, salt); } + /* + Changes the user's unsubscribe from email setting, to determine if you + should email them or not. It uses the `User.unsubscribe` attribute, so + `true` means they are ___not___ receiving emails, and `false` means they + ___will__ receive emails. + + + `setting boolean` -- Whether they are unsubscribed or not. + + ___return___ `number` -- Count of records changed, should be 1. + */ async emails(setting) { // don't change it if it's already set this way if(this.unsubscribe !== setting) { @@ -98,19 +161,39 @@ export class User extends Model.from_table('user') { } } - /* Users have many Payments, but Payment has only one User. */ + /* Has many mapping where Users have many Payments, but Payment has only one User. */ async payments() { return await this.has_many(Payment, { user_id: this.id }); } } +/* + Represents a payment in any system with Stripe, BTCPayServer, and Paypal + currently supported. It contains `sys_primary_id` and `sys_secondary_id` + fields for generic "id" fields that these services love to throw around, with + an `internal_id` for our tracking. Every payment processor has different uses + for the various IDs returned, so refer to the `/api/payments/` modules for how + these are mapped. + + [payment](/admin/#/table/payment) - admin this table + */ export class Payment extends Model.from_table('payment') { + /* Payment has only one User, but User has many Payments. */ get user() { return this.user_id !== undefined ? this.has_one(User, { id: this.user_id }) : undefined; } + /* + Used in testing and debugging to create a fake payment with no specific + system. Look in `tests/models/payment.js` to see how this works: + + ```javascript + let test1 = await Payment.fake_payment(); + ``` + + + ___return___ A fake `Payment` object for testing. + */ static fake_payment() { return Payment.insert({ system: 'fake', @@ -122,11 +205,49 @@ export class Payment extends Model.from_table('payment') { }); } - + /* + Generates a random `uuid()` for use in the `internal_id` field. + _WARNING_: It's not clear if the `uuid()` function is actually + secure or not, so don't rely on this for any cryptography. + */ static gen_internal_id() { return uuid(); } + /* + Indicates whether the `user` has paid for the site's product. Keep in mind + that this is using a model of a _single_ payment for access to the site. As + with all of The Bandolier it's not a full featured payment system, but it is + enough to get started. If you wanted to support subscriptions this would + most likely work, but if you want to sell multiple products or create a full + store then you've got some work to do. + + Another thing that this model won't support is the "merchant" experience, where + you let other people sell their stuff on your site. That is the _most_ complex + online business to run as it requires receiving money and giving money to other + people. + + ### Study This + + This contains a unique use of the `lib/ormish.js:Model.first` and `knex` to + construct a complex query with a custom builder: + + ```javascript + // ignore refunded payments + const payment = await Payment.first(builder => { + builder + .whereNotIn("status", ["refunded", "failed", "pending"]) + .where({user_id: user.id}) + }, ["user_id", "system", "status", "status_reason"]); + ``` + + This doesn't come up too often, but when you need it this little snippet + is gold. The idea is that `Model.first` can accept anything that `knex` + accepts, which includes a builder callback to create a refined query. + + + `user User` - The user to confirm payment on. + + ___return__ `[true, Payment]` if paid or `[false, undefined]` if not paid. + */ static async paid(user) { assert(user !== undefined, "Invalid user given to Payment.paid"); assert(user.id !== undefined, "User object given has an user.id === undefined."); @@ -146,40 +267,118 @@ export class Payment extends Model.from_table('payment') { } } +/* + Simple `media` table for storing metadata on various media you + will probably need to display. It's debatable whether this should + be in the database or simply output to straight `.json` files. The + database is easier to edit, but `.json` files are way easier to host + and transmit. I may come up with a hybrid model eventually. + + + [media](/admin/#/table/media) - admin this table + */ export class Media extends Model.from_table("media") { } +/* + A useful simle key=value table for storing various settings + and stats for the site. It contains a `set`, `get`, `increment`, and + `decrement` methods for efficient(ish) counting events or + actions. I think if you want to do this at significant scale + get a time series database. For simple things this works. + + [site](/admin/#/table/site) - admin this table + */ export class Site extends Model.from_table("site") { + + /* + Get the value for the given key. This is a `json` type + in the database, so YMMV if your database isn't SQlite3. + + + `key string` -- The key in the database. + + ___return___ `Object` -- Uses JSON.parse(), so not really tested on other databases. + */ static async get(key) { const row = await knex(this.table_name).first().where({key}); return row !== undefined ? JSON.parse(row.value) : undefined; } + /* + Set a key to a value, which is a generic JSON object type. This is + the inverse of `get()`. You really need to think what goes in here. + If it's very large you'll probably want to look at a document database + or store it on the disk. + + + + `key string` -- The key to set. + + `value Any JSON type` - This is run through `JSON.stringify`. + + ___return___ See `lib/ormish.js:Model.upsert` for what is returned. + */ static async set(key, value) { const json_value = JSON.stringify(value); return await Site.upsert({key, value: json_value}, "key"); } - // TODO: figure out how to get sqlite3 to do a return of the value + /* + Performs a SQL increment operation, but it's dubious whether this + works outside of SQLite3. I believe since SQLite3 technically + stores everything as a string it mostly ignores the `json` type + of the column and treats it like an integer. + + The reason you need this operation is it's more efficient to blindly + tell the database to add to a counter it knows, then to select that number, + add to it, then set it again. + + _TODO_: figure out how to get sqlite3 to do a return of the value. + + + `key string` - The key to set. + + `count number` - How much to increment. + */ static async increment(key, count) { return await knex(this.table_name).where({key}).increment("value", count); } + /* + Same as `increment()` but the inverse. Same parameters. + */ static async decrement(key, count) { return await knex(this.table_name).where({key}).decrement("value", count); } } +/* + Supports Livestream information, which includes viewer stats, scheduling information + and other features you need when doing a livestream. + + + [livestream](/admin/#/table/livestream) - admin this table + */ export class Livestream extends Model.from_table("livestream") { media() { return Media.first({id: this.media_id}); } + /* + Increments the viewer count of an arbitrary `Livestream` by its `id`. + + + `id number` -- `Livestream` to increment. + + ___return___ Currently should return whatever `knex`'s `increment` returns. + */ static add_viewers(id) { return knex(this.table_name).where({id}).increment('viewer_count', 1); } } +/* + Represents a Product in the system. This is very generic and meant to be something + you change. In my [learnjsthehardway.com](https://learnjsthehardway.com) site it's + called `Course` instead so you might see some leftover from the conversion. I changed + it since not everyone has a course in their system, but most people have a product. + + You'll also notice this has no connection to `Purchase`. That's because this demo + site is simple and there's just one `Payment` per user to give access to all `Product`s. + In most other sites you'd create a `Purchase` object that would tie together the `User`, + `Product`, and `Payment` each time they bought things. + + + [product](/admin/#/table/product) - admin this table + */ export class Product extends Model.from_table('product') { } diff --git a/lib/ormish.js b/lib/ormish.js index b98f76a..c570fa5 100644 --- a/lib/ormish.js +++ b/lib/ormish.js @@ -273,6 +273,7 @@ export class Model { + `model Model` - The other model to query. + `where Object` - The knex style selection criteria passed to `Model.first`. + `columns` - Passed to `Model.first` to restrict the columns returned. + + ___return___ `Model` subclass found, or `undefined` if nothing. */ async has_one(model, where, columns) { assert(where.id !== undefined, `where must at least have id for has_one ${model.table_name} you have ${JSON.stringify(where)}`); @@ -386,6 +387,7 @@ export class Model { + `where Object` - knex where specification. + `columns Array` - columns to return + + ___return___ `number` */ static async count(where, columns) { // the knex count api returns a DB specific result, so we need @@ -416,7 +418,7 @@ export class Model { doesn't support this it's a trash database that shouldn't be used. + `attr Object` - The attributes to store. - + `return Number` - returns the id of the inserted object + + ___return___ `number` - returns the id of the inserted object */ static async insert(attr) { assert(this.table_name !== undefined, "You must set class variable table_name."); @@ -433,10 +435,10 @@ export class Model { /* Implements an upsert (insert but update on conflict) for Postgres, MySQL, and SQLite3 only. - + attr { Object } - The attributes to insert or update. - + conflict_key { string } - The key that can cause a conflict then update. - + merge { boolean } - Defaults to true and will change the record. false will ignore and not update on conflict. - + return { number } - id or undefined + + attr `Object` - The attributes to insert or update. + + conflict_key `string` - The key that can cause a conflict then update. + + merge `boolean` - Defaults to true and will change the record. false will ignore and not update on conflict. + + ___return___ `number` - id or undefined */ static async upsert(attr, conflict_key, merge=true) { assert(conflict_key !== undefined, `You forgot to set the conflict_key on upsert to table ${this.table_name}`); @@ -463,6 +465,10 @@ export class Model { So it's mostly just syntactic sugar over `knex.js` (like everything in ORMish). If you need a more complex `update` then just use `knex()` directly. + + + `where Object` -- The knex where options for a query. + + `what Model` -- The table/Model to use as the update. + + ___return___ `number` -- _WARNING_: I believe this returns the count updated but that might be database specific. */ static async update(where, what) { assert(where, "You must give a where options."); @@ -482,7 +488,7 @@ export class Model { + `where Object` -- The `knex` where specification. + `columns Array` -- List of columns to include in the returned object. - + `return Object` + + ___return___ `Object` */ static async first(where, columns) { assert(where, "You must give a where options."); @@ -506,6 +512,7 @@ export class Model { ``` + `where Object` -- The where specification, doesn't have to be an Object. + + ___return___ `number` -- Number deleted, but that might be database specific. */ static async delete(where) { assert(where, "You must give a where options."); @@ -527,6 +534,7 @@ export class Model { + `where Object` -- The usual `knex` where specification, and can be anything `knex` likes. + `columns Array` -- The list of columns to return. + + ___return___ `Array` of `Model` subclass. */ static async all(where, columns) { assert(where, "You must give a where options."); @@ -587,7 +595,7 @@ export class Model { ``` + `where Object` -- the query specification for `knex`. - + `return boolean` -- Whether it exists or not. + + ___return___ `boolean` -- Whether it exists or not. */ static async exists(where) { let res = await knex(this.table_name).select('id').where(where).limit(1).first();