src/refer.ts
/* Copyright © 2022 Seneca Project Contributors, MIT License. */
type ReferOptions = {
debug?: boolean
token: {
len?: number
alphabet?: string
}
code: {
len?: number
alphabet?: string
}
}
function refer(this: any, options: ReferOptions) {
const seneca: any = this
const genToken = this.util.Nid(options.token)
const genCode = this.util.Nid(options.code)
seneca
.fix('biz:refer')
.message('create:entry', msgCreateEntry)
.message('accept:entry', msgAcceptEntry)
.message('update:occur', msgUpdateOccur)
.message('update:entry', msgUpdateEntry)
.message('load:entry', msgLoadEntry)
.message('ensure:entry', msgEnsureEntry)
.message('lost:entry', msgLostEntry)
.message('give:award', msgRewardEntry)
.message('load:rules', msgLoadRules)
// TODO: seneca.prepare should not be affected by seneca.fix
seneca
.prepare(prepare)
async function msgCreateEntry(this: any, msg: any) {
const seneca = this
// Sending user, not required
let user_id = msg.user_id
let method = msg.method || 'email' // 'email' | 'code'
let email = msg.email // required if method=email and mode=single
let code = msg.code // explicit code, otherwise generated
let token = msg.token // explicit code, otherwise generated
let mode = msg.mode || 'single' // 'single' | 'multi' | 'limit'
let limit = msg.limit || 1 // usage limit; -1 = unlimited
let kind = msg.kind || 'standard'
let peg = msg.peg || 'none' // app specific entry type
let active = null == msg.active ? true : !!msg.active
let EntryEnt = seneca.entity('refer/entry')
let OccurEnt = seneca.entity('refer/occur')
// Check single use email referral used only once
if ('email' === method && 'single' === mode) {
if (null == email || '' === email) {
return {
ok: false,
why: 'email-required',
}
}
let occur = await OccurEnt.load$({
email,
kind: 'accept',
})
if (occur) {
return {
ok: false,
why: 'entry-exists',
details: {
email
}
}
}
}
const entry = await EntryEnt.save$({
user_id,
kind,
email,
method,
mode,
limit,
peg,
// unique token for this referral, used for link validation
token: token || genToken(),
// unique code for this referral, used for human validation
code: code || genCode(),
// usage count
count: 0,
active,
})
let occur
// REVIEW: is this 'create' entry needed?
if ('single' === mode) {
occur = await OccurEnt.data$({
id: null,
id$: null,
user_id: msg.user_id,
entry_kind: msg.kind,
entry_mode: msg.mode,
entry_peg: msg.peg,
email: msg.email,
entry_id: entry.id,
kind: 'create',
code: entry.code,
token: entry.token,
}).save$()
}
return {
ok: true,
entry,
occur,
}
}
async function msgAcceptEntry(this: any, msg: any) {
const seneca = this
// If check=true, do not update occur
let check = true === msg.check ? true : false
// User using the referral, if known at creation
let user_id = msg.user_id
let token = msg.token
let code = msg.code
let q: any = {}
if (msg.token) {
q.token = msg.token
}
else if (msg.code) {
q.code = msg.code
}
else {
return {
ok: false,
why: 'no-token-or-code'
}
}
const entry = await seneca.entity('refer/entry').load$(q)
if (!entry) {
return {
ok: false,
why: 'entry-unknown',
details: {
token,
code,
}
}
}
if (!entry.active) {
return {
ok: false,
why: 'entry-not-active',
}
}
let occurs = await this.entity('refer/occur').list$({
entry_id: entry.id,
fields$: ['kind']
})
let isLost = occurs.find((occur: any) => 'lost' === occur.kind)
if (isLost) {
return {
ok: false,
why: 'entry-lost',
}
}
let accepts = occurs.filter((occur: any) => 'accept' === occur.kind)
if (('single' === entry.mode || 1 === entry.limit) && (1 <= accepts)) {
return {
ok: false,
why: 'entry-used',
}
}
else if (0 < entry.limit && entry.limit <= accepts.length) {
return {
ok: false,
why: 'entry-limit',
details: {
limit: entry.limit,
accepts: accepts.length,
}
}
}
let occur
if (!check) {
occur = await seneca.entity('refer/occur').save$({
user_id,
entry_kind: entry.kind,
email: entry.email,
entry_id: entry.id,
kind: 'accept',
code: entry.code,
token: entry.token,
})
entry.count = accepts.length + 1
await entry.save$()
}
return {
ok: true,
entry,
occur, // NOTE: will be undef if check=true
}
}
async function msgUpdateOccur(this: any, msg: any) {
const seneca = this
let occur_id = msg.occur_id
let code = msg.code
let token = msg.token
let occurUpdate = msg.occur
let q: any = {}
if (occur_id) {
q.id = occur_id
}
let occur = await seneca.entity('refer/occur').load$(q)
if (!occur) {
return {
ok: false,
why: 'not-found'
}
}
occur.data$(occurUpdate)
await occur.save$()
return {
ok: true,
occur,
}
}
async function msgUpdateEntry(this: any, msg: any) {
const seneca = this
let entry_id = msg.entry_id
let active = msg.active
let entry = seneca.entity('refer/entry').load$(entry_id)
if (!entry) {
return {
ok: false,
why: 'not-found'
}
}
if (null != active) {
entry.active = !!active
await entry.save$()
}
return {
ok: true,
entry,
}
}
async function msgLoadEntry(this: any, msg: any) {
const seneca = this
let entry_id = msg.entry_id
let entry = seneca.entity('refer/entry').load$(entry_id)
if (!entry) {
return {
ok: false,
why: 'not-found'
}
}
let occurs = seneca.entity('refer/occur').list$({
entry_id: entry.id
})
return {
ok: true,
entry,
occurs,
}
}
// Create if not exists, otherwise return match
// Most useful for mode=multi
async function msgEnsureEntry(this: any, msg: any) {
const seneca = this
let user_id = msg.user_id
let kind = msg.kind || 'standard'
let peg = msg.peg
let entry = await seneca.entity('refer/entry').load$({
user_id,
kind,
peg,
})
let out
if (null == entry) {
let createMsg = { ...msg, create: 'entry' }
delete createMsg.ensure
out = await seneca.post(createMsg)
}
else {
out = {
ok: true,
entry,
occur: [],
}
}
return out
}
async function msgLostEntry(this: any, msg: any) {
const seneca = this
const occurList = await seneca.entity('refer/occur').list$({
email: msg.email,
kind: 'create',
})
const unacceptedReferrals = occurList.filter(
(occur: any) => occur.user_id !== msg.userWinner
)
for (let i = 0; i < unacceptedReferrals.length; i++) {
await seneca.entity('refer/occur').save$({
user_id: unacceptedReferrals[i].user_id,
entry_kind: unacceptedReferrals[i].entry_kind,
email: msg.email,
entry_id: unacceptedReferrals[i].entry_id,
kind: 'lost',
})
}
}
async function msgRewardEntry(this: any, msg: any) {
const seneca = this
const entry = await seneca.entity('refer/occur').load$({
entry_id: msg.entry_id,
})
if (!entry) {
return {
ok: false,
why: 'unknown-entry'
}
}
let reward = await this.entity('refer/reward').load$({
entry_id: entry.id,
})
if (!reward) {
reward = seneca.make('refer/reward', {
entry_id: msg.entry_id,
entry_kind: msg.entry_kind,
kind: msg.kind,
award: msg.award,
})
reward[msg.field] = 0
}
reward[msg.field] = reward[msg.field] + 1
await reward.save$()
}
async function msgLoadRules(this: any, msg: any) {
const seneca = this
const rules = await seneca.entity('refer/rule').list$()
// TODO: handle rule updates?
// TODO: create a @seneca/rule plugin? later!
for (let rule of rules) {
if (rule.ent) {
const subpat = generateSubPat(seneca, rule)
seneca.sub(subpat, function(this: any, msg: any) {
if (rule.where.kind === 'create') {
rule.call.forEach((callmsg: any) => {
// TODO: use https://github.com/rjrodger/inks
callmsg.toaddr = msg.ent.email
callmsg.fromaddr = 'invite@example.com'
this.act(callmsg)
})
}
})
seneca.sub(subpat, function(this: any, msg: any) {
if (rule.where.kind === 'accept') {
rule.call.forEach((callmsg: any) => {
callmsg.ent = seneca.entity(rule.ent)
callmsg.entry_id = msg.q.entry_id
callmsg.entry_kind = msg.q.entry_kind
this.act(callmsg)
})
}
})
seneca.sub(subpat, function(this: any, msg: any) {
if (rule.where.kind === 'lost' && msg.q.kind === 'accept') {
rule.call.forEach((callmsg: any) => {
callmsg.ent = seneca.entity(rule.ent)
callmsg.email = msg.q.email
callmsg.userWinner = msg.q.user_id
this.act(callmsg)
})
}
})
}
// else ignore as not yet implemented
}
}
async function prepare(this: any) {
const seneca = this
await seneca.post('biz:refer,load:rules')
}
function generateSubPat(seneca: any, rule: any): object {
const ent = seneca.entity(rule.ent)
const canon = ent.canon$({ object: true })
Object.keys(canon).forEach((key) => {
if (!canon[key]) {
delete canon[key]
}
})
return {
role: 'entity',
cmd: rule.cmd,
q: rule.where,
...canon,
out$: true,
}
}
return {
exports: {
genToken,
genCode,
}
}
}
// Default options.
const defaults: ReferOptions = {
// TODO: Enable debug logging
debug: false,
token: {
len: 16,
alphabet: undefined,
},
code: {
len: 6,
alphabet: 'BCDFGHJKLMNPQRSTVWXYZ2456789'
}
}
Object.assign(refer, { defaults })
export default refer
if ('undefined' !== typeof module) {
module.exports = refer
}