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..431105f --- /dev/null +++ b/lib/check-nomad-badge.ts @@ -0,0 +1,111 @@ +import { sql, sqlSIW } from "../lib/db.js"; + +const MATTERS_CAMPAIGN_BEGINS = + process.env.MATTERS_CAMPAIGN_BEGINS || "2023-11-14T16:00:00.000Z"; // 00:00 in UTC+8 +const MATTERS_CAMPAIGN_ENDS = + process.env.MATTERS_CAMPAIGN_ENDS || "2024-01-14T15:59:59.999Z"; + +const ARRAY_TYPE = 1009; // Postgres internal value shouldn't be here; + +export async function checkNomadBadge({ + campaignTagIds = [157201], // https://matters.town/tags/157201-nomadmatters?type=latest + campaignBegins, + campaignEnds, +}: { + campaignTagIds?: number[]; + campaignBegins?: Date | string; + campaignEnds?: Date | string; +} = {}): 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 + + const referrals = await sql`-- select all referred users +SELECT extra->>'referralCode' AS "referralCode", COUNT(*) ::int +FROM public.user +WHERE created_at BETWEEN ${campaignBegins} AND ${campaignEnds} + AND extra->'referralCode' IS NOT NULL +GROUP BY 1 +HAVING COUNT(*) >=5 +ORDER BY count DESC -- LIMIT 13 `; + console.log("log user by referrals:", referrals); + const referralsMap = new Map( + referrals.map(({ referralCode, count }) => [referralCode, count]) + ); + + const allApplicants = await sql`-- query all campaign applicants +SELECT * FROM ( + SELECT DISTINCT ON (author_id) + user_name, display_name, author_id ::int, article.id ::int, article.title, article.created_at, + (ub.extra->'level')::int AS level -- current nomad badge level + FROM article_tag + LEFT JOIN public.article ON article_id=article.id + LEFT JOIN public.user u ON author_id=u.id + LEFT JOIN public.user_badge ub ON user_id=u.id AND ub.type='nomad' + WHERE created_at BETWEEN ${campaignBegins} AND ${campaignEnds} + AND tag_id =ANY(${campaignTagIds}) + ORDER BY author_id, article.created_at DESC +) t +ORDER BY created_at DESC ; `; + + console.log("consider all applicants:", allApplicants); + + const newNomadBadges: Array<{ + userId: string | number; + type: string; + extra: any; + }> = []; + allApplicants.forEach(({ userName, authorId, level: currentLevel }) => { + const referralCount = referralsMap.get(userName) || 0; + + let level: number; + if (referralCount >= 20) level = 4; + else if (referralCount >= 10) level = 3; + else if (referralCount >= 5) level = 2; + else level = 1; + + if (currentLevel >= level) { + // skip if there's existing level override + return; + } + + newNomadBadges.push({ + userId: authorId, + type: "nomad", + extra: { level }, + }); + }); + + console.log("consider new nomad badges:", newNomadBadges); + if (newNomadBadges.length === 0) 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(({ extra }) => JSON.stringify(extra)), + 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); + + return retBadges; +}