UNPKG

@seneca/refer

Version:

User Referral business logic plugin for the Seneca platform.

539 lines (414 loc) 11 kB
/* 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 }