diff --git a/bin/check-nomad-badge.ts b/bin/check-nomad-badge.ts new file mode 100644 index 0000000..e1a86de --- /dev/null +++ b/bin/check-nomad-badge.ts @@ -0,0 +1,11 @@ +#!/usr/bin/env -S node --trace-warnings --loader ts-node/esm + +import { checkNomadBadge } from "../lib/check-nomad-badge.js"; + +async function main() { + const args = process.argv.slice(2); + + await checkNomadBadge(); +} + +main().catch((err) => console.error(new Date(), "ERROR:", err)); diff --git a/handlers/check-nomad-badge.ts b/handlers/check-nomad-badge.ts new file mode 100644 index 0000000..faf3aab --- /dev/null +++ b/handlers/check-nomad-badge.ts @@ -0,0 +1,26 @@ +import { Context, APIGatewayProxyResult, APIGatewayEvent } from "aws-lambda"; +import { checkNomadBadge } from "../lib/check-nomad-badge.js"; + +export const handler = async ( + event: APIGatewayEvent & { + campaignTagIds?: number[]; + campaignBegins?: Date | string; + campaignEnds?: Date | string; + }, + context: Context +): Promise => { + console.log(`Event: %o`, event); + console.log(`Context: %o`, context); + + const { campaignTagIds, campaignBegins, campaignEnds } = event; + const retBadges = await checkNomadBadge({ + campaignTagIds, + campaignBegins, + campaignEnds, + }); + + return { + statusCode: 200, + body: JSON.stringify({ message: "done.", retBadges }), + }; +}; diff --git a/lib/check-nomad-badge.ts b/lib/check-nomad-badge.ts new file mode 100644 index 0000000..b00feaa --- /dev/null +++ b/lib/check-nomad-badge.ts @@ -0,0 +1,312 @@ +import type { Language } from "./types"; +import { Mail } from "./mail.js"; +import { sql, sqlSIW } from "../lib/db.js"; +import { DAY, EMAIL_FROM_ASK } from "./constants/index.js"; + +const siteDomain = process.env.MATTERS_SITE_DOMAIN || ""; +const newFeatureTagId = process.env.MATTERS_NEW_FEATURE_TAG_ID || ""; +const isProd = siteDomain === "https://matters.town"; + +const mail = new Mail(); + +const MATTERS_CAMPAIGN_TAGS_IDS = JSON.parse( + process.env.MATTERS_CAMPAIGN_TAGS_IDS || "[157201]" +); // https://matters.town/tags/157201-nomadmatters?type=latest +const MATTERS_CAMPAIGN_BEGINS = + process.env.MATTERS_CAMPAIGN_BEGINS || "2023-12-14T16:00:00.000Z"; // 2023-12-15T00:00 in UTC+8 +const MATTERS_CAMPAIGN_ENDS = + process.env.MATTERS_CAMPAIGN_ENDS || "2024-01-14T15:59:59.999Z"; // 2024-01-15T23:59 in UTC+8 + +const ARRAY_TYPE = 1009; // Postgres internal value shouldn't be here; + +export async function checkNomadBadge({ + campaignTagIds = MATTERS_CAMPAIGN_TAGS_IDS, // [157201],// https://matters.town/tags/157201-nomadmatters?type=latest + campaignBegins, + campaignEnds, + dryRun = true, +}: { + campaignTagIds?: number[]; + campaignBegins?: Date | string; + campaignEnds?: Date | string; + dryRun?: boolean; +} = {}): Promise { + // const [{ version, now }] = await sql` SELECT VERSION(), NOW() `; console.log("pgres:", { version, now }); + + if (!campaignBegins) campaignBegins = new Date(MATTERS_CAMPAIGN_BEGINS); + if (!campaignEnds) campaignEnds = new Date(MATTERS_CAMPAIGN_ENDS); + // const nomadTagId = 157201; // https://matters.town/tags/157201-nomadmatters?type=latest + // if (!campaignBegins || !campaignEnds) return; + + const referrals = await sql`-- select all referred users +SELECT referral_user_name, count, latest_referred +FROM ( + SELECT extra->>'referralCode' AS referral_user_name, COUNT(*) ::int, MAX(updated_at) AS latest_referred + FROM public.user + WHERE created_at BETWEEN ${campaignBegins} AND ${campaignEnds} + AND extra->'referralCode' IS NOT NULL + GROUP BY 1 + -- HAVING COUNT(*) >=5 +) t +JOIN public.user u ON referral_user_name=user_name -- use INNER JOIN; the referral_code must be existing user_names +WHERE COALESCE((u.extra->'referredCount')::int, 0) < count +ORDER BY latest_referred DESC, count DESC -- LIMIT 13 `; + console.log("log user by referrals:", referrals); + const referralsMap = new Map( + referrals.map(({ referralUserName, count }) => [referralUserName, count]) + ); + + // update eacho user's referCount + if (!dryRun) { + const usersWithUpdatedRefCount = await sqlSIW`-- upsert all new badges +UPDATE public.user u SET + extra=COALESCE(u.extra, '{}'::jsonb) - '{removeKeys}'::text[] || t.extra, updated_at=CURRENT_TIMESTAMP +FROM ( + SELECT * FROM UNNEST( + ${sqlSIW.array( + referrals.map(({ referralUserName }) => referralUserName), + ARRAY_TYPE + )} ::text[], + ${sqlSIW.array( + referrals.map(({ count, latestReferred }) => + JSON.stringify({ referredCount: count, latestReferred }) + ), + ARRAY_TYPE + )} ::jsonb[] + ) +) AS t(user_name, extra) +WHERE u.user_name=t.user_name -- skip AND u.extra->'referredCount' < t.extra->'referredCount' +RETURNING u.* ;`; + + console.log("log user by referrals:", usersWithUpdatedRefCount); + + // return; + } + + const allParticipants = await sql`-- query all campaign applicants +WITH all_applicant_articles AS ( + SELECT DISTINCT ON (author_id) + author_id ::int, article.id ::int, article.title, article.created_at + FROM article_tag + LEFT JOIN public.article ON article_id=article.id + WHERE article.created_at BETWEEN ${campaignBegins!} AND ${campaignEnds!} + AND tag_id =ANY(${campaignTagIds}) + ORDER BY author_id, article.created_at DESC +), all_apprs_to_applicants AS ( + SELECT DISTINCT ON (sender_id) sender_id ::int, created_at + FROM appreciation + WHERE reference_id IN ( SELECT id FROM all_applicant_articles ) + AND purpose IN ('appreciate') + AND created_at BETWEEN ${campaignBegins!} AND ${campaignEnds!} + ORDER BY sender_id, created_at DESC +), all_donations_to_applicants AS ( + SELECT DISTINCT ON (sender_id) sender_id ::int, created_at + FROM transaction + WHERE purpose='donation' AND state='succeeded' + AND target_type=4 AND target_id IN ( SELECT id FROM all_applicant_articles ) + AND created_at BETWEEN ${campaignBegins!} AND ${campaignEnds!} + ORDER BY sender_id, created_at DESC +), merged_all AS ( + SELECT DISTINCT ON (user_id) user_id, last_act, last_act_at + FROM ( + SELECT author_id ::int AS user_id, 'article_applicant' AS last_act, created_at AS last_act_at FROM all_applicant_articles + UNION ALL + SELECT sender_id ::int AS user_id, 'appr_to_applicant' AS last_act, created_at AS last_act_at FROM all_apprs_to_applicants + UNION ALL + SELECT sender_id ::int AS user_id, 'donate_to_applicant' AS last_act, created_at AS last_act_at FROM all_donations_to_applicants + ) t + ORDER BY user_id, last_act_at DESC +) + +SELECT user_name, display_name, aa.*, + u.email, u.language, u.created_at, u.state, + COALESCE((ub.extra->'level')::int, 0) AS current_level -- current nomad badge level +FROM merged_all aa +LEFT JOIN public.user u ON aa.user_id=u.id +LEFT JOIN public.user_badge ub ON ub.user_id=aa.user_id AND ub.type='nomad' +ORDER BY aa.last_act_at DESC ; `; + + console.log("consider all applicants:", allParticipants); + + const newNomad1BadgedUsers = allParticipants + .filter( + ({ currentLevel }) => currentLevel < 1 // && (referralsMap.get(userName) || 0) >= 0 + ) // for all ones got Lv1 + .map(({ userId, userName, displayName, email, language, ...rest }) => ({ + userId, + type: "nomad" as const, + newLevel: 1 as const, + userName, + referredCount: referralsMap.get(userName) || 0, + displayName, + email, + language: language as Language, + ...rest, + })); + + console.log("consider new nomad1 badges:", newNomad1BadgedUsers); + await putBadges(newNomad1BadgedUsers); + await sendNomadBadgeMail(newNomad1BadgedUsers); + // send badges, send mattersDB notification, send emails + + const newNomad2BadgedUsers = allParticipants + .filter( + ({ userName, currentLevel }) => + currentLevel < 2 && (referralsMap.get(userName) || 0) >= 5 + ) // for all ones got Lv2 + .map(({ userId, userName, displayName, email, language, ...rest }) => ({ + userId, + type: "nomad" as const, + newLevel: 2 as const, + userName, + referredCount: referralsMap.get(userName) || 0, + displayName, + email, + language: language as Language, + ...rest, + })); + + console.log("consider new nomad2 badges:", newNomad2BadgedUsers); + await putBadges(newNomad2BadgedUsers); + await sendNomadBadgeMail(newNomad2BadgedUsers); + // send badges, send mattersDB notification, send emails + + const newNomad3BadgedUsers = allParticipants + .filter( + ({ userName, currentLevel }) => + currentLevel < 3 && (referralsMap.get(userName) || 0) >= 10 + ) // for all ones got Lv3 + .map(({ userId, userName, displayName, email, language, ...rest }) => ({ + userId, + type: "nomad" as const, + newLevel: 3 as const, + userName, + referredCount: referralsMap.get(userName) || 0, + displayName, + email, + language: language as Language, + ...rest, + })); + + console.log("consider new nomad3 badges:", newNomad3BadgedUsers); + await putBadges(newNomad3BadgedUsers); + await sendNomadBadgeMail(newNomad3BadgedUsers); + // send badges, send mattersDB notification, send emails + + const newNomad4BadgedUsers = allParticipants + .filter( + ({ userName, currentLevel }) => + currentLevel < 4 && (referralsMap.get(userName) || 0) >= 20 + ) // for all ones got Lv4 + .map(({ userId, userName, displayName, email, language, ...rest }) => ({ + userId, + type: "nomad" as const, + newLevel: 4 as const, + userName, + referredCount: referralsMap.get(userName) || 0, + displayName, + email, + language: language as Language, + ...rest, + })); + + console.log("consider new nomad4 badged users:", newNomad4BadgedUsers); + await putBadges(newNomad4BadgedUsers); + await sendNomadBadgeMail(newNomad4BadgedUsers); + + // send badges, send mattersDB notification, send emails + + return; +} + +async function putBadges( + newNomadBadges: Array<{ + userId: number; + type: "nomad"; + newLevel: 1 | 2 | 3 | 4; + }>, + dryRun = true +) { + if (dryRun) return; + + const retBadges = await sqlSIW`-- upsert all new badges +INSERT INTO public.user_badge AS ub(user_id, type, extra) + SELECT * FROM UNNEST( + ${sqlSIW.array( + newNomadBadges.map(({ userId }) => userId), + ARRAY_TYPE + )} ::int[], + ${sqlSIW.array( + newNomadBadges.map(({ type }) => type), + ARRAY_TYPE + )} ::text[], + ${sqlSIW.array( + newNomadBadges.map(({ newLevel }) => JSON.stringify({ level: newLevel })), + ARRAY_TYPE + )} ::jsonb[] + ) +ON CONFLICT (user_id, type) +DO UPDATE + SET extra=((COALESCE(ub.extra, '{}' ::jsonb) - ${[ + "removeKeys", + ]} ::text[]) || EXCLUDED.extra) +RETURNING * ; +`; + + console.log("upsert'ed new badges:", retBadges); + + // send msg queue for mail + + return retBadges; +} + +const getTemplateId = (language: Language): string => { + const templateIdsDev = { + zh_hant: "d-550c209eef09442d8430fed10379593a", + zh_hans: "d-22b0f1c254d74cadaf6b2d246e0b4c14", + en: "d-550c209eef09442d8430fed10379593a", + }; + const templateIdsProd = { + zh_hant: "d-bc5695dcae564795ac76bc6a783a5ef7", + zh_hans: "d-7497ca1cfaa745a8bff4b3d20e92480a", + en: "d-bc5695dcae564795ac76bc6a783a5ef7", + }; + const templateIds = isProd ? templateIdsProd : templateIdsDev; + return templateIds[language]; +}; + +async function sendNomadBadgeMail( + newNomadBadgedUsers: Array<{ + // userId: number; type: string; extra: any; + displayName: string; + email: string; + language: Language; + newLevel: 1 | 2 | 3 | 4; + }>, + dryRun = true +) { + if (dryRun) return; + + return Promise.allSettled( + newNomadBadgedUsers.map(({ displayName, email, language, newLevel }) => { + const shareLink = `https://matters.town/xxxxx/xxxxxx?xxxx=xxxxxx123`; + + return mail.send({ + from: EMAIL_FROM_ASK, + templateId: getTemplateId(language), + personalizations: [ + { + to: email, + dynamicTemplateData: { + // subject, + displayName, + siteDomain, + shareLink, + newLevel, + // recipient, type, + }, + }, + ], + }); + }) + ); +}