This commit is contained in:
Admin 2025-05-22 10:38:30 +07:00
parent 168d458009
commit 4f9edf80d0
56 changed files with 4029 additions and 612 deletions

BIN
.DS_Store vendored

Binary file not shown.

View File

@ -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);
}
};

View File

@ -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>
);
}

View File

@ -1,122 +1,180 @@
import { Avatar, Button, LoadingOverlay, Menu, Modal, PasswordInput } from '@mantine/core';
import { useForm, zodResolver } from '@mantine/form';
import { useDisclosure } from '@mantine/hooks';
import { 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';
import {
Avatar,
Button,
LoadingOverlay,
Menu,
Modal,
PasswordInput,
} from "@mantine/core";
import { useForm, zodResolver } from "@mantine/form";
import { useDisclosure } from "@mantine/hooks";
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
.object({
currentPassword: z.string().min(6, 'Current password must be at least 6 characters'),
newPassword: z.string().min(6, 'New password must be at least 6 characters'),
confirmPassword: z.string(),
})
.refine((data) => data.newPassword === data.confirmPassword, {
path: ['confirmPassword'],
message: 'Passwords do not match',
});
.object({
currentPassword: z
.string()
.min(6, "Current password must be at least 6 characters"),
newPassword: z
.string()
.min(6, "New password must be at least 6 characters"),
confirmPassword: z.string(),
})
.refine((data) => data.newPassword === data.confirmPassword, {
path: ["confirmPassword"],
message: "Passwords do not match",
});
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 form = useForm({
initialValues: {
currentPassword: '',
newPassword: '',
confirmPassword: '',
},
validate: zodResolver(schema),
const navigate = useNavigate();
const form = useForm({
initialValues: {
currentPassword: "",
newPassword: "",
confirmPassword: "",
},
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) => {
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 handleChangePassword = async (values: typeof form.values) => {
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,
});
};
const handleChangePassword = async (values: typeof form.values) => {
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);
setLoading(false);
if (data && data.data) {
navigate(Links.LOGIN);
close();
}
},
});
};
if (data && data.data) {
navigate(Links.LOGIN);
close();
}
},
});
};
return (
<>
<Menu shadow="md" width={200}>
<Menu.Target>
<Avatar color="blue" radius="xl" className="cursor-pointer">
<IconUser size={20} />
</Avatar>
</Menu.Target>
return (
<>
<Menu shadow="md" width={200}>
<Menu.Target>
<Avatar color="blue" radius="xl" className="cursor-pointer">
<IconUser size={20} />
</Avatar>
</Menu.Target>
<Menu.Dropdown>
<Menu.Label>Account</Menu.Label>
<Menu.Item onClick={open} leftSection={<IconSettings size={14} />}>
Change password
</Menu.Item>
<Menu.Item
component={Link}
to={Links.GENERATE_KEYS}
leftSection={<IconKey size={14} />}
>
Keys
</Menu.Item>
<Menu.Dropdown>
<Menu.Label>Account</Menu.Label>
<Menu.Item onClick={open} leftSection={<IconSettings size={14} />}>
Change password
</Menu.Item>
<Menu.Item component={Link} to={Links.GENERATE_KEYS} leftSection={<IconKey size={14} />}>
Keys
</Menu.Item>
<Menu.Item
component={Link}
to={Links.CONFIGS}
leftSection={<IconCode size={14} />}
>
Configs
</Menu.Item>
<Menu.Divider />
<Menu.Divider />
<Menu.Item onClick={handleLogout} color="red" leftSection={<IconLogout size={14} />}>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Menu.Item
onClick={handleLogout}
color="red"
leftSection={<IconLogout size={14} />}
>
Logout
</Menu.Item>
</Menu.Dropdown>
</Menu>
<Modal className="relative" opened={opened} onClose={close} title="Change password" centered>
<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>
<Modal
className="relative"
opened={opened}
onClose={close}
title="Change password"
centered
>
<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 }} />
</Modal>
</>
);
<LoadingOverlay
visible={loading}
zIndex={1000}
overlayProps={{ blur: 2 }}
/>
</Modal>
</>
);
}

View File

@ -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>
);
}

View File

@ -1,5 +1,6 @@
export { default as Dashboard } from './dashboard';
export { default as Bids } from './bids';
export { default as OutBidsLog } from './out-bids-log';
export { default as Login } from './login';
export { default as App } from './app';
export { default as Dashboard } from "./dashboard";
export { default as Bids } from "./bids";
export { default as OutBidsLog } from "./out-bids-log";
export { default as Login } from "./login";
export { default as App } from "./app";
export { default as Configs } from "./configs";

