diff --git a/apps/dashboard/src/App.tsx b/apps/dashboard/src/App.tsx index fc582bb1f..eaa1ff238 100644 --- a/apps/dashboard/src/App.tsx +++ b/apps/dashboard/src/App.tsx @@ -21,6 +21,7 @@ const Tutor = lazy(() => import("@/pages/Tutor")); const Tutors = lazy(() => import("@/pages/Tutors")); const PlanInvites = lazy(() => import("@/pages/PlanInvites")); const Lesson = lazy(() => import("@/pages/Lesson")); +const AdMessage = lazy(() => import("@/pages/AdMessage")); const Main = lazy(() => import("@/pages/Main")); const router = createBrowserRouter([ @@ -67,6 +68,10 @@ const router = createBrowserRouter([ path: Dashboard.Lesson, element: } />, }, + { + path: Dashboard.AdMessage, + element: } />, + }, ], }, ]); diff --git a/apps/dashboard/src/components/Layout/Sidebar.tsx b/apps/dashboard/src/components/Layout/Sidebar.tsx index 550866433..d055176eb 100644 --- a/apps/dashboard/src/components/Layout/Sidebar.tsx +++ b/apps/dashboard/src/components/Layout/Sidebar.tsx @@ -21,6 +21,7 @@ import Tag from "@litespace/assets/Tag"; import Users from "@litespace/assets/Users"; import Video from "@litespace/assets/Video"; import Home from "@litespace/assets/Home"; +import Messages from "@litespace/assets/Monitor"; import { router } from "@/lib/route"; import { Icon } from "@/types/common"; @@ -174,6 +175,13 @@ const Sidebar: React.FC = () => { Icon: People, }; + const adMessage: LinkInfo = { + label: intl("dashboard.ad-message.title"), + route: Dashboard.AdMessage, + isActive: match(Dashboard.AdMessage), + Icon: Messages, + }; + if (user?.role === IUser.Role.Studio) return [photoSession]; return [ @@ -187,6 +195,7 @@ const Sidebar: React.FC = () => { photoSession, lessons, tutors, + adMessage, ]; }, [intl, location.pathname, user?.role]); diff --git a/apps/dashboard/src/hooks/authRoutes.ts b/apps/dashboard/src/hooks/authRoutes.ts index 7b2db4ec5..090d3581d 100644 --- a/apps/dashboard/src/hooks/authRoutes.ts +++ b/apps/dashboard/src/hooks/authRoutes.ts @@ -83,6 +83,9 @@ const routeConfigMap: Record = { [Dashboard.Lesson]: { whitelist: [superAdmin, regularAdmin], }, + [Dashboard.AdMessage]: { + whitelist: [superAdmin, regularAdmin], + }, [Dashboard.Main]: { whitelist: [superAdmin, regularAdmin], }, diff --git a/apps/dashboard/src/pages/AdMessage.tsx b/apps/dashboard/src/pages/AdMessage.tsx new file mode 100644 index 000000000..a87d0f934 --- /dev/null +++ b/apps/dashboard/src/pages/AdMessage.tsx @@ -0,0 +1,93 @@ +import BackLink from "@/components/Common/BackLink"; +import { useUsers } from "@litespace/headless/users"; +import { useSendAdMessage } from "@litespace/headless/student"; +import { IUser } from "@litespace/types"; +import { Button } from "@litespace/ui/Button"; +import { DateInput } from "@litespace/ui/DateInput"; +import { useFormatMessage } from "@litespace/ui/hooks/intl"; +import { Typography } from "@litespace/ui/Typography"; +import dayjs from "dayjs"; +import { useCallback, useState } from "react"; +import { useOnError } from "@/hooks/error"; +import { useToast } from "@litespace/ui/Toast"; + +const AdMessage: React.FC = () => { + const intl = useFormatMessage(); + const toast = useToast(); + + const [from, setFrom] = useState( + dayjs().subtract(1, "day").format("YYYY-MM-DD") + ); + const [to, setTo] = useState(dayjs().format("YYYY-MM-DD")); + + const findStudents = useUsers({ + role: IUser.Role.Student, + createdAt: { + gte: from, + lte: to, + }, + full: true, + }); + + const onError = useOnError({ + type: "mutation", + handler: (error) => toast.error({ title: intl(error.messageId) }), + }); + + const sendAdMessageMutation = useSendAdMessage({ + onSuccess: () => toast.success({ title: intl("labels.done") }), + onError, + }); + + const send = useCallback(() => { + sendAdMessageMutation.mutate({ + payload: { + createdAt: { + gte: from, + lte: to, + }, + }, + }); + }, [sendAdMessageMutation, from, to]); + + return ( +
+ + +
+ {intl("placeholders.from")} + +
+ +
+ {intl("placeholders.to")} + +
+ +
+ {findStudents.query.data?.list.map((student) => + student.phone ? ( + + {student.id} - {student.name} + + ) : null + )} +
+ +
+ +
+
+ ); +}; + +export default AdMessage; diff --git a/packages/atlas/src/api/student.ts b/packages/atlas/src/api/student.ts index 1204ba1ac..19349300d 100644 --- a/packages/atlas/src/api/student.ts +++ b/packages/atlas/src/api/student.ts @@ -19,4 +19,10 @@ export class Student extends Base { ): Promise { return this.get({ route: `api/v1/student/${query.id}` }); } + + async sendAdMessage( + payload: IStudent.SendAdMessageApiPayload + ): Promise { + return this.post({ route: `api/v1/student/send-ad-message`, payload }); + } } diff --git a/packages/headless/src/constants/mutation.ts b/packages/headless/src/constants/mutation.ts index a6a212784..6cbba763f 100644 --- a/packages/headless/src/constants/mutation.ts +++ b/packages/headless/src/constants/mutation.ts @@ -55,4 +55,5 @@ export enum MutationKey { FawryRefund = "fawry-refund", PlanCheckout = "plan-checkout", LessonCheckout = "lesson-checkout", + SendAdMessage = "send-ad-message", } diff --git a/packages/headless/src/student.ts b/packages/headless/src/student.ts index d693c4e79..1bc6aad9d 100644 --- a/packages/headless/src/student.ts +++ b/packages/headless/src/student.ts @@ -96,3 +96,26 @@ export function useFindStudentById(id: number): UseQueryResult { queryKey: [QueryKey.FindStudentById], }); } + +export function useSendAdMessage({ + onSuccess, + onError, +}: { + onSuccess?: OnSuccess; + onError?: OnError; +}) { + const api = useApi(); + + const send = useCallback( + async ({ payload }: { payload: IStudent.SendAdMessageApiPayload }) => + api.student.sendAdMessage(payload), + [api.student] + ); + + return useMutation({ + mutationFn: send, + mutationKey: [MutationKey.SendAdMessage], + onSuccess, + onError, + }); +} diff --git a/packages/models/src/users.ts b/packages/models/src/users.ts index 6aa1e61a9..a8f4d6a4e 100644 --- a/packages/models/src/users.ts +++ b/packages/models/src/users.ts @@ -1,6 +1,7 @@ import { countRows, knex, + withDateFilter, withListFilter, WithOptionalTx, withSkippablePagination, @@ -151,6 +152,7 @@ export class Users extends Model< gender, city, select, + createdAt, ...pagination }: WithOptionalTx>): Promise< Paginated> @@ -162,6 +164,7 @@ export class Users extends Model< if (verified) base.andWhere(this.column("verified_email"), verified); if (gender) base.andWhere(this.column("gender"), gender); if (city) base.andWhere(this.column("city"), city); + withDateFilter(base, this.column("created_at"), createdAt); const total = await countRows(base.clone(), { column: this.column("id"), diff --git a/packages/types/src/messenger.ts b/packages/types/src/messenger.ts index 8cf9c486f..790abedf0 100644 --- a/packages/types/src/messenger.ts +++ b/packages/types/src/messenger.ts @@ -48,6 +48,10 @@ export type Template = time: string; url: string; }; + } + | { + name: "ad_message"; + parameters: object; }; export type Message = { diff --git a/packages/types/src/student.ts b/packages/types/src/student.ts index 1e566ccf5..7c31d9b26 100644 --- a/packages/types/src/student.ts +++ b/packages/types/src/student.ts @@ -86,3 +86,11 @@ export type FindApiResponse = Paginated; export type FindByIdApiQuery = { id: number }; export type FindByIdApiResponse = Self; + +export type SendAdMessageApiPayload = { + createdAt: IFilter.Date; +}; + +export type SendAdMessageApiResponse = { + count: number; +}; diff --git a/packages/types/src/user.ts b/packages/types/src/user.ts index 30f823248..930ef843e 100644 --- a/packages/types/src/user.ts +++ b/packages/types/src/user.ts @@ -210,6 +210,7 @@ export type FindModelQuery = gender?: Gender; city?: City; select?: T[]; + createdAt?: IFilter.Date; }; export type FindUsersApiQuery = IFilter.SkippablePagination & { @@ -217,6 +218,7 @@ export type FindUsersApiQuery = IFilter.SkippablePagination & { verified?: boolean; gender?: Gender; city?: City; + createdAt?: IFilter.Date; }; export type FindStudentStatsApiResponse = { diff --git a/packages/ui/src/locales/ar-eg.json b/packages/ui/src/locales/ar-eg.json index 473527252..120b325d7 100644 --- a/packages/ui/src/locales/ar-eg.json +++ b/packages/ui/src/locales/ar-eg.json @@ -653,6 +653,7 @@ "dashboard.lesson.before-session": "قبل الجلسة", "dashboard.lesson.after-session": "بعد الجلسة", "dashboard.lesson.at-session": "عند بدء الجلسة", + "dashboard.ad-message.title": "الرسائل الدعائية", "global.labels.minutes": "د", "webrtc-check.title": "تنبيه: متصفحك لا يدعم تقنيه WebRTC", "webrtc-check.description": "بعض وظائف المنصة تتطلب تقنيه WebRTC و التي لا يدعمها متصفحك الحالي، للحصول علي افضل تجربة نوصي باستخدام متصفح مثل Chrome او Firefox او Safari او Edge باحدث اصداراته.", @@ -1064,6 +1065,7 @@ "labels.contact-us": "تواصل معنا", "labels.leave": "المغادرة", "labels.enable-notifications": "تفعيل الإشعارات", + "labels.send": "إرسال", "labels.percent": "{value}%", "labels.hash-value": "{value}#", "labels.upload.video": "جاري رفع الفيديو", diff --git a/packages/utils/src/routes/route.ts b/packages/utils/src/routes/route.ts index da6729420..b087c74d4 100644 --- a/packages/utils/src/routes/route.ts +++ b/packages/utils/src/routes/route.ts @@ -67,6 +67,7 @@ export enum Dashboard { Tutors = "/tutors", PlanInvites = "/plan-invites", SessionEvents = "/session-events", + AdMessage = "/ad-message", } export type StudentSettingsTabId = diff --git a/services/server/scripts/whatsapp.ts b/services/server/scripts/whatsapp.ts index 57f4021c6..dca9b54b9 100644 --- a/services/server/scripts/whatsapp.ts +++ b/services/server/scripts/whatsapp.ts @@ -32,16 +32,28 @@ const sendMessageCommand = new Command() "-r, --receiver ", "The phone number that should recieve the message" ) - .action(async ({ receiver }: { receiver: string }) => { - sendMsg({ - to: receiver, - template: { - name: "hello_world", - parameters: {}, - }, - method: IUser.NotificationMethod.Whatsapp, - }); - }); + .option( + "-t, --template ", + "The template to be sent: hello_world or ad_message" + ) + .action( + async ({ + receiver, + template, + }: { + receiver: string; + template?: "hello_world" | "ad_message"; + }) => { + sendMsg({ + to: receiver, + template: { + name: template || "hello_world", + parameters: {}, + }, + method: IUser.NotificationMethod.Whatsapp, + }); + } + ); new Command() .name("WhatsApp API") diff --git a/services/server/src/handlers/student.ts b/services/server/src/handlers/student.ts index 3395bf2b6..02b38a2c3 100644 --- a/services/server/src/handlers/student.ts +++ b/services/server/src/handlers/student.ts @@ -16,6 +16,7 @@ import { studentEnglishLevel, timePeriod, withNamedId, + dateFilter, } from "@/validation/utils"; import { encodeAuthJwt } from "@litespace/auth"; import { generateConfirmationCode } from "@/lib/confirmationCodes"; @@ -24,6 +25,7 @@ import { environment, jwtSecret } from "@/constants"; import dayjs from "@/lib/dayjs"; import { sendBackgroundMessage } from "@/workers"; import { isAdmin, isStudent, isTutor } from "@litespace/utils/user"; +import { sendMsg } from "@/lib/messenger"; const createStudentPayload: ZodSchema = zod.object({ email, @@ -52,6 +54,11 @@ const findStudentsQuery: ZodSchema = zod.object({ size: zod.optional(pageSize), }); +const sendAdMessagePayload: ZodSchema = + zod.object({ + createdAt: dateFilter, + }); + export async function create(req: Request, res: Response, next: NextFunction) { const payload: IStudent.CreateApiPayload = createStudentPayload.parse( req.body @@ -151,9 +158,52 @@ export async function findById( res.status(200).json(result); } +export async function sendAdMessage( + req: Request, + res: Response, + next: NextFunction +) { + const user = req.user; + const allowed = isAdmin(user); + if (!allowed) return next(forbidden()); + + const payload = sendAdMessagePayload.parse(req.body); + const result = await users.find({ + role: IUser.Role.Student, + createdAt: payload.createdAt, + full: true, + }); + + const students = result.list.filter((s) => s.phone); + + await Promise.all( + students.map((student) => + student.phone + ? sendMsg( + { + to: student.phone, + template: { + name: "ad_message", + parameters: {}, + }, + method: IUser.NotificationMethod.Whatsapp, + }, + true + ) + : undefined + ) + ); + + const response: IStudent.SendAdMessageApiResponse = { + count: students.length, + }; + res.status(200).json(response); +} + export default { create: safeRequest(create), update: safeRequest(update), find: safeRequest(find), findById: safeRequest(findById), + sendAdMessage: safeRequest(sendAdMessage), }; diff --git a/services/server/src/handlers/user.ts b/services/server/src/handlers/user.ts index 81613a0da..7842e18d6 100644 --- a/services/server/src/handlers/user.ts +++ b/services/server/src/handlers/user.ts @@ -49,6 +49,7 @@ import { id, queryBoolean, datetime, + dateFilter, } from "@/validation/utils"; import { environment, jwtSecret, paginationDefaults } from "@/constants"; import { drop, entries, first, groupBy } from "lodash"; @@ -141,6 +142,7 @@ const findUsersQuery: ZodSchema = zod.object({ city: zod.nativeEnum(IUser.City).optional(), page: pageNumber.optional(), size: pageSize.optional(), + createdAt: dateFilter.optional(), }); const findOnboardedTutorsQuery: ZodSchema = diff --git a/services/server/src/routes/student.ts b/services/server/src/routes/student.ts index be51adfa0..df9ba5d4d 100644 --- a/services/server/src/routes/student.ts +++ b/services/server/src/routes/student.ts @@ -5,7 +5,10 @@ const router = Router(); router.get("/:id", student.findById); router.get("/list", student.find); + router.post("/", student.create); +router.post("/send-ad-message", student.sendAdMessage); + router.patch("/", student.update); export default router;