Skip to content

Commit

Permalink
feat(nomad-badges): Digital Nomad program
Browse files Browse the repository at this point in the history
- putBadges for level1,2,3,4
- sendmail for level1,2,3,4
- incrementally, no skipping each level

for thematters/matters-server#3721
  • Loading branch information
Ubuntu authored and Ubuntu committed Dec 6, 2023
1 parent d26b7d2 commit c726d97
Show file tree
Hide file tree
Showing 3 changed files with 349 additions and 0 deletions.
11 changes: 11 additions & 0 deletions bin/check-nomad-badge.ts
Original file line number Diff line number Diff line change
@@ -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));
26 changes: 26 additions & 0 deletions handlers/check-nomad-badge.ts
Original file line number Diff line number Diff line change
@@ -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<APIGatewayProxyResult> => {
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 }),
};
};
312 changes: 312 additions & 0 deletions lib/check-nomad-badge.ts
Original file line number Diff line number Diff line change
@@ -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<any> {
// 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<string, number>(
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-ead2168972df477ca329d3c1e9ba2ca8",
zh_hans: "d-22b0f1c254d74cadaf6b2d246e0b4c14", // TO Change
en: "d-550c209eef09442d8430fed10379593a", // TO Change
};
const templateIdsProd = {
zh_hant: "d-bc5695dcae564795ac76bc6a783a5ef7", // TO Change
zh_hans: "d-7497ca1cfaa745a8bff4b3d20e92480a", // TO Change
en: "d-bc5695dcae564795ac76bc6a783a5ef7", // TO Change
};
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,
},
},
],
});
})
);
}

0 comments on commit c726d97

Please sign in to comment.