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..7c51ce1 --- /dev/null +++ b/handlers/check-nomad-badge.ts @@ -0,0 +1,42 @@ +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:`, event); + console.log(`Context:`, context); + + const { method, path } = (event?.requestContext as any)?.http || {}; + if ( + !( + path === "/refresh" && + event?.headers?.accept?.includes("application/json") + ) + ) { + return { + statusCode: 400, + body: JSON.stringify({ + error: + "input error, call with POST /refresh with accept: application/json", + }), + }; + } + + 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..09cc7bc --- /dev/null +++ b/lib/check-nomad-badge.ts @@ -0,0 +1,561 @@ +import crypto from "node:crypto"; + +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"; + +type NomadBadgeLevel = 1 | 2 | 3 | 4; + +const siteDomain = process.env.MATTERS_SITE_DOMAIN || ""; +// const siteOrigin = `https://${siteDomain}`; +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< + Array<{ + referralUserName: string; + count: number; + latestReferred: string | Date; + }> + >`-- select all referred users +SELECT t.* +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=u.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]) + ); + const getNewLevel = (userName: string): NomadBadgeLevel => { + const referredCount = referralsMap.get(userName) || 0; + if (referredCount >= 20) return 4 as const; + else if (referredCount >= 10) return 3 as const; + else if (referredCount >= 5) return 2 as const; + else return 1 as const; + }; + const shouldGetNewLevel = ( + userName: string, + newLevel: NomadBadgeLevel + ): boolean => { + const referredCount = referralsMap.get(userName) || 0; + switch (newLevel) { + case 4: + return referredCount >= 20; + case 3: + return referredCount >= 10; + case 2: + return referredCount >= 5; + default: + return true; + } + }; + + // update eacho user's referCount + updateReferredCount(referrals, true); + + 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' AND ub.enabled IS true +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 + // && shouldGetNewLevel(userName, 1) + ) // 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, true); + if (newNomad1BadgedUsers.length > 0) { + await notifyNomadBadge(newNomad1BadgedUsers, true); + await sendNomadBadgeMail(newNomad1BadgedUsers, true); + } + // send badges, send mattersDB notification, send emails + + const newNomad2BadgedUsers = allParticipants + .filter( + ({ userName, currentLevel }) => + currentLevel < 2 && // (referralsMap.get(userName) || 0) >= 5 + shouldGetNewLevel(userName, 2) + ) // 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, true); + if (newNomad2BadgedUsers.length > 0) { + await delay(1300); // give it a few seconds for all prior level mails sent + await sendNomadBadgeMail(newNomad2BadgedUsers, true); + } + // send badges, send mattersDB notification, send emails + + const newNomad3BadgedUsers = allParticipants + .filter( + ({ userName, currentLevel }) => + currentLevel < 3 && // (referralsMap.get(userName) || 0) >= 10 + shouldGetNewLevel(userName, 3) + ) // 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, true); + + if (newNomad3BadgedUsers.length > 0) { + await delay(1300); // give it a few seconds for all prior level mails sent + await sendNomadBadgeMail(newNomad3BadgedUsers, true); + } + // send badges, send mattersDB notification, send emails + + const newNomad4BadgedUsers = allParticipants + .filter( + ({ userName, currentLevel }) => + currentLevel < 4 && // (referralsMap.get(userName) || 0) >= 20 + shouldGetNewLevel(userName, 4) + ) // 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, true); + if (newNomad4BadgedUsers.length > 0) { + await delay(1300); // give it a few seconds for all prior level mails sent + await sendNomadBadgeMail(newNomad4BadgedUsers, true); + } + + // send badges, send mattersDB notification, send emails + + const allNewBadges = allParticipants + .map( + ({ + userId, + currentLevel, + userName, + displayName, + email, + language, + ...rest + }) => ({ + userId, + type: "nomad" as const, + currentLevel, + newLevel: getNewLevel(userName), + }) + ) + .filter(({ currentLevel, newLevel }) => currentLevel < newLevel); + await putBadges(allNewBadges, true); +} + +// update eacho user's referredCount +async function updateReferredCount( + referrals: Array<{ + referralUserName: string; + count: number; + latestReferred: Date | string; + }>, + doUpdate = false +) { + if (!doUpdate) return; + + 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); +} + +async function putBadges( + newNomadBadges: Array<{ + userId: number; + type: "nomad"; + newLevel: NomadBadgeLevel; + }>, + doUpdate = false +) { + if (!doUpdate) 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 enabled=true, + 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-ead2168972df477ca329d3c1e9ba2ca8", + zh_hans: "d-ead2168972df477ca329d3c1e9ba2ca8", // TO Change + en: "d-ead2168972df477ca329d3c1e9ba2ca8", // TO Change + }; + const templateIdsProd = { + zh_hant: "d-ead2168972df477ca329d3c1e9ba2ca8", // TO Change + zh_hans: "d-ead2168972df477ca329d3c1e9ba2ca8", // TO Change + en: "d-ead2168972df477ca329d3c1e9ba2ca8", // TO Change + }; + const templateIds = isProd ? templateIdsProd : templateIdsDev; + return templateIds[language]; +}; + +const getBadgeName = ( + language: Language, + newLevel: NomadBadgeLevel +): string => { + switch (language) { + case "zh_hans": + return newLevel >= 4 ? "" : newLevel >= 3 ? "" : newLevel >= 2 ? "" : ""; + case "en": + return newLevel >= 4 + ? "" + : newLevel >= 3 + ? "" + : newLevel >= 2 + ? "" + : `Level 1 Moonlight Dream`; + case "zh_hant": + default: + return `月之夢`; + } +}; + +const getNoticeMessage = ( + language: Language, + newLevel: NomadBadgeLevel +): string => { + switch (language) { + case "zh_hans": + return newLevel >= 4 + ? "你成功邀请了 20 位朋友参与游牧者计画,这是极少数人才能达到的成就,徽章已升级为最高等级火闪电!向社区展示你的徽章吧!" + : newLevel >= 3 + ? "你在游牧者之路邀请了10 位朋友,太厉害了!LV3 光轮号徽章已经飞到了你的创作主页。再邀请10 位同行者,一起迈向终点站,最高等级的荣誉正在等着你。" + : newLevel >= 2 + ? "你已成功邀请 5 位朋友关注游牧者计画,并升级为 LV2 流星号徽章,接下来再邀请五位就能继续升级!" + : "恭喜你获得游牧者计画LV1 月之梦徽章,已经展示在你的创作主页啰!接下来邀请5 位朋友注册并参与游牧者计画,你将获得更高等级的徽章。点击公告文最后一段指南,看看如何邀请朋友。"; + case "en": + return newLevel >= 4 + ? "You've successfully invited 20 friends to join the Nomad Matters, an achievement attained by very few. Your badge has now been upgraded to the highest level, Firebolt! Showcase your badge to the community." + : newLevel >= 3 + ? "Impressive! You've invited 10 friends on the Nomad's path. The Level 3 Nimbus Ferry badge has flown to your creative profile. Invite another 10 companions to journey together towards the final destination, where the highest level of honor awaits you." + : newLevel >= 2 + ? "You have successfully invited 5 friends to join the Nomad Matters and now upgraded to the Level 2 Meteor Canoe badge. Invite 5 more to continue leveling up!" + : "Congratulations on earning the Nomad Matters Level 1 Moonlight Dream badge! It's now displayed on your creative profile. Next, invite 5 friends to register and participate in the Nomad Matters, and you'll receive a higher-level badge. Click on the last paragraph for guidance on how to invite friends."; + case "zh_hant": + default: + return newLevel >= 4 + ? "你成功邀請了 20 位朋友參與遊牧者計畫,這是極少數人才能達到的成就,徽章已升級為最高等級火閃電!向社區展示你的徽章吧!" + : newLevel >= 3 + ? "你在遊牧者之路邀請了 10 位朋友,太厲害了!LV3 光輪號徽章已經飛到了你的創作主頁。再邀請 10 位同行者,一起邁向終點站,最高等級的榮譽正在等著你。" + : newLevel >= 2 + ? "你已成功邀請 5 位朋友關注遊牧者計畫,並升級為 LV2 流星號徽章,接下來再邀請五位就能繼續升級!" + : "恭喜你獲得遊牧者計畫 LV1 月之夢徽章,已經展示在你的創作主頁囉!接下來邀請 5 位朋友註冊並參與遊牧者計畫,你將獲得更高等級的徽章。點擊公告文最後一段指南,看看如何邀請朋友。"; + } +}; + +const getSubject = (language: Language, newLevel: NomadBadgeLevel): string => { + switch (language) { + case "zh_hans": + return newLevel >= 4 + ? "感谢参与游牧者计画,你已经升级并获得最高荣誉「火闪电」徽章! " + : newLevel >= 3 + ? "感谢参与游牧者计画,你已经升级并获得「光轮号」徽章! " + : newLevel >= 2 + ? "感谢参与游牧者计画,你已经升级并获得「流星号」徽章! " + : "感谢参与游牧者计画,你已经升级并获得「月之梦」徽章! "; + case "en": + return newLevel >= 4 + ? "You have earned Level 1 Moonlight Dream badge in Nomad Matters!" + : newLevel >= 3 + ? "You have earned Level 3 Nimbus Ferry badge in Nomad Matters!" + : newLevel >= 2 + ? "You have earned Level 2 Meteor Canoe badge in Nomad Matters!" + : "You have earned Level 1 Moonlight Dream badge in Nomad Matters!"; + case "zh_hant": + default: + return newLevel >= 4 + ? "感謝參與遊牧者計畫,你已經升級並獲得最高榮譽「火閃電」徽章!" + : newLevel >= 3 + ? "感謝參與遊牧者計畫,你已經升級並獲得「光輪號」徽章!" + : newLevel >= 2 + ? "感謝參與遊牧者計畫,你已經升級並獲得「流星號」徽章!" + : "感謝參與遊牧者計畫,你已經升級並獲得「月之夢」徽章!"; + } +}; + +async function notifyNomadBadge( + newNomadBadgedUsers: Array<{ + userId: number; + userName: string; + displayName: string; + email: string; + language: Language; + newLevel: NomadBadgeLevel; + }>, + doNotify = false +) { + if (!doNotify) return; + + const allNotices = newNomadBadgedUsers.map( + ({ language, newLevel, ...rest }) => ({ + language, + newLevel, + ...rest, + message: getNoticeMessage(language, newLevel), + // noticeId: undefined as number | string | undefined, + }) + ); + const allMessages = Array.from( + new Set(allNotices.map(({ message }) => message)) + ); + const messageIds = await sql< + Array<{ id: number | string; noticeType: string; message: string }> + >`SELECT * FROM notice_detail WHERE created_at>='2023-12-01' AND notice_type='official_announcement' AND message=ANY(${allMessages}) ;`; + const existingMessages = new Map( + messageIds.map(({ id, message }) => [message, id]) + ); + console.log(`got existings messageIds:`, existingMessages); + + if (existingMessages.size < allMessages.length) { + const missingOnes = allMessages.filter((msg) => !existingMessages.has(msg)); + const newInserted = + await sqlSIW`INSERT INTO notice_detail(notice_type, message) SELECT * FROM UNNEST( + ${sqlSIW.array( + missingOnes.map(() => "official_announcement"), + ARRAY_TYPE + )} ::text[], + ${sqlSIW.array( + missingOnes, // all missing messages + ARRAY_TYPE + )} ::text[] +) RETURNING * ;`; + + console.log(`got new inserted messageIds:`, newInserted); + newInserted.forEach(({ id, message }) => existingMessages.set(message, id)); + } + + console.log(`got all messageIds:`, existingMessages); + const retNewNotices = + await sqlSIW`INSERT INTO notice(uuid, notice_detail_id, recipient_id) SELECT * FROM UNNEST( + ${sqlSIW.array( + allNotices.map(() => crypto.randomUUID()), + ARRAY_TYPE + )} ::uuid[], + ${sqlSIW.array( + allNotices.map(({ message }) => existingMessages.get(message)!), // notice_detail_id, + ARRAY_TYPE + )} ::int[], + ${sqlSIW.array( + allNotices.map(({ userId }) => userId), // recipient_id + ARRAY_TYPE + )} ::int[] +) RETURNING * ;`; + + console.log(`got all retNewNotices:`, retNewNotices); +} + +async function sendNomadBadgeMail( + newNomadBadgedUsers: Array<{ + // userId: number; type: string; extra: any; + userId: number; + userName: string; + displayName: string; + email: string; + language: Language; + newLevel: NomadBadgeLevel; + }>, + doSendMail = false +) { + if (!doSendMail) return; + + return Promise.allSettled( + newNomadBadgedUsers.map( + ({ userName, displayName, email, language, newLevel }) => { + const shareLink = `https://matters-4ex58f9pl-matters.vercel.app/@${userName}?showBadges`; + console.log(`send mail notification to:`, { + userName, + displayName, + email, + language, + newLevel, + shareLink, + }); + + return mail.send({ + from: EMAIL_FROM_ASK, + templateId: getTemplateId(language), + personalizations: [ + { + to: email, + dynamicTemplateData: { + subject: getSubject(language, newLevel), + displayName, + siteDomain, + shareLink, + newLevel, + // recipient, type, + }, + }, + ], + }); + } + ) + ); +} + +const delay = (timeout: number) => + new Promise((fulfilled) => setTimeout(fulfilled, timeout));