More documentation.

main
Zed A. Shaw 2 years ago
parent 6f49bc2455
commit eba5034c8e
  1. 223
      lib/models.js
  2. 22
      lib/ormish.js

@ -7,11 +7,49 @@ import crypto from "crypto";
const log = logging.create("lib/models.js"); const log = logging.create("lib/models.js");
export const RESET_CODE_SIZE = 8; // how big to make the unique email reset code /* Number of bytes in the reset code sent. */
export const UNSUB_CODE_SIZE = 16; // how big to make the unique email unsubscribe code 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') { 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) { static async auth(username, password) {
try { try {
const uname = username.toLowerCase(); 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 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, ensure that the password and password_repeat are the same, lowercase the email,
* clean out unwanted attrbiutes with User.clean, generate required keys, etc. clean out unwanted attrbiutes with User.clean, generate required keys, etc.
*
* @param {Object} attr - attributes usually from a web form + `attr Object` - attributes usually from a web form
* @return {Object} undefined or the new user on success + ___return___ `Object`, `undefined` or the new user on success
*/ */
static async register(attr) { static async register(attr) {
let user = undefined; 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) { static random_hex(size) {
assert(size, `random_hex size can't be falsy ${ size }`); assert(size, `random_hex size can't be falsy ${ size }`);
return crypto.randomBytes(size).toString("hex"); 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) { static encrypt_password(password) {
assert(password, `Password cannot be falsy: ${ password }`); assert(password, `Password cannot be falsy: ${ password }`);
let salt = bcrypt.genSaltSync(10); let salt = bcrypt.genSaltSync(10);
return bcrypt.hashSync(password, salt); 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) { async emails(setting) {
// don't change it if it's already set this way // don't change it if it's already set this way
if(this.unsubscribe !== setting) { 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() { async payments() {
return await this.has_many(Payment, { user_id: this.id }); 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') { export class Payment extends Model.from_table('payment') {
/* Payment has only one User, but User has many Payments. */ /* Payment has only one User, but User has many Payments. */
get user() { get user() {
return this.user_id !== undefined ? this.has_one(User, { id: this.user_id }) : undefined; 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() { static fake_payment() {
return Payment.insert({ return Payment.insert({
system: 'fake', 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() { static gen_internal_id() {
return uuid(); 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) { static async paid(user) {
assert(user !== undefined, "Invalid user given to Payment.paid"); assert(user !== undefined, "Invalid user given to Payment.paid");
assert(user.id !== undefined, "User object given has an user.id === undefined."); 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") { 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") { 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) { static async get(key) {
const row = await knex(this.table_name).first().where({key}); const row = await knex(this.table_name).first().where({key});
return row !== undefined ? JSON.parse(row.value) : undefined; 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) { static async set(key, value) {
const json_value = JSON.stringify(value); const json_value = JSON.stringify(value);
return await Site.upsert({key, value: json_value}, "key"); 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) { static async increment(key, count) {
return await knex(this.table_name).where({key}).increment("value", 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) { static async decrement(key, count) {
return await knex(this.table_name).where({key}).decrement("value", 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") { export class Livestream extends Model.from_table("livestream") {
media() { media() {
return Media.first({id: this.media_id}); 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) { static add_viewers(id) {
return knex(this.table_name).where({id}).increment('viewer_count', 1); 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') { export class Product extends Model.from_table('product') {
} }

@ -273,6 +273,7 @@ export class Model {
+ `model Model` - The other model to query. + `model Model` - The other model to query.
+ `where Object` - The knex style selection criteria passed to `Model.first`. + `where Object` - The knex style selection criteria passed to `Model.first`.
+ `columns` - Passed to `Model.first` to restrict the columns returned. + `columns` - Passed to `Model.first` to restrict the columns returned.
+ ___return___ `Model` subclass found, or `undefined` if nothing.
*/ */
async has_one(model, where, columns) { 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)}`); 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. + `where Object` - knex where specification.
+ `columns Array` - columns to return + `columns Array` - columns to return
+ ___return___ `number`
*/ */
static async count(where, columns) { static async count(where, columns) {
// the knex count api returns a DB specific result, so we need // 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. doesn't support this it's a trash database that shouldn't be used.
+ `attr Object` - The attributes to store. + `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) { static async insert(attr) {
assert(this.table_name !== undefined, "You must set class variable table_name."); 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. Implements an upsert (insert but update on conflict) for Postgres, MySQL, and SQLite3 only.
+ attr { Object } - The attributes to insert or update. + attr `Object` - The attributes to insert or update.
+ conflict_key { string } - The key that can cause a conflict then 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. + merge `boolean` - Defaults to true and will change the record. false will ignore and not update on conflict.
+ return { number } - id or undefined + ___return___ `number` - id or undefined
*/ */
static async upsert(attr, conflict_key, merge=true) { 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}`); 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 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. 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) { static async update(where, what) {
assert(where, "You must give a where options."); assert(where, "You must give a where options.");
@ -482,7 +488,7 @@ export class Model {
+ `where Object` -- The `knex` where specification. + `where Object` -- The `knex` where specification.
+ `columns Array` -- List of columns to include in the returned object. + `columns Array` -- List of columns to include in the returned object.
+ `return Object` + ___return___ `Object`
*/ */
static async first(where, columns) { static async first(where, columns) {
assert(where, "You must give a where options."); 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. + `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) { static async delete(where) {
assert(where, "You must give a where options."); 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. + `where Object` -- The usual `knex` where specification, and can be anything `knex` likes.
+ `columns Array` -- The list of columns to return. + `columns Array` -- The list of columns to return.
+ ___return___ `Array` of `Model` subclass.
*/ */
static async all(where, columns) { static async all(where, columns) {
assert(where, "You must give a where options."); assert(where, "You must give a where options.");
@ -587,7 +595,7 @@ export class Model {
``` ```
+ `where Object` -- the query specification for `knex`. + `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) { static async exists(where) {
let res = await knex(this.table_name).select('id').where(where).limit(1).first(); let res = await knex(this.table_name).select('id').where(where).limit(1).first();

Loading…
Cancel
Save