View File

@ -1,70 +1,87 @@
import { IconHammer, IconHome2, IconKey, IconMessage, IconOutlet, IconPageBreak, IconUserCheck } from '@tabler/icons-react';
import { Bids, 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';
import {
IconHammer,
IconHome2,
IconKey,
IconMessage,
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 {
public static DASHBOARD = '/dashboard';
public static BIDS = '/bids';
public static WEBS = '/webs';
public static OUT_BIDS_LOG = '/out-bids-log';
public static SEND_MESSAGE_HISTORIES = '/send-message-histories';
public static GENERATE_KEYS = '/generate-keys';
public static ADMINS = '/admins';
public static DASHBOARD = "/dashboard";
public static BIDS = "/bids";
public static WEBS = "/webs";
public static OUT_BIDS_LOG = "/out-bids-log";
public static SEND_MESSAGE_HISTORIES = "/send-message-histories";
public static GENERATE_KEYS = "/generate-keys";
public static ADMINS = "/admins";
public static CONFIGS = "/configs";
public static HOME = '/';
public static LOGIN = '/login';
public static HOME = "/";
public static LOGIN = "/login";
public static MENUS = [
{
path: this.DASHBOARD,
title: 'Dashboard',
icon: IconHome2,
element: Dashboard,
show: true,
},
{
path: this.ADMINS,
title: 'Admins',
icon: IconUserCheck,
element: Admins,
show: true,
},
{
path: this.WEBS,
title: 'Webs',
icon: IconPageBreak,
element: WebBids,
show: true,
},
{
path: this.BIDS,
title: 'Bids',
icon: IconHammer,
element: Bids,
show: true,
},
{
path: this.OUT_BIDS_LOG,
title: 'Out bids log',
icon: IconOutlet,
element: OutBidsLog,
show: true,
},
{
path: this.SEND_MESSAGE_HISTORIES,
title: 'Send message histories',
icon: IconMessage,
element: SendMessageHistories,
show: true,
},
{
path: this.GENERATE_KEYS,
title: 'Generate keys',
icon: IconKey,
element: GenerateKeys,
show: false,
},
];
public static MENUS = [
{
path: this.DASHBOARD,
title: "Dashboard",
icon: IconHome2,
element: Dashboard,
show: true,
},
{
path: this.ADMINS,
title: "Admins",
icon: IconUserCheck,
element: Admins,
show: true,
},
{
path: this.WEBS,
title: "Webs",
icon: IconPageBreak,
element: WebBids,
show: true,
},
{
path: this.BIDS,
title: "Bids",
icon: IconHammer,
element: Bids,
show: true,
},
{
path: this.OUT_BIDS_LOG,
title: "Out bids log",
icon: IconOutlet,
element: OutBidsLog,
show: true,
},
{
path: this.SEND_MESSAGE_HISTORIES,
title: "Send message histories",
icon: IconMessage,
element: SendMessageHistories,
show: true,
},
{
path: this.GENERATE_KEYS,
title: "Generate keys",
icon: IconKey,
element: GenerateKeys,
show: false,
},
{
path: this.CONFIGS,
title: "Configs",
icon: IconSettings,
element: Configs,
show: false,
},
];
}

View File

@ -82,6 +82,13 @@ export interface IBid extends ITimestamp {
web_bid: IWebBid;
}
export interface IConfig extends ITimestamp {
id: number;
key_name: string;
value: string;
type: "string" | "number";
}
export interface IPermission extends ITimestamp {
id: number;
name: string;

View File

@ -1 +1 @@
{"createdAt":1747701959077}
{"createdAt":1747812172479}

View File

@ -10,6 +10,7 @@
"license": "UNLICENSED",
"dependencies": {
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/bull": "^11.0.2",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^4.0.1",
"@nestjs/core": "^10.0.0",
@ -24,6 +25,7 @@
"@nestjs/websockets": "^11.0.11",
"axios": "^1.8.3",
"bcrypt": "^5.1.1",
"bull": "^4.16.5",
"cheerio": "^1.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
@ -31,6 +33,7 @@
"cookie-parser": "^1.4.7",
"dayjs": "^1.11.13",
"imap": "^0.8.19",
"ioredis": "^5.6.1",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"multer": "^1.4.5-lts.1",
@ -1649,6 +1652,12 @@
"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": {
"version": "8.0.2",
"resolved": "https://registry.npmjs.org/@isaacs/cliui/-/cliui-8.0.2.tgz",
@ -2337,6 +2346,84 @@
"license": "MIT",
"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": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/@nestjs-modules/mailer/-/mailer-2.0.2.tgz",
@ -2412,6 +2499,34 @@
"@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": {
"version": "10.4.9",
"resolved": "https://registry.npmjs.org/@nestjs/cli/-/cli-10.4.9.tgz",
@ -4663,6 +4778,33 @@
"integrity": "sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ==",
"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": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@ -5068,6 +5210,15 @@
"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": {
"version": "4.6.0",
"resolved": "https://registry.npmjs.org/co/-/co-4.6.0.tgz",
@ -5400,6 +5551,18 @@
"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": {
"version": "7.0.6",
"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",
"integrity": "sha512-g/Q1aTSDOxFpchXC4i8ZWvxA1lnPqx/JHqcpIw0/LX9T8x/GBbi6YnlN5nhaKIFkT8oFsscUKgDJYxfwfS6QsQ==",
"license": "MIT",
"optional": true,
"engines": {
"node": ">=8"
},
@ -7904,6 +8066,30 @@
"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": {
"version": "1.9.1",
"resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz",
@ -9506,12 +9692,24 @@
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
"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": {
"version": "4.3.0",
"resolved": "https://registry.npmjs.org/lodash.includes/-/lodash.includes-4.3.0.tgz",
"integrity": "sha512-W3Bx6mdkRTGtlJISOvVD/lbqjTlPPUDTMnlXZFnVwi9NKJ6tiAk6LVdlhZMm17VZisqhKcgzpO5Wz91PCt5b0w==",
"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": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/lodash.isboolean/-/lodash.isboolean-3.0.3.tgz",
@ -10421,6 +10619,37 @@
"integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"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": {
"version": "1.4.5-lts.1",
"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": {
"version": "0.4.0",
"resolved": "https://registry.npmjs.org/node-int64/-/node-int64-0.4.0.tgz",
@ -11847,6 +12091,27 @@
"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": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/reflect-metadata/-/reflect-metadata-0.2.2.tgz",
@ -12979,6 +13244,12 @@
"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": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/statuses/-/statuses-2.0.1.tgz",

View File

@ -26,6 +26,7 @@
},
"dependencies": {
"@nestjs-modules/mailer": "^2.0.2",
"@nestjs/bull": "^11.0.2",
"@nestjs/common": "^10.0.0",
"@nestjs/config": "^4.0.1",
"@nestjs/core": "^10.0.0",
@ -40,6 +41,7 @@
"@nestjs/websockets": "^11.0.11",
"axios": "^1.8.3",
"bcrypt": "^5.1.1",
"bull": "^4.16.5",
"cheerio": "^1.0.0",
"class-transformer": "^0.5.1",
"class-validator": "^0.14.1",
@ -47,6 +49,7 @@
"cookie-parser": "^1.4.7",
"dayjs": "^1.11.13",
"imap": "^0.8.19",
"ioredis": "^5.6.1",
"lodash": "^4.17.21",
"moment": "^2.30.1",
"multer": "^1.4.5-lts.1",

View File

@ -1,8 +1,10 @@
import { Module } from '@nestjs/common';
import { BullModule } from '@nestjs/bull';
import { Global, Module } from '@nestjs/common';
import { ConfigModule } from '@nestjs/config';
import { EventEmitterModule } from '@nestjs/event-emitter';
import { ScheduleModule } from '@nestjs/schedule';
@Global()
@Module({
imports: [
ConfigModule.forRoot({
@ -12,7 +14,17 @@ import { ScheduleModule } from '@nestjs/schedule';
wildcard: true,
global: true,
}),
ScheduleModule.forRoot()
BullModule.forRoot({
redis: {
host: 'localhost',
port: 6379,
},
}),
BullModule.registerQueue({
name: 'mail-queue',
}),
ScheduleModule.forRoot(),
],
exports: [BullModule],
})
export class AppConfigsModule {}

View File

@ -30,6 +30,7 @@ import { AdminDashboardController } from './controllers/admin/admin-dashboard.co
import { TasksService } from './services/tasks.servise';
import { ConfigsService } from './services/configs.service';
import { Config } from './entities/configs.entity';
import { AdminConfigsController } from './controllers/admin/admin-configs.controller';
@Module({
imports: [
@ -55,6 +56,7 @@ import { Config } from './entities/configs.entity';
AdminWebBidsController,
AdminSendMessageHistoriesController,
AdminDashboardController,
AdminConfigsController,
],
providers: [
BidsService,
@ -76,6 +78,7 @@ import { Config } from './entities/configs.entity';
SendMessageHistoriesService,
BidsService,
ConfigsService,
DashboardService,
],
})
export class BidsModule {}

View File

@ -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);
}
}

View File

@ -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';
}

View File

@ -16,6 +16,8 @@ import { SendMessageHistoriesService } from './send-message-histories.service';
import { NotificationService } from '@/modules/notification/notification.service';
import { isTimeReached } from '@/ultils';
import { BidsService } from './bids.service';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Event } from '../utils/events';
@Injectable()
export class BidHistoriesService {
@ -28,6 +30,7 @@ export class BidHistoriesService {
readonly sendMessageHistoriesService: SendMessageHistoriesService,
private readonly notificationService: NotificationService,
private readonly bidsService: BidsService,
private eventEmitter: EventEmitter2,
) {}
async index() {
@ -38,6 +41,7 @@ export class BidHistoriesService {
// Tìm thông tin bid từ database
const bid = await this.bidsService.bidsRepo.findOne({
where: { id: bid_id },
relations: { web_bid: true },
});
// 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 };
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
this.sendMessageHistoriesService.sendMessageRepo.save({
message: this.botTelegramApi.formatBidMessage(botData),

View File

@ -153,7 +153,10 @@ export class BidsService {
}
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) {
throw new NotFoundException(
@ -301,7 +304,10 @@ export class BidsService {
async outBid(id: Bid['id']) {
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));
@ -353,7 +359,10 @@ export class BidsService {
}
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)
throw new NotFoundException(

View File

@ -1,8 +1,14 @@
import { Injectable } from '@nestjs/common';
import {
BadRequestException,
HttpStatus,
Injectable,
NotFoundException,
} from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { Config } from '../entities/configs.entity';
import AppResponse from '@/response/app-response';
@Injectable()
export class ConfigsService {
@ -29,4 +35,41 @@ export class ConfigsService {
'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);
}
}

View File

@ -7,6 +7,8 @@ export class Event {
public static ADMIN_BIDS_UPDATED = 'adminBidsUpdated';
public static WEB_UPDATED = 'webUpdated';
public static LOGIN_STATUS = 'login-status';
public static BID_SUBMITED = 'bid-submited';
public static BID_STATUS = 'bid-status';
public static verifyCode(data: WebBid) {
return `${this.VERIFY_CODE}.${data.origin_url}`;

View File

@ -2,7 +2,8 @@ import { MailerModule } from '@nestjs-modules/mailer';
import { Module } from '@nestjs/common';
import { MailsService } from './services/mails.service';
import { ConfigModule, ConfigService } from '@nestjs/config';
import { BullModule } from '@nestjs/bull';
import { MailProcessor } from './process/mail.processor';
@Module({
imports: [
MailerModule.forRootAsync({
@ -21,7 +22,7 @@ import { ConfigModule, ConfigService } from '@nestjs/config';
inject: [ConfigService],
}),
],
providers: [MailsService],
providers: [MailsService, MailProcessor],
exports: [MailsService],
})
export class MailsModule {}

View File

@ -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);
}
}

View File

@ -1,11 +1,22 @@
import { Injectable } from '@nestjs/common';
import { MailerService } from '@nestjs-modules/mailer';
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()
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) {
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) {
const emails = to
.split(',')
@ -33,17 +52,34 @@ export class MailsService {
}
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
.map(
(p) => `
<tr>
<td><img src="${p.image_url}" alt="Product Image" style="height: 40px; object-fit: contain; border-radius: 4px;" /></td>
<td>${p.name}</td>
<td style="font-weight: bold; color: #e03131;">$${p.current_price}</td>
<td><a href="${p.url}" target="_blank" style="color: #007bff;">View</a></td>
<td>${extractDomain(p.scrap_config.web_bid.origin_url)}</td>
</tr>
`,
<tr>
<td><img src="${p.image_url}" alt="Product Image" style="height: 40px; object-fit: contain; border-radius: 4px;" /></td>
<td>${p.name}</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>${extractDomainSmart(p.scrap_config.web_bid.origin_url)}</td>
</tr>
`,
)
.join('');
@ -78,12 +114,197 @@ export class MailsService {
`;
}
async sendWithTemplate(to: string, subject: string, payload: any) {
await this.mailerService.sendMail({
to,
subject,
template: './welcome', // đường dẫn tương đối trong /templates
context: payload, // dữ liệu cho template
});
getAuctionStatusEmailContent(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 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>
`;
}
}

View File

@ -1,3 +1,3 @@
export const NAME_EVENTS = {
BID_STATUS: 'notify.bid-status',
};
// export const NAME_EVENTS = {
// BID_STATUS: 'notify.bid-status',
// };

View File

@ -20,7 +20,8 @@ export class ClientNotificationController {
@Post('test')
async test() {
const bid = await this.bidsService.bidsRepo.findOne({
where: { lot_id: '26077023' },
where: { lot_id: '23755862' },
relations: { web_bid: true },
});
return await this.notifyService.emitBidStatus({

View File

@ -1,16 +1,23 @@
import { Injectable } from '@nestjs/common';
import { OnEvent } from '@nestjs/event-emitter';
import { NAME_EVENTS } from '../constants';
import { Bid } from '@/modules/bids/entities/bid.entity';
import { Notification } from '../entities/notification.entity';
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()
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)
handleBidStatus({
@OnEvent(Event.BID_STATUS)
async handleBidStatus({
bid,
notification,
}: {
@ -20,5 +27,30 @@ export class AdminNotificationListener {
if (JSON.parse(notification.send_to).length <= 0) return;
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'),
});
}
}

View File

@ -8,11 +8,13 @@ import { AdminNotificationListener } from './listeners/admin-notification.listen
import { NotificationService } from './notification.service';
import { SendMessageHistoriesService } from '../bids/services/send-message-histories.service';
import { SendMessageHistory } from '../bids/entities/send-message-histories.entity';
import { MailsModule } from '../mails/mails.module';
@Module({
imports: [
forwardRef(() => BidsModule),
TypeOrmModule.forFeature([Notification, SendMessageHistory]),
MailsModule,
],
controllers: [NotificationController, ClientNotificationController],
providers: [NotificationService, AdminNotificationListener],

View File

@ -1,7 +1,6 @@
import { Injectable } from '@nestjs/common';
import { EventEmitter2 } from '@nestjs/event-emitter';
import { Bid } from '../bids/entities/bid.entity';
import { NAME_EVENTS } from './constants';
import { BotTelegramApi } from '../bids/apis/bot-telegram.api';
import { InjectRepository } from '@nestjs/typeorm';
import { Notification } from './entities/notification.entity';
@ -17,6 +16,7 @@ import { Column } from 'nestjs-paginate/lib/helper';
import AppResponse from '@/response/app-response';
import { SendMessageHistoriesService } from '../bids/services/send-message-histories.service';
import { SendMessageHistory } from '../bids/entities/send-message-histories.entity';
import { Event } from '../bids/utils/events';
@Injectable()
export class NotificationService {
@ -116,7 +116,7 @@ export class NotificationService {
message: notification.message,
type: bid.status,
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,
type: bid.status,
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,
status: 'out-bid',
// status: 'out-bid',
},
notification,
});

View File

@ -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();
}
}

View File

@ -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[],
);
}
}

View File

@ -1,22 +1,12 @@
import { MailsService } from '@/modules/mails/services/mails.service';
import { Body, Controller, Get, Param, Post, Put } from '@nestjs/common';
import { Between, IsNull, Not } from 'typeorm';
import { Body, Controller, Param, Post, Put } from '@nestjs/common';
import { CreateScrapConfigDto } from '../dto/scrap-config/create-scrap-config';
import { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
import { ScrapConfig } from '../entities/scrap-config.entity';
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')
export class ScrapConfigsController {
constructor(
private readonly scrapConfigsService: ScrapConfigsService,
private readonly scrapItemsService: ScrapItemsService,
private readonly mailsService: MailsService,
private readonly configsSerivce: ConfigsService,
) {}
constructor(private readonly scrapConfigsService: ScrapConfigsService) {}
@Post()
async create(@Body() data: CreateScrapConfigDto) {
@ -30,56 +20,4 @@ export class ScrapConfigsController {
) {
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' };
}
}

View File

@ -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;
}

View File

@ -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>;
}

View File

@ -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()),
);
};
}

View File

@ -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;
};
}

View File

@ -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;
};
}

View File

@ -8,6 +8,8 @@ import { TasksService } from './services/tasks.service';
import { ScrapItemsService } from './services/scrap-item-config.service';
import { MailsModule } from '../mails/mails.module';
import { BidsModule } from '../bids/bids.module';
import { ClientScrapConfigsController } from './controllers/client/scrap-configs.controller';
import { ClientScrapItemsController } from './controllers/client/scrap-items.controller';
@Module({
imports: [
@ -17,6 +19,10 @@ import { BidsModule } from '../bids/bids.module';
],
providers: [ScrapConfigsService, TasksService, ScrapItemsService],
exports: [ScrapConfigsService, TasksService, ScrapItemsService],
controllers: [ScrapConfigsController],
controllers: [
ScrapConfigsController,
ClientScrapConfigsController,
ClientScrapItemsController,
],
})
export class ScrapsModule {}

View File

@ -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 { 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 { UpdateScrapConfigDto } from '../dto/scrap-config/update-scrap-config';
import axios from 'axios';
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';
import { ScrapConfig } from '../entities/scrap-config.entity';
@Injectable()
export class ScrapConfigsService {
@ -18,6 +14,17 @@ export class ScrapConfigsService {
readonly scrapConfigRepo: Repository<ScrapConfig>,
) {}
async clientGetScrapeConfigs() {
const data = await this.scrapConfigRepo.find({
where: { search_url: Not(IsNull()), keywords: Not(IsNull()) },
relations: {
web_bid: true,
},
});
return AppResponse.toResponse(plainToClass(ScrapConfig, data));
}
async create(data: CreateScrapConfigDto) {
const result = await this.scrapConfigRepo.save({
search_url: data.search_url,
@ -40,22 +47,4 @@ export class ScrapConfigsService {
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);
}
}

View File

@ -1,7 +1,8 @@
import { Injectable } from '@nestjs/common';
import { BadRequestException, Injectable } from '@nestjs/common';
import { InjectRepository } from '@nestjs/typeorm';
import { Repository } from 'typeorm';
import { ScrapItem } from '../entities/scrap-item.entity';
import AppResponse from '@/response/app-response';
@Injectable()
export class ScrapItemsService {
@ -58,4 +59,12 @@ export class ScrapItemsService {
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);
}
}

View File

@ -1,11 +1,13 @@
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 { Cron, CronExpression } from '@nestjs/schedule';
import { Between, IsNull, Not } from 'typeorm';
import { Cron } from '@nestjs/schedule';
import * as moment from 'moment';
import { Between } from 'typeorm';
import { ScrapConfigsService } from './scrap-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()
export class TasksService {
@ -16,57 +18,79 @@ export class TasksService {
private readonly scrapItemsService: ScrapItemsService,
private readonly mailsService: MailsService,
private readonly configsSerivce: ConfigsService,
private readonly dashboardService: DashboardService,
) {}
@Cron('0 2 * * *')
async handleScraps() {
async runProcessAndSendReport(processName: string) {
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({
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);
}
}),
);
// Reset và chạy process
await this.dashboardService.resetProcessByName(processName);
console.log(`Process ${processName} started.`);
// Đợ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();
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' },
});
try {
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),
);
await this.mailsService.sendHtmlMailJob({
to: mails,
subject: `Auction Items Matching Your Keywords Daily Update ${moment().format('YYYY-MM-DD HH:mm')}`,
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);
}
}

View File

@ -1,4 +1,5 @@
import { Bid } from '@/modules/bids/entities/bid.entity';
import * as moment from 'moment';
export function extractModelId(url: string): string | null {
switch (extractDomain(url)) {
@ -31,6 +32,25 @@ export function subtractMinutes(timeStr: string, minutes: number) {
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) {
const date = new Date(time);
date.setSeconds(date.getSeconds() - seconds);
@ -185,3 +205,13 @@ export function extractNumber(str: string) {
const match = str.match(/\d+(\.\d+)?/);
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));

57
scrape-data-keyword/.gitignore vendored Normal file
View File

@ -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

View File

@ -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 [];
}
};

View File

@ -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,
},
],
};

View File

@ -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();

View File

@ -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;
};
}

View File

@ -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;
};
}

View File

@ -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;
};
}

View File

@ -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;
};
}

View File

@ -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;
};
}

View File

@ -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) => [];
}

1876
scrape-data-keyword/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -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"
}
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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;

View File

@ -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;
}
}