Deploy to production #56
			
				
			
		
		
		
	| 
						 | 
					@ -0,0 +1,33 @@
 | 
				
			||||||
 | 
					import { handleError } from ".";
 | 
				
			||||||
 | 
					import axios from "../lib/axios";
 | 
				
			||||||
 | 
					import { IConfig } from "../system/type";
 | 
				
			||||||
 | 
					import { removeFalsyValues } from "../utils";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const CONFIG_KEYS = {
 | 
				
			||||||
 | 
					  MAIL_SCRAP_REPORT: "MAIL_SCRAP_REPORT",
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const getConfig = async (key: keyof typeof CONFIG_KEYS) => {
 | 
				
			||||||
 | 
					  return await axios({
 | 
				
			||||||
 | 
					    url: "configs/" + key,
 | 
				
			||||||
 | 
					    withCredentials: true,
 | 
				
			||||||
 | 
					    method: "GET",
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const upsertConfig = async (data: IConfig) => {
 | 
				
			||||||
 | 
					  const { key_name, value, type } = removeFalsyValues(data, ["value"]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const { data } = await axios({
 | 
				
			||||||
 | 
					      url: "configs/upsert",
 | 
				
			||||||
 | 
					      withCredentials: true,
 | 
				
			||||||
 | 
					      method: "POST",
 | 
				
			||||||
 | 
					      data: { key_name, value: value || null, type },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return data;
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    handleError(error);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -16,7 +16,10 @@ export const createScrapConfig = async (
 | 
				
			||||||
      url: "scrap-configs",
 | 
					      url: "scrap-configs",
 | 
				
			||||||
      withCredentials: true,
 | 
					      withCredentials: true,
 | 
				
			||||||
      method: "POST",
 | 
					      method: "POST",
 | 
				
			||||||
      data: newData,
 | 
					      data: {
 | 
				
			||||||
 | 
					        ...newData,
 | 
				
			||||||
 | 
					        enable: newData.enable === "1",
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    handleSuccess(data);
 | 
					    handleSuccess(data);
 | 
				
			||||||
| 
						 | 
					@ -28,14 +31,14 @@ export const createScrapConfig = async (
 | 
				
			||||||
};
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export const updateScrapConfig = async (scrapConfig: Partial<IScrapConfig>) => {
 | 
					export const updateScrapConfig = async (scrapConfig: Partial<IScrapConfig>) => {
 | 
				
			||||||
  const { search_url, keywords, id } = removeFalsyValues(scrapConfig);
 | 
					  const { search_url, keywords, id, enable } = removeFalsyValues(scrapConfig);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  try {
 | 
					  try {
 | 
				
			||||||
    const { data } = await axios({
 | 
					    const { data } = await axios({
 | 
				
			||||||
      url: "scrap-configs/" + id,
 | 
					      url: "scrap-configs/" + id,
 | 
				
			||||||
      withCredentials: true,
 | 
					      withCredentials: true,
 | 
				
			||||||
      method: "PUT",
 | 
					      method: "PUT",
 | 
				
			||||||
      data: { search_url, keywords },
 | 
					      data: { search_url, keywords, enable: enable === "1" },
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    handleSuccess(data);
 | 
					    handleSuccess(data);
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,161 @@
 | 
				
			||||||
 | 
					import {
 | 
				
			||||||
 | 
					  ActionIcon,
 | 
				
			||||||
 | 
					  Box,
 | 
				
			||||||
 | 
					  Card,
 | 
				
			||||||
 | 
					  Group,
 | 
				
			||||||
 | 
					  LoadingOverlay,
 | 
				
			||||||
 | 
					  Text,
 | 
				
			||||||
 | 
					  TextInput,
 | 
				
			||||||
 | 
					} from "@mantine/core";
 | 
				
			||||||
 | 
					import { useForm, zodResolver } from "@mantine/form";
 | 
				
			||||||
 | 
					import { IconAt, IconMinus, IconPlus } from "@tabler/icons-react";
 | 
				
			||||||
 | 
					import { useEffect, useMemo, useState } from "react";
 | 
				
			||||||
 | 
					import { z } from "zod";
 | 
				
			||||||
 | 
					import { getConfig, upsertConfig } from "../../apis/config";
 | 
				
			||||||
 | 
					import { IConfig } from "../../system/type";
 | 
				
			||||||
 | 
					import { useConfirmStore } from "../../lib/zustand/use-confirm";
 | 
				
			||||||
 | 
					import { useDisclosure } from "@mantine/hooks";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const schema = z.object({
 | 
				
			||||||
 | 
					  email: z
 | 
				
			||||||
 | 
					    .string({ message: "Email is required" })
 | 
				
			||||||
 | 
					    .email({ message: "Invalid email address" }),
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const MailInput = ({
 | 
				
			||||||
 | 
					  initValue,
 | 
				
			||||||
 | 
					  onDelete,
 | 
				
			||||||
 | 
					  onAdd,
 | 
				
			||||||
 | 
					}: {
 | 
				
			||||||
 | 
					  initValue?: string;
 | 
				
			||||||
 | 
					  onDelete?: (data: string) => void;
 | 
				
			||||||
 | 
					  onAdd?: (data: string) => Promise<void>;
 | 
				
			||||||
 | 
					}) => {
 | 
				
			||||||
 | 
					  const form = useForm({
 | 
				
			||||||
 | 
					    initialValues: {
 | 
				
			||||||
 | 
					      email: initValue || "",
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    validate: zodResolver(schema),
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <form
 | 
				
			||||||
 | 
					      onSubmit={form.onSubmit(
 | 
				
			||||||
 | 
					        onAdd
 | 
				
			||||||
 | 
					          ? async (values) => {
 | 
				
			||||||
 | 
					              await onAdd(values.email);
 | 
				
			||||||
 | 
					              form.reset();
 | 
				
			||||||
 | 
					            }
 | 
				
			||||||
 | 
					          : () => {}
 | 
				
			||||||
 | 
					      )}
 | 
				
			||||||
 | 
					      className="flex items-start gap-2 w-full"
 | 
				
			||||||
 | 
					    >
 | 
				
			||||||
 | 
					      <TextInput
 | 
				
			||||||
 | 
					        {...form.getInputProps("email")}
 | 
				
			||||||
 | 
					        leftSection={<IconAt size={14} />}
 | 
				
			||||||
 | 
					        placeholder="Enter email"
 | 
				
			||||||
 | 
					        className="flex-1"
 | 
				
			||||||
 | 
					        size="xs"
 | 
				
			||||||
 | 
					      />
 | 
				
			||||||
 | 
					      <ActionIcon
 | 
				
			||||||
 | 
					        onClick={initValue && onDelete ? () => onDelete(initValue) : undefined}
 | 
				
			||||||
 | 
					        type={!initValue ? "submit" : "button"}
 | 
				
			||||||
 | 
					        color={initValue ? "red" : "blue"}
 | 
				
			||||||
 | 
					        variant="light"
 | 
				
			||||||
 | 
					      >
 | 
				
			||||||
 | 
					        {initValue ? <IconMinus size={14} /> : <IconPlus size={14} />}
 | 
				
			||||||
 | 
					      </ActionIcon>
 | 
				
			||||||
 | 
					    </form>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function MailsConfig() {
 | 
				
			||||||
 | 
					  const [config, setConfig] = useState<null | IConfig>(null);
 | 
				
			||||||
 | 
					  const { setConfirm } = useConfirmStore();
 | 
				
			||||||
 | 
					  const [opened, { open, close }] = useDisclosure(false);
 | 
				
			||||||
 | 
					  useEffect(() => {
 | 
				
			||||||
 | 
					    fetchConfig();
 | 
				
			||||||
 | 
					  }, []);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const mails = useMemo(() => {
 | 
				
			||||||
 | 
					    if (!config) return [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return config?.value?.split(", ").length > 0
 | 
				
			||||||
 | 
					      ? config?.value.split(",")
 | 
				
			||||||
 | 
					      : [];
 | 
				
			||||||
 | 
					  }, [config]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const fetchConfig = async () => {
 | 
				
			||||||
 | 
					    const response = await getConfig("MAIL_SCRAP_REPORT");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!response || ![200, 201].includes(response.data?.status_code)) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    setConfig(response.data.data);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleDelete = (mail: string) => {
 | 
				
			||||||
 | 
					    setConfirm({
 | 
				
			||||||
 | 
					      message: "Are you want to delete: " + mail,
 | 
				
			||||||
 | 
					      title: "Delete",
 | 
				
			||||||
 | 
					      handleOk: async () => {
 | 
				
			||||||
 | 
					        open();
 | 
				
			||||||
 | 
					        const newMails = mails.filter((item) => item !== mail);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (!config) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        const response = await upsertConfig({
 | 
				
			||||||
 | 
					          ...(config as IConfig),
 | 
				
			||||||
 | 
					          value: newMails.join(", "),
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (response) {
 | 
				
			||||||
 | 
					          fetchConfig();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					        close();
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleAdd = async (mail: string) => {
 | 
				
			||||||
 | 
					    const newMails = [...mails, mail];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    open();
 | 
				
			||||||
 | 
					    const response = await upsertConfig({
 | 
				
			||||||
 | 
					      ...(config as IConfig),
 | 
				
			||||||
 | 
					      value: newMails.join(", "),
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (response) {
 | 
				
			||||||
 | 
					      fetchConfig();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					    close();
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Card withBorder shadow="sm" radius="md" w={400}>
 | 
				
			||||||
 | 
					      <Card.Section withBorder inheritPadding py="xs">
 | 
				
			||||||
 | 
					        <Group justify="space-between">
 | 
				
			||||||
 | 
					          <Text fw={500}>Mails</Text>
 | 
				
			||||||
 | 
					        </Group>
 | 
				
			||||||
 | 
					      </Card.Section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <Card.Section p="md">
 | 
				
			||||||
 | 
					        <Box className="flex flex-col gap-2">
 | 
				
			||||||
 | 
					          {mails.length > 0 &&
 | 
				
			||||||
 | 
					            mails.map((mail) => {
 | 
				
			||||||
 | 
					              return (
 | 
				
			||||||
 | 
					                <MailInput
 | 
				
			||||||
 | 
					                  onDelete={handleDelete}
 | 
				
			||||||
 | 
					                  key={mail}
 | 
				
			||||||
 | 
					                  initValue={mail}
 | 
				
			||||||
 | 
					                />
 | 
				
			||||||
 | 
					              );
 | 
				
			||||||
 | 
					            })}
 | 
				
			||||||
 | 
					          <MailInput onAdd={handleAdd} />
 | 
				
			||||||
 | 
					        </Box>
 | 
				
			||||||
 | 
					      </Card.Section>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      <LoadingOverlay visible={opened} />
 | 
				
			||||||
 | 
					    </Card>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,122 +1,180 @@
 | 
				
			||||||
import { Avatar, Button, LoadingOverlay, Menu, Modal, PasswordInput } from '@mantine/core';
 | 
					import {
 | 
				
			||||||
import { useForm, zodResolver } from '@mantine/form';
 | 
					  Avatar,
 | 
				
			||||||
import { useDisclosure } from '@mantine/hooks';
 | 
					  Button,
 | 
				
			||||||
import { IconKey, IconLogout, IconSettings, IconUser } from '@tabler/icons-react';
 | 
					  LoadingOverlay,
 | 
				
			||||||
import { useState } from 'react';
 | 
					  Menu,
 | 
				
			||||||
import { useNavigate } from 'react-router';
 | 
					  Modal,
 | 
				
			||||||
import { Link } from 'react-router-dom';
 | 
					  PasswordInput,
 | 
				
			||||||
import { z } from 'zod';
 | 
					} from "@mantine/core";
 | 
				
			||||||
import { changePassword, logout } from '../apis/auth';
 | 
					import { useForm, zodResolver } from "@mantine/form";
 | 
				
			||||||
import { useConfirmStore } from '../lib/zustand/use-confirm';
 | 
					import { useDisclosure } from "@mantine/hooks";
 | 
				
			||||||
import Links from '../system/links';
 | 
					import {
 | 
				
			||||||
 | 
					  IconCode,
 | 
				
			||||||
 | 
					  IconKey,
 | 
				
			||||||
 | 
					  IconLogout,
 | 
				
			||||||
 | 
					  IconSettings,
 | 
				
			||||||
 | 
					  IconUser,
 | 
				
			||||||
 | 
					} from "@tabler/icons-react";
 | 
				
			||||||
 | 
					import { useState } from "react";
 | 
				
			||||||
 | 
					import { useNavigate } from "react-router";
 | 
				
			||||||
 | 
					import { Link } from "react-router-dom";
 | 
				
			||||||
 | 
					import { z } from "zod";
 | 
				
			||||||
 | 
					import { changePassword, logout } from "../apis/auth";
 | 
				
			||||||
 | 
					import { useConfirmStore } from "../lib/zustand/use-confirm";
 | 
				
			||||||
 | 
					import Links from "../system/links";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
const schema = z
 | 
					const schema = z
 | 
				
			||||||
    .object({
 | 
					  .object({
 | 
				
			||||||
        currentPassword: z.string().min(6, 'Current password must be at least 6 characters'),
 | 
					    currentPassword: z
 | 
				
			||||||
        newPassword: z.string().min(6, 'New password must be at least 6 characters'),
 | 
					      .string()
 | 
				
			||||||
        confirmPassword: z.string(),
 | 
					      .min(6, "Current password must be at least 6 characters"),
 | 
				
			||||||
    })
 | 
					    newPassword: z
 | 
				
			||||||
    .refine((data) => data.newPassword === data.confirmPassword, {
 | 
					      .string()
 | 
				
			||||||
        path: ['confirmPassword'],
 | 
					      .min(6, "New password must be at least 6 characters"),
 | 
				
			||||||
        message: 'Passwords do not match',
 | 
					    confirmPassword: z.string(),
 | 
				
			||||||
    });
 | 
					  })
 | 
				
			||||||
 | 
					  .refine((data) => data.newPassword === data.confirmPassword, {
 | 
				
			||||||
 | 
					    path: ["confirmPassword"],
 | 
				
			||||||
 | 
					    message: "Passwords do not match",
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function UserMenu() {
 | 
					export default function UserMenu() {
 | 
				
			||||||
    const [opened, { open, close }] = useDisclosure(false);
 | 
					  const [opened, { open, close }] = useDisclosure(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const { setConfirm } = useConfirmStore();
 | 
					  const { setConfirm } = useConfirmStore();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const [loading, setLoading] = useState(false);
 | 
					  const [loading, setLoading] = useState(false);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const navigate = useNavigate();
 | 
					  const navigate = useNavigate();
 | 
				
			||||||
    const form = useForm({
 | 
					  const form = useForm({
 | 
				
			||||||
        initialValues: {
 | 
					    initialValues: {
 | 
				
			||||||
            currentPassword: '',
 | 
					      currentPassword: "",
 | 
				
			||||||
            newPassword: '',
 | 
					      newPassword: "",
 | 
				
			||||||
            confirmPassword: '',
 | 
					      confirmPassword: "",
 | 
				
			||||||
        },
 | 
					    },
 | 
				
			||||||
        validate: zodResolver(schema),
 | 
					    validate: zodResolver(schema),
 | 
				
			||||||
 | 
					  });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleSubmit = async (values: typeof form.values) => {
 | 
				
			||||||
 | 
					    await handleChangePassword(values);
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const handleLogout = async () => {
 | 
				
			||||||
 | 
					    setConfirm({
 | 
				
			||||||
 | 
					      title: "Are you wan't to logout?",
 | 
				
			||||||
 | 
					      message: "This account will logout !",
 | 
				
			||||||
 | 
					      okButton: { value: "Logout" },
 | 
				
			||||||
 | 
					      handleOk: async () => {
 | 
				
			||||||
 | 
					        const data = await logout();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (data && data.data) {
 | 
				
			||||||
 | 
					          navigate(Links.LOGIN);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const handleSubmit = async (values: typeof form.values) => {
 | 
					  const handleChangePassword = async (values: typeof form.values) => {
 | 
				
			||||||
        await handleChangePassword(values);
 | 
					    setConfirm({
 | 
				
			||||||
    };
 | 
					      title: "Are you wan't to update password",
 | 
				
			||||||
 | 
					      message: "This account will change password !",
 | 
				
			||||||
    const handleLogout = async () => {
 | 
					      okButton: { value: "Sure" },
 | 
				
			||||||
        setConfirm({
 | 
					      handleOk: async () => {
 | 
				
			||||||
            title: "Are you wan't to logout?",
 | 
					        setLoading(true);
 | 
				
			||||||
            message: 'This account will logout !',
 | 
					        const data = await changePassword({
 | 
				
			||||||
            okButton: { value: 'Logout' },
 | 
					          newPassword: values.newPassword,
 | 
				
			||||||
            handleOk: async () => {
 | 
					          password: values.currentPassword,
 | 
				
			||||||
                const data = await logout();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
                if (data && data.data) {
 | 
					 | 
				
			||||||
                    navigate(Links.LOGIN);
 | 
					 | 
				
			||||||
                }
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
        });
 | 
					        });
 | 
				
			||||||
    };
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const handleChangePassword = async (values: typeof form.values) => {
 | 
					        setLoading(false);
 | 
				
			||||||
        setConfirm({
 | 
					 | 
				
			||||||
            title: "Are you wan't to update password",
 | 
					 | 
				
			||||||
            message: 'This account will change password !',
 | 
					 | 
				
			||||||
            okButton: { value: 'Sure' },
 | 
					 | 
				
			||||||
            handleOk: async () => {
 | 
					 | 
				
			||||||
                setLoading(true);
 | 
					 | 
				
			||||||
                const data = await changePassword({
 | 
					 | 
				
			||||||
                    newPassword: values.newPassword,
 | 
					 | 
				
			||||||
                    password: values.currentPassword,
 | 
					 | 
				
			||||||
                });
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
                setLoading(false);
 | 
					        if (data && data.data) {
 | 
				
			||||||
 | 
					          navigate(Links.LOGIN);
 | 
				
			||||||
 | 
					          close();
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                if (data && data.data) {
 | 
					  return (
 | 
				
			||||||
                    navigate(Links.LOGIN);
 | 
					    <>
 | 
				
			||||||
                    close();
 | 
					      <Menu shadow="md" width={200}>
 | 
				
			||||||
                }
 | 
					        <Menu.Target>
 | 
				
			||||||
            },
 | 
					          <Avatar color="blue" radius="xl" className="cursor-pointer">
 | 
				
			||||||
        });
 | 
					            <IconUser size={20} />
 | 
				
			||||||
    };
 | 
					          </Avatar>
 | 
				
			||||||
 | 
					        </Menu.Target>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return (
 | 
					        <Menu.Dropdown>
 | 
				
			||||||
        <>
 | 
					          <Menu.Label>Account</Menu.Label>
 | 
				
			||||||
            <Menu shadow="md" width={200}>
 | 
					          <Menu.Item onClick={open} leftSection={<IconSettings size={14} />}>
 | 
				
			||||||
                <Menu.Target>
 | 
					            Change password
 | 
				
			||||||
                    <Avatar color="blue" radius="xl" className="cursor-pointer">
 | 
					          </Menu.Item>
 | 
				
			||||||
                        <IconUser size={20} />
 | 
					          <Menu.Item
 | 
				
			||||||
                    </Avatar>
 | 
					            component={Link}
 | 
				
			||||||
                </Menu.Target>
 | 
					            to={Links.GENERATE_KEYS}
 | 
				
			||||||
 | 
					            leftSection={<IconKey size={14} />}
 | 
				
			||||||
 | 
					          >
 | 
				
			||||||
 | 
					            Keys
 | 
				
			||||||
 | 
					          </Menu.Item>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                <Menu.Dropdown>
 | 
					          <Menu.Item
 | 
				
			||||||
                    <Menu.Label>Account</Menu.Label>
 | 
					            component={Link}
 | 
				
			||||||
                    <Menu.Item onClick={open} leftSection={<IconSettings size={14} />}>
 | 
					            to={Links.CONFIGS}
 | 
				
			||||||
                        Change password
 | 
					            leftSection={<IconCode size={14} />}
 | 
				
			||||||
                    </Menu.Item>
 | 
					          >
 | 
				
			||||||
                    <Menu.Item component={Link} to={Links.GENERATE_KEYS} leftSection={<IconKey size={14} />}>
 | 
					            Configs
 | 
				
			||||||
                        Keys
 | 
					          </Menu.Item>
 | 
				
			||||||
                    </Menu.Item>
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    <Menu.Divider />
 | 
					          <Menu.Divider />
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                    <Menu.Item onClick={handleLogout} color="red" leftSection={<IconLogout size={14} />}>
 | 
					          <Menu.Item
 | 
				
			||||||
                        Logout
 | 
					            onClick={handleLogout}
 | 
				
			||||||
                    </Menu.Item>
 | 
					            color="red"
 | 
				
			||||||
                </Menu.Dropdown>
 | 
					            leftSection={<IconLogout size={14} />}
 | 
				
			||||||
            </Menu>
 | 
					          >
 | 
				
			||||||
 | 
					            Logout
 | 
				
			||||||
 | 
					          </Menu.Item>
 | 
				
			||||||
 | 
					        </Menu.Dropdown>
 | 
				
			||||||
 | 
					      </Menu>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
            <Modal className="relative" opened={opened} onClose={close} title="Change password" centered>
 | 
					      <Modal
 | 
				
			||||||
                <form onSubmit={form.onSubmit(handleSubmit)} className="flex flex-col gap-2.5">
 | 
					        className="relative"
 | 
				
			||||||
                    <PasswordInput size="sm" label="Current password" {...form.getInputProps('currentPassword')} />
 | 
					        opened={opened}
 | 
				
			||||||
                    <PasswordInput size="sm" label="New password" {...form.getInputProps('newPassword')} />
 | 
					        onClose={close}
 | 
				
			||||||
                    <PasswordInput size="sm" label="Confirm password" {...form.getInputProps('confirmPassword')} />
 | 
					        title="Change password"
 | 
				
			||||||
                    <Button type="submit" fullWidth size="sm" mt="md">
 | 
					        centered
 | 
				
			||||||
                        Change
 | 
					      >
 | 
				
			||||||
                    </Button>
 | 
					        <form
 | 
				
			||||||
                </form>
 | 
					          onSubmit={form.onSubmit(handleSubmit)}
 | 
				
			||||||
 | 
					          className="flex flex-col gap-2.5"
 | 
				
			||||||
 | 
					        >
 | 
				
			||||||
 | 
					          <PasswordInput
 | 
				
			||||||
 | 
					            size="sm"
 | 
				
			||||||
 | 
					            label="Current password"
 | 
				
			||||||
 | 
					            {...form.getInputProps("currentPassword")}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					          <PasswordInput
 | 
				
			||||||
 | 
					            size="sm"
 | 
				
			||||||
 | 
					            label="New password"
 | 
				
			||||||
 | 
					            {...form.getInputProps("newPassword")}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					          <PasswordInput
 | 
				
			||||||
 | 
					            size="sm"
 | 
				
			||||||
 | 
					            label="Confirm password"
 | 
				
			||||||
 | 
					            {...form.getInputProps("confirmPassword")}
 | 
				
			||||||
 | 
					          />
 | 
				
			||||||
 | 
					          <Button type="submit" fullWidth size="sm" mt="md">
 | 
				
			||||||
 | 
					            Change
 | 
				
			||||||
 | 
					          </Button>
 | 
				
			||||||
 | 
					        </form>
 | 
				
			||||||
 | 
					
 | 
				
			||||||
                <LoadingOverlay visible={loading} zIndex={1000} overlayProps={{ blur: 2 }} />
 | 
					        <LoadingOverlay
 | 
				
			||||||
            </Modal>
 | 
					          visible={loading}
 | 
				
			||||||
        </>
 | 
					          zIndex={1000}
 | 
				
			||||||
    );
 | 
					          overlayProps={{ blur: 2 }}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
 | 
					      </Modal>
 | 
				
			||||||
 | 
					    </>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -4,6 +4,7 @@ import {
 | 
				
			||||||
  LoadingOverlay,
 | 
					  LoadingOverlay,
 | 
				
			||||||
  Modal,
 | 
					  Modal,
 | 
				
			||||||
  ModalProps,
 | 
					  ModalProps,
 | 
				
			||||||
 | 
					  Select,
 | 
				
			||||||
  Textarea,
 | 
					  Textarea,
 | 
				
			||||||
  TextInput,
 | 
					  TextInput,
 | 
				
			||||||
} from "@mantine/core";
 | 
					} from "@mantine/core";
 | 
				
			||||||
| 
						 | 
					@ -28,6 +29,7 @@ const schema = z.object({
 | 
				
			||||||
    .string({ message: "Keyword is required" })
 | 
					    .string({ message: "Keyword is required" })
 | 
				
			||||||
    .min(1, { message: "Keyword is required" })
 | 
					    .min(1, { message: "Keyword is required" })
 | 
				
			||||||
    .optional(),
 | 
					    .optional(),
 | 
				
			||||||
 | 
					  enable: z.enum(["1", "0"], { required_error: "Enable is required" }),
 | 
				
			||||||
});
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export default function ScrapConfigModal({
 | 
					export default function ScrapConfigModal({
 | 
				
			||||||
| 
						 | 
					@ -93,9 +95,18 @@ export default function ScrapConfigModal({
 | 
				
			||||||
    form.reset();
 | 
					    form.reset();
 | 
				
			||||||
    if (!data) return;
 | 
					    if (!data) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    form.setValues(data.scrap_config);
 | 
					    const values = {
 | 
				
			||||||
 | 
					      ...data.scrap_config,
 | 
				
			||||||
 | 
					      enable: (data.scrap_config?.enable === undefined
 | 
				
			||||||
 | 
					        ? "1"
 | 
				
			||||||
 | 
					        : data.scrap_config.enable
 | 
				
			||||||
 | 
					        ? "1"
 | 
				
			||||||
 | 
					        : "0") as "0" | "1",
 | 
				
			||||||
 | 
					    };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    prevData.current = data.scrap_config;
 | 
					    form.setValues(values);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    prevData.current = values;
 | 
				
			||||||
    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
					    // eslint-disable-next-line react-hooks/exhaustive-deps
 | 
				
			||||||
  }, [data]);
 | 
					  }, [data]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -121,6 +132,23 @@ export default function ScrapConfigModal({
 | 
				
			||||||
        onSubmit={form.onSubmit(handleSubmit)}
 | 
					        onSubmit={form.onSubmit(handleSubmit)}
 | 
				
			||||||
        className="grid grid-cols-2 gap-2.5"
 | 
					        className="grid grid-cols-2 gap-2.5"
 | 
				
			||||||
      >
 | 
					      >
 | 
				
			||||||
 | 
					        <Select
 | 
				
			||||||
 | 
					          className="col-span-2"
 | 
				
			||||||
 | 
					          label="Enable scrape"
 | 
				
			||||||
 | 
					          defaultChecked={true}
 | 
				
			||||||
 | 
					          defaultValue={"1"}
 | 
				
			||||||
 | 
					          data={[
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					              label: "Enbale",
 | 
				
			||||||
 | 
					              value: "1",
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					            {
 | 
				
			||||||
 | 
					              label: "Disable",
 | 
				
			||||||
 | 
					              value: "0",
 | 
				
			||||||
 | 
					            },
 | 
				
			||||||
 | 
					          ]}
 | 
				
			||||||
 | 
					          {...form.getInputProps("enable")}
 | 
				
			||||||
 | 
					        />
 | 
				
			||||||
        <TextInput
 | 
					        <TextInput
 | 
				
			||||||
          className="col-span-2"
 | 
					          className="col-span-2"
 | 
				
			||||||
          size="sm"
 | 
					          size="sm"
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,10 @@
 | 
				
			||||||
 | 
					import { Box } from "@mantine/core";
 | 
				
			||||||
 | 
					import MailsConfig from "../components/config/mails-config";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default function Configs() {
 | 
				
			||||||
 | 
					  return (
 | 
				
			||||||
 | 
					    <Box className="flex">
 | 
				
			||||||
 | 
					      <MailsConfig />
 | 
				
			||||||
 | 
					    </Box>
 | 
				
			||||||
 | 
					  );
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,5 +1,6 @@
 | 
				
			||||||
export { default as Dashboard } from './dashboard';
 | 
					export { default as Dashboard } from "./dashboard";
 | 
				
			||||||
export { default as Bids } from './bids';
 | 
					export { default as Bids } from "./bids";
 | 
				
			||||||
export { default as OutBidsLog } from './out-bids-log';
 | 
					export { default as OutBidsLog } from "./out-bids-log";
 | 
				
			||||||
export { default as Login } from './login';
 | 
					export { default as Login } from "./login";
 | 
				
			||||||
export { default as App } from './app';
 | 
					export { default as App } from "./app";
 | 
				
			||||||
 | 
					export { default as Configs } from "./configs";
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,70 +1,87 @@
 | 
				
			||||||
import { IconHammer, IconHome2, IconKey, IconMessage, IconOutlet, IconPageBreak, IconUserCheck } from '@tabler/icons-react';
 | 
					import {
 | 
				
			||||||
import { Bids, Dashboard, OutBidsLog } from '../pages';
 | 
					  IconHammer,
 | 
				
			||||||
import WebBids from '../pages/web-bids';
 | 
					  IconHome2,
 | 
				
			||||||
import SendMessageHistories from '../pages/send-message-histories';
 | 
					  IconKey,
 | 
				
			||||||
import Admins from '../pages/admins';
 | 
					  IconMessage,
 | 
				
			||||||
import GenerateKeys from '../pages/generate-keys';
 | 
					  IconOutlet,
 | 
				
			||||||
 | 
					  IconPageBreak,
 | 
				
			||||||
 | 
					  IconSettings,
 | 
				
			||||||
 | 
					  IconUserCheck,
 | 
				
			||||||
 | 
					} from "@tabler/icons-react";
 | 
				
			||||||
 | 
					import { Bids, Configs, Dashboard, OutBidsLog } from "../pages";
 | 
				
			||||||
 | 
					import WebBids from "../pages/web-bids";
 | 
				
			||||||
 | 
					import SendMessageHistories from "../pages/send-message-histories";
 | 
				
			||||||
 | 
					import Admins from "../pages/admins";
 | 
				
			||||||
 | 
					import GenerateKeys from "../pages/generate-keys";
 | 
				
			||||||
export default class Links {
 | 
					export default class Links {
 | 
				
			||||||
    public static DASHBOARD = '/dashboard';
 | 
					  public static DASHBOARD = "/dashboard";
 | 
				
			||||||
    public static BIDS = '/bids';
 | 
					  public static BIDS = "/bids";
 | 
				
			||||||
    public static WEBS = '/webs';
 | 
					  public static WEBS = "/webs";
 | 
				
			||||||
    public static OUT_BIDS_LOG = '/out-bids-log';
 | 
					  public static OUT_BIDS_LOG = "/out-bids-log";
 | 
				
			||||||
    public static SEND_MESSAGE_HISTORIES = '/send-message-histories';
 | 
					  public static SEND_MESSAGE_HISTORIES = "/send-message-histories";
 | 
				
			||||||
    public static GENERATE_KEYS = '/generate-keys';
 | 
					  public static GENERATE_KEYS = "/generate-keys";
 | 
				
			||||||
    public static ADMINS = '/admins';
 | 
					  public static ADMINS = "/admins";
 | 
				
			||||||
 | 
					  public static CONFIGS = "/configs";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static HOME = '/';
 | 
					  public static HOME = "/";
 | 
				
			||||||
    public static LOGIN = '/login';
 | 
					  public static LOGIN = "/login";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    public static MENUS = [
 | 
					  public static MENUS = [
 | 
				
			||||||
        {
 | 
					    {
 | 
				
			||||||
            path: this.DASHBOARD,
 | 
					      path: this.DASHBOARD,
 | 
				
			||||||
            title: 'Dashboard',
 | 
					      title: "Dashboard",
 | 
				
			||||||
            icon: IconHome2,
 | 
					      icon: IconHome2,
 | 
				
			||||||
            element: Dashboard,
 | 
					      element: Dashboard,
 | 
				
			||||||
            show: true,
 | 
					      show: true,
 | 
				
			||||||
        },
 | 
					    },
 | 
				
			||||||
        {
 | 
					    {
 | 
				
			||||||
            path: this.ADMINS,
 | 
					      path: this.ADMINS,
 | 
				
			||||||
            title: 'Admins',
 | 
					      title: "Admins",
 | 
				
			||||||
            icon: IconUserCheck,
 | 
					      icon: IconUserCheck,
 | 
				
			||||||
            element: Admins,
 | 
					      element: Admins,
 | 
				
			||||||
            show: true,
 | 
					      show: true,
 | 
				
			||||||
        },
 | 
					    },
 | 
				
			||||||
        {
 | 
					    {
 | 
				
			||||||
            path: this.WEBS,
 | 
					      path: this.WEBS,
 | 
				
			||||||
            title: 'Webs',
 | 
					      title: "Webs",
 | 
				
			||||||
            icon: IconPageBreak,
 | 
					      icon: IconPageBreak,
 | 
				
			||||||
            element: WebBids,
 | 
					      element: WebBids,
 | 
				
			||||||
            show: true,
 | 
					      show: true,
 | 
				
			||||||
        },
 | 
					    },
 | 
				
			||||||
        {
 | 
					    {
 | 
				
			||||||
            path: this.BIDS,
 | 
					      path: this.BIDS,
 | 
				
			||||||
            title: 'Bids',
 | 
					      title: "Bids",
 | 
				
			||||||
            icon: IconHammer,
 | 
					      icon: IconHammer,
 | 
				
			||||||
            element: Bids,
 | 
					      element: Bids,
 | 
				
			||||||
            show: true,
 | 
					      show: true,
 | 
				
			||||||
        },
 | 
					    },
 | 
				
			||||||
        {
 | 
					    {
 | 
				
			||||||
            path: this.OUT_BIDS_LOG,
 | 
					      path: this.OUT_BIDS_LOG,
 | 
				
			||||||
            title: 'Out bids log',
 | 
					      title: "Out bids log",
 | 
				
			||||||
            icon: IconOutlet,
 | 
					      icon: IconOutlet,
 | 
				
			||||||
            element: OutBidsLog,
 | 
					      element: OutBidsLog,
 | 
				
			||||||
            show: true,
 | 
					      show: true,
 | 
				
			||||||
        },
 | 
					    },
 | 
				
			||||||
        {
 | 
					    {
 | 
				
			||||||
            path: this.SEND_MESSAGE_HISTORIES,
 | 
					      path: this.SEND_MESSAGE_HISTORIES,
 | 
				
			||||||
            title: 'Send message histories',
 | 
					      title: "Send message histories",
 | 
				
			||||||
            icon: IconMessage,
 | 
					      icon: IconMessage,
 | 
				
			||||||
            element: SendMessageHistories,
 | 
					      element: SendMessageHistories,
 | 
				
			||||||
            show: true,
 | 
					      show: true,
 | 
				
			||||||
        },
 | 
					    },
 | 
				
			||||||
        {
 | 
					    {
 | 
				
			||||||
            path: this.GENERATE_KEYS,
 | 
					      path: this.GENERATE_KEYS,
 | 
				
			||||||
            title: 'Generate keys',
 | 
					      title: "Generate keys",
 | 
				
			||||||
            icon: IconKey,
 | 
					      icon: IconKey,
 | 
				
			||||||
            element: GenerateKeys,
 | 
					      element: GenerateKeys,
 | 
				
			||||||
            show: false,
 | 
					      show: false,
 | 
				
			||||||
        },
 | 
					    },
 | 
				
			||||||
    ];
 | 
					    {
 | 
				
			||||||
 | 
					      path: this.CONFIGS,
 | 
				
			||||||
 | 
					      title: "Configs",
 | 
				
			||||||
 | 
					      icon: IconSettings,
 | 
				
			||||||
 | 
					      element: Configs,
 | 
				
			||||||
 | 
					      show: false,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  ];
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -35,6 +35,7 @@ export interface IScrapConfig extends ITimestamp {
 | 
				
			||||||
  id: number;
 | 
					  id: number;
 | 
				
			||||||
  search_url: string;
 | 
					  search_url: string;
 | 
				
			||||||
  keywords: string;
 | 
					  keywords: string;
 | 
				
			||||||
 | 
					  enable: boolean | "0" | "1";
 | 
				
			||||||
  scrap_items: IScrapItem[];
 | 
					  scrap_items: IScrapItem[];
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -82,6 +83,13 @@ export interface IBid extends ITimestamp {
 | 
				
			||||||
  web_bid: IWebBid;
 | 
					  web_bid: IWebBid;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export interface IConfig extends ITimestamp {
 | 
				
			||||||
 | 
					  id: number;
 | 
				
			||||||
 | 
					  key_name: string;
 | 
				
			||||||
 | 
					  value: string;
 | 
				
			||||||
 | 
					  type: "string" | "number";
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export interface IPermission extends ITimestamp {
 | 
					export interface IPermission extends ITimestamp {
 | 
				
			||||||
  id: number;
 | 
					  id: number;
 | 
				
			||||||
  name: string;
 | 
					  name: string;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1 +1 @@
 | 
				
			||||||
{"createdAt":1747701959077}
 | 
					{"createdAt":1747812172479}
 | 
				
			||||||
| 
						 | 
					@ -10,6 +10,7 @@
 | 
				
			||||||
      "license": "UNLICENSED",
 | 
					      "license": "UNLICENSED",
 | 
				
			||||||
      "dependencies": {
 | 
					      "dependencies": {
 | 
				
			||||||
        "@nestjs-modules/mailer": "^2.0.2",
 | 
					        "@nestjs-modules/mailer": "^2.0.2",
 | 
				
			||||||
 | 
					        "@nestjs/bull": "^11.0.2",
 | 
				
			||||||
        "@nestjs/common": "^10.0.0",
 | 
					        "@nestjs/common": "^10.0.0",
 | 
				
			||||||
        "@nestjs/config": "^4.0.1",
 | 
					        "@nestjs/config": "^4.0.1",
 | 
				
			||||||
        "@nestjs/core": "^10.0.0",
 | 
					        "@nestjs/core": "^10.0.0",
 | 
				
			||||||
| 
						 | 
					@ -24,6 +25,7 @@
 | 
				
			||||||
        "@nestjs/websockets": "^11.0.11",
 | 
					        "@nestjs/websockets": "^11.0.11",
 | 
				
			||||||
        "axios": "^1.8.3",
 | 
					        "axios": "^1.8.3",
 | 
				
			||||||
        "bcrypt": "^5.1.1",
 | 
					        "bcrypt": "^5.1.1",
 | 
				
			||||||
 | 
					        "bull": "^4.16.5",
 | 
				
			||||||
        "cheerio": "^1.0.0",
 | 
					        "cheerio": "^1.0.0",
 | 
				
			||||||
        "class-transformer": "^0.5.1",
 | 
					        "class-transformer": "^0.5.1",
 | 
				
			||||||
        "class-validator": "^0.14.1",
 | 
					        "class-validator": "^0.14.1",
 | 
				
			||||||
| 
						 | 
					@ -31,6 +33,7 @@
 | 
				
			||||||
        "cookie-parser": "^1.4.7",
 | 
					        "cookie-parser": "^1.4.7",
 | 
				
			||||||
        "dayjs": "^1.11.13",
 | 
					        "dayjs": "^1.11.13",
 | 
				
			||||||
        "imap": "^0.8.19",
 | 
					        "imap": "^0.8.19",
 | 
				
			||||||
 | 
					        "ioredis": "^5.6.1",
 | 
				
			||||||
        "lodash": "^4.17.21",
 | 
					        "lodash": "^4.17.21",
 | 
				
			||||||
        "moment": "^2.30.1",
 | 
					        "moment": "^2.30.1",
 | 
				
			||||||
        "multer": "^1.4.5-lts.1",
 | 
					        "multer": "^1.4.5-lts.1",
 | 
				
			||||||
| 
						 | 
					@ -1649,6 +1652,12 @@
 | 
				
			||||||
        "url": "https://opencollective.com/libvips"
 | 
					        "url": "https://opencollective.com/libvips"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@ioredis/commands": {
 | 
				
			||||||
 | 
					      "version": "1.2.0",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@ioredis/commands/-/commands-1.2.0.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-Sx1pU8EM64o2BrqNpEO1CNLtKQwyhuXuqyfH7oGKCk+1a33d2r5saW8zNwm3j6BTExtjrv2BxTgzzkMwts6vGg==",
 | 
				
			||||||
 | 
					      "license": "MIT"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/@isaacs/cliui": {
 | 
					    "node_modules/@isaacs/cliui": {
 | 
				
			||||||
      "version": "8.0.2",
 | 
					      "version": "8.0.2",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
 | 
				
			||||||
| 
						 | 
					@ -2337,6 +2346,84 @@
 | 
				
			||||||
      "license": "MIT",
 | 
					      "license": "MIT",
 | 
				
			||||||
      "peer": true
 | 
					      "peer": true
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@msgpackr-extract/msgpackr-extract-darwin-arm64": {
 | 
				
			||||||
 | 
					      "version": "3.0.3",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-arm64/-/msgpackr-extract-darwin-arm64-3.0.3.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-QZHtlVgbAdy2zAqNA9Gu1UpIuI8Xvsd1v8ic6B2pZmeFnFcMWiPLfWXh7TVw4eGEZ/C9TH281KwhVoeQUKbyjw==",
 | 
				
			||||||
 | 
					      "cpu": [
 | 
				
			||||||
 | 
					        "arm64"
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "optional": true,
 | 
				
			||||||
 | 
					      "os": [
 | 
				
			||||||
 | 
					        "darwin"
 | 
				
			||||||
 | 
					      ]
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@msgpackr-extract/msgpackr-extract-darwin-x64": {
 | 
				
			||||||
 | 
					      "version": "3.0.3",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-darwin-x64/-/msgpackr-extract-darwin-x64-3.0.3.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-mdzd3AVzYKuUmiWOQ8GNhl64/IoFGol569zNRdkLReh6LRLHOXxU4U8eq0JwaD8iFHdVGqSy4IjFL4reoWCDFw==",
 | 
				
			||||||
 | 
					      "cpu": [
 | 
				
			||||||
 | 
					        "x64"
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "optional": true,
 | 
				
			||||||
 | 
					      "os": [
 | 
				
			||||||
 | 
					        "darwin"
 | 
				
			||||||
 | 
					      ]
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm": {
 | 
				
			||||||
 | 
					      "version": "3.0.3",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm/-/msgpackr-extract-linux-arm-3.0.3.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-fg0uy/dG/nZEXfYilKoRe7yALaNmHoYeIoJuJ7KJ+YyU2bvY8vPv27f7UKhGRpY6euFYqEVhxCFZgAUNQBM3nw==",
 | 
				
			||||||
 | 
					      "cpu": [
 | 
				
			||||||
 | 
					        "arm"
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "optional": true,
 | 
				
			||||||
 | 
					      "os": [
 | 
				
			||||||
 | 
					        "linux"
 | 
				
			||||||
 | 
					      ]
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@msgpackr-extract/msgpackr-extract-linux-arm64": {
 | 
				
			||||||
 | 
					      "version": "3.0.3",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-arm64/-/msgpackr-extract-linux-arm64-3.0.3.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-YxQL+ax0XqBJDZiKimS2XQaf+2wDGVa1enVRGzEvLLVFeqa5kx2bWbtcSXgsxjQB7nRqqIGFIcLteF/sHeVtQg==",
 | 
				
			||||||
 | 
					      "cpu": [
 | 
				
			||||||
 | 
					        "arm64"
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "optional": true,
 | 
				
			||||||
 | 
					      "os": [
 | 
				
			||||||
 | 
					        "linux"
 | 
				
			||||||
 | 
					      ]
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@msgpackr-extract/msgpackr-extract-linux-x64": {
 | 
				
			||||||
 | 
					      "version": "3.0.3",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-linux-x64/-/msgpackr-extract-linux-x64-3.0.3.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-cvwNfbP07pKUfq1uH+S6KJ7dT9K8WOE4ZiAcsrSes+UY55E/0jLYc+vq+DO7jlmqRb5zAggExKm0H7O/CBaesg==",
 | 
				
			||||||
 | 
					      "cpu": [
 | 
				
			||||||
 | 
					        "x64"
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "optional": true,
 | 
				
			||||||
 | 
					      "os": [
 | 
				
			||||||
 | 
					        "linux"
 | 
				
			||||||
 | 
					      ]
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@msgpackr-extract/msgpackr-extract-win32-x64": {
 | 
				
			||||||
 | 
					      "version": "3.0.3",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@msgpackr-extract/msgpackr-extract-win32-x64/-/msgpackr-extract-win32-x64-3.0.3.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-x0fWaQtYp4E6sktbsdAqnehxDgEc/VwM7uLsRCYWaiGu0ykYdZPiS8zCWdnjHwyiumousxfBm4SO31eXqwEZhQ==",
 | 
				
			||||||
 | 
					      "cpu": [
 | 
				
			||||||
 | 
					        "x64"
 | 
				
			||||||
 | 
					      ],
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "optional": true,
 | 
				
			||||||
 | 
					      "os": [
 | 
				
			||||||
 | 
					        "win32"
 | 
				
			||||||
 | 
					      ]
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/@nestjs-modules/mailer": {
 | 
					    "node_modules/@nestjs-modules/mailer": {
 | 
				
			||||||
      "version": "2.0.2",
 | 
					      "version": "2.0.2",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/@nestjs-modules/mailer/-/mailer-2.0.2.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/@nestjs-modules/mailer/-/mailer-2.0.2.tgz",
 | 
				
			||||||
| 
						 | 
					@ -2412,6 +2499,34 @@
 | 
				
			||||||
        "@pkgjs/parseargs": "^0.11.0"
 | 
					        "@pkgjs/parseargs": "^0.11.0"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@nestjs/bull": {
 | 
				
			||||||
 | 
					      "version": "11.0.2",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@nestjs/bull/-/bull-11.0.2.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-RjyP9JZUuLmMhmq1TMNIZqolkAd14az1jyXMMVki+C9dYvaMjWzBSwcZAtKs9Pk15Rm7qN1xn3R11aMV2Xv4gg==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "@nestjs/bull-shared": "^11.0.2",
 | 
				
			||||||
 | 
					        "tslib": "2.8.1"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependencies": {
 | 
				
			||||||
 | 
					        "@nestjs/common": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
 | 
				
			||||||
 | 
					        "@nestjs/core": "^8.0.0 || ^9.0.0 || ^10.0.0 || ^11.0.0",
 | 
				
			||||||
 | 
					        "bull": "^3.3 || ^4.0.0"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/@nestjs/bull-shared": {
 | 
				
			||||||
 | 
					      "version": "11.0.2",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/@nestjs/bull-shared/-/bull-shared-11.0.2.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-dFlttJvBqIFD6M8JVFbkrR4Feb39OTAJPJpFVILU50NOJCM4qziRw3dSNG84Q3v+7/M6xUGMFdZRRGvBBKxoSA==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "tslib": "2.8.1"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "peerDependencies": {
 | 
				
			||||||
 | 
					        "@nestjs/common": "^10.0.0 || ^11.0.0",
 | 
				
			||||||
 | 
					        "@nestjs/core": "^10.0.0 || ^11.0.0"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/@nestjs/cli": {
 | 
					    "node_modules/@nestjs/cli": {
 | 
				
			||||||
      "version": "10.4.9",
 | 
					      "version": "10.4.9",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
 | 
				
			||||||
| 
						 | 
					@ -4663,6 +4778,33 @@
 | 
				
			||||||
      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
 | 
					      "integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
 | 
				
			||||||
      "license": "MIT"
 | 
					      "license": "MIT"
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/bull": {
 | 
				
			||||||
 | 
					      "version": "4.16.5",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/bull/-/bull-4.16.5.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-lDsx2BzkKe7gkCYiT5Acj02DpTwDznl/VNN7Psn7M3USPG7Vs/BaClZJJTAG+ufAR9++N1/NiUTdaFBWDIl5TQ==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "cron-parser": "^4.9.0",
 | 
				
			||||||
 | 
					        "get-port": "^5.1.1",
 | 
				
			||||||
 | 
					        "ioredis": "^5.3.2",
 | 
				
			||||||
 | 
					        "lodash": "^4.17.21",
 | 
				
			||||||
 | 
					        "msgpackr": "^1.11.2",
 | 
				
			||||||
 | 
					        "semver": "^7.5.2",
 | 
				
			||||||
 | 
					        "uuid": "^8.3.0"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "engines": {
 | 
				
			||||||
 | 
					        "node": ">=12"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/bull/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==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "bin": {
 | 
				
			||||||
 | 
					        "uuid": "dist/bin/uuid"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/busboy": {
 | 
					    "node_modules/busboy": {
 | 
				
			||||||
      "version": "1.6.0",
 | 
					      "version": "1.6.0",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
 | 
				
			||||||
| 
						 | 
					@ -5068,6 +5210,15 @@
 | 
				
			||||||
        "node": ">=0.8"
 | 
					        "node": ">=0.8"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/cluster-key-slot": {
 | 
				
			||||||
 | 
					      "version": "1.1.2",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/cluster-key-slot/-/cluster-key-slot-1.1.2.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-RMr0FhtfXemyinomL4hrWcYJxmX6deFdCxpJzhDttxgO1+bcCnkk+9drydLVDmAMG7NE6aN/fl4F7ucU/90gAA==",
 | 
				
			||||||
 | 
					      "license": "Apache-2.0",
 | 
				
			||||||
 | 
					      "engines": {
 | 
				
			||||||
 | 
					        "node": ">=0.10.0"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/co": {
 | 
					    "node_modules/co": {
 | 
				
			||||||
      "version": "4.6.0",
 | 
					      "version": "4.6.0",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
 | 
				
			||||||
| 
						 | 
					@ -5400,6 +5551,18 @@
 | 
				
			||||||
        "node": ">=18.x"
 | 
					        "node": ">=18.x"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/cron-parser": {
 | 
				
			||||||
 | 
					      "version": "4.9.0",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/cron-parser/-/cron-parser-4.9.0.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-p0SaNjrHOnQeR8/VnfGbmg9te2kfyYSQ7Sc/j/6DtPL3JQvKxmjO9TSjNFpujqV3vEYYBvNNvXSxzyksBWAx1Q==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "luxon": "^3.2.1"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "engines": {
 | 
				
			||||||
 | 
					        "node": ">=12.0.0"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/cross-spawn": {
 | 
					    "node_modules/cross-spawn": {
 | 
				
			||||||
      "version": "7.0.6",
 | 
					      "version": "7.0.6",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
 | 
				
			||||||
| 
						 | 
					@ -7316,7 +7479,6 @@
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/get-port/-/get-port-5.1.1.tgz",
 | 
				
			||||||
      "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==",
 | 
					      "integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==",
 | 
				
			||||||
      "license": "MIT",
 | 
					      "license": "MIT",
 | 
				
			||||||
      "optional": true,
 | 
					 | 
				
			||||||
      "engines": {
 | 
					      "engines": {
 | 
				
			||||||
        "node": ">=8"
 | 
					        "node": ">=8"
 | 
				
			||||||
      },
 | 
					      },
 | 
				
			||||||
| 
						 | 
					@ -7904,6 +8066,30 @@
 | 
				
			||||||
        "node": ">=12.0.0"
 | 
					        "node": ">=12.0.0"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/ioredis": {
 | 
				
			||||||
 | 
					      "version": "5.6.1",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/ioredis/-/ioredis-5.6.1.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-UxC0Yv1Y4WRJiGQxQkP0hfdL0/5/6YvdfOOClRgJ0qppSarkhneSa6UvkMkms0AkdGimSH3Ikqm+6mkMmX7vGA==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "@ioredis/commands": "^1.1.1",
 | 
				
			||||||
 | 
					        "cluster-key-slot": "^1.1.0",
 | 
				
			||||||
 | 
					        "debug": "^4.3.4",
 | 
				
			||||||
 | 
					        "denque": "^2.1.0",
 | 
				
			||||||
 | 
					        "lodash.defaults": "^4.2.0",
 | 
				
			||||||
 | 
					        "lodash.isarguments": "^3.1.0",
 | 
				
			||||||
 | 
					        "redis-errors": "^1.2.0",
 | 
				
			||||||
 | 
					        "redis-parser": "^3.0.0",
 | 
				
			||||||
 | 
					        "standard-as-callback": "^2.1.0"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "engines": {
 | 
				
			||||||
 | 
					        "node": ">=12.22.0"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "funding": {
 | 
				
			||||||
 | 
					        "type": "opencollective",
 | 
				
			||||||
 | 
					        "url": "https://opencollective.com/ioredis"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/ipaddr.js": {
 | 
					    "node_modules/ipaddr.js": {
 | 
				
			||||||
      "version": "1.9.1",
 | 
					      "version": "1.9.1",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
 | 
				
			||||||
| 
						 | 
					@ -9506,12 +9692,24 @@
 | 
				
			||||||
      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
 | 
					      "integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
 | 
				
			||||||
      "license": "MIT"
 | 
					      "license": "MIT"
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/lodash.defaults": {
 | 
				
			||||||
 | 
					      "version": "4.2.0",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/lodash.defaults/-/lodash.defaults-4.2.0.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-qjxPLHd3r5DnsdGacqOMU6pb/avJzdh9tFX2ymgoZE27BmjXrNy/y4LoaiTeAb+O3gL8AfpJGtqfX/ae2leYYQ==",
 | 
				
			||||||
 | 
					      "license": "MIT"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/lodash.includes": {
 | 
					    "node_modules/lodash.includes": {
 | 
				
			||||||
      "version": "4.3.0",
 | 
					      "version": "4.3.0",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
 | 
				
			||||||
      "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
 | 
					      "integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
 | 
				
			||||||
      "license": "MIT"
 | 
					      "license": "MIT"
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/lodash.isarguments": {
 | 
				
			||||||
 | 
					      "version": "3.1.0",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/lodash.isarguments/-/lodash.isarguments-3.1.0.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-chi4NHZlZqZD18a0imDHnZPrDeBbTtVN7GXMwuGdRH9qotxAjYs3aVLKc7zNOG9eddR5Ksd8rvFEBc9SsggPpg==",
 | 
				
			||||||
 | 
					      "license": "MIT"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/lodash.isboolean": {
 | 
					    "node_modules/lodash.isboolean": {
 | 
				
			||||||
      "version": "3.0.3",
 | 
					      "version": "3.0.3",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
 | 
				
			||||||
| 
						 | 
					@ -10421,6 +10619,37 @@
 | 
				
			||||||
      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
 | 
					      "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
 | 
				
			||||||
      "license": "MIT"
 | 
					      "license": "MIT"
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/msgpackr": {
 | 
				
			||||||
 | 
					      "version": "1.11.4",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/msgpackr/-/msgpackr-1.11.4.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-uaff7RG9VIC4jacFW9xzL3jc0iM32DNHe4jYVycBcjUePT/Klnfj7pqtWJt9khvDFizmjN2TlYniYmSS2LIaZg==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "optionalDependencies": {
 | 
				
			||||||
 | 
					        "msgpackr-extract": "^3.0.2"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/msgpackr-extract": {
 | 
				
			||||||
 | 
					      "version": "3.0.3",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/msgpackr-extract/-/msgpackr-extract-3.0.3.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-P0efT1C9jIdVRefqjzOQ9Xml57zpOXnIuS+csaB4MdZbTdmGDLo8XhzBG1N7aO11gKDDkJvBLULeFTo46wwreA==",
 | 
				
			||||||
 | 
					      "hasInstallScript": true,
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "optional": true,
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "node-gyp-build-optional-packages": "5.2.2"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "bin": {
 | 
				
			||||||
 | 
					        "download-msgpackr-prebuilds": "bin/download-prebuilds.js"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "optionalDependencies": {
 | 
				
			||||||
 | 
					        "@msgpackr-extract/msgpackr-extract-darwin-arm64": "3.0.3",
 | 
				
			||||||
 | 
					        "@msgpackr-extract/msgpackr-extract-darwin-x64": "3.0.3",
 | 
				
			||||||
 | 
					        "@msgpackr-extract/msgpackr-extract-linux-arm": "3.0.3",
 | 
				
			||||||
 | 
					        "@msgpackr-extract/msgpackr-extract-linux-arm64": "3.0.3",
 | 
				
			||||||
 | 
					        "@msgpackr-extract/msgpackr-extract-linux-x64": "3.0.3",
 | 
				
			||||||
 | 
					        "@msgpackr-extract/msgpackr-extract-win32-x64": "3.0.3"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/multer": {
 | 
					    "node_modules/multer": {
 | 
				
			||||||
      "version": "1.4.5-lts.1",
 | 
					      "version": "1.4.5-lts.1",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/multer/-/multer-1.4.5-lts.1.tgz",
 | 
				
			||||||
| 
						 | 
					@ -10598,6 +10827,21 @@
 | 
				
			||||||
        }
 | 
					        }
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/node-gyp-build-optional-packages": {
 | 
				
			||||||
 | 
					      "version": "5.2.2",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/node-gyp-build-optional-packages/-/node-gyp-build-optional-packages-5.2.2.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-s+w+rBWnpTMwSFbaE0UXsRlg7hU4FjekKU4eyAih5T8nJuNZT1nNsskXpxmeqSK9UzkBl6UgRlnKc8hz8IEqOw==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "optional": true,
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "detect-libc": "^2.0.1"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "bin": {
 | 
				
			||||||
 | 
					        "node-gyp-build-optional-packages": "bin.js",
 | 
				
			||||||
 | 
					        "node-gyp-build-optional-packages-optional": "optional.js",
 | 
				
			||||||
 | 
					        "node-gyp-build-optional-packages-test": "build-test.js"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/node-int64": {
 | 
					    "node_modules/node-int64": {
 | 
				
			||||||
      "version": "0.4.0",
 | 
					      "version": "0.4.0",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
 | 
				
			||||||
| 
						 | 
					@ -11847,6 +12091,27 @@
 | 
				
			||||||
        "node": ">= 12.13.0"
 | 
					        "node": ">= 12.13.0"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/redis-errors": {
 | 
				
			||||||
 | 
					      "version": "1.2.0",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/redis-errors/-/redis-errors-1.2.0.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "engines": {
 | 
				
			||||||
 | 
					        "node": ">=4"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/redis-parser": {
 | 
				
			||||||
 | 
					      "version": "3.0.0",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/redis-parser/-/redis-parser-3.0.0.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A==",
 | 
				
			||||||
 | 
					      "license": "MIT",
 | 
				
			||||||
 | 
					      "dependencies": {
 | 
				
			||||||
 | 
					        "redis-errors": "^1.0.0"
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      "engines": {
 | 
				
			||||||
 | 
					        "node": ">=4"
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/reflect-metadata": {
 | 
					    "node_modules/reflect-metadata": {
 | 
				
			||||||
      "version": "0.2.2",
 | 
					      "version": "0.2.2",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
 | 
				
			||||||
| 
						 | 
					@ -12979,6 +13244,12 @@
 | 
				
			||||||
        "node": ">=8"
 | 
					        "node": ">=8"
 | 
				
			||||||
      }
 | 
					      }
 | 
				
			||||||
    },
 | 
					    },
 | 
				
			||||||
 | 
					    "node_modules/standard-as-callback": {
 | 
				
			||||||
 | 
					      "version": "2.1.0",
 | 
				
			||||||
 | 
					      "resolved": "https://registry.npmjs.org/standard-as-callback/-/standard-as-callback-2.1.0.tgz",
 | 
				
			||||||
 | 
					      "integrity": "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A==",
 | 
				
			||||||
 | 
					      "license": "MIT"
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
    "node_modules/statuses": {
 | 
					    "node_modules/statuses": {
 | 
				
			||||||
      "version": "2.0.1",
 | 
					      "version": "2.0.1",
 | 
				
			||||||
      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
 | 
					      "resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -26,6 +26,7 @@
 | 
				
			||||||
  },
 | 
					  },
 | 
				
			||||||
  "dependencies": {
 | 
					  "dependencies": {
 | 
				
			||||||
    "@nestjs-modules/mailer": "^2.0.2",
 | 
					    "@nestjs-modules/mailer": "^2.0.2",
 | 
				
			||||||
 | 
					    "@nestjs/bull": "^11.0.2",
 | 
				
			||||||
    "@nestjs/common": "^10.0.0",
 | 
					    "@nestjs/common": "^10.0.0",
 | 
				
			||||||
    "@nestjs/config": "^4.0.1",
 | 
					    "@nestjs/config": "^4.0.1",
 | 
				
			||||||
    "@nestjs/core": "^10.0.0",
 | 
					    "@nestjs/core": "^10.0.0",
 | 
				
			||||||
| 
						 | 
					@ -40,6 +41,7 @@
 | 
				
			||||||
    "@nestjs/websockets": "^11.0.11",
 | 
					    "@nestjs/websockets": "^11.0.11",
 | 
				
			||||||
    "axios": "^1.8.3",
 | 
					    "axios": "^1.8.3",
 | 
				
			||||||
    "bcrypt": "^5.1.1",
 | 
					    "bcrypt": "^5.1.1",
 | 
				
			||||||
 | 
					    "bull": "^4.16.5",
 | 
				
			||||||
    "cheerio": "^1.0.0",
 | 
					    "cheerio": "^1.0.0",
 | 
				
			||||||
    "class-transformer": "^0.5.1",
 | 
					    "class-transformer": "^0.5.1",
 | 
				
			||||||
    "class-validator": "^0.14.1",
 | 
					    "class-validator": "^0.14.1",
 | 
				
			||||||
| 
						 | 
					@ -47,6 +49,7 @@
 | 
				
			||||||
    "cookie-parser": "^1.4.7",
 | 
					    "cookie-parser": "^1.4.7",
 | 
				
			||||||
    "dayjs": "^1.11.13",
 | 
					    "dayjs": "^1.11.13",
 | 
				
			||||||
    "imap": "^0.8.19",
 | 
					    "imap": "^0.8.19",
 | 
				
			||||||
 | 
					    "ioredis": "^5.6.1",
 | 
				
			||||||
    "lodash": "^4.17.21",
 | 
					    "lodash": "^4.17.21",
 | 
				
			||||||
    "moment": "^2.30.1",
 | 
					    "moment": "^2.30.1",
 | 
				
			||||||
    "multer": "^1.4.5-lts.1",
 | 
					    "multer": "^1.4.5-lts.1",
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,8 +1,10 @@
 | 
				
			||||||
import { Module } from '@nestjs/common';
 | 
					import { BullModule } from '@nestjs/bull';
 | 
				
			||||||
import { ConfigModule } from '@nestjs/config';
 | 
					import { Global, Module } from '@nestjs/common';
 | 
				
			||||||
 | 
					import { ConfigModule, ConfigService } from '@nestjs/config';
 | 
				
			||||||
import { EventEmitterModule } from '@nestjs/event-emitter';
 | 
					import { EventEmitterModule } from '@nestjs/event-emitter';
 | 
				
			||||||
import { ScheduleModule } from '@nestjs/schedule';
 | 
					import { ScheduleModule } from '@nestjs/schedule';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Global()
 | 
				
			||||||
@Module({
 | 
					@Module({
 | 
				
			||||||
  imports: [
 | 
					  imports: [
 | 
				
			||||||
    ConfigModule.forRoot({
 | 
					    ConfigModule.forRoot({
 | 
				
			||||||
| 
						 | 
					@ -12,7 +14,21 @@ import { ScheduleModule } from '@nestjs/schedule';
 | 
				
			||||||
      wildcard: true,
 | 
					      wildcard: true,
 | 
				
			||||||
      global: true,
 | 
					      global: true,
 | 
				
			||||||
    }),
 | 
					    }),
 | 
				
			||||||
    ScheduleModule.forRoot()
 | 
					    BullModule.forRootAsync({
 | 
				
			||||||
 | 
					      imports: [ConfigModule],
 | 
				
			||||||
 | 
					      useFactory: (configService: ConfigService) => ({
 | 
				
			||||||
 | 
					        redis: {
 | 
				
			||||||
 | 
					          host: configService.get<string>('REDIS_HOST'),
 | 
				
			||||||
 | 
					          port: configService.get<number>('REDIS_PORT'),
 | 
				
			||||||
 | 
					        },
 | 
				
			||||||
 | 
					      }),
 | 
				
			||||||
 | 
					      inject: [ConfigService],
 | 
				
			||||||
 | 
					    }),
 | 
				
			||||||
 | 
					    BullModule.registerQueue({
 | 
				
			||||||
 | 
					      name: 'mail-queue',
 | 
				
			||||||
 | 
					    }),
 | 
				
			||||||
 | 
					    ScheduleModule.forRoot(),
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
 | 
					  exports: [BullModule],
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
export class AppConfigsModule {}
 | 
					export class AppConfigsModule {}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -30,6 +30,7 @@ import { AdminDashboardController } from './controllers/admin/admin-dashboard.co
 | 
				
			||||||
import { TasksService } from './services/tasks.servise';
 | 
					import { TasksService } from './services/tasks.servise';
 | 
				
			||||||
import { ConfigsService } from './services/configs.service';
 | 
					import { ConfigsService } from './services/configs.service';
 | 
				
			||||||
import { Config } from './entities/configs.entity';
 | 
					import { Config } from './entities/configs.entity';
 | 
				
			||||||
 | 
					import { AdminConfigsController } from './controllers/admin/admin-configs.controller';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Module({
 | 
					@Module({
 | 
				
			||||||
  imports: [
 | 
					  imports: [
 | 
				
			||||||
| 
						 | 
					@ -55,6 +56,7 @@ import { Config } from './entities/configs.entity';
 | 
				
			||||||
    AdminWebBidsController,
 | 
					    AdminWebBidsController,
 | 
				
			||||||
    AdminSendMessageHistoriesController,
 | 
					    AdminSendMessageHistoriesController,
 | 
				
			||||||
    AdminDashboardController,
 | 
					    AdminDashboardController,
 | 
				
			||||||
 | 
					    AdminConfigsController,
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
  providers: [
 | 
					  providers: [
 | 
				
			||||||
    BidsService,
 | 
					    BidsService,
 | 
				
			||||||
| 
						 | 
					@ -76,6 +78,7 @@ import { Config } from './entities/configs.entity';
 | 
				
			||||||
    SendMessageHistoriesService,
 | 
					    SendMessageHistoriesService,
 | 
				
			||||||
    BidsService,
 | 
					    BidsService,
 | 
				
			||||||
    ConfigsService,
 | 
					    ConfigsService,
 | 
				
			||||||
 | 
					    DashboardService,
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
export class BidsModule {}
 | 
					export class BidsModule {}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,20 @@
 | 
				
			||||||
 | 
					import { Body, Controller, Get, Param, Post } from '@nestjs/common';
 | 
				
			||||||
 | 
					import { DashboardService } from '../../services/dashboard.service';
 | 
				
			||||||
 | 
					import { Config } from '../../entities/configs.entity';
 | 
				
			||||||
 | 
					import { ConfigsService } from '../../services/configs.service';
 | 
				
			||||||
 | 
					import { UpsertConfigDto } from '../../dto/config/upsert-config.dto';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Controller('admin/configs')
 | 
				
			||||||
 | 
					export class AdminConfigsController {
 | 
				
			||||||
 | 
					  constructor(private readonly configsService: ConfigsService) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Post('upsert')
 | 
				
			||||||
 | 
					  async upsertConfig(@Body() data: UpsertConfigDto) {
 | 
				
			||||||
 | 
					    return await this.configsService.upsertConfig(data);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Get(':key')
 | 
				
			||||||
 | 
					  async getConfig(@Param('key') key: Config['key_name']) {
 | 
				
			||||||
 | 
					    return await this.configsService.getConfigRes(key);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,13 @@
 | 
				
			||||||
 | 
					import { IsEnum, IsNumber, IsOptional, IsString } from 'class-validator';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class UpsertConfigDto {
 | 
				
			||||||
 | 
					  @IsString()
 | 
				
			||||||
 | 
					  key_name: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @IsString()
 | 
				
			||||||
 | 
					  @IsOptional()
 | 
				
			||||||
 | 
					  value: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @IsEnum(['string', 'number'])
 | 
				
			||||||
 | 
					  type: 'string' | 'number';
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -16,6 +16,8 @@ import { SendMessageHistoriesService } from './send-message-histories.service';
 | 
				
			||||||
import { NotificationService } from '@/modules/notification/notification.service';
 | 
					import { NotificationService } from '@/modules/notification/notification.service';
 | 
				
			||||||
import { isTimeReached } from '@/ultils';
 | 
					import { isTimeReached } from '@/ultils';
 | 
				
			||||||
import { BidsService } from './bids.service';
 | 
					import { BidsService } from './bids.service';
 | 
				
			||||||
 | 
					import { EventEmitter2 } from '@nestjs/event-emitter';
 | 
				
			||||||
 | 
					import { Event } from '../utils/events';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
export class BidHistoriesService {
 | 
					export class BidHistoriesService {
 | 
				
			||||||
| 
						 | 
					@ -28,6 +30,7 @@ export class BidHistoriesService {
 | 
				
			||||||
    readonly sendMessageHistoriesService: SendMessageHistoriesService,
 | 
					    readonly sendMessageHistoriesService: SendMessageHistoriesService,
 | 
				
			||||||
    private readonly notificationService: NotificationService,
 | 
					    private readonly notificationService: NotificationService,
 | 
				
			||||||
    private readonly bidsService: BidsService,
 | 
					    private readonly bidsService: BidsService,
 | 
				
			||||||
 | 
					    private eventEmitter: EventEmitter2,
 | 
				
			||||||
  ) {}
 | 
					  ) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async index() {
 | 
					  async index() {
 | 
				
			||||||
| 
						 | 
					@ -38,6 +41,7 @@ export class BidHistoriesService {
 | 
				
			||||||
    // Tìm thông tin bid từ database
 | 
					    // Tìm thông tin bid từ database
 | 
				
			||||||
    const bid = await this.bidsService.bidsRepo.findOne({
 | 
					    const bid = await this.bidsService.bidsRepo.findOne({
 | 
				
			||||||
      where: { id: bid_id },
 | 
					      where: { id: bid_id },
 | 
				
			||||||
 | 
					      relations: { web_bid: true },
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Nếu không tìm thấy bid, trả về lỗi 404
 | 
					    // Nếu không tìm thấy bid, trả về lỗi 404
 | 
				
			||||||
| 
						 | 
					@ -104,6 +108,9 @@ export class BidHistoriesService {
 | 
				
			||||||
    const botData = { ...bid, histories: response };
 | 
					    const botData = { ...bid, histories: response };
 | 
				
			||||||
    this.botTelegramApi.sendBidInfo(botData);
 | 
					    this.botTelegramApi.sendBidInfo(botData);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Send event thống place bid
 | 
				
			||||||
 | 
					    this.eventEmitter.emit(Event.BID_SUBMITED, botData);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    // Lưu message đã gửi để theo dõi
 | 
					    // Lưu message đã gửi để theo dõi
 | 
				
			||||||
    this.sendMessageHistoriesService.sendMessageRepo.save({
 | 
					    this.sendMessageHistoriesService.sendMessageRepo.save({
 | 
				
			||||||
      message: this.botTelegramApi.formatBidMessage(botData),
 | 
					      message: this.botTelegramApi.formatBidMessage(botData),
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -153,7 +153,10 @@ export class BidsService {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async toggle(id: Bid['id']) {
 | 
					  async toggle(id: Bid['id']) {
 | 
				
			||||||
    const bid = await this.bidsRepo.findOne({ where: { id } });
 | 
					    const bid = await this.bidsRepo.findOne({
 | 
				
			||||||
 | 
					      where: { id },
 | 
				
			||||||
 | 
					      relations: { web_bid: true },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!bid) {
 | 
					    if (!bid) {
 | 
				
			||||||
      throw new NotFoundException(
 | 
					      throw new NotFoundException(
 | 
				
			||||||
| 
						 | 
					@ -301,7 +304,10 @@ export class BidsService {
 | 
				
			||||||
  async outBid(id: Bid['id']) {
 | 
					  async outBid(id: Bid['id']) {
 | 
				
			||||||
    const result = await this.bidsRepo.update(id, { status: 'out-bid' });
 | 
					    const result = await this.bidsRepo.update(id, { status: 'out-bid' });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const bid = await this.bidsRepo.findOne({ where: { id } });
 | 
					    const bid = await this.bidsRepo.findOne({
 | 
				
			||||||
 | 
					      where: { id },
 | 
				
			||||||
 | 
					      relations: { web_bid: true },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!result) throw new BadRequestException(AppResponse.toResponse(false));
 | 
					    if (!result) throw new BadRequestException(AppResponse.toResponse(false));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -353,7 +359,10 @@ export class BidsService {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async updateStatusByPrice(id: Bid['id'], data: UpdateStatusByPriceDto) {
 | 
					  async updateStatusByPrice(id: Bid['id'], data: UpdateStatusByPriceDto) {
 | 
				
			||||||
    const bid = await this.bidsRepo.findOne({ where: { id } });
 | 
					    const bid = await this.bidsRepo.findOne({
 | 
				
			||||||
 | 
					      where: { id },
 | 
				
			||||||
 | 
					      relations: { web_bid: true },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!bid)
 | 
					    if (!bid)
 | 
				
			||||||
      throw new NotFoundException(
 | 
					      throw new NotFoundException(
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,8 +1,14 @@
 | 
				
			||||||
import { Injectable } from '@nestjs/common';
 | 
					import {
 | 
				
			||||||
 | 
					  BadRequestException,
 | 
				
			||||||
 | 
					  HttpStatus,
 | 
				
			||||||
 | 
					  Injectable,
 | 
				
			||||||
 | 
					  NotFoundException,
 | 
				
			||||||
 | 
					} from '@nestjs/common';
 | 
				
			||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
 | 
					import { EventEmitter2 } from '@nestjs/event-emitter';
 | 
				
			||||||
import { InjectRepository } from '@nestjs/typeorm';
 | 
					import { InjectRepository } from '@nestjs/typeorm';
 | 
				
			||||||
import { Repository } from 'typeorm';
 | 
					import { Repository } from 'typeorm';
 | 
				
			||||||
import { Config } from '../entities/configs.entity';
 | 
					import { Config } from '../entities/configs.entity';
 | 
				
			||||||
 | 
					import AppResponse from '@/response/app-response';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
export class ConfigsService {
 | 
					export class ConfigsService {
 | 
				
			||||||
| 
						 | 
					@ -29,4 +35,41 @@ export class ConfigsService {
 | 
				
			||||||
      'key_name',
 | 
					      'key_name',
 | 
				
			||||||
    ]);
 | 
					    ]);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async getConfigRes(key_name: string) {
 | 
				
			||||||
 | 
					    const result = await this.getConfig(
 | 
				
			||||||
 | 
					      key_name as keyof typeof ConfigsService.CONFIG_KEYS,
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!result)
 | 
				
			||||||
 | 
					      throw new NotFoundException(
 | 
				
			||||||
 | 
					        AppResponse.toResponse(null, {
 | 
				
			||||||
 | 
					          message: 'Config key name not found',
 | 
				
			||||||
 | 
					          status_code: HttpStatus.NOT_FOUND,
 | 
				
			||||||
 | 
					        }),
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return AppResponse.toResponse(result);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async upsertConfig(data: Partial<Config>) {
 | 
				
			||||||
 | 
					    let response = null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const prevConfig = await this.configRepo.findOne({
 | 
				
			||||||
 | 
					      where: { key_name: data.key_name },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!prevConfig) {
 | 
				
			||||||
 | 
					      response = await this.configRepo.save(data);
 | 
				
			||||||
 | 
					    } else {
 | 
				
			||||||
 | 
					      response = await this.configRepo.update(
 | 
				
			||||||
 | 
					        { key_name: data.key_name },
 | 
				
			||||||
 | 
					        data,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!response) throw new BadRequestException(AppResponse.toResponse(false));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return AppResponse.toResponse(!!response);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -7,6 +7,8 @@ export class Event {
 | 
				
			||||||
  public static ADMIN_BIDS_UPDATED = 'adminBidsUpdated';
 | 
					  public static ADMIN_BIDS_UPDATED = 'adminBidsUpdated';
 | 
				
			||||||
  public static WEB_UPDATED = 'webUpdated';
 | 
					  public static WEB_UPDATED = 'webUpdated';
 | 
				
			||||||
  public static LOGIN_STATUS = 'login-status';
 | 
					  public static LOGIN_STATUS = 'login-status';
 | 
				
			||||||
 | 
					  public static BID_SUBMITED = 'bid-submited';
 | 
				
			||||||
 | 
					  public static BID_STATUS = 'bid-status';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  public static verifyCode(data: WebBid) {
 | 
					  public static verifyCode(data: WebBid) {
 | 
				
			||||||
    return `${this.VERIFY_CODE}.${data.origin_url}`;
 | 
					    return `${this.VERIFY_CODE}.${data.origin_url}`;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -2,7 +2,8 @@ import { MailerModule } from '@nestjs-modules/mailer';
 | 
				
			||||||
import { Module } from '@nestjs/common';
 | 
					import { Module } from '@nestjs/common';
 | 
				
			||||||
import { MailsService } from './services/mails.service';
 | 
					import { MailsService } from './services/mails.service';
 | 
				
			||||||
import { ConfigModule, ConfigService } from '@nestjs/config';
 | 
					import { ConfigModule, ConfigService } from '@nestjs/config';
 | 
				
			||||||
 | 
					import { BullModule } from '@nestjs/bull';
 | 
				
			||||||
 | 
					import { MailProcessor } from './process/mail.processor';
 | 
				
			||||||
@Module({
 | 
					@Module({
 | 
				
			||||||
  imports: [
 | 
					  imports: [
 | 
				
			||||||
    MailerModule.forRootAsync({
 | 
					    MailerModule.forRootAsync({
 | 
				
			||||||
| 
						 | 
					@ -21,7 +22,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
 | 
				
			||||||
      inject: [ConfigService],
 | 
					      inject: [ConfigService],
 | 
				
			||||||
    }),
 | 
					    }),
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
  providers: [MailsService],
 | 
					  providers: [MailsService, MailProcessor],
 | 
				
			||||||
  exports: [MailsService],
 | 
					  exports: [MailsService],
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
export class MailsModule {}
 | 
					export class MailsModule {}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,16 @@
 | 
				
			||||||
 | 
					// processors/mail.processor.ts
 | 
				
			||||||
 | 
					import { Process, Processor } from '@nestjs/bull';
 | 
				
			||||||
 | 
					import { Job } from 'bull';
 | 
				
			||||||
 | 
					import { MailsService } from '../services/mails.service';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Processor('mail-queue')
 | 
				
			||||||
 | 
					export class MailProcessor {
 | 
				
			||||||
 | 
					  constructor(private readonly mailsService: MailsService) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Process('send-mail')
 | 
				
			||||||
 | 
					  async handleSendMail(job: Job) {
 | 
				
			||||||
 | 
					    const { to, subject, html } = job.data;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await this.mailsService.sendPlainHtml(to, subject, html);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,11 +1,22 @@
 | 
				
			||||||
import { Injectable } from '@nestjs/common';
 | 
					import { Injectable } from '@nestjs/common';
 | 
				
			||||||
import { MailerService } from '@nestjs-modules/mailer';
 | 
					import { MailerService } from '@nestjs-modules/mailer';
 | 
				
			||||||
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
 | 
					import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
 | 
				
			||||||
import { extractDomain } from '@/ultils';
 | 
					import {
 | 
				
			||||||
 | 
					  extractDomain,
 | 
				
			||||||
 | 
					  extractDomainSmart,
 | 
				
			||||||
 | 
					  formatEndTime,
 | 
				
			||||||
 | 
					  isTimeReached,
 | 
				
			||||||
 | 
					} from '@/ultils';
 | 
				
			||||||
 | 
					import { Bid } from '@/modules/bids/entities/bid.entity';
 | 
				
			||||||
 | 
					import { InjectQueue } from '@nestjs/bull';
 | 
				
			||||||
 | 
					import { Queue } from 'bull';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
export class MailsService {
 | 
					export class MailsService {
 | 
				
			||||||
  constructor(private readonly mailerService: MailerService) {}
 | 
					  constructor(
 | 
				
			||||||
 | 
					    private readonly mailerService: MailerService,
 | 
				
			||||||
 | 
					    @InjectQueue('mail-queue') private mailQueue: Queue,
 | 
				
			||||||
 | 
					  ) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async sendPlainText(to: string, subject: string, content: string) {
 | 
					  async sendPlainText(to: string, subject: string, content: string) {
 | 
				
			||||||
    await this.mailerService.sendMail({
 | 
					    await this.mailerService.sendMail({
 | 
				
			||||||
| 
						 | 
					@ -15,6 +26,14 @@ export class MailsService {
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async sendHtmlMailJob(mailData: {
 | 
				
			||||||
 | 
					    to: string;
 | 
				
			||||||
 | 
					    subject: string;
 | 
				
			||||||
 | 
					    html: string;
 | 
				
			||||||
 | 
					  }) {
 | 
				
			||||||
 | 
					    await this.mailQueue.add('send-mail', mailData);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async sendPlainHtml(to: string, subject: string, html: string) {
 | 
					  async sendPlainHtml(to: string, subject: string, html: string) {
 | 
				
			||||||
    const emails = to
 | 
					    const emails = to
 | 
				
			||||||
      .split(',')
 | 
					      .split(',')
 | 
				
			||||||
| 
						 | 
					@ -33,17 +52,34 @@ export class MailsService {
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  generateProductTableHTML(products: ScrapItem[]): string {
 | 
					  generateProductTableHTML(products: ScrapItem[]): string {
 | 
				
			||||||
 | 
					    if (!products.length) {
 | 
				
			||||||
 | 
					      return `
 | 
				
			||||||
 | 
					    <!DOCTYPE html>
 | 
				
			||||||
 | 
					    <html lang="en">
 | 
				
			||||||
 | 
					    <head>
 | 
				
			||||||
 | 
					      <meta charset="UTF-8" />
 | 
				
			||||||
 | 
					      <meta name="viewport" content="width=device-width, initial-scale=1.0"/>
 | 
				
			||||||
 | 
					      <title>Products</title>
 | 
				
			||||||
 | 
					    </head>
 | 
				
			||||||
 | 
					    <body style="font-family: sans-serif; background: #f8f9fa; padding: 20px;">
 | 
				
			||||||
 | 
					      <h2 style="text-align: center; color: #333;">Product Listing</h2>
 | 
				
			||||||
 | 
					      <p style="text-align: center; color: #666;">No matching products found for your keywords today.</p>
 | 
				
			||||||
 | 
					    </body>
 | 
				
			||||||
 | 
					    </html>
 | 
				
			||||||
 | 
					    `;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const rows = products
 | 
					    const rows = products
 | 
				
			||||||
      .map(
 | 
					      .map(
 | 
				
			||||||
        (p) => `
 | 
					        (p) => `
 | 
				
			||||||
        <tr>
 | 
					      <tr>
 | 
				
			||||||
          <td><img src="${p.image_url}" alt="Product Image" style="height: 40px; object-fit: contain; border-radius: 4px;" /></td>
 | 
					        <td><img src="${p.image_url}" alt="Product Image" style="height: 40px; object-fit: contain; border-radius: 4px;" /></td>
 | 
				
			||||||
          <td>${p.name}</td>
 | 
					        <td>${p.name}</td>
 | 
				
			||||||
          <td style="font-weight: bold; color: #e03131;">$${p.current_price}</td>
 | 
					        <td style="font-weight: bold; color: #e03131;">${p.current_price ? '$' + p.current_price : 'None'}</td>
 | 
				
			||||||
          <td><a href="${p.url}" target="_blank" style="color: #007bff;">View</a></td>
 | 
					        <td><a href="${p.url}" target="_blank" style="color: #007bff;">View</a></td>
 | 
				
			||||||
          <td>${extractDomain(p.scrap_config.web_bid.origin_url)}</td>
 | 
					        <td>${extractDomainSmart(p.scrap_config.web_bid.origin_url)}</td>
 | 
				
			||||||
        </tr>
 | 
					      </tr>
 | 
				
			||||||
      `,
 | 
					    `,
 | 
				
			||||||
      )
 | 
					      )
 | 
				
			||||||
      .join('');
 | 
					      .join('');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -78,12 +114,197 @@ export class MailsService {
 | 
				
			||||||
  `;
 | 
					  `;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async sendWithTemplate(to: string, subject: string, payload: any) {
 | 
					  getAuctionStatusEmailContent(bid: Bid): string {
 | 
				
			||||||
    await this.mailerService.sendMail({
 | 
					    const webname = extractDomain(bid.web_bid.origin_url);
 | 
				
			||||||
      to,
 | 
					    const title = `[${webname}] ${bid.name || 'Unnamed Item'}`;
 | 
				
			||||||
      subject,
 | 
					    const endTime = formatEndTime(bid.close_time, false);
 | 
				
			||||||
      template: './welcome', // đường dẫn tương đối trong /templates
 | 
					    const competitor = `$${bid.current_price}`;
 | 
				
			||||||
      context: payload, // dữ liệu cho template
 | 
					    const max = `$${bid.max_price}`;
 | 
				
			||||||
    });
 | 
					    const submitted = `$${bid.max_price}`;
 | 
				
			||||||
 | 
					    const nextBid = bid.max_price + bid.plus_price;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const cardStyle = `
 | 
				
			||||||
 | 
					    max-width: 600px; 
 | 
				
			||||||
 | 
					    margin: 20px auto; 
 | 
				
			||||||
 | 
					    font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif; 
 | 
				
			||||||
 | 
					    background: #ffffff; 
 | 
				
			||||||
 | 
					    border-radius: 8px; 
 | 
				
			||||||
 | 
					    box-shadow: 0 4px 8px rgba(0,0,0,0.1);
 | 
				
			||||||
 | 
					    color: #333;
 | 
				
			||||||
 | 
					    padding: 20px;
 | 
				
			||||||
 | 
					  `;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const headerStyle = (color: string) =>
 | 
				
			||||||
 | 
					      `font-size: 22px; font-weight: 700; color: ${color}; margin-bottom: 15px;`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const labelStyle = `font-weight: 600; width: 120px; display: inline-block; color: #555;`;
 | 
				
			||||||
 | 
					    const valueStyle = `color: #222;`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const renderRow = (label: string, value: string) =>
 | 
				
			||||||
 | 
					      `<p><span style="${labelStyle}">${label}:</span> <span style="${valueStyle}">${value}</span></p>`;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    switch (bid.status) {
 | 
				
			||||||
 | 
					      case 'biding':
 | 
				
			||||||
 | 
					        return `
 | 
				
			||||||
 | 
					        <div style="${cardStyle}">
 | 
				
			||||||
 | 
					          <h2 style="${headerStyle('#2c7a7b')}">✅ Auto Bid Started</h2>
 | 
				
			||||||
 | 
					          ${renderRow('Title', title)}
 | 
				
			||||||
 | 
					          ${renderRow('Max', max)}
 | 
				
			||||||
 | 
					          ${renderRow('End time', endTime)}
 | 
				
			||||||
 | 
					          ${renderRow('Competitor', competitor)}
 | 
				
			||||||
 | 
					          ${renderRow('Bid submitted', submitted)}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      `;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      case 'out-bid': {
 | 
				
			||||||
 | 
					        const overLimit = bid.current_price >= nextBid;
 | 
				
			||||||
 | 
					        const belowReserve = bid.reserve_price > nextBid;
 | 
				
			||||||
 | 
					        const timeExtended = bid.close_time ? 'Time extended' : 'No extension';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (isTimeReached(bid.close_time)) {
 | 
				
			||||||
 | 
					          return `
 | 
				
			||||||
 | 
					          <div style="${cardStyle}">
 | 
				
			||||||
 | 
					            <h2 style="${headerStyle('#718096')}">⏳ Auction Ended</h2>
 | 
				
			||||||
 | 
					            ${renderRow('Title', title)}
 | 
				
			||||||
 | 
					            ${renderRow('End time', endTime)}
 | 
				
			||||||
 | 
					            ${renderRow('Final price', competitor)}
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        `;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        if (overLimit || belowReserve) {
 | 
				
			||||||
 | 
					          return `
 | 
				
			||||||
 | 
					          <div style="${cardStyle}">
 | 
				
			||||||
 | 
					            <h2 style="${headerStyle('#dd6b20')}">⚠️ Outbid (${timeExtended})</h2>
 | 
				
			||||||
 | 
					            ${renderRow('Title', title)}
 | 
				
			||||||
 | 
					            ${renderRow('Competitor', competitor)}
 | 
				
			||||||
 | 
					            ${renderRow('Max', max)}
 | 
				
			||||||
 | 
					            ${renderRow('Next bid at', `$${nextBid}`)}
 | 
				
			||||||
 | 
					            ${renderRow('End time', endTime)}
 | 
				
			||||||
 | 
					            <p style="color:#c05621; font-weight: 600;">⚠️ Current bid exceeds your max bid.</p>
 | 
				
			||||||
 | 
					          </div>
 | 
				
			||||||
 | 
					        `;
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        return `
 | 
				
			||||||
 | 
					        <div style="${cardStyle}">
 | 
				
			||||||
 | 
					          <h2 style="${headerStyle('#e53e3e')}">🛑 Auction Canceled (${timeExtended})</h2>
 | 
				
			||||||
 | 
					          ${renderRow('Title', title)}
 | 
				
			||||||
 | 
					          ${renderRow('Competitor', competitor)}
 | 
				
			||||||
 | 
					          ${renderRow('Max', max)}
 | 
				
			||||||
 | 
					          ${renderRow('Next bid at', `$${nextBid}`)}
 | 
				
			||||||
 | 
					          ${renderRow('End time', endTime)}
 | 
				
			||||||
 | 
					          <p style="color:#9b2c2c; font-weight: 600;">🛑 Auction has been canceled.</p>
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      `;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      case 'win-bid':
 | 
				
			||||||
 | 
					        return `
 | 
				
			||||||
 | 
					        <div style="${cardStyle}">
 | 
				
			||||||
 | 
					          <h2 style="${headerStyle('#2b6cb0')}">🎉 You Won!</h2>
 | 
				
			||||||
 | 
					          ${renderRow('Title', title)}
 | 
				
			||||||
 | 
					          ${renderRow('Price won', `$${bid.current_price}`)}
 | 
				
			||||||
 | 
					          ${renderRow('Max', max)}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      `;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      default:
 | 
				
			||||||
 | 
					        return `
 | 
				
			||||||
 | 
					        <div style="${cardStyle}">
 | 
				
			||||||
 | 
					          <h2 style="${headerStyle('#718096')}">❓ Unknown Status</h2>
 | 
				
			||||||
 | 
					          ${renderRow('Title', title)}
 | 
				
			||||||
 | 
					        </div>
 | 
				
			||||||
 | 
					      `;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getBidSubmittedEmailContent(bid: Bid): string {
 | 
				
			||||||
 | 
					    const webname = extractDomain(bid.web_bid.origin_url);
 | 
				
			||||||
 | 
					    const title = `[${webname}] ${bid.name || 'Unnamed Item'}`;
 | 
				
			||||||
 | 
					    const endTime = formatEndTime(bid.close_time, false);
 | 
				
			||||||
 | 
					    const competitor = `$${bid.current_price}`;
 | 
				
			||||||
 | 
					    const max = `$${bid.max_price}`;
 | 
				
			||||||
 | 
					    const submitted = `$${bid.max_price}`;
 | 
				
			||||||
 | 
					    const maxReached = bid.max_price <= bid.max_price;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return `
 | 
				
			||||||
 | 
					  <!DOCTYPE html>
 | 
				
			||||||
 | 
					  <html lang="en">
 | 
				
			||||||
 | 
					  <head>
 | 
				
			||||||
 | 
					    <style>
 | 
				
			||||||
 | 
					      body {
 | 
				
			||||||
 | 
					        font-family: Arial, sans-serif;
 | 
				
			||||||
 | 
					        background: #f9f9f9;
 | 
				
			||||||
 | 
					        color: #333;
 | 
				
			||||||
 | 
					        padding: 20px;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      .container {
 | 
				
			||||||
 | 
					        background: #fff;
 | 
				
			||||||
 | 
					        padding: 20px;
 | 
				
			||||||
 | 
					        border-radius: 8px;
 | 
				
			||||||
 | 
					        box-shadow: 0 2px 8px rgba(0,0,0,0.1);
 | 
				
			||||||
 | 
					        max-width: 600px;
 | 
				
			||||||
 | 
					        margin: auto;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      h2 {
 | 
				
			||||||
 | 
					        color: #007bff;
 | 
				
			||||||
 | 
					        text-align: center;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      table {
 | 
				
			||||||
 | 
					        width: 100%;
 | 
				
			||||||
 | 
					        border-collapse: collapse;
 | 
				
			||||||
 | 
					        margin-top: 15px;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      th, td {
 | 
				
			||||||
 | 
					        padding: 10px;
 | 
				
			||||||
 | 
					        border-bottom: 1px solid #ddd;
 | 
				
			||||||
 | 
					        text-align: left;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      th {
 | 
				
			||||||
 | 
					        background-color: #f1f1f1;
 | 
				
			||||||
 | 
					        color: #555;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      .highlight {
 | 
				
			||||||
 | 
					        color: #e03131;
 | 
				
			||||||
 | 
					        font-weight: bold;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      .max-reach {
 | 
				
			||||||
 | 
					        color: #d6336c;
 | 
				
			||||||
 | 
					        font-weight: bold;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    </style>
 | 
				
			||||||
 | 
					  </head>
 | 
				
			||||||
 | 
					  <body>
 | 
				
			||||||
 | 
					    <div class="container">
 | 
				
			||||||
 | 
					      <h2>Bid Submitted${bid.close_time ? ', Time extended' : ', No extension'}${maxReached ? ' <span class="max-reach">* MAX REACH *</span>' : ''}</h2>
 | 
				
			||||||
 | 
					      <table>
 | 
				
			||||||
 | 
					        <tbody>
 | 
				
			||||||
 | 
					          <tr>
 | 
				
			||||||
 | 
					            <th>Title</th>
 | 
				
			||||||
 | 
					            <td>${title}</td>
 | 
				
			||||||
 | 
					          </tr>
 | 
				
			||||||
 | 
					          <tr>
 | 
				
			||||||
 | 
					            <th>Competitor</th>
 | 
				
			||||||
 | 
					            <td>${competitor}</td>
 | 
				
			||||||
 | 
					          </tr>
 | 
				
			||||||
 | 
					          <tr>
 | 
				
			||||||
 | 
					            <th>Bid Submitted</th>
 | 
				
			||||||
 | 
					            <td>${submitted} ${maxReached ? '<span class="max-reach">(<b>***MAXIMUM REACH***</b>)</span>' : ''}</td>
 | 
				
			||||||
 | 
					          </tr>
 | 
				
			||||||
 | 
					          <tr>
 | 
				
			||||||
 | 
					            <th>Max</th>
 | 
				
			||||||
 | 
					            <td class="highlight">${max}</td>
 | 
				
			||||||
 | 
					          </tr>
 | 
				
			||||||
 | 
					          <tr>
 | 
				
			||||||
 | 
					            <th>End Time</th>
 | 
				
			||||||
 | 
					            <td>${endTime}</td>
 | 
				
			||||||
 | 
					          </tr>
 | 
				
			||||||
 | 
					        </tbody>
 | 
				
			||||||
 | 
					      </table>
 | 
				
			||||||
 | 
					    </div>
 | 
				
			||||||
 | 
					  </body>
 | 
				
			||||||
 | 
					  </html>
 | 
				
			||||||
 | 
					  `;
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,3 +1,3 @@
 | 
				
			||||||
export const NAME_EVENTS = {
 | 
					// export const NAME_EVENTS = {
 | 
				
			||||||
  BID_STATUS: 'notify.bid-status',
 | 
					//   BID_STATUS: 'notify.bid-status',
 | 
				
			||||||
};
 | 
					// };
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -20,7 +20,8 @@ export class ClientNotificationController {
 | 
				
			||||||
  @Post('test')
 | 
					  @Post('test')
 | 
				
			||||||
  async test() {
 | 
					  async test() {
 | 
				
			||||||
    const bid = await this.bidsService.bidsRepo.findOne({
 | 
					    const bid = await this.bidsService.bidsRepo.findOne({
 | 
				
			||||||
      where: { lot_id: '26077023' },
 | 
					      where: { lot_id: '23755862' },
 | 
				
			||||||
 | 
					      relations: { web_bid: true },
 | 
				
			||||||
    });
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return await this.notifyService.emitBidStatus({
 | 
					    return await this.notifyService.emitBidStatus({
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,16 +1,23 @@
 | 
				
			||||||
import { Injectable } from '@nestjs/common';
 | 
					import { Injectable } from '@nestjs/common';
 | 
				
			||||||
import { OnEvent } from '@nestjs/event-emitter';
 | 
					import { OnEvent } from '@nestjs/event-emitter';
 | 
				
			||||||
import { NAME_EVENTS } from '../constants';
 | 
					 | 
				
			||||||
import { Bid } from '@/modules/bids/entities/bid.entity';
 | 
					import { Bid } from '@/modules/bids/entities/bid.entity';
 | 
				
			||||||
import { Notification } from '../entities/notification.entity';
 | 
					import { Notification } from '../entities/notification.entity';
 | 
				
			||||||
import { BotTelegramApi } from '@/modules/bids/apis/bot-telegram.api';
 | 
					import { BotTelegramApi } from '@/modules/bids/apis/bot-telegram.api';
 | 
				
			||||||
 | 
					import { MailsService } from '@/modules/mails/services/mails.service';
 | 
				
			||||||
 | 
					import { ConfigsService } from '@/modules/bids/services/configs.service';
 | 
				
			||||||
 | 
					import * as moment from 'moment';
 | 
				
			||||||
 | 
					import { Event } from '@/modules/bids/utils/events';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
export class AdminNotificationListener {
 | 
					export class AdminNotificationListener {
 | 
				
			||||||
  constructor(private readonly botTelegramApi: BotTelegramApi) {}
 | 
					  constructor(
 | 
				
			||||||
 | 
					    private readonly botTelegramApi: BotTelegramApi,
 | 
				
			||||||
 | 
					    private readonly mailsService: MailsService,
 | 
				
			||||||
 | 
					    private readonly configsSerice: ConfigsService,
 | 
				
			||||||
 | 
					  ) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @OnEvent(NAME_EVENTS.BID_STATUS)
 | 
					  @OnEvent(Event.BID_STATUS)
 | 
				
			||||||
  handleBidStatus({
 | 
					  async handleBidStatus({
 | 
				
			||||||
    bid,
 | 
					    bid,
 | 
				
			||||||
    notification,
 | 
					    notification,
 | 
				
			||||||
  }: {
 | 
					  }: {
 | 
				
			||||||
| 
						 | 
					@ -20,5 +27,30 @@ export class AdminNotificationListener {
 | 
				
			||||||
    if (JSON.parse(notification.send_to).length <= 0) return;
 | 
					    if (JSON.parse(notification.send_to).length <= 0) return;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    this.botTelegramApi.sendMessage(notification.message);
 | 
					    this.botTelegramApi.sendMessage(notification.message);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const mails =
 | 
				
			||||||
 | 
					      (await this.configsSerice.getConfig('MAIL_SCRAP_REPORT')).value || '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.mailsService.sendHtmlMailJob({
 | 
				
			||||||
 | 
					      to: mails,
 | 
				
			||||||
 | 
					      html: this.mailsService.getAuctionStatusEmailContent(bid),
 | 
				
			||||||
 | 
					      subject:
 | 
				
			||||||
 | 
					        'Report Auto Auctions System ' +
 | 
				
			||||||
 | 
					        moment(new Date()).format('YYYY-MM-DD HH:mm'),
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @OnEvent(Event.BID_SUBMITED)
 | 
				
			||||||
 | 
					  async handleBidSubmited(bid: Bid) {
 | 
				
			||||||
 | 
					    const mails =
 | 
				
			||||||
 | 
					      (await this.configsSerice.getConfig('MAIL_SCRAP_REPORT')).value || '';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    this.mailsService.sendHtmlMailJob({
 | 
				
			||||||
 | 
					      to: mails,
 | 
				
			||||||
 | 
					      html: this.mailsService.getBidSubmittedEmailContent(bid),
 | 
				
			||||||
 | 
					      subject:
 | 
				
			||||||
 | 
					        'Report Auto Auctions System ' +
 | 
				
			||||||
 | 
					        moment(new Date()).format('YYYY-MM-DD HH:mm'),
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -8,11 +8,13 @@ import { AdminNotificationListener } from './listeners/admin-notification.listen
 | 
				
			||||||
import { NotificationService } from './notification.service';
 | 
					import { NotificationService } from './notification.service';
 | 
				
			||||||
import { SendMessageHistoriesService } from '../bids/services/send-message-histories.service';
 | 
					import { SendMessageHistoriesService } from '../bids/services/send-message-histories.service';
 | 
				
			||||||
import { SendMessageHistory } from '../bids/entities/send-message-histories.entity';
 | 
					import { SendMessageHistory } from '../bids/entities/send-message-histories.entity';
 | 
				
			||||||
 | 
					import { MailsModule } from '../mails/mails.module';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Module({
 | 
					@Module({
 | 
				
			||||||
  imports: [
 | 
					  imports: [
 | 
				
			||||||
    forwardRef(() => BidsModule),
 | 
					    forwardRef(() => BidsModule),
 | 
				
			||||||
    TypeOrmModule.forFeature([Notification, SendMessageHistory]),
 | 
					    TypeOrmModule.forFeature([Notification, SendMessageHistory]),
 | 
				
			||||||
 | 
					    MailsModule,
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
  controllers: [NotificationController, ClientNotificationController],
 | 
					  controllers: [NotificationController, ClientNotificationController],
 | 
				
			||||||
  providers: [NotificationService, AdminNotificationListener],
 | 
					  providers: [NotificationService, AdminNotificationListener],
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,7 +1,6 @@
 | 
				
			||||||
import { Injectable } from '@nestjs/common';
 | 
					import { Injectable } from '@nestjs/common';
 | 
				
			||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
 | 
					import { EventEmitter2 } from '@nestjs/event-emitter';
 | 
				
			||||||
import { Bid } from '../bids/entities/bid.entity';
 | 
					import { Bid } from '../bids/entities/bid.entity';
 | 
				
			||||||
import { NAME_EVENTS } from './constants';
 | 
					 | 
				
			||||||
import { BotTelegramApi } from '../bids/apis/bot-telegram.api';
 | 
					import { BotTelegramApi } from '../bids/apis/bot-telegram.api';
 | 
				
			||||||
import { InjectRepository } from '@nestjs/typeorm';
 | 
					import { InjectRepository } from '@nestjs/typeorm';
 | 
				
			||||||
import { Notification } from './entities/notification.entity';
 | 
					import { Notification } from './entities/notification.entity';
 | 
				
			||||||
| 
						 | 
					@ -17,6 +16,7 @@ import { Column } from 'nestjs-paginate/lib/helper';
 | 
				
			||||||
import AppResponse from '@/response/app-response';
 | 
					import AppResponse from '@/response/app-response';
 | 
				
			||||||
import { SendMessageHistoriesService } from '../bids/services/send-message-histories.service';
 | 
					import { SendMessageHistoriesService } from '../bids/services/send-message-histories.service';
 | 
				
			||||||
import { SendMessageHistory } from '../bids/entities/send-message-histories.entity';
 | 
					import { SendMessageHistory } from '../bids/entities/send-message-histories.entity';
 | 
				
			||||||
 | 
					import { Event } from '../bids/utils/events';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
export class NotificationService {
 | 
					export class NotificationService {
 | 
				
			||||||
| 
						 | 
					@ -116,7 +116,7 @@ export class NotificationService {
 | 
				
			||||||
          message: notification.message,
 | 
					          message: notification.message,
 | 
				
			||||||
          type: bid.status,
 | 
					          type: bid.status,
 | 
				
			||||||
          max_price: bid.max_price,
 | 
					          max_price: bid.max_price,
 | 
				
			||||||
          reserve_price: bid.reserve_price
 | 
					          reserve_price: bid.reserve_price,
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
| 
						 | 
					@ -127,13 +127,13 @@ export class NotificationService {
 | 
				
			||||||
        message: notification.message,
 | 
					        message: notification.message,
 | 
				
			||||||
        type: bid.status,
 | 
					        type: bid.status,
 | 
				
			||||||
        max_price: bid.max_price,
 | 
					        max_price: bid.max_price,
 | 
				
			||||||
        reserve_price: bid.reserve_price
 | 
					        reserve_price: bid.reserve_price,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
      this.eventEmitter.emit(NAME_EVENTS.BID_STATUS, {
 | 
					      this.eventEmitter.emit(Event.BID_STATUS, {
 | 
				
			||||||
        bid: {
 | 
					        bid: {
 | 
				
			||||||
          ...bid,
 | 
					          ...bid,
 | 
				
			||||||
          status: 'out-bid',
 | 
					          // status: 'out-bid',
 | 
				
			||||||
        },
 | 
					        },
 | 
				
			||||||
        notification,
 | 
					        notification,
 | 
				
			||||||
      });
 | 
					      });
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,12 @@
 | 
				
			||||||
 | 
					import { Controller, Get } from '@nestjs/common';
 | 
				
			||||||
 | 
					import { ScrapConfigsService } from '../../services/scrap-config.service';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Controller('scrap-configs')
 | 
				
			||||||
 | 
					export class ClientScrapConfigsController {
 | 
				
			||||||
 | 
					  constructor(private readonly scrapConfigsService: ScrapConfigsService) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Get()
 | 
				
			||||||
 | 
					  async clientGetScrapeConfigs() {
 | 
				
			||||||
 | 
					    return await this.scrapConfigsService.clientGetScrapeConfigs();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,17 @@
 | 
				
			||||||
 | 
					import { Body, Controller, Get, Post } from '@nestjs/common';
 | 
				
			||||||
 | 
					import { ScrapConfigsService } from '../../services/scrap-config.service';
 | 
				
			||||||
 | 
					import { ScrapItemsService } from '../../services/scrap-item-config.service';
 | 
				
			||||||
 | 
					import { UpsertScrapItemDto } from '../../dto/scrap-items/upsert-scrap-item.dto';
 | 
				
			||||||
 | 
					import { ScrapItem } from '../../entities/scrap-item.entity';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					@Controller('scrap-items')
 | 
				
			||||||
 | 
					export class ClientScrapItemsController {
 | 
				
			||||||
 | 
					  constructor(private readonly scrapItemsService: ScrapItemsService) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Post('upsert')
 | 
				
			||||||
 | 
					  async upsertScrapItems(@Body() data: UpsertScrapItemDto[]) {
 | 
				
			||||||
 | 
					    return await this.scrapItemsService.upsertScrapItemsRes(
 | 
				
			||||||
 | 
					      data as ScrapItem[],
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -1,22 +1,12 @@
 | 
				
			||||||
import { MailsService } from '@/modules/mails/services/mails.service';
 | 
					import { Body, Controller, Param, Post, Put } from '@nestjs/common';
 | 
				
			||||||
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
 | 
					 | 
				
			||||||
import { Between, IsNull, Not } from 'typeorm';
 | 
					 | 
				
			||||||
import { CreateScrapConfigDto } from '../dto/scrap-config/create-scrap-config';
 | 
					import { CreateScrapConfigDto } from '../dto/scrap-config/create-scrap-config';
 | 
				
			||||||
import { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
 | 
					import { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
 | 
				
			||||||
import { ScrapConfig } from '../entities/scrap-config.entity';
 | 
					import { ScrapConfig } from '../entities/scrap-config.entity';
 | 
				
			||||||
import { ScrapConfigsService } from '../services/scrap-config.service';
 | 
					import { ScrapConfigsService } from '../services/scrap-config.service';
 | 
				
			||||||
import { ScrapItemsService } from '../services/scrap-item-config.service';
 | 
					 | 
				
			||||||
import { ConfigsService } from '@/modules/bids/services/configs.service';
 | 
					 | 
				
			||||||
import * as moment from 'moment';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Controller('admin/scrap-configs')
 | 
					@Controller('admin/scrap-configs')
 | 
				
			||||||
export class ScrapConfigsController {
 | 
					export class ScrapConfigsController {
 | 
				
			||||||
  constructor(
 | 
					  constructor(private readonly scrapConfigsService: ScrapConfigsService) {}
 | 
				
			||||||
    private readonly scrapConfigsService: ScrapConfigsService,
 | 
					 | 
				
			||||||
    private readonly scrapItemsService: ScrapItemsService,
 | 
					 | 
				
			||||||
    private readonly mailsService: MailsService,
 | 
					 | 
				
			||||||
    private readonly configsSerivce: ConfigsService,
 | 
					 | 
				
			||||||
  ) {}
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Post()
 | 
					  @Post()
 | 
				
			||||||
  async create(@Body() data: CreateScrapConfigDto) {
 | 
					  async create(@Body() data: CreateScrapConfigDto) {
 | 
				
			||||||
| 
						 | 
					@ -30,56 +20,4 @@ export class ScrapConfigsController {
 | 
				
			||||||
  ) {
 | 
					  ) {
 | 
				
			||||||
    return await this.scrapConfigsService.update(id, data);
 | 
					    return await this.scrapConfigsService.update(id, data);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					 | 
				
			||||||
  @Get()
 | 
					 | 
				
			||||||
  async test() {
 | 
					 | 
				
			||||||
    const mails = (await this.configsSerivce.getConfig('MAIL_SCRAP_REPORT'))
 | 
					 | 
				
			||||||
      .value;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    if (!mails) return;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const scrapConfigs = await this.scrapConfigsService.scrapConfigRepo.find({
 | 
					 | 
				
			||||||
      where: {
 | 
					 | 
				
			||||||
        search_url: Not(IsNull()),
 | 
					 | 
				
			||||||
        keywords: Not(IsNull()),
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      relations: {
 | 
					 | 
				
			||||||
        web_bid: true,
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
    const models = this.scrapConfigsService.scrapModels(scrapConfigs);
 | 
					 | 
				
			||||||
    await Promise.allSettled(
 | 
					 | 
				
			||||||
      models.map(async (item) => {
 | 
					 | 
				
			||||||
        await item.action();
 | 
					 | 
				
			||||||
        for (const key of Object.keys(item.results)) {
 | 
					 | 
				
			||||||
          const dataArray = item.results[key];
 | 
					 | 
				
			||||||
          const result =
 | 
					 | 
				
			||||||
            await this.scrapItemsService.upsertScrapItems(dataArray);
 | 
					 | 
				
			||||||
          console.log(result);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }),
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const startOfDay = new Date();
 | 
					 | 
				
			||||||
    startOfDay.setHours(0, 0, 0, 0);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const endOfDay = new Date();
 | 
					 | 
				
			||||||
    endOfDay.setHours(23, 59, 59, 999);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const data = await this.scrapItemsService.scrapItemRepo.find({
 | 
					 | 
				
			||||||
      where: {
 | 
					 | 
				
			||||||
        updated_at: Between(startOfDay, endOfDay),
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      relations: { scrap_config: { web_bid: true } },
 | 
					 | 
				
			||||||
      order: { updated_at: 'DESC' },
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    await this.mailsService.sendPlainHtml(
 | 
					 | 
				
			||||||
      mails,
 | 
					 | 
				
			||||||
      `Auction Items Matching Your Keywords – Daily Update ${moment(new Date()).format('YYYY-MM-DD HH:mm')}`,
 | 
					 | 
				
			||||||
      this.mailsService.generateProductTableHTML(data),
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return { a: 'abc' };
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,9 +1,19 @@
 | 
				
			||||||
import { IsNumber, IsOptional, IsString, IsUrl } from 'class-validator';
 | 
					import {
 | 
				
			||||||
 | 
					  IsBoolean,
 | 
				
			||||||
 | 
					  IsNumber,
 | 
				
			||||||
 | 
					  IsOptional,
 | 
				
			||||||
 | 
					  IsString,
 | 
				
			||||||
 | 
					  IsUrl,
 | 
				
			||||||
 | 
					} from 'class-validator';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export class CreateScrapConfigDto {
 | 
					export class CreateScrapConfigDto {
 | 
				
			||||||
  @IsUrl()
 | 
					  @IsUrl()
 | 
				
			||||||
  search_url: string;
 | 
					  search_url: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @IsBoolean()
 | 
				
			||||||
 | 
					  @IsOptional()
 | 
				
			||||||
 | 
					  enable: boolean;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @IsString()
 | 
					  @IsString()
 | 
				
			||||||
  @IsOptional()
 | 
					  @IsOptional()
 | 
				
			||||||
  keywords: string;
 | 
					  keywords: string;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,18 @@
 | 
				
			||||||
 | 
					import { IsNumber, IsString, IsUrl } from 'class-validator';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class UpsertScrapItemDto {
 | 
				
			||||||
 | 
					  @IsUrl()
 | 
				
			||||||
 | 
					  url: string;
 | 
				
			||||||
 | 
					  @IsString()
 | 
				
			||||||
 | 
					  image_url: string;
 | 
				
			||||||
 | 
					  @IsString()
 | 
				
			||||||
 | 
					  name: string;
 | 
				
			||||||
 | 
					  @IsString()
 | 
				
			||||||
 | 
					  keyword: string;
 | 
				
			||||||
 | 
					  @IsNumber()
 | 
				
			||||||
 | 
					  current_price: number;
 | 
				
			||||||
 | 
					  @IsNumber()
 | 
				
			||||||
 | 
					  scrap_config_id: number;
 | 
				
			||||||
 | 
					  @IsString()
 | 
				
			||||||
 | 
					  model: string;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -22,6 +22,9 @@ export class ScrapConfig extends Timestamp {
 | 
				
			||||||
  @Column({ default: 'cisco' })
 | 
					  @Column({ default: 'cisco' })
 | 
				
			||||||
  keywords: string;
 | 
					  keywords: string;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Column({ default: true })
 | 
				
			||||||
 | 
					  enable: boolean;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @OneToOne(() => WebBid, (web) => web.scrap_config, { onDelete: 'CASCADE' })
 | 
					  @OneToOne(() => WebBid, (web) => web.scrap_config, { onDelete: 'CASCADE' })
 | 
				
			||||||
  @JoinColumn()
 | 
					  @JoinColumn()
 | 
				
			||||||
  web_bid: WebBid;
 | 
					  web_bid: WebBid;
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,10 +0,0 @@
 | 
				
			||||||
import { ScrapItem } from '../entities/scrap-item.entity';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export interface ScrapInterface {
 | 
					 | 
				
			||||||
  getItemsInHtml: (data: {
 | 
					 | 
				
			||||||
    html: string;
 | 
					 | 
				
			||||||
    keyword: string;
 | 
					 | 
				
			||||||
  }) => Promise<ScrapItem[]>;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  action: () => Promise<void>;
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,61 +0,0 @@
 | 
				
			||||||
import { ScrapConfig } from '../entities/scrap-config.entity';
 | 
					 | 
				
			||||||
import { ScrapItem } from '../entities/scrap-item.entity';
 | 
					 | 
				
			||||||
import { ScrapInterface } from './scrap-interface';
 | 
					 | 
				
			||||||
import { Element } from 'domhandler';
 | 
					 | 
				
			||||||
export class ScrapModel implements ScrapInterface {
 | 
					 | 
				
			||||||
  protected keywords: string;
 | 
					 | 
				
			||||||
  protected search_url: string;
 | 
					 | 
				
			||||||
  protected scrap_config_id: ScrapConfig['id'];
 | 
					 | 
				
			||||||
  public results: Record<string, ScrapItem[]> = {};
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  constructor({
 | 
					 | 
				
			||||||
    keywords,
 | 
					 | 
				
			||||||
    search_url,
 | 
					 | 
				
			||||||
    scrap_config_id,
 | 
					 | 
				
			||||||
  }: {
 | 
					 | 
				
			||||||
    keywords: string;
 | 
					 | 
				
			||||||
    search_url: string;
 | 
					 | 
				
			||||||
    scrap_config_id: ScrapConfig['id'];
 | 
					 | 
				
			||||||
  }) {
 | 
					 | 
				
			||||||
    this.keywords = keywords;
 | 
					 | 
				
			||||||
    this.search_url = search_url;
 | 
					 | 
				
			||||||
    this.scrap_config_id = scrap_config_id;
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  protected buildUrlWithKey(rawUrl: string, keyword: string) {
 | 
					 | 
				
			||||||
    return rawUrl.replaceAll('{{keyword}}', keyword);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  protected extractUrls(): { url: string; keyword: string }[] {
 | 
					 | 
				
			||||||
    // Tách từng key ra từ một chuỗi keywords
 | 
					 | 
				
			||||||
    const keywordList = this.keywords.split(', ');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Dừng hàm nếu không có key nào
 | 
					 | 
				
			||||||
    if (keywordList.length <= 0) return [];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    // Lập qua từng key để lấy url
 | 
					 | 
				
			||||||
    return keywordList.map((keyword) => {
 | 
					 | 
				
			||||||
      return {
 | 
					 | 
				
			||||||
        url: this.buildUrlWithKey(this.search_url, keyword),
 | 
					 | 
				
			||||||
        keyword,
 | 
					 | 
				
			||||||
      };
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  action: () => Promise<void>;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getInfoItems: (
 | 
					 | 
				
			||||||
    data: { name: string; el: Element }[],
 | 
					 | 
				
			||||||
  ) => Record<string, string>[];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getItemsInHtml: (data: {
 | 
					 | 
				
			||||||
    html: string;
 | 
					 | 
				
			||||||
    keyword: string;
 | 
					 | 
				
			||||||
  }) => Promise<ScrapItem[]>;
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  protected filterItemByKeyword = (keyword: string, data: ScrapItem[]) => {
 | 
					 | 
				
			||||||
    return data.filter((item) =>
 | 
					 | 
				
			||||||
      item.name.toLowerCase().includes(keyword.toLowerCase()),
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,83 +0,0 @@
 | 
				
			||||||
import axios from 'axios';
 | 
					 | 
				
			||||||
import { ScrapInterface } from '../scrap-interface';
 | 
					 | 
				
			||||||
import { ScrapModel } from '../scrap-model';
 | 
					 | 
				
			||||||
import * as cheerio from 'cheerio';
 | 
					 | 
				
			||||||
import { Element } from 'domhandler';
 | 
					 | 
				
			||||||
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
 | 
					 | 
				
			||||||
import { extractModelId, extractNumber } from '@/ultils';
 | 
					 | 
				
			||||||
export class GraysScrapModel extends ScrapModel {
 | 
					 | 
				
			||||||
  action = async () => {
 | 
					 | 
				
			||||||
    const urls = this.extractUrls();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const results = await Promise.allSettled(
 | 
					 | 
				
			||||||
      urls.map(async (item) => ({
 | 
					 | 
				
			||||||
        html: (await axios.get(item.url)).data,
 | 
					 | 
				
			||||||
        keyword: item.keyword,
 | 
					 | 
				
			||||||
      })),
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const htmlsData = results
 | 
					 | 
				
			||||||
      .filter((res) => res.status === 'fulfilled')
 | 
					 | 
				
			||||||
      .map((res) => (res as PromiseFulfilledResult<any>).value);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    await Promise.all(
 | 
					 | 
				
			||||||
      htmlsData.map(async (cur) => {
 | 
					 | 
				
			||||||
        try {
 | 
					 | 
				
			||||||
          const data = await this.getItemsInHtml(cur);
 | 
					 | 
				
			||||||
          const results = this.filterItemByKeyword(cur.keyword, data);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          this.results[cur.keyword] = results; // hoặc push như gợi ý trên
 | 
					 | 
				
			||||||
          return results;
 | 
					 | 
				
			||||||
        } catch (err) {
 | 
					 | 
				
			||||||
          console.error(`❌ Error with keyword ${cur.keyword}:`, err);
 | 
					 | 
				
			||||||
          return []; // fallback
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }),
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getPriceByEl = ($: cheerio.CheerioAPI, el: Element): number | null => {
 | 
					 | 
				
			||||||
    const selectors = [
 | 
					 | 
				
			||||||
      '.sc-ijDOKB.sc-bStcSt.ikmQUw.eEycyP', // Single product price
 | 
					 | 
				
			||||||
      '.sc-ijDOKB.ikmQUw', // Multiple product price
 | 
					 | 
				
			||||||
    ];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    for (const selector of selectors) {
 | 
					 | 
				
			||||||
      const text = $(el).find(selector).text();
 | 
					 | 
				
			||||||
      const price = extractNumber(text);
 | 
					 | 
				
			||||||
      if (price) return price;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return null;
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getItemsInHtml = async ({
 | 
					 | 
				
			||||||
    html,
 | 
					 | 
				
			||||||
    keyword,
 | 
					 | 
				
			||||||
  }: {
 | 
					 | 
				
			||||||
    html: string;
 | 
					 | 
				
			||||||
    keyword: string;
 | 
					 | 
				
			||||||
  }) => {
 | 
					 | 
				
			||||||
    const $ = cheerio.load(html);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const container = $('.sc-102aeaf3-1.eYPitT');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const items = container.children('div').toArray();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const results = items.map((el) => {
 | 
					 | 
				
			||||||
      const url = $(el).find('.sc-pKqro.sc-gFnajm.gqkMpZ.dzWUkJ').attr('href');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return {
 | 
					 | 
				
			||||||
        name: $(el).find('.sc-jlGgGc.dJRywx').text().trim(),
 | 
					 | 
				
			||||||
        image_url: $(el).find('img.sc-gtJxfw.jbgdlx').attr('src'),
 | 
					 | 
				
			||||||
        model: extractModelId(url),
 | 
					 | 
				
			||||||
        keyword,
 | 
					 | 
				
			||||||
        url,
 | 
					 | 
				
			||||||
        current_price: this.getPriceByEl($, el),
 | 
					 | 
				
			||||||
        scrap_config_id: this.scrap_config_id,
 | 
					 | 
				
			||||||
      } as ScrapItem;
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return results;
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -1,108 +0,0 @@
 | 
				
			||||||
import { ScrapItem } from '@/modules/scraps/entities/scrap-item.entity';
 | 
					 | 
				
			||||||
import { extractModelId, extractNumber } from '@/ultils';
 | 
					 | 
				
			||||||
import axios from 'axios';
 | 
					 | 
				
			||||||
import * as cheerio from 'cheerio';
 | 
					 | 
				
			||||||
import { Element } from 'domhandler';
 | 
					 | 
				
			||||||
import { ScrapModel } from '../scrap-model';
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
export class LangtonsScrapModel extends ScrapModel {
 | 
					 | 
				
			||||||
  action = async () => {
 | 
					 | 
				
			||||||
    const urls = this.extractUrls();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    console.log({ urls });
 | 
					 | 
				
			||||||
    const results = await Promise.allSettled(
 | 
					 | 
				
			||||||
      urls.map(async (item) => ({
 | 
					 | 
				
			||||||
        html: (
 | 
					 | 
				
			||||||
          await axios.get(item.url, {
 | 
					 | 
				
			||||||
            headers: {
 | 
					 | 
				
			||||||
              'User-Agent':
 | 
					 | 
				
			||||||
                'Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/119.0.0.0 Safari/537.36',
 | 
					 | 
				
			||||||
              Accept: 'text/html',
 | 
					 | 
				
			||||||
            },
 | 
					 | 
				
			||||||
            maxRedirects: 5, // default là 5, bạn có thể tăng lên
 | 
					 | 
				
			||||||
          })
 | 
					 | 
				
			||||||
        ).data,
 | 
					 | 
				
			||||||
        keyword: item.keyword,
 | 
					 | 
				
			||||||
      })),
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    console.log(
 | 
					 | 
				
			||||||
      results.map((r) => ({
 | 
					 | 
				
			||||||
        status: r.status,
 | 
					 | 
				
			||||||
        reason:
 | 
					 | 
				
			||||||
          r.status === 'rejected'
 | 
					 | 
				
			||||||
            ? (r as PromiseRejectedResult).reason.message
 | 
					 | 
				
			||||||
            : undefined,
 | 
					 | 
				
			||||||
      })),
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const htmlsData = results
 | 
					 | 
				
			||||||
      .filter((res) => res.status === 'fulfilled')
 | 
					 | 
				
			||||||
      .map((res) => (res as PromiseFulfilledResult<any>).value);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    console.log({ htmlsData });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    await Promise.all(
 | 
					 | 
				
			||||||
      htmlsData.map(async (cur) => {
 | 
					 | 
				
			||||||
        try {
 | 
					 | 
				
			||||||
          const data = await this.getItemsInHtml(cur);
 | 
					 | 
				
			||||||
          const results = this.filterItemByKeyword(cur.keyword, data);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
          this.results[cur.keyword] = results; // hoặc push như gợi ý trên
 | 
					 | 
				
			||||||
          return results;
 | 
					 | 
				
			||||||
        } catch (err) {
 | 
					 | 
				
			||||||
          console.error(`❌ Error with keyword ${cur.keyword}:`, err);
 | 
					 | 
				
			||||||
          return []; // fallback
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }),
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getPriceByEl = ($: cheerio.CheerioAPI, el: Element): number | null => {
 | 
					 | 
				
			||||||
    const selectors = [
 | 
					 | 
				
			||||||
      '.sc-ijDOKB.sc-bStcSt.ikmQUw.eEycyP', // Single product price
 | 
					 | 
				
			||||||
      '.sc-ijDOKB.ikmQUw', // Multiple product price
 | 
					 | 
				
			||||||
    ];
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    for (const selector of selectors) {
 | 
					 | 
				
			||||||
      const text = $(el).find(selector).text();
 | 
					 | 
				
			||||||
      const price = extractNumber(text);
 | 
					 | 
				
			||||||
      if (price) return price;
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    return null;
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  getItemsInHtml = async ({
 | 
					 | 
				
			||||||
    html,
 | 
					 | 
				
			||||||
    keyword,
 | 
					 | 
				
			||||||
  }: {
 | 
					 | 
				
			||||||
    html: string;
 | 
					 | 
				
			||||||
    keyword: string;
 | 
					 | 
				
			||||||
  }) => {
 | 
					 | 
				
			||||||
    const $ = cheerio.load(html);
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const container = $('.row.product-grid.grid-view');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const items = container.children('div').toArray();
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    const results = items.map((el) => {
 | 
					 | 
				
			||||||
      const url = $(el).find('a.js-pdp-link.pdp-link-anchor').attr('href');
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
      return {
 | 
					 | 
				
			||||||
        name: $(el).find('.link.js-pdp-link').text().trim(),
 | 
					 | 
				
			||||||
        image_url: $(el).find('img.tile-image.loaded').attr('src'),
 | 
					 | 
				
			||||||
        model: extractModelId(url),
 | 
					 | 
				
			||||||
        keyword,
 | 
					 | 
				
			||||||
        url,
 | 
					 | 
				
			||||||
        current_price: extractNumber(
 | 
					 | 
				
			||||||
          $(el).find('div.max-bid-price.price').text(),
 | 
					 | 
				
			||||||
        ),
 | 
					 | 
				
			||||||
        scrap_config_id: this.scrap_config_id,
 | 
					 | 
				
			||||||
      } as ScrapItem;
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
    console.log({ results });
 | 
					 | 
				
			||||||
    return results;
 | 
					 | 
				
			||||||
  };
 | 
					 | 
				
			||||||
}
 | 
					 | 
				
			||||||
| 
						 | 
					@ -8,6 +8,8 @@ import { TasksService } from './services/tasks.service';
 | 
				
			||||||
import { ScrapItemsService } from './services/scrap-item-config.service';
 | 
					import { ScrapItemsService } from './services/scrap-item-config.service';
 | 
				
			||||||
import { MailsModule } from '../mails/mails.module';
 | 
					import { MailsModule } from '../mails/mails.module';
 | 
				
			||||||
import { BidsModule } from '../bids/bids.module';
 | 
					import { BidsModule } from '../bids/bids.module';
 | 
				
			||||||
 | 
					import { ClientScrapConfigsController } from './controllers/client/scrap-configs.controller';
 | 
				
			||||||
 | 
					import { ClientScrapItemsController } from './controllers/client/scrap-items.controller';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Module({
 | 
					@Module({
 | 
				
			||||||
  imports: [
 | 
					  imports: [
 | 
				
			||||||
| 
						 | 
					@ -17,6 +19,10 @@ import { BidsModule } from '../bids/bids.module';
 | 
				
			||||||
  ],
 | 
					  ],
 | 
				
			||||||
  providers: [ScrapConfigsService, TasksService, ScrapItemsService],
 | 
					  providers: [ScrapConfigsService, TasksService, ScrapItemsService],
 | 
				
			||||||
  exports: [ScrapConfigsService, TasksService, ScrapItemsService],
 | 
					  exports: [ScrapConfigsService, TasksService, ScrapItemsService],
 | 
				
			||||||
  controllers: [ScrapConfigsController],
 | 
					  controllers: [
 | 
				
			||||||
 | 
					    ScrapConfigsController,
 | 
				
			||||||
 | 
					    ClientScrapConfigsController,
 | 
				
			||||||
 | 
					    ClientScrapItemsController,
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
})
 | 
					})
 | 
				
			||||||
export class ScrapsModule {}
 | 
					export class ScrapsModule {}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,15 +1,11 @@
 | 
				
			||||||
import { Injectable } from '@nestjs/common';
 | 
					 | 
				
			||||||
import { EventEmitter2 } from '@nestjs/event-emitter';
 | 
					 | 
				
			||||||
import { InjectRepository } from '@nestjs/typeorm';
 | 
					 | 
				
			||||||
import { Repository } from 'typeorm';
 | 
					 | 
				
			||||||
import { ScrapConfig } from '../entities/scrap-config.entity';
 | 
					 | 
				
			||||||
import AppResponse from '@/response/app-response';
 | 
					import AppResponse from '@/response/app-response';
 | 
				
			||||||
 | 
					import { Injectable } from '@nestjs/common';
 | 
				
			||||||
 | 
					import { InjectRepository } from '@nestjs/typeorm';
 | 
				
			||||||
 | 
					import { plainToClass } from 'class-transformer';
 | 
				
			||||||
 | 
					import { IsNull, Not, Repository } from 'typeorm';
 | 
				
			||||||
import { CreateScrapConfigDto } from '../dto/scrap-config/create-scrap-config';
 | 
					import { CreateScrapConfigDto } from '../dto/scrap-config/create-scrap-config';
 | 
				
			||||||
import { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
 | 
					import { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
 | 
				
			||||||
import axios from 'axios';
 | 
					import { ScrapConfig } from '../entities/scrap-config.entity';
 | 
				
			||||||
import { WebBid } from '@/modules/bids/entities/wed-bid.entity';
 | 
					 | 
				
			||||||
import { GraysScrapModel } from '../models/www.grays.com/grays-scrap-model';
 | 
					 | 
				
			||||||
import { LangtonsScrapModel } from '../models/www.langtons.com.au/langtons-scrap-model';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
export class ScrapConfigsService {
 | 
					export class ScrapConfigsService {
 | 
				
			||||||
| 
						 | 
					@ -18,6 +14,21 @@ export class ScrapConfigsService {
 | 
				
			||||||
    readonly scrapConfigRepo: Repository<ScrapConfig>,
 | 
					    readonly scrapConfigRepo: Repository<ScrapConfig>,
 | 
				
			||||||
  ) {}
 | 
					  ) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async clientGetScrapeConfigs() {
 | 
				
			||||||
 | 
					    const data = await this.scrapConfigRepo.find({
 | 
				
			||||||
 | 
					      where: {
 | 
				
			||||||
 | 
					        search_url: Not(IsNull()),
 | 
				
			||||||
 | 
					        keywords: Not(IsNull()),
 | 
				
			||||||
 | 
					        enable: true,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					      relations: {
 | 
				
			||||||
 | 
					        web_bid: true,
 | 
				
			||||||
 | 
					      },
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return AppResponse.toResponse(plainToClass(ScrapConfig, data));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  async create(data: CreateScrapConfigDto) {
 | 
					  async create(data: CreateScrapConfigDto) {
 | 
				
			||||||
    const result = await this.scrapConfigRepo.save({
 | 
					    const result = await this.scrapConfigRepo.save({
 | 
				
			||||||
      search_url: data.search_url,
 | 
					      search_url: data.search_url,
 | 
				
			||||||
| 
						 | 
					@ -40,22 +51,4 @@ export class ScrapConfigsService {
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    return AppResponse.toResponse(true);
 | 
					    return AppResponse.toResponse(true);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					 | 
				
			||||||
  scrapModel(scrapConfig: ScrapConfig) {
 | 
					 | 
				
			||||||
    switch (scrapConfig.web_bid.origin_url) {
 | 
					 | 
				
			||||||
      case 'https://www.grays.com': {
 | 
					 | 
				
			||||||
        return new GraysScrapModel({
 | 
					 | 
				
			||||||
          ...scrapConfig,
 | 
					 | 
				
			||||||
          scrap_config_id: scrapConfig.id,
 | 
					 | 
				
			||||||
        });
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
      default: {
 | 
					 | 
				
			||||||
        return null;
 | 
					 | 
				
			||||||
      }
 | 
					 | 
				
			||||||
    }
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
 | 
					 | 
				
			||||||
  scrapModels(data: ScrapConfig[]) {
 | 
					 | 
				
			||||||
    return data.map((item) => this.scrapModel(item)).filter((item) => !!item);
 | 
					 | 
				
			||||||
  }
 | 
					 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,7 +1,8 @@
 | 
				
			||||||
import { Injectable } from '@nestjs/common';
 | 
					import { BadRequestException, Injectable } from '@nestjs/common';
 | 
				
			||||||
import { InjectRepository } from '@nestjs/typeorm';
 | 
					import { InjectRepository } from '@nestjs/typeorm';
 | 
				
			||||||
import { Repository } from 'typeorm';
 | 
					import { Repository } from 'typeorm';
 | 
				
			||||||
import { ScrapItem } from '../entities/scrap-item.entity';
 | 
					import { ScrapItem } from '../entities/scrap-item.entity';
 | 
				
			||||||
 | 
					import AppResponse from '@/response/app-response';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
export class ScrapItemsService {
 | 
					export class ScrapItemsService {
 | 
				
			||||||
| 
						 | 
					@ -58,4 +59,12 @@ export class ScrapItemsService {
 | 
				
			||||||
      updated: toUpdate.length,
 | 
					      updated: toUpdate.length,
 | 
				
			||||||
    };
 | 
					    };
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async upsertScrapItemsRes(items: ScrapItem[]) {
 | 
				
			||||||
 | 
					    const rs = await this.upsertScrapItems(items);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!rs) throw new BadRequestException(AppResponse.toResponse(null));
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return AppResponse.toResponse(rs);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,72 +1,95 @@
 | 
				
			||||||
 | 
					import { ConfigsService } from '@/modules/bids/services/configs.service';
 | 
				
			||||||
 | 
					import { DashboardService } from '@/modules/bids/services/dashboard.service';
 | 
				
			||||||
 | 
					import { MailsService } from '@/modules/mails/services/mails.service';
 | 
				
			||||||
 | 
					import { delay } from '@/ultils';
 | 
				
			||||||
import { Injectable, Logger } from '@nestjs/common';
 | 
					import { Injectable, Logger } from '@nestjs/common';
 | 
				
			||||||
import { Cron, CronExpression } from '@nestjs/schedule';
 | 
					import { Cron } from '@nestjs/schedule';
 | 
				
			||||||
import { Between, IsNull, Not } from 'typeorm';
 | 
					import * as moment from 'moment';
 | 
				
			||||||
 | 
					import { Between } from 'typeorm';
 | 
				
			||||||
import { ScrapConfigsService } from './scrap-config.service';
 | 
					import { ScrapConfigsService } from './scrap-config.service';
 | 
				
			||||||
import { ScrapItemsService } from './scrap-item-config.service';
 | 
					import { ScrapItemsService } from './scrap-item-config.service';
 | 
				
			||||||
import { MailsService } from '@/modules/mails/services/mails.service';
 | 
					 | 
				
			||||||
import { ConfigsService } from '@/modules/bids/services/configs.service';
 | 
					 | 
				
			||||||
import * as moment from 'moment';
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
@Injectable()
 | 
					@Injectable()
 | 
				
			||||||
export class TasksService {
 | 
					export class TasksService {
 | 
				
			||||||
  private readonly logger = new Logger(TasksService.name);
 | 
					  private readonly logger = new Logger(TasksService.name);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  constructor(
 | 
					  constructor(
 | 
				
			||||||
    private readonly scrapConfigsService: ScrapConfigsService,
 | 
					 | 
				
			||||||
    private readonly scrapItemsService: ScrapItemsService,
 | 
					    private readonly scrapItemsService: ScrapItemsService,
 | 
				
			||||||
    private readonly mailsService: MailsService,
 | 
					    private readonly mailsService: MailsService,
 | 
				
			||||||
    private readonly configsSerivce: ConfigsService,
 | 
					    private readonly configsSerivce: ConfigsService,
 | 
				
			||||||
 | 
					    private readonly dashboardService: DashboardService,
 | 
				
			||||||
  ) {}
 | 
					  ) {}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
  @Cron('0 2 * * *')
 | 
					  async runProcessAndSendReport(processName: string) {
 | 
				
			||||||
  async handleScraps() {
 | 
					 | 
				
			||||||
    const mails = (await this.configsSerivce.getConfig('MAIL_SCRAP_REPORT'))
 | 
					    const mails = (await this.configsSerivce.getConfig('MAIL_SCRAP_REPORT'))
 | 
				
			||||||
      .value;
 | 
					      ?.value;
 | 
				
			||||||
 | 
					    if (!mails) {
 | 
				
			||||||
 | 
					      console.warn('No mails configured for report. Skipping.');
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    if (!mails) return;
 | 
					    // Nếu process đang chạy, không chạy lại
 | 
				
			||||||
 | 
					    const initialStatus =
 | 
				
			||||||
 | 
					      await this.dashboardService.getStatusProcessByName(processName);
 | 
				
			||||||
 | 
					    if (initialStatus === 'online') {
 | 
				
			||||||
 | 
					      console.log(
 | 
				
			||||||
 | 
					        `Process ${processName} is already running. Skipping execution.`,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const scrapConfigs = await this.scrapConfigsService.scrapConfigRepo.find({
 | 
					    // Reset và chạy process
 | 
				
			||||||
      where: {
 | 
					    await this.dashboardService.resetProcessByName(processName);
 | 
				
			||||||
        search_url: Not(IsNull()),
 | 
					    console.log(`Process ${processName} started.`);
 | 
				
			||||||
        keywords: Not(IsNull()),
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
      relations: {
 | 
					 | 
				
			||||||
        web_bid: true,
 | 
					 | 
				
			||||||
      },
 | 
					 | 
				
			||||||
    });
 | 
					 | 
				
			||||||
    const models = this.scrapConfigsService.scrapModels(scrapConfigs);
 | 
					 | 
				
			||||||
    await Promise.allSettled(
 | 
					 | 
				
			||||||
      models.map(async (item) => {
 | 
					 | 
				
			||||||
        await item.action();
 | 
					 | 
				
			||||||
        for (const key of Object.keys(item.results)) {
 | 
					 | 
				
			||||||
          const dataArray = item.results[key];
 | 
					 | 
				
			||||||
          const result =
 | 
					 | 
				
			||||||
            await this.scrapItemsService.upsertScrapItems(dataArray);
 | 
					 | 
				
			||||||
          console.log(result);
 | 
					 | 
				
			||||||
        }
 | 
					 | 
				
			||||||
      }),
 | 
					 | 
				
			||||||
    );
 | 
					 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Đợi process kết thúc, có timeout
 | 
				
			||||||
 | 
					    const maxAttempts = 60; // 10 phút
 | 
				
			||||||
 | 
					    let attempts = 0;
 | 
				
			||||||
 | 
					    let status = 'online';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    while (status === 'online' && attempts < maxAttempts) {
 | 
				
			||||||
 | 
					      await delay(10000); // 10 giây
 | 
				
			||||||
 | 
					      status = await this.dashboardService.getStatusProcessByName(processName);
 | 
				
			||||||
 | 
					      attempts++;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (status === 'online') {
 | 
				
			||||||
 | 
					      console.warn(
 | 
				
			||||||
 | 
					        `Process ${processName} still running after timeout. Skipping report.`,
 | 
				
			||||||
 | 
					      );
 | 
				
			||||||
 | 
					      return;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    // Khi process kết thúc => gửi mail
 | 
				
			||||||
    const startOfDay = new Date();
 | 
					    const startOfDay = new Date();
 | 
				
			||||||
    startOfDay.setHours(0, 0, 0, 0);
 | 
					    startOfDay.setHours(0, 0, 0, 0);
 | 
				
			||||||
 | 
					 | 
				
			||||||
    const endOfDay = new Date();
 | 
					    const endOfDay = new Date();
 | 
				
			||||||
    endOfDay.setHours(23, 59, 59, 999);
 | 
					    endOfDay.setHours(23, 59, 59, 999);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    const data = await this.scrapItemsService.scrapItemRepo.find({
 | 
					    try {
 | 
				
			||||||
      where: {
 | 
					      const data = await this.scrapItemsService.scrapItemRepo.find({
 | 
				
			||||||
        updated_at: Between(startOfDay, endOfDay),
 | 
					        where: {
 | 
				
			||||||
      },
 | 
					          updated_at: Between(startOfDay, endOfDay),
 | 
				
			||||||
      relations: { scrap_config: { web_bid: true } },
 | 
					        },
 | 
				
			||||||
      order: { updated_at: 'DESC' },
 | 
					        relations: { scrap_config: { web_bid: true } },
 | 
				
			||||||
    });
 | 
					        order: { updated_at: 'DESC' },
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    await this.mailsService.sendPlainHtml(
 | 
					      await this.mailsService.sendHtmlMailJob({
 | 
				
			||||||
      mails,
 | 
					        to: mails,
 | 
				
			||||||
      `Auction Items Matching Your Keywords – Daily Update ${moment(new Date()).format('YYYY-MM-DD HH:mm')}`,
 | 
					        subject: `Auction Items Matching Your Keywords – Daily Update ${moment().format('YYYY-MM-DD HH:mm')}`,
 | 
				
			||||||
      this.mailsService.generateProductTableHTML(data),
 | 
					        html: this.mailsService.generateProductTableHTML(data),
 | 
				
			||||||
    );
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
    console.log('Send report success');
 | 
					      console.log('Report mail sent successfully.');
 | 
				
			||||||
 | 
					    } catch (err) {
 | 
				
			||||||
 | 
					      console.error('Failed to generate or send report:', err);
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  @Cron('0 2 * * *')
 | 
				
			||||||
 | 
					  async handleScraps() {
 | 
				
			||||||
 | 
					    const processName = 'scrape-data-keyword';
 | 
				
			||||||
 | 
					    await this.runProcessAndSendReport(processName);
 | 
				
			||||||
  }
 | 
					  }
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -1,4 +1,5 @@
 | 
				
			||||||
import { Bid } from '@/modules/bids/entities/bid.entity';
 | 
					import { Bid } from '@/modules/bids/entities/bid.entity';
 | 
				
			||||||
 | 
					import * as moment from 'moment';
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function extractModelId(url: string): string | null {
 | 
					export function extractModelId(url: string): string | null {
 | 
				
			||||||
  switch (extractDomain(url)) {
 | 
					  switch (extractDomain(url)) {
 | 
				
			||||||
| 
						 | 
					@ -31,6 +32,25 @@ export function subtractMinutes(timeStr: string, minutes: number) {
 | 
				
			||||||
  return date.toISOString(); // Trả về dạng chuẩn ISO
 | 
					  return date.toISOString(); // Trả về dạng chuẩn ISO
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function extractDomainSmart(url: string) {
 | 
				
			||||||
 | 
					  const PUBLIC_SUFFIXES = ['com.au', 'co.uk', 'com.vn', 'org.au', 'gov.uk'];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const hostname = new URL(url).hostname.replace(/^www\./, ''); // remove "www."
 | 
				
			||||||
 | 
					    const parts = hostname.split('.');
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (let i = 0; i < PUBLIC_SUFFIXES.length; i++) {
 | 
				
			||||||
 | 
					      if (hostname.endsWith(PUBLIC_SUFFIXES[i])) {
 | 
				
			||||||
 | 
					        return parts[parts.length - PUBLIC_SUFFIXES[i].split('.').length - 1];
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return parts[parts.length - 2];
 | 
				
			||||||
 | 
					  } catch (e) {
 | 
				
			||||||
 | 
					    return url;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
export function subtractSeconds(time: string, seconds: number) {
 | 
					export function subtractSeconds(time: string, seconds: number) {
 | 
				
			||||||
  const date = new Date(time);
 | 
					  const date = new Date(time);
 | 
				
			||||||
  date.setSeconds(date.getSeconds() - seconds);
 | 
					  date.setSeconds(date.getSeconds() - seconds);
 | 
				
			||||||
| 
						 | 
					@ -185,3 +205,13 @@ export function extractNumber(str: string) {
 | 
				
			||||||
  const match = str.match(/\d+(\.\d+)?/);
 | 
					  const match = str.match(/\d+(\.\d+)?/);
 | 
				
			||||||
  return match ? parseFloat(match[0]) : null;
 | 
					  return match ? parseFloat(match[0]) : null;
 | 
				
			||||||
}
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function formatEndTime(
 | 
				
			||||||
 | 
					  closeTime: string | Date,
 | 
				
			||||||
 | 
					  extended: boolean,
 | 
				
			||||||
 | 
					): string {
 | 
				
			||||||
 | 
					  return `${moment(closeTime).format('YYYY-MM-DD HH:mm')} (${extended ? 'extended' : 'no extension'})`;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const delay = (ms: number) =>
 | 
				
			||||||
 | 
					  new Promise((resolve) => setTimeout(resolve, ms));
 | 
				
			||||||
| 
						 | 
					
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,57 @@
 | 
				
			||||||
 | 
					# compiled output
 | 
				
			||||||
 | 
					/dist
 | 
				
			||||||
 | 
					/node_modules
 | 
				
			||||||
 | 
					/build
 | 
				
			||||||
 | 
					/public
 | 
				
			||||||
 | 
					/system/error-images
 | 
				
			||||||
 | 
					/system/profiles
 | 
				
			||||||
 | 
					/system/local-data
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Logs
 | 
				
			||||||
 | 
					logs
 | 
				
			||||||
 | 
					*.log
 | 
				
			||||||
 | 
					npm-debug.log*
 | 
				
			||||||
 | 
					pnpm-debug.log*
 | 
				
			||||||
 | 
					yarn-debug.log*
 | 
				
			||||||
 | 
					yarn-error.log*
 | 
				
			||||||
 | 
					lerna-debug.log*
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# OS
 | 
				
			||||||
 | 
					.DS_Store
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Tests
 | 
				
			||||||
 | 
					/coverage
 | 
				
			||||||
 | 
					/.nyc_output
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# IDEs and editors
 | 
				
			||||||
 | 
					/.idea
 | 
				
			||||||
 | 
					.project
 | 
				
			||||||
 | 
					.classpath
 | 
				
			||||||
 | 
					.c9/
 | 
				
			||||||
 | 
					*.launch
 | 
				
			||||||
 | 
					.settings/
 | 
				
			||||||
 | 
					*.sublime-workspace
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# IDE - VSCode
 | 
				
			||||||
 | 
					.vscode/*
 | 
				
			||||||
 | 
					!.vscode/settings.json
 | 
				
			||||||
 | 
					!.vscode/tasks.json
 | 
				
			||||||
 | 
					!.vscode/launch.json
 | 
				
			||||||
 | 
					!.vscode/extensions.json
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# dotenv environment variable files
 | 
				
			||||||
 | 
					.env
 | 
				
			||||||
 | 
					.env.local
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# temp directory
 | 
				
			||||||
 | 
					.temp
 | 
				
			||||||
 | 
					.tmp
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Runtime data
 | 
				
			||||||
 | 
					pids
 | 
				
			||||||
 | 
					*.pid
 | 
				
			||||||
 | 
					*.seed
 | 
				
			||||||
 | 
					*.pid.lock
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					# Diagnostic reports (https://nodejs.org/api/report.html)
 | 
				
			||||||
 | 
					report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,38 @@
 | 
				
			||||||
 | 
					import axios from "../system/axios.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const getScapeConfigs = async () => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const { data } = await axios({
 | 
				
			||||||
 | 
					      method: "GET",
 | 
				
			||||||
 | 
					      url: "scrap-configs",
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!data || !data?.data) {
 | 
				
			||||||
 | 
					      return [];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return data.data;
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.log("❌ ERROR IN SERVER (getScapeConfigs): ", error);
 | 
				
			||||||
 | 
					    return [];
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export const upsertScapeItems = async (values) => {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const { data } = await axios({
 | 
				
			||||||
 | 
					      method: "POST",
 | 
				
			||||||
 | 
					      url: "scrap-items/upsert",
 | 
				
			||||||
 | 
					      data: values,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    if (!data || !data?.data) {
 | 
				
			||||||
 | 
					      return [];
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return data.data;
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    console.log("❌ ERROR IN SERVER (upsertScapeItems): ", error);
 | 
				
			||||||
 | 
					    return [];
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,17 @@
 | 
				
			||||||
 | 
					module.exports = {
 | 
				
			||||||
 | 
					  apps: [
 | 
				
			||||||
 | 
					    {
 | 
				
			||||||
 | 
					      name: "scrape-data-keyword",
 | 
				
			||||||
 | 
					      script: "./index.js",
 | 
				
			||||||
 | 
					      instances: 1,
 | 
				
			||||||
 | 
					      exec_mode: "fork",
 | 
				
			||||||
 | 
					      watch: false,
 | 
				
			||||||
 | 
					      log_date_format: "YYYY-MM-DD HH:mm:ss",
 | 
				
			||||||
 | 
					      output: "./logs/out.log",
 | 
				
			||||||
 | 
					      error: "./logs/error.log",
 | 
				
			||||||
 | 
					      merge_logs: true,
 | 
				
			||||||
 | 
					      max_memory_restart: "1G",
 | 
				
			||||||
 | 
					      autorestart: false,
 | 
				
			||||||
 | 
					    },
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,44 @@
 | 
				
			||||||
 | 
					import dotenv from "dotenv";
 | 
				
			||||||
 | 
					import "dotenv/config";
 | 
				
			||||||
 | 
					dotenv.config();
 | 
				
			||||||
 | 
					import { getScapeConfigs, upsertScapeItems } from "./apis/scrape.js";
 | 
				
			||||||
 | 
					import { ScrapConfigsService } from "./services/scrap-configs-service.js";
 | 
				
			||||||
 | 
					import browser from "./system/browser.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const init = async () => {
 | 
				
			||||||
 | 
					  const scrapConfigs = await getScapeConfigs();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const page = await browser.newPage();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await page.setUserAgent(
 | 
				
			||||||
 | 
					      "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36"
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const models = ScrapConfigsService.scrapModels(scrapConfigs, page);
 | 
				
			||||||
 | 
					    console.log(`Loaded ${models.length} scrape models`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (let model of models) {
 | 
				
			||||||
 | 
					      try {
 | 
				
			||||||
 | 
					        await model.action();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					        for (const key of Object.keys(model.results)) {
 | 
				
			||||||
 | 
					          const dataArray = model.results[key];
 | 
				
			||||||
 | 
					          const result = await upsertScapeItems(dataArray);
 | 
				
			||||||
 | 
					          console.log(`Upserted ${dataArray.length} items for ${key}:`, result);
 | 
				
			||||||
 | 
					        }
 | 
				
			||||||
 | 
					      } catch (err) {
 | 
				
			||||||
 | 
					        console.error(
 | 
				
			||||||
 | 
					          `Error in model ${model.config?.name || "unknown"}:`,
 | 
				
			||||||
 | 
					          err
 | 
				
			||||||
 | 
					        );
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await page.close();
 | 
				
			||||||
 | 
					  } finally {
 | 
				
			||||||
 | 
					    await browser.close();
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					};
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					init();
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,119 @@
 | 
				
			||||||
 | 
					import browser from "../system/browser.js";
 | 
				
			||||||
 | 
					import { extractModelId, extractNumber } from "../system/ultils.js";
 | 
				
			||||||
 | 
					import { ScrapModel } from "./scrap-model.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class AllbidsScrapModel extends ScrapModel {
 | 
				
			||||||
 | 
					  action = async () => {
 | 
				
			||||||
 | 
					    const urlsData = this.extractUrls();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (let item of urlsData) {
 | 
				
			||||||
 | 
					      await this.page.goto(item.url);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const data = await this.getItemsInHtml(item);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const results = this.filterItemByKeyword(item.keyword, data);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      this.results[item.keyword] = results;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      console.log({ results: this.results });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async getPrice(url) {
 | 
				
			||||||
 | 
					    const newPage = await browser.newPage(); // cần truyền 'url' từ bên ngoài nếu chưa có
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await newPage.goto(`${this.web_bid.origin_url}${url}`, {
 | 
				
			||||||
 | 
					        waitUntil: "domcontentloaded", // hoặc "networkidle2" nếu cần đợi AJAX
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await newPage.waitForSelector("#bidPrefix > span.font-weight-bold", {
 | 
				
			||||||
 | 
					        timeout: 10000,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const priceText = await newPage
 | 
				
			||||||
 | 
					        .$eval("#bidPrefix > span.font-weight-bold", (el) =>
 | 
				
			||||||
 | 
					          el.textContent.trim()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return extractNumber(priceText) || 0;
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error(`Error getting price for ${url}:`, error);
 | 
				
			||||||
 | 
					      return 0;
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      await newPage.close();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getPriceByEl = async (elementHandle, model) => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const priceText = await elementHandle
 | 
				
			||||||
 | 
					        .$eval(`#ps-bg-buy-btn-${model} .pds-button-label`, (el) =>
 | 
				
			||||||
 | 
					          el.textContent.trim()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return extractNumber(priceText) || 0;
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      return 0;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getItemsInHtml = async (data) => {
 | 
				
			||||||
 | 
					    await this.page.waitForSelector('input[name="searchText"]', {
 | 
				
			||||||
 | 
					      timeout: 10000,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await this.page.type('input[name="searchText"]', data.keyword);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await Promise.all([
 | 
				
			||||||
 | 
					      this.page.click(
 | 
				
			||||||
 | 
					        "form .btn.btn-lg.btn-primary.waves-effect.allbids-cta-bid"
 | 
				
			||||||
 | 
					      ),
 | 
				
			||||||
 | 
					      this.page.waitForNavigation({ waitUntil: "networkidle0" }), // hoặc 'networkidle2'
 | 
				
			||||||
 | 
					    ]);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const elements = await this.page.$$("tbody > tr.row.ng-scope");
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const results = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (const el of elements) {
 | 
				
			||||||
 | 
					      const url = await el
 | 
				
			||||||
 | 
					        .$eval(".col-md-5.col-lg-7.title > a", (el) => el.getAttribute("href"))
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const model = extractModelId(url);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const image_url = await el
 | 
				
			||||||
 | 
					        .$eval(`#list${model} > div > img`, (img) => img.getAttribute("src"))
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const name = await el
 | 
				
			||||||
 | 
					        .$eval(`#list${model} > div:nth-child(1) > h3`, (el) =>
 | 
				
			||||||
 | 
					          el.textContent.trim()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const priceText = await el
 | 
				
			||||||
 | 
					        .$eval(
 | 
				
			||||||
 | 
					          `#list${model} > div:nth-child(1) > div:nth-child(1) > span`,
 | 
				
			||||||
 | 
					          (el) => el.textContent.trim()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      results.push({
 | 
				
			||||||
 | 
					        url,
 | 
				
			||||||
 | 
					        image_url,
 | 
				
			||||||
 | 
					        name,
 | 
				
			||||||
 | 
					        keyword: data.keyword,
 | 
				
			||||||
 | 
					        model,
 | 
				
			||||||
 | 
					        current_price: extractNumber(priceText) || 0,
 | 
				
			||||||
 | 
					        scrap_config_id: this.scrap_config_id,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    console.log({ results });
 | 
				
			||||||
 | 
					    return results;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,74 @@
 | 
				
			||||||
 | 
					import { extractModelId, extractNumber } from "../system/ultils.js";
 | 
				
			||||||
 | 
					import { ScrapModel } from "./scrap-model.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class GraysScrapModel extends ScrapModel {
 | 
				
			||||||
 | 
					  action = async () => {
 | 
				
			||||||
 | 
					    const urlsData = this.extractUrls();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (let item of urlsData) {
 | 
				
			||||||
 | 
					      await this.page.goto(item.url);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const data = await this.getItemsInHtml(item);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const results = this.filterItemByKeyword(item.keyword, data);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      this.results[item.keyword] = results;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    console.log({ results: this.results });
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getPriceByEl = async (elementHandle) => {
 | 
				
			||||||
 | 
					    const selectors = [
 | 
				
			||||||
 | 
					      ".sc-ijDOKB.sc-bStcSt.ikmQUw.eEycyP", // Single product price
 | 
				
			||||||
 | 
					      ".sc-ijDOKB.ikmQUw", // Multiple product price
 | 
				
			||||||
 | 
					    ];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (const selector of selectors) {
 | 
				
			||||||
 | 
					      const priceText = await elementHandle
 | 
				
			||||||
 | 
					        .$eval(selector, (el) => el.textContent.trim())
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					      if (priceText) {
 | 
				
			||||||
 | 
					        const price = extractNumber(priceText);
 | 
				
			||||||
 | 
					        if (price) return price;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getItemsInHtml = async (data) => {
 | 
				
			||||||
 | 
					    const elements = await this.page.$$(".sc-102aeaf3-1.eYPitT > div");
 | 
				
			||||||
 | 
					    const results = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (const el of elements) {
 | 
				
			||||||
 | 
					      const url = await el
 | 
				
			||||||
 | 
					        .$eval(".sc-pKqro.sc-gFnajm.gqkMpZ.dzWUkJ", (el) =>
 | 
				
			||||||
 | 
					          el.getAttribute("href")
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const image_url = await el
 | 
				
			||||||
 | 
					        .$eval("img.sc-gtJxfw.jbgdlx", (img) => img.getAttribute("src"))
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const name = await el
 | 
				
			||||||
 | 
					        .$eval(".sc-jlGgGc.dJRywx", (el) => el.textContent.trim())
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const current_price = await this.getPriceByEl(el); // Gọi hàm async được định nghĩa trong class
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      results.push({
 | 
				
			||||||
 | 
					        url,
 | 
				
			||||||
 | 
					        image_url,
 | 
				
			||||||
 | 
					        name,
 | 
				
			||||||
 | 
					        keyword: data.keyword,
 | 
				
			||||||
 | 
					        model: extractModelId(url),
 | 
				
			||||||
 | 
					        current_price,
 | 
				
			||||||
 | 
					        scrap_config_id: this.scrap_config_id,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return results;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,55 @@
 | 
				
			||||||
 | 
					import { extractModelId, extractNumber } from "../system/ultils.js";
 | 
				
			||||||
 | 
					import { ScrapModel } from "./scrap-model.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class LangtonsScrapModel extends ScrapModel {
 | 
				
			||||||
 | 
					  action = async () => {
 | 
				
			||||||
 | 
					    const urlsData = this.extractUrls();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (let item of urlsData) {
 | 
				
			||||||
 | 
					      await this.page.goto(item.url);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const data = await this.getItemsInHtml(item);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const results = this.filterItemByKeyword(item.keyword, data);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      this.results[item.keyword] = results;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      console.log({ results: this.results });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getItemsInHtml = async (data) => {
 | 
				
			||||||
 | 
					    const elements = await this.page.$$(".row.product-grid.grid-view");
 | 
				
			||||||
 | 
					    const results = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (const el of elements) {
 | 
				
			||||||
 | 
					      const url = await el
 | 
				
			||||||
 | 
					        .$eval("a.js-pdp-link.pdp-link-anchor", (el) => el.getAttribute("href"))
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const image_url = await el
 | 
				
			||||||
 | 
					        .$eval("img.tile-image.loaded", (img) => img.getAttribute("src"))
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const name = await el
 | 
				
			||||||
 | 
					        .$eval(".link.js-pdp-link", (el) => el.textContent.trim())
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const current_price = await el
 | 
				
			||||||
 | 
					        .$eval("div.max-bid-price.price", (el) => el.textContent.trim())
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      results.push({
 | 
				
			||||||
 | 
					        url: `${this.web_bid.origin_url}${url}`,
 | 
				
			||||||
 | 
					        image_url,
 | 
				
			||||||
 | 
					        name,
 | 
				
			||||||
 | 
					        keyword: data.keyword,
 | 
				
			||||||
 | 
					        model: extractModelId(`${this.web_bid.origin_url}${url}`),
 | 
				
			||||||
 | 
					        current_price: extractNumber(current_price),
 | 
				
			||||||
 | 
					        scrap_config_id: this.scrap_config_id,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return results;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,95 @@
 | 
				
			||||||
 | 
					import browser from "../system/browser.js";
 | 
				
			||||||
 | 
					import { extractModelId, extractNumber } from "../system/ultils.js";
 | 
				
			||||||
 | 
					import { ScrapModel } from "./scrap-model.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class LawsonsScrapModel extends ScrapModel {
 | 
				
			||||||
 | 
					  action = async () => {
 | 
				
			||||||
 | 
					    const urlsData = this.extractUrls();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (let item of urlsData) {
 | 
				
			||||||
 | 
					      await this.page.goto(item.url);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const data = await this.getItemsInHtml(item);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const results = this.filterItemByKeyword(item.keyword, data);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      this.results[item.keyword] = results;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      console.log({ results: this.results });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async getPrice(url) {
 | 
				
			||||||
 | 
					    const newPage = await browser.newPage();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    await newPage.setUserAgent(
 | 
				
			||||||
 | 
					      "Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/115.0.0.0 Safari/537.36"
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await newPage.goto(`${this.web_bid.origin_url}${url}`, {
 | 
				
			||||||
 | 
					        waitUntil: "domcontentloaded", // hoặc "networkidle2" nếu cần đợi AJAX
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await newPage.waitForSelector("#bidPrefix > span.font-weight-bold", {
 | 
				
			||||||
 | 
					        timeout: 10000,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const priceText = await newPage
 | 
				
			||||||
 | 
					        .$eval("#bidPrefix > span.font-weight-bold", (el) =>
 | 
				
			||||||
 | 
					          el.textContent.trim()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return extractNumber(priceText) || 0;
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error(`Error getting price for ${url}:`, error);
 | 
				
			||||||
 | 
					      return 0;
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      await newPage.close();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getItemsInHtml = async (data) => {
 | 
				
			||||||
 | 
					    await this.page.waitForSelector(".row.row-spacing > .lot-container", {
 | 
				
			||||||
 | 
					      timeout: 10000,
 | 
				
			||||||
 | 
					    });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const elements = await this.page.$$(".row.row-spacing > .lot-container");
 | 
				
			||||||
 | 
					    const results = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (const el of elements) {
 | 
				
			||||||
 | 
					      const url = await el
 | 
				
			||||||
 | 
					        .$eval("aside.search-lot--content.text-left > a", (el) =>
 | 
				
			||||||
 | 
					          el.getAttribute("href")
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const image_url = await el
 | 
				
			||||||
 | 
					        .$eval("figure.text-center.imgContainer img", (img) =>
 | 
				
			||||||
 | 
					          img.getAttribute("src")
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const name = await el
 | 
				
			||||||
 | 
					        .$eval(".font-weight-normal.text-grey.title", (el) =>
 | 
				
			||||||
 | 
					          el.textContent.trim()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const current_price = await this.getPrice(url);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      results.push({
 | 
				
			||||||
 | 
					        url: `${this.web_bid.origin_url}${url}`,
 | 
				
			||||||
 | 
					        image_url,
 | 
				
			||||||
 | 
					        name,
 | 
				
			||||||
 | 
					        keyword: data.keyword,
 | 
				
			||||||
 | 
					        model: extractModelId(`${this.web_bid.origin_url}${url}`),
 | 
				
			||||||
 | 
					        current_price: current_price,
 | 
				
			||||||
 | 
					        scrap_config_id: this.scrap_config_id,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return results;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,108 @@
 | 
				
			||||||
 | 
					import browser from "../system/browser.js";
 | 
				
			||||||
 | 
					import { extractModelId, extractNumber } from "../system/ultils.js";
 | 
				
			||||||
 | 
					import { ScrapModel } from "./scrap-model.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class PicklesScrapModel extends ScrapModel {
 | 
				
			||||||
 | 
					  action = async () => {
 | 
				
			||||||
 | 
					    const urlsData = this.extractUrls();
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (let item of urlsData) {
 | 
				
			||||||
 | 
					      await this.page.goto(item.url);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const data = await this.getItemsInHtml(item);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const results = this.filterItemByKeyword(item.keyword, data);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      this.results[item.keyword] = results;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      console.log({ results: this.results });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  async getPrice(url) {
 | 
				
			||||||
 | 
					    const newPage = await browser.newPage(); // cần truyền 'url' từ bên ngoài nếu chưa có
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      await newPage.goto(`${this.web_bid.origin_url}${url}`, {
 | 
				
			||||||
 | 
					        waitUntil: "domcontentloaded", // hoặc "networkidle2" nếu cần đợi AJAX
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      await newPage.waitForSelector("#bidPrefix > span.font-weight-bold", {
 | 
				
			||||||
 | 
					        timeout: 10000,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const priceText = await newPage
 | 
				
			||||||
 | 
					        .$eval("#bidPrefix > span.font-weight-bold", (el) =>
 | 
				
			||||||
 | 
					          el.textContent.trim()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return extractNumber(priceText) || 0;
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      console.error(`Error getting price for ${url}:`, error);
 | 
				
			||||||
 | 
					      return 0;
 | 
				
			||||||
 | 
					    } finally {
 | 
				
			||||||
 | 
					      await newPage.close();
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getPriceByEl = async (elementHandle, model) => {
 | 
				
			||||||
 | 
					    try {
 | 
				
			||||||
 | 
					      const priceText = await elementHandle
 | 
				
			||||||
 | 
					        .$eval(`#ps-bg-buy-btn-${model} .pds-button-label`, (el) =>
 | 
				
			||||||
 | 
					          el.textContent.trim()
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      return extractNumber(priceText) || 0;
 | 
				
			||||||
 | 
					    } catch (error) {
 | 
				
			||||||
 | 
					      return 0;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  getItemsInHtml = async (data) => {
 | 
				
			||||||
 | 
					    await this.page.waitForSelector(
 | 
				
			||||||
 | 
					      ".content-wrapper_contentgridwrapper__3RCQZ > div.column",
 | 
				
			||||||
 | 
					      {
 | 
				
			||||||
 | 
					        timeout: 10000,
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    const elements = await this.page.$$(
 | 
				
			||||||
 | 
					      ".content-wrapper_contentgridwrapper__3RCQZ > div.column"
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					    const results = [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    for (const el of elements) {
 | 
				
			||||||
 | 
					      const url = await el
 | 
				
			||||||
 | 
					        .$eval("main > a", (el) => el.getAttribute("href"))
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const image_url = await el
 | 
				
			||||||
 | 
					        .$eval("div > div:first-child > div > div:first-child > img", (img) =>
 | 
				
			||||||
 | 
					          img.getAttribute("src")
 | 
				
			||||||
 | 
					        )
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const name = await el
 | 
				
			||||||
 | 
					        .$eval("header > h2:first-of-type", (el) => el.textContent.trim())
 | 
				
			||||||
 | 
					        .catch(() => null);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const model = extractModelId(`${this.web_bid.origin_url}${url}`);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      const current_price = await this.getPriceByEl(el, model);
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					      results.push({
 | 
				
			||||||
 | 
					        url: `${this.web_bid.origin_url}${url}`,
 | 
				
			||||||
 | 
					        image_url,
 | 
				
			||||||
 | 
					        name,
 | 
				
			||||||
 | 
					        keyword: data.keyword,
 | 
				
			||||||
 | 
					        model,
 | 
				
			||||||
 | 
					        current_price: current_price,
 | 
				
			||||||
 | 
					        scrap_config_id: this.scrap_config_id,
 | 
				
			||||||
 | 
					      });
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return results;
 | 
				
			||||||
 | 
					  };
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,35 @@
 | 
				
			||||||
 | 
					export class ScrapModel {
 | 
				
			||||||
 | 
					  constructor({ keywords, search_url, scrap_config_id, page, web_bid }) {
 | 
				
			||||||
 | 
					    this.keywords = keywords;
 | 
				
			||||||
 | 
					    this.search_url = search_url;
 | 
				
			||||||
 | 
					    this.scrap_config_id = scrap_config_id;
 | 
				
			||||||
 | 
					    this.results = {};
 | 
				
			||||||
 | 
					    this.page = page;
 | 
				
			||||||
 | 
					    this.web_bid = web_bid;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  buildUrlWithKey(rawUrl, keyword) {
 | 
				
			||||||
 | 
					    return rawUrl.replaceAll("{{keyword}}", keyword);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  extractUrls() {
 | 
				
			||||||
 | 
					    const keywordList = this.keywords.split(", ");
 | 
				
			||||||
 | 
					    if (keywordList.length <= 0) return [];
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					    return keywordList.map((keyword) => ({
 | 
				
			||||||
 | 
					      url: this.buildUrlWithKey(this.search_url, keyword),
 | 
				
			||||||
 | 
					      keyword,
 | 
				
			||||||
 | 
					    }));
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  filterItemByKeyword(keyword, data) {
 | 
				
			||||||
 | 
					    return data.filter((item) =>
 | 
				
			||||||
 | 
					      item.name.toLowerCase().includes(keyword.toLowerCase())
 | 
				
			||||||
 | 
					    );
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  // Phần này bạn cần truyền từ bên ngoài khi khởi tạo hoặc kế thừa class:
 | 
				
			||||||
 | 
					  action = async (page) => {};
 | 
				
			||||||
 | 
					  getInfoItems = (data) => [];
 | 
				
			||||||
 | 
					  getItemsInHtml = async (data) => [];
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
										
											
												File diff suppressed because it is too large
												Load Diff
											
										
									
								
							| 
						 | 
					@ -0,0 +1,22 @@
 | 
				
			||||||
 | 
					{
 | 
				
			||||||
 | 
					  "name": "scrape-data-keyword",
 | 
				
			||||||
 | 
					  "version": "1.0.0",
 | 
				
			||||||
 | 
					  "description": "",
 | 
				
			||||||
 | 
					  "main": "index.js",
 | 
				
			||||||
 | 
					  "scripts": {
 | 
				
			||||||
 | 
					    "test": "echo \"Error: no test specified\" && exit 1",
 | 
				
			||||||
 | 
					    "start": "node index.js"
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					  "keywords": [],
 | 
				
			||||||
 | 
					  "author": "",
 | 
				
			||||||
 | 
					  "license": "ISC",
 | 
				
			||||||
 | 
					  "type": "module",
 | 
				
			||||||
 | 
					  "dependencies": {
 | 
				
			||||||
 | 
					    "axios": "^1.8.2",
 | 
				
			||||||
 | 
					    "dotenv": "^16.4.7",
 | 
				
			||||||
 | 
					    "lodash": "^4.17.21",
 | 
				
			||||||
 | 
					    "puppeteer": "^24.4.0",
 | 
				
			||||||
 | 
					    "puppeteer-extra": "^3.3.6",
 | 
				
			||||||
 | 
					    "puppeteer-extra-plugin-stealth": "^2.11.2"
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,56 @@
 | 
				
			||||||
 | 
					import { AllbidsScrapModel } from "../models/allbids-scrap-model.js";
 | 
				
			||||||
 | 
					import { GraysScrapModel } from "../models/grays-scrap-model.js";
 | 
				
			||||||
 | 
					import { LangtonsScrapModel } from "../models/langtons-scrap-model.js";
 | 
				
			||||||
 | 
					import { LawsonsScrapModel } from "../models/lawsons-scrap-model.js";
 | 
				
			||||||
 | 
					import { PicklesScrapModel } from "../models/pickles-scrap-model.js";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export class ScrapConfigsService {
 | 
				
			||||||
 | 
					  static scrapModel(scrapConfig, page) {
 | 
				
			||||||
 | 
					    switch (scrapConfig.web_bid.origin_url) {
 | 
				
			||||||
 | 
					      case "https://www.grays.com": {
 | 
				
			||||||
 | 
					        return new GraysScrapModel({
 | 
				
			||||||
 | 
					          ...scrapConfig,
 | 
				
			||||||
 | 
					          scrap_config_id: scrapConfig.id,
 | 
				
			||||||
 | 
					          page: page,
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      case "https://www.langtons.com.au": {
 | 
				
			||||||
 | 
					        return new LangtonsScrapModel({
 | 
				
			||||||
 | 
					          ...scrapConfig,
 | 
				
			||||||
 | 
					          scrap_config_id: scrapConfig.id,
 | 
				
			||||||
 | 
					          page: page,
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      case "https://www.lawsons.com.au": {
 | 
				
			||||||
 | 
					        return new LawsonsScrapModel({
 | 
				
			||||||
 | 
					          ...scrapConfig,
 | 
				
			||||||
 | 
					          scrap_config_id: scrapConfig.id,
 | 
				
			||||||
 | 
					          page: page,
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      case "https://www.pickles.com.au": {
 | 
				
			||||||
 | 
					        return new PicklesScrapModel({
 | 
				
			||||||
 | 
					          ...scrapConfig,
 | 
				
			||||||
 | 
					          scrap_config_id: scrapConfig.id,
 | 
				
			||||||
 | 
					          page: page,
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      case "https://www.allbids.com.au": {
 | 
				
			||||||
 | 
					        return new AllbidsScrapModel({
 | 
				
			||||||
 | 
					          ...scrapConfig,
 | 
				
			||||||
 | 
					          scrap_config_id: scrapConfig.id,
 | 
				
			||||||
 | 
					          page: page,
 | 
				
			||||||
 | 
					        });
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      default: {
 | 
				
			||||||
 | 
					        return null;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  static scrapModels(data, page) {
 | 
				
			||||||
 | 
					    return data
 | 
				
			||||||
 | 
					      .map((item) => this.scrapModel(item, page))
 | 
				
			||||||
 | 
					      .filter((item) => !!item);
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,11 @@
 | 
				
			||||||
 | 
					import ax from "axios";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					const axios = ax.create({
 | 
				
			||||||
 | 
					  // baseURL: 'http://172.18.2.125/api/v1/',
 | 
				
			||||||
 | 
					  baseURL: process.env.BASE_URL,
 | 
				
			||||||
 | 
					  headers: {
 | 
				
			||||||
 | 
					    Authorization: process.env.CLIENT_KEY,
 | 
				
			||||||
 | 
					  },
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default axios;
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,39 @@
 | 
				
			||||||
 | 
					// import puppeteer from 'puppeteer';
 | 
				
			||||||
 | 
					import puppeteer from "puppeteer-extra";
 | 
				
			||||||
 | 
					import StealthPlugin from "puppeteer-extra-plugin-stealth";
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					puppeteer.use(StealthPlugin());
 | 
				
			||||||
 | 
					const browser = await puppeteer.launch({
 | 
				
			||||||
 | 
					  headless: process.env.ENVIRONMENT === "prod" ? "new" : false,
 | 
				
			||||||
 | 
					  // userDataDir: CONSTANTS.PROFILE_PATH, // Thư mục lưu profile
 | 
				
			||||||
 | 
					  timeout: 60000,
 | 
				
			||||||
 | 
					  args: [
 | 
				
			||||||
 | 
					    "--no-sandbox",
 | 
				
			||||||
 | 
					    "--disable-setuid-sandbox",
 | 
				
			||||||
 | 
					    "--disable-dev-shm-usage",
 | 
				
			||||||
 | 
					    "--disable-gpu",
 | 
				
			||||||
 | 
					    "--disable-software-rasterizer",
 | 
				
			||||||
 | 
					    "--disable-background-networking",
 | 
				
			||||||
 | 
					    "--disable-sync",
 | 
				
			||||||
 | 
					    "--mute-audio",
 | 
				
			||||||
 | 
					    "--no-first-run",
 | 
				
			||||||
 | 
					    "--no-default-browser-check",
 | 
				
			||||||
 | 
					    "--ignore-certificate-errors",
 | 
				
			||||||
 | 
					    "--start-maximized",
 | 
				
			||||||
 | 
					    "--disable-site-isolation-trials", // Tắt sandbox riêng cho từng site
 | 
				
			||||||
 | 
					    "--memory-pressure-off", // Tắt cơ chế bảo vệ bộ nhớ
 | 
				
			||||||
 | 
					    "--disk-cache-size=0", // Không dùng cache để giảm bộ nhớ
 | 
				
			||||||
 | 
					    "--enable-low-end-device-mode", // Kích hoạt chế độ tiết kiệm RAM
 | 
				
			||||||
 | 
					    "--disable-best-effort-tasks", // Tắt tác vụ không quan trọng
 | 
				
			||||||
 | 
					    "--disable-accelerated-2d-canvas", // Không dùng GPU để vẽ canvas
 | 
				
			||||||
 | 
					    "--disable-threaded-animation", // Giảm animation chạy trên nhiều thread
 | 
				
			||||||
 | 
					    "--disable-threaded-scrolling", // Tắt cuộn trang đa luồng
 | 
				
			||||||
 | 
					    "--disable-logging", // Tắt log debug
 | 
				
			||||||
 | 
					    "--blink-settings=imagesEnabled=false", // Không tải hình ảnh,
 | 
				
			||||||
 | 
					    "--disable-background-timer-throttling", // Tránh việc throttling các timer khi chạy nền.
 | 
				
			||||||
 | 
					    "--disable-webrtc",
 | 
				
			||||||
 | 
					    "--disable-ipc-flooding-protection", // Nếu có extension cần IPC, cái này giúp tối ưu.
 | 
				
			||||||
 | 
					  ],
 | 
				
			||||||
 | 
					});
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export default browser;
 | 
				
			||||||
| 
						 | 
					@ -0,0 +1,47 @@
 | 
				
			||||||
 | 
					export function extractNumber(str) {
 | 
				
			||||||
 | 
					  if (typeof str !== "string") return null;
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					  const cleaned = str.replace(/,/g, "");
 | 
				
			||||||
 | 
					  const match = cleaned.match(/\d+(\.\d+)?/);
 | 
				
			||||||
 | 
					  return match ? parseFloat(match[0]) : null;
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function extractModelId(url) {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    switch (extractDomain(url)) {
 | 
				
			||||||
 | 
					      case "https://www.grays.com": {
 | 
				
			||||||
 | 
					        const match = url.match(/\/lot\/([\d-]+)\//);
 | 
				
			||||||
 | 
					        return match ? match[1] : null;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      case "https://www.langtons.com.au": {
 | 
				
			||||||
 | 
					        const match = url.match(/auc-var-\d+/);
 | 
				
			||||||
 | 
					        return match[0];
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      case "https://www.lawsons.com.au": {
 | 
				
			||||||
 | 
					        const match = url.split("_");
 | 
				
			||||||
 | 
					        return match ? match[1] : null;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      case "https://www.pickles.com.au": {
 | 
				
			||||||
 | 
					        const model = url.split("/").pop();
 | 
				
			||||||
 | 
					        return model ? model : null;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      case "https://www.allbids.com.au": {
 | 
				
			||||||
 | 
					        const match = url.match(/-(\d+)(?:[\?#]|$)/);
 | 
				
			||||||
 | 
					        return match ? match[1] : null;
 | 
				
			||||||
 | 
					      }
 | 
				
			||||||
 | 
					      default:
 | 
				
			||||||
 | 
					        return null;
 | 
				
			||||||
 | 
					    }
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
 | 
					
 | 
				
			||||||
 | 
					export function extractDomain(url) {
 | 
				
			||||||
 | 
					  try {
 | 
				
			||||||
 | 
					    const parsedUrl = new URL(url);
 | 
				
			||||||
 | 
					    return parsedUrl.origin;
 | 
				
			||||||
 | 
					  } catch (error) {
 | 
				
			||||||
 | 
					    return null;
 | 
				
			||||||
 | 
					  }
 | 
				
			||||||
 | 
					}
 | 
				
			||||||
		Loading…
	
		Reference in New Issue