-
Notifications
You must be signed in to change notification settings - Fork 0
Commit
This commit does not belong to any branch on this repository, and may belong to a fork outside of the repository.
feat(nomad-badges): Digital Nomad program
- 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 85ee4fa
Showing
3 changed files
with
349 additions
and
0 deletions.
There are no files selected for viewing
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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)); |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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 }), | ||
}; | ||
}; |
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
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-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, | ||
}, | ||
}, | ||
], | ||
}); | ||
}) | ||
); | ||
} |