/ *
This contains the base classes for the ` Model ` s in the database . It uses
the [ lib / ormish . js ] ( /admin/ # / docs / lib / ormish . js ) module to build up classes for :
+ ` User ` -- A simple user model .
+ ` Payment ` -- A Payment model that is simple but can support Stripe , Paypal , and BTCPayServer .
+ ` Product ` -- A simple Product for the site .
+ ` Media ` -- Media information common to video and music files , but could work with anything .
+ ` Livestream ` -- Metadata for livestream sessions ( see ` client/pages/Livestream.svelte ` ) .
+ ` Site ` -- Simple key = value store for settings you might want in your site .
As with all other code in this project these models are _complete _ but don ' t have everything
under the sun . For example , it acts as if ` User ` pays one time to access the entire site and
all ` Product ` . That is one very valid model of payment , and it ' s the simplest one which you
can then build on . The rationale is you get a starter kit that you can then mold into what you
need because this is simple enough to understand while also being useful to get started .
# # # Tests
You should look at the tests in ` tests/models ` for many examples of using these .
* /
import { knex , Model } from './ormish.js' ;
import bcrypt from 'bcryptjs' ;
import { v4 as uuid } from "uuid" ;
import logging from "./logging.js" ;
import assert from "assert" ;
import crypto from "crypto" ;
const log = logging . create ( "lib/models.js" ) ;
/* 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 ( ) ;
const res = await knex ( 'user' ) . where ( { email : uname } ) . select ( ) ;
if ( res === undefined ) {
log . error ( res , "Received undefined but knex claims only Array returns." ) ;
return undefined ;
} else if ( res . length > 1 ) {
log . error ( res , ` Multiple accounts under the same email ${ uname } ` ) ;
return undefined ;
} else if ( res . length === 0 ) {
// no user found
return undefined ;
} else {
// user found, check password
const user = res [ 0 ] ;
const good = bcrypt . compareSync ( password , user . password ) ;
return good ? user : undefined ;
}
} catch ( error ) {
log . error ( error ) ;
return undefined ;
}
}
/ *
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 attributes 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 ;
if ( attr . password != attr . password _repeat ) {
return undefined ;
} else {
user = User . clean ( attr ) ;
}
user . email = user . email . toLowerCase ( ) ;
// validate here?
let exists = await User . first ( { email : user . email } ) ;
if ( exists ) {
log . error ( "User exists" , user . email ) ;
return undefined ;
} else {
user . password = User . encrypt _password ( user . password ) ;
user . unsubkey = User . random _hex ( UNSUB _CODE _SIZE ) ;
let res = await knex ( 'user' ) . insert ( user ) ;
user . id = res [ 0 ] ;
return res . length == 0 ? undefined : 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 ) {
return await User . update ( { id : this . id } , {
unsubscribe : setting , unsubscribed _on : Date . now ( )
} ) ;
} else {
return 1 ;
}
}
/* 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' ,
status : 'complete' ,
internal _id : Payment . gen _internal _id ( ) ,
sys _primary _id : Payment . gen _internal _id ( ) ,
sys _secondary _id : Payment . gen _internal _id ( ) ,
sys _created _on : new Date ( )
} ) ;
}
/ *
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." ) ;
// 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" ] ) ;
// it's paid if there's a payment and it is complete
const paid = payment !== undefined && payment . status === "complete" ;
// this now returns paid and also paid is false with more info about why
return [ paid , 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" ) ;
}
/ *
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" ) {
/ *
Return the ` Media ` attached to this ( 1 : 1 ) ` Livestream ` . This is set
after the stream is done and is stored for replay later .
* /
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' ) {
}