From 4d62e4968d08ef670863eff2d6091c12f009e77f Mon Sep 17 00:00:00 2001 From: dotslashf <38921923+dotslashf@users.noreply.github.com> Date: Wed, 25 Sep 2024 21:33:38 +0700 Subject: [PATCH] feat: add reset streak mechanism --- cron.ts | 37 +++++++ package-lock.json | 183 ++++++++++++++++++++++++++++++--- package.json | 4 + src/env.js | 2 + src/lib/constant.tsx | 2 + src/lib/utils.ts | 11 +- src/server/api/root.ts | 2 + src/server/api/routers/cron.ts | 63 ++++++++++++ 8 files changed, 291 insertions(+), 13 deletions(-) create mode 100644 cron.ts create mode 100644 src/server/api/routers/cron.ts diff --git a/cron.ts b/cron.ts new file mode 100644 index 0000000..37d3045 --- /dev/null +++ b/cron.ts @@ -0,0 +1,37 @@ +import cron from "node-cron"; +import fetch from "node-fetch"; + +const CRON_SECRET = process.env.CRON_SECRET; +const API_URL = process.env.NEXT_PUBLIC_API_URL; + +// Run every day at midnight Jakarta time (5pm UTC) +cron.schedule( + "0 17 * * *", + async () => { + try { + const response = await fetch( + `${API_URL}/api/trpc/cron.dailyStreakReset`, + { + method: "POST", + headers: { + "Content-Type": "application/json", + }, + body: JSON.stringify({ + json: { + secret: CRON_SECRET, + }, + }), + }, + ); + + const result = await response.json(); + console.log("Cron job result:", result); + } catch (error) { + console.error("Error running cron job:", error); + } + }, + { + scheduled: true, + timezone: "UTC", + }, +); diff --git a/package-lock.json b/package-lock.json index 35aa910..1131752 100644 --- a/package-lock.json +++ b/package-lock.json @@ -45,6 +45,7 @@ "clsx": "^2.1.1", "cmdk": "^1.0.0", "date-fns": "^3.6.0", + "date-fns-tz": "^3.1.3", "framer-motion": "^11.3.24", "geist": "^1.3.0", "googleapis": "^143.0.0", @@ -53,6 +54,8 @@ "next-auth": "^4.24.7", "next-themes": "^0.3.0", "nextjs-toploader": "^3.6.15", + "node-cron": "^3.0.3", + "node-fetch": "^3.3.2", "react": "^18.3.1", "react-day-picker": "8.10.1", "react-dom": "^18.3.1", @@ -72,6 +75,7 @@ "devDependencies": { "@types/eslint": "^8.56.10", "@types/node": "^20.14.10", + "@types/node-cron": "^3.0.11", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@types/uuid": "^10.0.0", @@ -2563,6 +2567,12 @@ "undici-types": "~6.19.2" } }, + "node_modules/@types/node-cron": { + "version": "3.0.11", + "resolved": "https://registry.npmjs.org/@types/node-cron/-/node-cron-3.0.11.tgz", + "integrity": "sha512-0ikrnug3/IyneSHqCBeslAhlK2aBfYek1fGo4bP4QnZPmiqSGRK+Oy7ZMisLWkesffJvQ1cqAcBnJC+8+nxIAg==", + "dev": true + }, "node_modules/@types/prop-types": { "version": "15.7.12", "resolved": "https://registry.npmjs.org/@types/prop-types/-/prop-types-15.7.12.tgz", @@ -4033,6 +4043,14 @@ "integrity": "sha512-sdQSFB7+llfUcQHUQO3+B8ERRj0Oa4w9POWMI/puGtuf7gFywGmkaLCElnudfTiKZV+NvHqL0ifzdrI8Ro7ESA==", "dev": true }, + "node_modules/data-uri-to-buffer": { + "version": "4.0.1", + "resolved": "https://registry.npmjs.org/data-uri-to-buffer/-/data-uri-to-buffer-4.0.1.tgz", + "integrity": "sha512-0R9ikRb668HB7QDxT1vkpuUBtqc53YyAwMwGeUFKRojY/NWKvdZ+9UYtRfGmhqNbRkTSVpMbmyhXipFFv2cb/A==", + "engines": { + "node": ">= 12" + } + }, "node_modules/data-view-buffer": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/data-view-buffer/-/data-view-buffer-1.0.1.tgz", @@ -4093,6 +4111,14 @@ "url": "https://github.com/sponsors/kossnocorp" } }, + "node_modules/date-fns-tz": { + "version": "3.1.3", + "resolved": "https://registry.npmjs.org/date-fns-tz/-/date-fns-tz-3.1.3.tgz", + "integrity": "sha512-ZfbMu+nbzW0mEzC8VZrLiSWvUIaI3aRHeq33mTe7Y38UctKukgqPR4nTDwcwS4d64Gf8GghnVsroBuMY3eiTeA==", + "peerDependencies": { + "date-fns": "^3.0.0" + } + }, "node_modules/debug": { "version": "4.3.6", "resolved": "https://registry.npmjs.org/debug/-/debug-4.3.6.tgz", @@ -5125,6 +5151,28 @@ "reusify": "^1.0.4" } }, + "node_modules/fetch-blob": { + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/fetch-blob/-/fetch-blob-3.2.0.tgz", + "integrity": "sha512-7yAQpD2UMJzLi1Dqv7qFYnPbaPx7ZfFK6PiIxQ4PfkGPyNyl2Ugx+a/umUonmKqjhM4DnfbMvdX6otXq83soQQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "paypal", + "url": "https://paypal.me/jimmywarting" + } + ], + "dependencies": { + "node-domexception": "^1.0.0", + "web-streams-polyfill": "^3.0.3" + }, + "engines": { + "node": "^12.20 || >= 14.13" + } + }, "node_modules/file-entry-cache": { "version": "6.0.1", "resolved": "https://registry.npmjs.org/file-entry-cache/-/file-entry-cache-6.0.1.tgz", @@ -5221,6 +5269,17 @@ "node": ">= 0.12" } }, + "node_modules/formdata-polyfill": { + "version": "4.0.10", + "resolved": "https://registry.npmjs.org/formdata-polyfill/-/formdata-polyfill-4.0.10.tgz", + "integrity": "sha512-buewHzMvYL29jdeQTVILecSaZKnt/RJWjoZCF5OW60Z67/GmSLBkOFM7qh1PI3zFNtJbaZL5eQu1vLfazOwj4g==", + "dependencies": { + "fetch-blob": "^3.1.2" + }, + "engines": { + "node": ">=12.20.0" + } + }, "node_modules/framer-motion": { "version": "11.3.30", "resolved": "https://registry.npmjs.org/framer-motion/-/framer-motion-11.3.30.tgz", @@ -5314,6 +5373,25 @@ "node": ">=14" } }, + "node_modules/gaxios/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/gaxios/node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -5546,6 +5624,25 @@ "node": ">=14" } }, + "node_modules/google-gax/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/google-gax/node_modules/object-hash": { "version": "3.0.0", "resolved": "https://registry.npmjs.org/object-hash/-/object-hash-3.0.0.tgz", @@ -6798,23 +6895,58 @@ "react-dom": ">= 16.0.0" } }, - "node_modules/node-fetch": { - "version": "2.7.0", - "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", - "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "node_modules/node-cron": { + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/node-cron/-/node-cron-3.0.3.tgz", + "integrity": "sha512-dOal67//nohNgYWb+nWmg5dkFdIwDm8EpeGYMekPMrngV3637lqnX0lbUcCtgibHTz6SEz7DAIjKvKDFYCnO1A==", "dependencies": { - "whatwg-url": "^5.0.0" + "uuid": "8.3.2" }, "engines": { - "node": "4.x || >=6.0.0" + "node": ">=6.0.0" + } + }, + "node_modules/node-cron/node_modules/uuid": { + "version": "8.3.2", + "resolved": "https://registry.npmjs.org/uuid/-/uuid-8.3.2.tgz", + "integrity": "sha512-+NYs2QeMWy+GWFOEm9xnn6HCDp0l7QBD7ml8zLUmJ+93Q5NF0NocErnwkTkXVFNiX3/fpC6afS8Dhb/gz7R7eg==", + "bin": { + "uuid": "dist/bin/uuid" + } + }, + "node_modules/node-domexception": { + "version": "1.0.0", + "resolved": "https://registry.npmjs.org/node-domexception/-/node-domexception-1.0.0.tgz", + "integrity": "sha512-/jKZoMpw0F8GRwl4/eLROPA3cfcXtLApP0QzLmUT/HuPCZWyB7IY9ZrMeKw2O/nFIqPQB3PVM9aYm0F312AXDQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/jimmywarting" + }, + { + "type": "github", + "url": "https://paypal.me/jimmywarting" + } + ], + "engines": { + "node": ">=10.5.0" + } + }, + "node_modules/node-fetch": { + "version": "3.3.2", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-3.3.2.tgz", + "integrity": "sha512-dRB78srN/l6gqWulah9SrxeYnxeddIG30+GOqK/9OlLVyLg3HPnr6SqOWTWOXKRwC2eGYCkZ59NNuSgvSrpgOA==", + "dependencies": { + "data-uri-to-buffer": "^4.0.0", + "fetch-blob": "^3.1.4", + "formdata-polyfill": "^4.0.10" }, - "peerDependencies": { - "encoding": "^0.1.0" + "engines": { + "node": "^12.20.0 || ^14.13.1 || >=16.0.0" }, - "peerDependenciesMeta": { - "encoding": { - "optional": true - } + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/node-fetch" } }, "node_modules/normalize-path": { @@ -8636,6 +8768,25 @@ "node": ">= 6" } }, + "node_modules/teeny-request/node_modules/node-fetch": { + "version": "2.7.0", + "resolved": "https://registry.npmjs.org/node-fetch/-/node-fetch-2.7.0.tgz", + "integrity": "sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==", + "dependencies": { + "whatwg-url": "^5.0.0" + }, + "engines": { + "node": "4.x || >=6.0.0" + }, + "peerDependencies": { + "encoding": "^0.1.0" + }, + "peerDependenciesMeta": { + "encoding": { + "optional": true + } + } + }, "node_modules/teeny-request/node_modules/uuid": { "version": "9.0.1", "resolved": "https://registry.npmjs.org/uuid/-/uuid-9.0.1.tgz", @@ -9010,6 +9161,14 @@ "d3-timer": "^3.0.1" } }, + "node_modules/web-streams-polyfill": { + "version": "3.3.3", + "resolved": "https://registry.npmjs.org/web-streams-polyfill/-/web-streams-polyfill-3.3.3.tgz", + "integrity": "sha512-d2JWLCivmZYTSIoge9MsgFCZrt571BikcWGYkjC1khllbTeDlGqZ2D8vD8E/lJa8WGWbb7Plm8/XJYV7IJHZZw==", + "engines": { + "node": ">= 8" + } + }, "node_modules/webidl-conversions": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/webidl-conversions/-/webidl-conversions-3.0.1.tgz", diff --git a/package.json b/package.json index 17d9240..67c1ed1 100644 --- a/package.json +++ b/package.json @@ -54,6 +54,7 @@ "clsx": "^2.1.1", "cmdk": "^1.0.0", "date-fns": "^3.6.0", + "date-fns-tz": "^3.1.3", "framer-motion": "^11.3.24", "geist": "^1.3.0", "googleapis": "^143.0.0", @@ -62,6 +63,8 @@ "next-auth": "^4.24.7", "next-themes": "^0.3.0", "nextjs-toploader": "^3.6.15", + "node-cron": "^3.0.3", + "node-fetch": "^3.3.2", "react": "^18.3.1", "react-day-picker": "8.10.1", "react-dom": "^18.3.1", @@ -81,6 +84,7 @@ "devDependencies": { "@types/eslint": "^8.56.10", "@types/node": "^20.14.10", + "@types/node-cron": "^3.0.11", "@types/react": "^18.3.3", "@types/react-dom": "^18.3.0", "@types/uuid": "^10.0.0", diff --git a/src/env.js b/src/env.js index be2d2a0..c16b050 100644 --- a/src/env.js +++ b/src/env.js @@ -33,6 +33,7 @@ export const env = createEnv({ GCP_PROJECT_ID: z.string(), GOOGLE_APPLICATION_CREDENTIALS: z.string(), GCS_BUCKET_NAME: z.string(), + CRON_SECRET: z.string(), }, /** @@ -64,6 +65,7 @@ export const env = createEnv({ GCP_PROJECT_ID: process.env.GCP_PROJECT_ID, GCS_BUCKET_NAME: process.env.GCS_BUCKET_NAME, GOOGLE_APPLICATION_CREDENTIALS: process.env.GOOGLE_APPLICATION_CREDENTIALS, + CRON_SECRET: process.env.CRON_SECRET, }, /** * Run `build` or `dev` with `SKIP_ENV_VALIDATION` to skip env validation. This is especially diff --git a/src/lib/constant.tsx b/src/lib/constant.tsx index 7e06f41..f907600 100644 --- a/src/lib/constant.tsx +++ b/src/lib/constant.tsx @@ -159,3 +159,5 @@ export const ENGAGEMENT_SCORE = { DeleteReaction: -3, DeleteCollection: -3, }; + +export const TIMEZONE = "Asia/Jakarta"; diff --git a/src/lib/utils.ts b/src/lib/utils.ts index dafce3c..c3d4ca2 100644 --- a/src/lib/utils.ts +++ b/src/lib/utils.ts @@ -2,6 +2,7 @@ import { twMerge } from "tailwind-merge"; import { type $Enums, EngagementAction, OriginSource } from "@prisma/client"; import { type ClassValue, clsx } from "clsx"; import { format, formatDistance, parse } from "date-fns"; +import { format as formatTz, toZonedTime } from "date-fns-tz"; import { id } from "date-fns/locale"; import { type Breadcrumb, @@ -14,7 +15,7 @@ import { type WebSite, type SearchAction, } from "schema-dts"; -import { baseUrl } from "./constant"; +import { baseUrl, TIMEZONE } from "./constant"; export function cn(...inputs: ClassValue[]) { return twMerge(clsx(inputs)); @@ -32,6 +33,14 @@ export function parseDate(date: string) { return parse(date, "d MMMM yyyy", new Date(), { locale: id }); } +export function getJakartaDate(date: Date = new Date()): Date { + return toZonedTime(date, TIMEZONE); +} + +export function getJakartaDateString(date: Date = new Date()): string { + return formatTz(getJakartaDate(date), "yyyy-MM-dd", { timeZone: TIMEZONE }); +} + export function trimContent(content: string, length = 255) { if (!content) { return "😱😱😱"; diff --git a/src/server/api/root.ts b/src/server/api/root.ts index 5291520..a2a66e6 100644 --- a/src/server/api/root.ts +++ b/src/server/api/root.ts @@ -9,6 +9,7 @@ import { uploadRouter } from "./routers/upload"; import { statisticsRouter } from "./routers/statistics"; import { collectionRouter } from "./routers/collection"; import { engagementLogsRouter } from "./routers/engagementLogs"; +import { cronRouter } from "./routers/cron"; /** * This is the primary router for your server. @@ -26,6 +27,7 @@ export const appRouter = createTRPCRouter({ statistics: statisticsRouter, collection: collectionRouter, engagementLogs: engagementLogsRouter, + cron: cronRouter, }); // export type definition of API diff --git a/src/server/api/routers/cron.ts b/src/server/api/routers/cron.ts new file mode 100644 index 0000000..1beb28c --- /dev/null +++ b/src/server/api/routers/cron.ts @@ -0,0 +1,63 @@ +import { z } from "zod"; +import { createTRPCRouter, publicProcedure } from "../trpc"; +import { TRPCError } from "@trpc/server"; +import { env } from "~/env"; +import { getJakartaDate, getJakartaDateString } from "~/lib/utils"; + +export const cronRouter = createTRPCRouter({ + healthCheck: publicProcedure + .input( + z.object({ + secret: z.string(), + }), + ) + .mutation(async ({ input }) => { + if (input.secret !== env.CRON_SECRET) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid secret for cron job", + }); + } + + return `I am healthy`; + }), + + dailyStreakReset: publicProcedure + .input( + z.object({ + secret: z.string(), + }), + ) + .mutation(async ({ ctx, input }) => { + if (input.secret !== env.CRON_SECRET) { + throw new TRPCError({ + code: "UNAUTHORIZED", + message: "Invalid secret for cron job", + }); + } + + const jakartaYesterday = getJakartaDate( + new Date(Date.now() - 24 * 60 * 60 * 1000), + ); + + const resetResult = await ctx.db.user.updateMany({ + where: { + lastPostedAt: { + lt: jakartaYesterday, + }, + currentStreak: { + gt: 0, + }, + }, + data: { + currentStreak: 0, + }, + }); + + return { + message: "Daily streak reset", + resetCount: resetResult.count, + jakartaDate: getJakartaDateString(), + }; + }), +});