senecajs/seneca-refer

View on GitHub
src/refer.ts

Summary

Maintainability
F
3 days
Test Coverage
/* 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
}