Merge pull request 'Deploy to prod' (#8) from main into production
Reviewed-on: #8
This commit is contained in:
commit
926de90254
|
|
@ -19,6 +19,20 @@ export const login = async (credentials: { username: string; password: string })
|
|||
}
|
||||
};
|
||||
|
||||
export const checkStatus = async () => {
|
||||
try {
|
||||
const { data } = await axios({
|
||||
url: 'auth/check-status',
|
||||
withCredentials: true,
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
return data as IResponse<boolean>;
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const logout = async () => {
|
||||
try {
|
||||
const { data } = await axios({
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { generateNestParams, handleError, handleSuccess } from '.';
|
||||
import axios from '../lib/axios';
|
||||
import { IBid } from '../system/type';
|
||||
import { IBid, IWebBid } from '../system/type';
|
||||
import { removeFalsyValues } from '../utils';
|
||||
|
||||
export const getBids = async (params: Record<string, string | number>) => {
|
||||
|
|
@ -104,3 +104,17 @@ export const deletesBid = async (bids: IBid[]) => {
|
|||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const getImagesWorking = async (values: (IBid | IWebBid) & { type: string }) => {
|
||||
try {
|
||||
const { data } = await axios({
|
||||
url: `bids/images-working/${values.type.toLocaleLowerCase().replace('_', '-')}/${values.id}`,
|
||||
withCredentials: true,
|
||||
method: 'GET',
|
||||
});
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,67 @@
|
|||
import { generateNestParams, handleError, handleSuccess } from '.';
|
||||
import axios from '../lib/axios';
|
||||
import { IKey } from '../system/type';
|
||||
|
||||
export const getKeys = async (params: Record<string, string | number>) => {
|
||||
return await axios({
|
||||
url: 'keys',
|
||||
params: generateNestParams(params),
|
||||
withCredentials: true,
|
||||
method: 'GET',
|
||||
});
|
||||
};
|
||||
|
||||
export const createKey = async () => {
|
||||
try {
|
||||
const { data } = await axios({
|
||||
url: 'keys',
|
||||
withCredentials: true,
|
||||
method: 'POST',
|
||||
});
|
||||
|
||||
handleSuccess(data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deleteKey = async (key: IKey) => {
|
||||
try {
|
||||
const { data } = await axios({
|
||||
url: 'keys/' + key.id,
|
||||
withCredentials: true,
|
||||
method: 'DELETE',
|
||||
});
|
||||
|
||||
handleSuccess(data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
||||
export const deletesKey = async (keys: IKey[]) => {
|
||||
const ids = keys.reduce((prev, cur) => {
|
||||
prev.push(cur.id);
|
||||
return prev;
|
||||
}, [] as number[]);
|
||||
try {
|
||||
const { data } = await axios({
|
||||
url: 'keys/deletes',
|
||||
withCredentials: true,
|
||||
method: 'POST',
|
||||
data: {
|
||||
ids,
|
||||
},
|
||||
});
|
||||
|
||||
handleSuccess(data);
|
||||
|
||||
return data;
|
||||
} catch (error) {
|
||||
handleError(error);
|
||||
}
|
||||
};
|
||||
|
|
@ -3,6 +3,7 @@ import { useDisclosure } from '@mantine/hooks';
|
|||
import moment from 'moment';
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Socket } from 'socket.io-client';
|
||||
import { getImagesWorking } from '../../apis/bid';
|
||||
import { IBid, IWebBid } from '../../system/type';
|
||||
import ShowImageModal from './show-image-modal';
|
||||
|
||||
|
|
@ -16,19 +17,27 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
|
|||
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
|
||||
const [imageSrc, setImageSrc] = useState(`${import.meta.env.VITE_BASE_URL}bids/status-working/${data.type.replace('_', '-').toLowerCase()}/${data.id}/working`);
|
||||
const [imageSrc, setImageSrc] = useState<string | null>(null);
|
||||
|
||||
const [lastUpdate, setLastUpdate] = useState(new Date());
|
||||
const [lastUpdate, setLastUpdate] = useState<Date | null>(null);
|
||||
|
||||
function isIBid(obj: IBid | IWebBid): obj is IBid {
|
||||
return 'name' in obj;
|
||||
}
|
||||
|
||||
const renderUrl = ({ type, id }: (IBid | IWebBid) & { type: string }, name: string) => {
|
||||
return `${import.meta.env.VITE_BASE_URL}bids/status-working/${type.replace('_', '-').toLowerCase()}/${id}/${name}`;
|
||||
};
|
||||
|
||||
const extractTime = (filename: string) => {
|
||||
return Number(filename.split('-')[0]) || 0;
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
const updateImage = ({ type, id, filename }: { type: string; filename: string; id: IBid['id'] }) => {
|
||||
if (type == data.type && id == data.id) {
|
||||
setLastUpdate(new Date());
|
||||
setImageSrc(`${import.meta.env.VITE_BASE_URL}bids/status-working/${type.replace('_', '-').toLowerCase()}/${id}/${filename}`);
|
||||
setLastUpdate(new Date(extractTime(filename)));
|
||||
setImageSrc(renderUrl(data, filename));
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -37,8 +46,23 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
|
|||
return () => {
|
||||
socket.off('working', updateImage);
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [socket, data.id, data.type]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
const result = await getImagesWorking(data);
|
||||
|
||||
if (!result || !result.data) return;
|
||||
|
||||
const filename = result.data[0];
|
||||
|
||||
setImageSrc(renderUrl(data, filename));
|
||||
setLastUpdate(new Date(extractTime(filename)));
|
||||
})();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<>
|
||||
<Box className="rounded-md overflow-hidden relative shadow-lg">
|
||||
|
|
@ -74,7 +98,7 @@ export default function WorkingPage({ data, socket }: IWorkingPageProps) {
|
|||
</Box>
|
||||
</Box>
|
||||
|
||||
<ShowImageModal src={imageSrc} fallbackSrc={fallbackSrc} opened={opened} onClose={close} />
|
||||
<ShowImageModal src={imageSrc || fallbackSrc} fallbackSrc={fallbackSrc} opened={opened} onClose={close} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,13 +1,14 @@
|
|||
import { Avatar, Button, LoadingOverlay, Menu, Modal, PasswordInput } from '@mantine/core';
|
||||
import { useForm, zodResolver } from '@mantine/form';
|
||||
import { useDisclosure } from '@mantine/hooks';
|
||||
import { IconLogout, IconSettings, IconUser } from '@tabler/icons-react';
|
||||
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 { useState } from 'react';
|
||||
|
||||
const schema = z
|
||||
.object({
|
||||
|
|
@ -92,6 +93,9 @@ export default function UserMenu() {
|
|||
<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.Divider />
|
||||
|
||||
|
|
|
|||
|
|
@ -29,7 +29,7 @@ export default function PrivateLayout() {
|
|||
<Logo />
|
||||
|
||||
<Box className="flex items-center gap-4">
|
||||
{Links.MENUS.map((menu, index) => (
|
||||
{Links.MENUS.filter((i) => i.show).map((menu, index) => (
|
||||
<Button
|
||||
size="xs"
|
||||
component={Link}
|
||||
|
|
|
|||
|
|
@ -243,15 +243,17 @@ export default function Bids() {
|
|||
>
|
||||
Histories
|
||||
</Menu.Item>
|
||||
<Menu.Item
|
||||
onClick={() => {
|
||||
setClickData(row);
|
||||
historiesGraysApiModel.open();
|
||||
}}
|
||||
leftSection={<IconHammer size={14} />}
|
||||
>
|
||||
Bids
|
||||
</Menu.Item>
|
||||
{['https://www.grays.com'].includes(row?.web_bid.origin_url) && (
|
||||
<Menu.Item
|
||||
onClick={() => {
|
||||
setClickData(row);
|
||||
historiesGraysApiModel.open();
|
||||
}}
|
||||
leftSection={<IconHammer size={14} />}
|
||||
>
|
||||
Bids
|
||||
</Menu.Item>
|
||||
)}
|
||||
|
||||
<Menu.Item
|
||||
disabled={row.status === 'win-bid'}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,12 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import { useEffect, useState } from 'react';
|
||||
import { Box, Text, Title } from '@mantine/core';
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import io from 'socket.io-client';
|
||||
import { WorkingPage } from '../components/dashboard';
|
||||
import { IBid, IWebBid } from '../system/type';
|
||||
import { checkStatus } from '../apis/auth';
|
||||
|
||||
const socket = io(import.meta.env.VITE_SOCKET_URL, {
|
||||
const socket = io(`${import.meta.env.VITE_SOCKET_URL}/admin-bid-ws`, {
|
||||
autoConnect: true,
|
||||
transports: ['websocket'],
|
||||
});
|
||||
|
|
@ -13,18 +14,24 @@ const socket = io(import.meta.env.VITE_SOCKET_URL, {
|
|||
export default function DashBoard() {
|
||||
const [workingData, setWorkingData] = useState<(IWebBid & { type: string })[] | (IBid & { type: string })[]>([]);
|
||||
|
||||
const RETRY_CONNECT = useRef(2);
|
||||
|
||||
useEffect(() => {
|
||||
socket.connect();
|
||||
|
||||
socket.on('connect', () => {
|
||||
console.log('✅ WebSocket connected:', socket.id);
|
||||
|
||||
// 🔥 Gửi yêu cầu lấy dữ liệu ngay khi kết nối
|
||||
socket.emit('getBidsData');
|
||||
});
|
||||
|
||||
socket.on('disconnect', () => {
|
||||
console.log('❌ WebSocket disconnected');
|
||||
socket.on('disconnect', async () => {
|
||||
if (RETRY_CONNECT.current > 0) {
|
||||
await checkStatus();
|
||||
|
||||
socket.connect();
|
||||
|
||||
RETRY_CONNECT.current--;
|
||||
return;
|
||||
}
|
||||
});
|
||||
|
||||
socket.on('adminBidsUpdated', (data: IWebBid[]) => {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,168 @@
|
|||
import { ActionIcon, Box, CopyButton, Menu, TextInput } from '@mantine/core';
|
||||
import { IconCopy, IconMenu, IconTrash } from '@tabler/icons-react';
|
||||
import { useMemo, useRef } from 'react';
|
||||
import { createKey, deleteKey, deletesKey, getKeys } from '../apis/generate-key';
|
||||
import Table from '../lib/table/table';
|
||||
import { IColumn, TRefTableFn } from '../lib/table/type';
|
||||
import { useConfirmStore } from '../lib/zustand/use-confirm';
|
||||
import { IKey } from '../system/type';
|
||||
import { formatTime } from '../utils';
|
||||
|
||||
export default function GenerateKeys() {
|
||||
const refTableFn: TRefTableFn<IKey> = useRef({});
|
||||
|
||||
const { setConfirm } = useConfirmStore();
|
||||
|
||||
const columns: IColumn<IKey>[] = [
|
||||
{
|
||||
key: 'id',
|
||||
title: 'ID',
|
||||
typeFilter: 'number',
|
||||
},
|
||||
{
|
||||
key: 'client_key',
|
||||
title: 'Client key',
|
||||
typeFilter: 'text',
|
||||
renderRow(row) {
|
||||
return (
|
||||
<CopyButton value={row.client_key}>
|
||||
{({ copied, copy }) => (
|
||||
<TextInput
|
||||
size="xs"
|
||||
value={row.client_key}
|
||||
rightSection={
|
||||
<ActionIcon className="z-30 cursor-pointer" size="xs" color={copied ? 'teal' : 'blue'} onClick={copy}>
|
||||
<IconCopy size={12} />
|
||||
</ActionIcon>
|
||||
}
|
||||
/>
|
||||
)}
|
||||
</CopyButton>
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'created_at',
|
||||
title: 'Create at',
|
||||
typeFilter: 'none',
|
||||
renderRow(row) {
|
||||
return <span className="text-sm">{formatTime(row.created_at)}</span>;
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const handleDelete = (bid: IKey) => {
|
||||
setConfirm({
|
||||
title: 'Delete ?',
|
||||
message: 'This key will be delete',
|
||||
handleOk: async () => {
|
||||
await deleteKey(bid);
|
||||
|
||||
if (refTableFn.current?.fetchData) {
|
||||
refTableFn.current.fetchData();
|
||||
}
|
||||
},
|
||||
});
|
||||
};
|
||||
|
||||
const table = useMemo(() => {
|
||||
return (
|
||||
<Table
|
||||
actionsOptions={{
|
||||
actions: [
|
||||
{
|
||||
key: 'create',
|
||||
title: 'New key',
|
||||
callback: async () => {
|
||||
setConfirm({
|
||||
okButton: {
|
||||
color: 'lime',
|
||||
value: 'Ok',
|
||||
},
|
||||
title: 'A new key will generate',
|
||||
handleOk: async () => {
|
||||
await createKey();
|
||||
|
||||
if (!refTableFn.current?.fetchData) return;
|
||||
|
||||
refTableFn.current.fetchData();
|
||||
},
|
||||
});
|
||||
},
|
||||
},
|
||||
{
|
||||
key: 'delete',
|
||||
title: 'Delete',
|
||||
callback: (data) => {
|
||||
if (!data.length) return;
|
||||
setConfirm({
|
||||
title: 'Delete',
|
||||
message: `${data.length} keys will be delete`,
|
||||
handleOk: async () => {
|
||||
const result = await deletesKey(data);
|
||||
|
||||
if (!result) return;
|
||||
if (refTableFn.current.fetchData) {
|
||||
refTableFn.current.fetchData();
|
||||
}
|
||||
},
|
||||
});
|
||||
},
|
||||
disabled: (data) => data.length <= 0,
|
||||
},
|
||||
],
|
||||
}}
|
||||
refTableFn={refTableFn}
|
||||
striped
|
||||
showLoading={true}
|
||||
highlightOnHover
|
||||
styleDefaultHead={{
|
||||
justifyContent: 'flex-start',
|
||||
width: 'fit-content',
|
||||
}}
|
||||
options={{
|
||||
query: getKeys,
|
||||
pathToData: 'data.data',
|
||||
keyOptions: {
|
||||
last_page: 'lastPage',
|
||||
per_page: 'perPage',
|
||||
from: 'from',
|
||||
to: 'to',
|
||||
total: 'total',
|
||||
},
|
||||
}}
|
||||
actions={{
|
||||
title: <Box className="w-full text-center">Action</Box>,
|
||||
body: (row) => {
|
||||
return (
|
||||
<Menu shadow="md" width={200}>
|
||||
<Menu.Target>
|
||||
<Box onClick={(e) => e.stopPropagation()} className="flex w-full items-center justify-center">
|
||||
<ActionIcon size="sm" variant="light">
|
||||
<IconMenu size={14} />
|
||||
</ActionIcon>
|
||||
</Box>
|
||||
</Menu.Target>
|
||||
|
||||
<Menu.Dropdown onClick={(e) => e.stopPropagation()}>
|
||||
<Menu.Item onClick={() => handleDelete(row)} leftSection={<IconTrash color="red" size={14} />}>
|
||||
Delete
|
||||
</Menu.Item>
|
||||
</Menu.Dropdown>
|
||||
</Menu>
|
||||
);
|
||||
},
|
||||
}}
|
||||
rows={[]}
|
||||
withColumnBorders
|
||||
showChooses={true}
|
||||
withTableBorder
|
||||
columns={columns}
|
||||
rowKey="id"
|
||||
/>
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
return <Box>{table}</Box>;
|
||||
}
|
||||
|
|
@ -1,14 +1,16 @@
|
|||
import { IconHammer, IconHome2, IconMessage, IconOutlet, IconPageBreak, IconUserCheck } from '@tabler/icons-react';
|
||||
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';
|
||||
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 HOME = '/';
|
||||
|
|
@ -20,36 +22,49 @@ export default class Links {
|
|||
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,
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,11 @@ export interface IAdmin extends ITimestamp {
|
|||
permissions: [];
|
||||
}
|
||||
|
||||
export interface IKey extends ITimestamp {
|
||||
id: number;
|
||||
client_key: string;
|
||||
}
|
||||
|
||||
export interface ITimestamp {
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
|
|
@ -29,6 +34,7 @@ export interface IBid extends ITimestamp {
|
|||
first_bid: boolean;
|
||||
status: 'biding' | 'out-bid' | 'win-bid';
|
||||
histories: IHistory[];
|
||||
web_bid: IWebBid;
|
||||
}
|
||||
|
||||
export interface IHistory extends ITimestamp {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"cSpell.words": ["LANGTONS", "LAWSONS"]
|
||||
}
|
||||
|
|
@ -0,0 +1,178 @@
|
|||
<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.0 Transitional//EN">
|
||||
<html>
|
||||
<head>
|
||||
<meta http-equiv=3D"Content-Type" content=3D"text/html; charset=3Dutf-8">
|
||||
<title>Callcentric Email</title>
|
||||
<style type=3D"text/css" media=3D"all">
|
||||
body {
|
||||
font-family: arial,helvetica,sans-serif;
|
||||
font-size: 14px;
|
||||
line-height: 22px;
|
||||
color: #444;
|
||||
margin: 0;
|
||||
padding: 20px 0;
|
||||
background-color: #e5e5e5;
|
||||
outline: 0;
|
||||
vertical-align: top;
|
||||
}
|
||||
p {
|
||||
margin-bottom: 1.5em;
|
||||
}
|
||||
img {
|
||||
display: block;
|
||||
}
|
||||
a:link,
|
||||
a:visited {
|
||||
color: #006699;
|
||||
text-decoration: underline;
|
||||
margin: 0px;
|
||||
padding: 0px;
|
||||
}
|
||||
a:hover {=20
|
||||
text-decoration: none;
|
||||
}
|
||||
li {
|
||||
margin-bottom: 1em;
|
||||
line-height: 22px;
|
||||
}=09
|
||||
=2Efooter a {
|
||||
color: #aaaaaa;
|
||||
text-decoration: underline;
|
||||
}
|
||||
=2Efooter a:hover {
|
||||
text-decoration: none;
|
||||
}
|
||||
=2Eframe {
|
||||
width: 580px;
|
||||
}
|
||||
#btn {
|
||||
background: #3498db;
|
||||
background-image: -webkit-linear-gradient(top, #3498db, #2980b9);
|
||||
background-image: -moz-linear-gradient(top, #3498db, #2980b9);
|
||||
background-image: -ms-linear-gradient(top, #3498db, #2980b9);
|
||||
background-image: -o-linear-gradient(top, #3498db, #2980b9);
|
||||
background-image: linear-gradient(to bottom, #3498db, #2980b9);
|
||||
-webkit-border-radius: 28;
|
||||
-moz-border-radius: 28;
|
||||
border-radius: 28px;
|
||||
font-family: Arial;
|
||||
color: #ffffff;
|
||||
font-size: 20px;
|
||||
padding: 10px 20px 10px 20px;
|
||||
text-decoration: none;
|
||||
display: inline-block;
|
||||
}
|
||||
#btn:hover {
|
||||
background: #3cb0fd;
|
||||
background-image: -webkit-linear-gradient(top, #3cb0fd, #3498db);
|
||||
background-image: -moz-linear-gradient(top, #3cb0fd, #3498db);
|
||||
background-image: -ms-linear-gradient(top, #3cb0fd, #3498db);
|
||||
background-image: -o-linear-gradient(top, #3cb0fd, #3498db);
|
||||
background-image: linear-gradient(to bottom, #3cb0fd, #3498db);
|
||||
text-decoration: none;
|
||||
}
|
||||
=2Esms-box { font-family: arial,helvetica,sans-serif; font-size: 14px; padd=
|
||||
ing:12px; border:1px solid #e0e0e0; background-color: #e0e0e0; -webkit-bord=
|
||||
er-radius: 12px; -moz-border-radius: 12px; border-radius: 12px; word-wrap:b=
|
||||
reak-word; display:inline-block; }
|
||||
p { font-family: arial,helvetica,sans-serif; font-size: 14px; margin-bottom=
|
||||
: 1.5em; line-height: 22px; }
|
||||
|
||||
@media only screen and (max-device-width: 480px) {
|
||||
.frame {
|
||||
width: 442px !important;
|
||||
}
|
||||
|
||||
p {
|
||||
font-size: 22px !important;
|
||||
line-height: 28px !important;
|
||||
}
|
||||
ul li {
|
||||
font-size: 22px !important;
|
||||
line-height: 28px !important;
|
||||
}
|
||||
.section_header{
|
||||
font-size: 28px !important;
|
||||
line-height: 30px !important;
|
||||
}
|
||||
#btn {
|
||||
font-size: 28px !important;
|
||||
padding: 14px 24px 14px 24px !important;
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
</head>
|
||||
<body style=3D"font-family: Arial, sans-serif; font-size: 14px; margin-bott=
|
||||
om: 1.5em; line-height: 22px; color: black; width: 100% !important; margin:=
|
||||
0; padding: 0px; background-color: #e5e5e5; outline: 0; vertical-align: to=
|
||||
p;" bgcolor=3D"#e5e5e5">
|
||||
<table width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0"><tr>=
|
||||
<td style=3D"padding: 0px 0px;" align=3D"center">
|
||||
<table class=3D"head" width=3D"100%" bgcolor=3D"#c9c9c9" style=3D"backgroun=
|
||||
d-color:#c9c9c9;">
|
||||
<tr><td align=3D"center" style=3D"color:#f3f3f3; padding:5px; font-size:12=
|
||||
px;">Please do not respond to this automated message, you will not receive =
|
||||
a reply</td></tr>
|
||||
</table>
|
||||
</td></tr><tr><td style=3D"padding: 0px 10px;" align=3D"center">
|
||||
<table class=3D"frame" width=3D"580" cellpadding=3D"0" cellspacing=3D"0" bo=
|
||||
rder=3D"0" align=3D"center"><tbody>
|
||||
<tr><td>
|
||||
<table width=3D"100%" cellpadding=3D"0" cellspacing=3D"0" border=3D"0">
|
||||
<tr><td style=3D"padding: 30px 0px 10px 0px; color: #444; text-align: le=
|
||||
ft; vertical-align: middle;" align=3D"left"><a href=3D"https://www.callcent=
|
||||
ric.com"><img src=3D"https://www.callcentric.com/img/callcentric_logo_250_t=
|
||||
rans.png" width=3D"125" height=3D"33" alt=3D"Callcentric" border=3D"0"></a>=
|
||||
</td></tr>
|
||||
</table>
|
||||
</td></tr>
|
||||
<tr><td style=3D"background-color: #ffffff; padding: 0px 0px 50px; border-=
|
||||
radius: 5px 5px 0px 0px;">
|
||||
<table style=3D"padding-top: 40px;" width=3D"100%" cellpadding=3D"0" cell=
|
||||
spacing=3D"0" border=3D"0"><tbody>
|
||||
<tr><td class=3D"flex-column" style=3D"width: 40px;"></td>
|
||||
<td style=3D"text-align: left;" align=3D"left">
|
||||
<p>Dear Morgan Vu,</p><p>You have received an inbound text message from 223=
|
||||
95 on your DID 17146893350:</p><div class=3D"sms-box">Your LANGTONS verific=
|
||||
ation code is: 012152. This code will expire in 10 minutes.</div>
|
||||
|
||||
</td><td class=3D"flex-column" style=3D"width: 40px;"></td></tr>
|
||||
</tbody></table>
|
||||
</td></tr>
|
||||
<tr><td align=3D"left" class=3D"footer" style=3D"background-color: #f2f2f2=
|
||||
; color: #999999; padding: 0px 0px 40px; border-radius: 0px 0px 5px 5px;">
|
||||
<table border=3D"0" cellpadding=3D"0" cellspacing=3D"0" style=3D"padding-=
|
||||
top: 40px;" width=3D"100%"><tbody>
|
||||
<tr><td style=3D"width: 40px;"></td>
|
||||
<td align=3D"left" style=3D"text-align: left; font-family:Arial, Helvet=
|
||||
ica, sans-serif; font-size: 15px; line-height: 22px; color: #999999;">
|
||||
|
||||
Follow us on Twitter at:<br>
|
||||
<a href=3D"https://twitter.com/Callcentric">https://twitter.com/Callcentric=
|
||||
</a>
|
||||
<br><br>
|
||||
|
||||
If you have any questions/concerns, please contact our Customer Service Tea=
|
||||
m via the following page:<br>
|
||||
<a href=3D"https://www.callcentric.com/CustomerService">https://www.callcen=
|
||||
tric.com/CustomerService</a><br><br>
|
||||
|
||||
For instructions on how to unsubscribe from our Newsletters/Service Announc=
|
||||
ements please visit:<br>
|
||||
|
||||
<a href=3D"https://www.callcentric.com/faq/11/306">https://www.callcentric=
|
||||
=2Ecom/faq/11/306</a><br><br>
|
||||
|
||||
<a href=3D"https://www.callcentric.com">www.callcentric.com</a><br><br>
|
||||
|
||||
</td><td style=3D"width: 40px;"></td>
|
||||
</tr>
|
||||
</tbody></table>
|
||||
</td></tr>
|
||||
<tr><td style=3D"padding: 20px 0; font-size: 15px; line-height: 25px; colo=
|
||||
r: #999999; text-align: center;" align=3D"center"></td></tr>
|
||||
</tbody></table></td></tr></table>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
|
|
@ -0,0 +1,32 @@
|
|||
{
|
||||
"id": -1002593407119,
|
||||
"title": "Bid histories dev",
|
||||
"type": "supergroup",
|
||||
"invite_link": "https://t.me/+CSBIA7mbyBhkM2Jl",
|
||||
"permissions": {
|
||||
"can_send_messages": true,
|
||||
"can_send_media_messages": true,
|
||||
"can_send_audios": true,
|
||||
"can_send_documents": true,
|
||||
"can_send_photos": true,
|
||||
"can_send_videos": true,
|
||||
"can_send_video_notes": true,
|
||||
"can_send_voice_notes": true,
|
||||
"can_send_polls": true,
|
||||
"can_send_other_messages": true,
|
||||
"can_add_web_page_previews": true,
|
||||
"can_change_info": true,
|
||||
"can_invite_users": true,
|
||||
"can_pin_messages": true,
|
||||
"can_manage_topics": true
|
||||
},
|
||||
"join_to_send_messages": true,
|
||||
"accepted_gift_types": {
|
||||
"unlimited_gifts": false,
|
||||
"limited_gifts": false,
|
||||
"unique_gifts": false,
|
||||
"premium_subscription": false
|
||||
},
|
||||
"max_reaction_count": 11,
|
||||
"accent_color_id": 2
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
{"createdAt":1744361020595}
|
||||
|
|
@ -24,24 +24,32 @@
|
|||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie": "^1.0.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dayjs": "^1.11.13",
|
||||
"imap": "^0.8.19",
|
||||
"lodash": "^4.17.21",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.13.0",
|
||||
"nestjs-paginate": "^11.1.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.33.5",
|
||||
"typeorm": "^0.3.21"
|
||||
"simple-imap": "^0.1.6",
|
||||
"typeorm": "^0.3.21",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/imap": "^0.8.42",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/supertest": "^6.0.0",
|
||||
|
|
@ -2823,6 +2831,13 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/cookie": {
|
||||
"version": "0.6.0",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie/-/cookie-0.6.0.tgz",
|
||||
"integrity": "sha512-4Kh9a6B2bQciAhf7FSuMRRkUWecJgJu9nPnx3yzpsfXX/c50REIqpHY4C82bXP90qrLtXtkDxTZosYO3UpOwlA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/cookie-parser": {
|
||||
"version": "1.4.8",
|
||||
"resolved": "https://registry.npmjs.org/@types/cookie-parser/-/cookie-parser-1.4.8.tgz",
|
||||
|
|
@ -2921,6 +2936,16 @@
|
|||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/imap": {
|
||||
"version": "0.8.42",
|
||||
"resolved": "https://registry.npmjs.org/@types/imap/-/imap-0.8.42.tgz",
|
||||
"integrity": "sha512-FusePG9Cp2GYN6OLow9xBCkjznFkAR7WCz0Fm+j1p/ER6C8V8P71DtjpSmwrZsS7zekCeqdTPHEk9N5OgPwcsg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/istanbul-lib-coverage": {
|
||||
"version": "2.0.6",
|
||||
"resolved": "https://registry.npmjs.org/@types/istanbul-lib-coverage/-/istanbul-lib-coverage-2.0.6.tgz",
|
||||
|
|
@ -2975,6 +3000,13 @@
|
|||
"@types/node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/@types/lodash": {
|
||||
"version": "4.17.16",
|
||||
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.16.tgz",
|
||||
"integrity": "sha512-HX7Em5NYQAXKW+1T+FiuG27NGwzJfCX3s1GjOa7ujxZa52kjJLOr4FUxT+giF6Tgxv1e+/czV/iTtBw27WTU9g==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@types/methods": {
|
||||
"version": "1.1.4",
|
||||
"resolved": "https://registry.npmjs.org/@types/methods/-/methods-1.1.4.tgz",
|
||||
|
|
@ -3548,6 +3580,12 @@
|
|||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/addressparser": {
|
||||
"version": "1.0.1",
|
||||
"resolved": "https://registry.npmjs.org/addressparser/-/addressparser-1.0.1.tgz",
|
||||
"integrity": "sha512-aQX7AISOMM7HFE0iZ3+YnD07oIeJqWGVnJ+ZIKaBZAk03ftmVYVqsGas/rbXKR21n4D/hKCSHypvcyOkds/xzg==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/agent-base": {
|
||||
"version": "6.0.2",
|
||||
"resolved": "https://registry.npmjs.org/agent-base/-/agent-base-6.0.2.tgz",
|
||||
|
|
@ -4719,12 +4757,12 @@
|
|||
"license": "MIT"
|
||||
},
|
||||
"node_modules/cookie": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/cookie-parser": {
|
||||
|
|
@ -5179,6 +5217,27 @@
|
|||
"node": ">= 0.8"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding": {
|
||||
"version": "0.1.13",
|
||||
"resolved": "https://registry.npmjs.org/encoding/-/encoding-0.1.13.tgz",
|
||||
"integrity": "sha512-ETBauow1T35Y/WZMkio9jiM0Z5xjHHmJ4XmjZOq1l/dXz3lr2sRn87nJy20RupqSh1F2m3HHPSp8ShIPQJrJ3A==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"iconv-lite": "^0.6.2"
|
||||
}
|
||||
},
|
||||
"node_modules/encoding/node_modules/iconv-lite": {
|
||||
"version": "0.6.3",
|
||||
"resolved": "https://registry.npmjs.org/iconv-lite/-/iconv-lite-0.6.3.tgz",
|
||||
"integrity": "sha512-4fCk79wshMdzMp2rH06qWrJE4iolqLhCUH+OiuIgU++RB0+94NlDL81atO7GX55uUKueo0txHNtvEyI6D7WdMw==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"safer-buffer": ">= 2.1.2 < 3.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/engine.io": {
|
||||
"version": "6.6.4",
|
||||
"resolved": "https://registry.npmjs.org/engine.io/-/engine.io-6.6.4.tgz",
|
||||
|
|
@ -5736,6 +5795,15 @@
|
|||
"url": "https://opencollective.com/express"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/cookie": {
|
||||
"version": "0.7.1",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-0.7.1.tgz",
|
||||
"integrity": "sha512-6DnInpx7SJ2AK3+CTUE/ZM0vWTUboZCegxhC2xiIydHR9jNuTAASBrfEpHhiGOZw/nX51bHt6YQl8jsGo4y/0w==",
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/express/node_modules/debug": {
|
||||
"version": "2.6.9",
|
||||
"resolved": "https://registry.npmjs.org/debug/-/debug-2.6.9.tgz",
|
||||
|
|
@ -6752,6 +6820,42 @@
|
|||
"node": ">= 4"
|
||||
}
|
||||
},
|
||||
"node_modules/imap": {
|
||||
"version": "0.8.19",
|
||||
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.19.tgz",
|
||||
"integrity": "sha512-z5DxEA1uRnZG73UcPA4ES5NSCGnPuuouUx43OPX7KZx1yzq3N8/vx2mtXEShT5inxB3pRgnfG1hijfu7XN2YMw==",
|
||||
"dependencies": {
|
||||
"readable-stream": "1.1.x",
|
||||
"utf7": ">=1.0.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/imap/node_modules/isarray": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
|
||||
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/imap/node_modules/readable-stream": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
|
||||
"integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.1",
|
||||
"isarray": "0.0.1",
|
||||
"string_decoder": "~0.10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/imap/node_modules/string_decoder": {
|
||||
"version": "0.10.31",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
|
||||
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/import-fresh": {
|
||||
"version": "3.3.1",
|
||||
"resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz",
|
||||
|
|
@ -8088,16 +8192,6 @@
|
|||
"set-cookie-parser": "^2.6.0"
|
||||
}
|
||||
},
|
||||
"node_modules/light-my-request/node_modules/cookie": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/cookie/-/cookie-1.0.2.tgz",
|
||||
"integrity": "sha512-9Kr/j4O16ISv8zBBhJoi4bXOYNTkFLOqSL3UDB0njXxCXNezjeyVrJyGOWtgfs/q2km1gwBcfH8q1yEGoMYunA==",
|
||||
"license": "MIT",
|
||||
"peer": true,
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/lines-and-columns": {
|
||||
"version": "1.2.4",
|
||||
"resolved": "https://registry.npmjs.org/lines-and-columns/-/lines-and-columns-1.2.4.tgz",
|
||||
|
|
@ -8254,6 +8348,18 @@
|
|||
"node": ">=12"
|
||||
}
|
||||
},
|
||||
"node_modules/mailparser": {
|
||||
"version": "0.4.6",
|
||||
"resolved": "https://registry.npmjs.org/mailparser/-/mailparser-0.4.6.tgz",
|
||||
"integrity": "sha512-G9D9e+v0UKpG7jiANGNkMCinqcG+Q70GngQQm8WknkMEFLx7Cyp+vnoAJaOtVCR9dPuyVAKaUJlOgDE6HTwmSw==",
|
||||
"deprecated": "Mailparser versions older than v2.3.0 are deprecated",
|
||||
"dependencies": {
|
||||
"encoding": ">=0.1.4",
|
||||
"mime": "*",
|
||||
"mimelib": ">=0.2.17",
|
||||
"uue": "~1.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/make-dir": {
|
||||
"version": "4.0.0",
|
||||
"resolved": "https://registry.npmjs.org/make-dir/-/make-dir-4.0.0.tgz",
|
||||
|
|
@ -8413,6 +8519,17 @@
|
|||
"node": ">= 0.6"
|
||||
}
|
||||
},
|
||||
"node_modules/mimelib": {
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/mimelib/-/mimelib-0.3.1.tgz",
|
||||
"integrity": "sha512-ipzNp6TBsNfD3hButGlPVlGmuCgybIM9SBf8YwIG+SYmBgtU0u8wjf+BSrJX0mvqtv59SLmwphw/XiCbkLWv7w==",
|
||||
"deprecated": "This project is unmaintained",
|
||||
"license": "EUPL-1.1",
|
||||
"dependencies": {
|
||||
"addressparser": "~1.0.1",
|
||||
"encoding": "~0.1.12"
|
||||
}
|
||||
},
|
||||
"node_modules/mimic-fn": {
|
||||
"version": "2.1.0",
|
||||
"resolved": "https://registry.npmjs.org/mimic-fn/-/mimic-fn-2.1.0.tgz",
|
||||
|
|
@ -8500,6 +8617,14 @@
|
|||
"mkdirp": "bin/cmd.js"
|
||||
}
|
||||
},
|
||||
"node_modules/moment": {
|
||||
"version": "2.8.3",
|
||||
"resolved": "https://registry.npmjs.org/moment/-/moment-2.8.3.tgz",
|
||||
"integrity": "sha512-BV9tebhfgcO0mT3desIQEM8vdFGOsqwfJ71a+mz/ECVa0uiokosBq3A3Vyk7XhyNUzzQECX/T/bpHQANemf5Vg==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/ms": {
|
||||
"version": "2.1.3",
|
||||
"resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
|
||||
|
|
@ -10119,6 +10244,64 @@
|
|||
"url": "https://github.com/sponsors/isaacs"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-imap": {
|
||||
"version": "0.1.6",
|
||||
"resolved": "https://registry.npmjs.org/simple-imap/-/simple-imap-0.1.6.tgz",
|
||||
"integrity": "sha512-mdmaq1u/qEOa8Ow3ZPKv2KXZbV5oHfFewqgqi8FnxD0mQzMzMLtKcE3TEmI7qhp1FmRcdqBlYeUMtOt7nS7XkA==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"imap": "0.8.14",
|
||||
"mailparser": "0.4.6",
|
||||
"moment": "2.8.3"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-imap/node_modules/imap": {
|
||||
"version": "0.8.14",
|
||||
"resolved": "https://registry.npmjs.org/imap/-/imap-0.8.14.tgz",
|
||||
"integrity": "sha512-8opGClGwiZ7/NBc3wofk7PbGkaLqggXx8W0D/Bc6YQdufl9CixmMBKm5rrn9gyelMVdMQVfpXp3D6gdXBgtAdg==",
|
||||
"dependencies": {
|
||||
"readable-stream": "1.1.x",
|
||||
"utf7": "1.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=0.8.0"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-imap/node_modules/isarray": {
|
||||
"version": "0.0.1",
|
||||
"resolved": "https://registry.npmjs.org/isarray/-/isarray-0.0.1.tgz",
|
||||
"integrity": "sha512-D2S+3GLxWH+uhrNEcoh/fnmYeP8E8/zHl644d/jdA0g2uyXvy3sb0qxotE+ne0LtccHknQzWwZEzhak7oJ0COQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-imap/node_modules/readable-stream": {
|
||||
"version": "1.1.14",
|
||||
"resolved": "https://registry.npmjs.org/readable-stream/-/readable-stream-1.1.14.tgz",
|
||||
"integrity": "sha512-+MeVjFf4L44XUkhM1eYbD8fyEsxcV81pqMSR5gblfcLCHfZvbrqy4/qYHE+/R5HoBUT11WV5O08Cr1n3YXkWVQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"core-util-is": "~1.0.0",
|
||||
"inherits": "~2.0.1",
|
||||
"isarray": "0.0.1",
|
||||
"string_decoder": "~0.10.x"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-imap/node_modules/string_decoder": {
|
||||
"version": "0.10.31",
|
||||
"resolved": "https://registry.npmjs.org/string_decoder/-/string_decoder-0.10.31.tgz",
|
||||
"integrity": "sha512-ev2QzSzWPYmy9GuqfIVildA4OdcGLeFZQrq5ys6RtiuF+RQQiZWr8TZNyAcuVXyQRYfEO+MsoB/1BuQVhOJuoQ==",
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/simple-imap/node_modules/utf7": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.0.tgz",
|
||||
"integrity": "sha512-vMfAo9fTQeZG7W8PEOWa9g++rkHxYqBBrb22OWY+OMUnSFFtQURySIIJ2LstpF5JI6SzsGQNaglin2aLwA7pTA==",
|
||||
"engines": {
|
||||
"node": "*"
|
||||
}
|
||||
},
|
||||
"node_modules/simple-swizzle": {
|
||||
"version": "0.2.2",
|
||||
"resolved": "https://registry.npmjs.org/simple-swizzle/-/simple-swizzle-0.2.2.tgz",
|
||||
|
|
@ -11362,6 +11545,23 @@
|
|||
"punycode": "^2.1.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utf7": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/utf7/-/utf7-1.0.2.tgz",
|
||||
"integrity": "sha512-qQrPtYLLLl12NF4DrM9CvfkxkYI97xOb5dsnGZHE3teFr0tWiEZ9UdgMPczv24vl708cYMpe6mGXGHrotIp3Bw==",
|
||||
"dependencies": {
|
||||
"semver": "~5.3.0"
|
||||
}
|
||||
},
|
||||
"node_modules/utf7/node_modules/semver": {
|
||||
"version": "5.3.0",
|
||||
"resolved": "https://registry.npmjs.org/semver/-/semver-5.3.0.tgz",
|
||||
"integrity": "sha512-mfmm3/H9+67MCVix1h+IXTpDwL6710LyHuk7+cWC9T1mE0qz4iHhh6r4hU2wrIT9iTsAAC2XQRvfblL028cpLw==",
|
||||
"license": "ISC",
|
||||
"bin": {
|
||||
"semver": "bin/semver"
|
||||
}
|
||||
},
|
||||
"node_modules/util-deprecate": {
|
||||
"version": "1.0.2",
|
||||
"resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz",
|
||||
|
|
@ -11377,6 +11577,11 @@
|
|||
"node": ">= 0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/uue": {
|
||||
"version": "1.0.0",
|
||||
"resolved": "https://registry.npmjs.org/uue/-/uue-1.0.0.tgz",
|
||||
"integrity": "sha512-gEwvMeDsv0Q4SFsjhp/PmsXizTfEn3B8SXqtRiz5jE2nsRgEBCY5W5gmxOFrPVzbnDCELwOKyMnOEoL/W/TG7w=="
|
||||
},
|
||||
"node_modules/uuid": {
|
||||
"version": "11.1.0",
|
||||
"resolved": "https://registry.npmjs.org/uuid/-/uuid-11.1.0.tgz",
|
||||
|
|
|
|||
|
|
@ -40,24 +40,32 @@
|
|||
"bcrypt": "^5.1.1",
|
||||
"class-transformer": "^0.5.1",
|
||||
"class-validator": "^0.14.1",
|
||||
"cookie": "^1.0.2",
|
||||
"cookie-parser": "^1.4.7",
|
||||
"dayjs": "^1.11.13",
|
||||
"imap": "^0.8.19",
|
||||
"lodash": "^4.17.21",
|
||||
"multer": "^1.4.5-lts.1",
|
||||
"mysql2": "^3.13.0",
|
||||
"nestjs-paginate": "^11.1.0",
|
||||
"reflect-metadata": "^0.2.0",
|
||||
"rxjs": "^7.8.1",
|
||||
"sharp": "^0.33.5",
|
||||
"typeorm": "^0.3.21"
|
||||
"simple-imap": "^0.1.6",
|
||||
"typeorm": "^0.3.21",
|
||||
"uuid": "^11.1.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@nestjs/cli": "^10.0.0",
|
||||
"@nestjs/schematics": "^10.0.0",
|
||||
"@nestjs/testing": "^10.0.0",
|
||||
"@types/bcrypt": "^5.0.2",
|
||||
"@types/cookie": "^0.6.0",
|
||||
"@types/cookie-parser": "^1.4.8",
|
||||
"@types/express": "^4.17.17",
|
||||
"@types/imap": "^0.8.42",
|
||||
"@types/jest": "^29.5.2",
|
||||
"@types/lodash": "^4.17.16",
|
||||
"@types/multer": "^1.4.12",
|
||||
"@types/node": "^20.3.1",
|
||||
"@types/supertest": "^6.0.0",
|
||||
|
|
|
|||
|
|
@ -6,8 +6,14 @@ import { AppValidatorsModule } from './modules/app-validators/app-validators.mod
|
|||
import { AuthModule } from './modules/auth/auth.module';
|
||||
import { AdminsModule } from './modules/admins/admins.module';
|
||||
import { AuthenticationMiddleware } from './modules/auth/middlewares/authentication.middleware';
|
||||
import { excludeAuth, excludeAuthor } from './system/routes/exclude-route';
|
||||
import {
|
||||
clientExcludeAuth,
|
||||
excludeAuth,
|
||||
excludeAuthor,
|
||||
} from './system/routes/exclude-route';
|
||||
import { AuthorizationMiddleware } from './modules/admins/middlewares/authorization.middleware';
|
||||
import { ClientAuthenticationMiddleware } from './modules/auth/middlewares/client-authentication.middleware';
|
||||
import { NotificationModule } from './modules/notification/notification.module';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -17,6 +23,7 @@ import { AuthorizationMiddleware } from './modules/admins/middlewares/authorizat
|
|||
AppValidatorsModule,
|
||||
AuthModule,
|
||||
AdminsModule,
|
||||
NotificationModule,
|
||||
],
|
||||
controllers: [],
|
||||
providers: [],
|
||||
|
|
@ -32,5 +39,10 @@ export class AppModule {
|
|||
.apply(AuthorizationMiddleware)
|
||||
.exclude(...excludeAuthor)
|
||||
.forRoutes({ path: 'admin/*', method: RequestMethod.ALL });
|
||||
|
||||
consumer
|
||||
.apply(ClientAuthenticationMiddleware)
|
||||
.exclude(...clientExcludeAuth)
|
||||
.forRoutes({ path: '/*', method: RequestMethod.ALL });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -12,7 +12,7 @@ async function bootstrap() {
|
|||
const prefix_version = '/api/v1';
|
||||
|
||||
app.enableCors({
|
||||
origin: process.env.CORS.split(', '),
|
||||
origin: process.env.CORS.split(', ') || '*',
|
||||
methods: ['GET', 'PUT', 'POST', 'DELETE', 'PATCH'],
|
||||
allowedHeaders: 'Content-Type, Authorization',
|
||||
credentials: true,
|
||||
|
|
|
|||
|
|
@ -6,11 +6,18 @@ import { AdminsController } from './controllers/admins.controller';
|
|||
import Permission from './entities/permission.entity';
|
||||
import { PermissionService } from './services/permission.service';
|
||||
import { PermissionsController } from './controllers/permissions.controller';
|
||||
import { GenerateKey } from './entities/generate-key.entity';
|
||||
import { GenerateKeysController } from './controllers/generate-keys.controller';
|
||||
import { GenerateKeysService } from './services/generate-key.service';
|
||||
|
||||
@Module({
|
||||
imports: [TypeOrmModule.forFeature([Admin, Permission])],
|
||||
providers: [AdminsService, PermissionService],
|
||||
exports: [AdminsService, PermissionService],
|
||||
controllers: [AdminsController, PermissionsController],
|
||||
imports: [TypeOrmModule.forFeature([Admin, Permission, GenerateKey])],
|
||||
providers: [AdminsService, PermissionService, GenerateKeysService],
|
||||
exports: [AdminsService, PermissionService, GenerateKeysService],
|
||||
controllers: [
|
||||
AdminsController,
|
||||
PermissionsController,
|
||||
GenerateKeysController,
|
||||
],
|
||||
})
|
||||
export class AdminsModule {}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
import {
|
||||
Body,
|
||||
Controller,
|
||||
Delete,
|
||||
Get,
|
||||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Req,
|
||||
UseGuards,
|
||||
} from '@nestjs/common';
|
||||
import { AdminsService } from '../services/admins.service';
|
||||
import { Paginate, PaginateQuery } from 'nestjs-paginate';
|
||||
import Admin from '../entities/admin.entity';
|
||||
import { UpdateDto } from '../dtos/update.dto';
|
||||
import { DeletesDto } from '../dtos/deletes.dto';
|
||||
import { Request } from 'express';
|
||||
import { CreateDto } from '../dtos/create.dto';
|
||||
import Permission from '../entities/permission.entity';
|
||||
import { GrantNewPasswordDto } from '../dtos/grant-new-password.dto';
|
||||
import { SystemAccountGuard } from '@/system/guards/system-account.guard';
|
||||
import { GenerateKeysService } from '../services/generate-key.service';
|
||||
|
||||
@Controller('admin/keys')
|
||||
export class GenerateKeysController {
|
||||
constructor(private readonly generateKeyService: GenerateKeysService) {}
|
||||
|
||||
@Get()
|
||||
async index(@Paginate() query: PaginateQuery) {
|
||||
return await this.generateKeyService.index(query);
|
||||
}
|
||||
|
||||
@Delete(':id')
|
||||
async delete(@Param('id') id: Admin['id']) {
|
||||
return await this.generateKeyService.delete(id);
|
||||
}
|
||||
|
||||
@Post('')
|
||||
async create(@Req() request: Request) {
|
||||
return await this.generateKeyService.create(request);
|
||||
}
|
||||
|
||||
@Post('deletes')
|
||||
async deletes(@Body() data: DeletesDto) {
|
||||
return await this.generateKeyService.deletes(data.ids);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,7 +1,15 @@
|
|||
import { Exclude } from 'class-transformer';
|
||||
import { Column, Entity, ManyToMany, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import {
|
||||
Column,
|
||||
Entity,
|
||||
ManyToMany,
|
||||
ManyToOne,
|
||||
OneToMany,
|
||||
PrimaryGeneratedColumn,
|
||||
} from 'typeorm';
|
||||
import Permission from './permission.entity';
|
||||
import { Timestamp } from './timestamp';
|
||||
import { GenerateKey } from './generate-key.entity';
|
||||
|
||||
@Entity('admins')
|
||||
export default class Admin extends Timestamp {
|
||||
|
|
@ -29,4 +37,9 @@ export default class Admin extends Timestamp {
|
|||
onDelete: 'CASCADE',
|
||||
})
|
||||
permissions: Permission[];
|
||||
|
||||
@OneToMany(() => GenerateKey, (key) => key.admin, {
|
||||
cascade: true,
|
||||
})
|
||||
generateKeys: GenerateKey[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,18 @@
|
|||
import { Exclude } from 'class-transformer';
|
||||
import { Column, Entity, ManyToOne, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import Admin from './admin.entity';
|
||||
import { Timestamp } from './timestamp';
|
||||
|
||||
@Entity('generate_keys')
|
||||
export class GenerateKey extends Timestamp {
|
||||
@PrimaryGeneratedColumn('increment')
|
||||
id: number;
|
||||
|
||||
@Column({ unique: true })
|
||||
client_key: string;
|
||||
|
||||
@ManyToOne(() => Admin, (admin) => admin.generateKeys, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
admin: Admin;
|
||||
}
|
||||
|
|
@ -119,6 +119,7 @@ export class AdminsService {
|
|||
| 'is_system_account'
|
||||
| 'permissions'
|
||||
| 'posts'
|
||||
| 'generateKeys'
|
||||
>) {
|
||||
const hashPassword = await bcrypt.hash(password, 10);
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,106 @@
|
|||
import AppResponse from '@/response/app-response';
|
||||
import {
|
||||
BadRequestException,
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NotFoundException,
|
||||
} from '@nestjs/common';
|
||||
import { InjectRepository } from '@nestjs/typeorm';
|
||||
import { Request } from 'express';
|
||||
import {
|
||||
FilterOperator,
|
||||
FilterSuffix,
|
||||
paginate,
|
||||
PaginateQuery,
|
||||
} from 'nestjs-paginate';
|
||||
import { Column } from 'nestjs-paginate/lib/helper';
|
||||
import { In, Repository } from 'typeorm';
|
||||
import { v1 as uuidV1 } from 'uuid';
|
||||
import Admin from '../entities/admin.entity';
|
||||
import { GenerateKey } from '../entities/generate-key.entity';
|
||||
import Permission from '../entities/permission.entity';
|
||||
|
||||
@Injectable()
|
||||
export class GenerateKeysService {
|
||||
constructor(
|
||||
@InjectRepository(GenerateKey)
|
||||
readonly generateKeyRepo: Repository<GenerateKey>,
|
||||
@InjectRepository(Permission)
|
||||
readonly permissionRepo: Repository<Permission>,
|
||||
) {}
|
||||
|
||||
async index(query: PaginateQuery) {
|
||||
const filterableColumns: {
|
||||
[key in Column<Admin> | (string & {})]?:
|
||||
| (FilterOperator | FilterSuffix)[]
|
||||
| true;
|
||||
} = {
|
||||
id: true,
|
||||
client_key: [FilterOperator.ILIKE],
|
||||
};
|
||||
|
||||
query.filter = AppResponse.processFilters(query.filter, filterableColumns);
|
||||
|
||||
const data = await paginate(query, this.generateKeyRepo, {
|
||||
sortableColumns: ['id', 'client_key'],
|
||||
searchableColumns: ['id', 'client_key'],
|
||||
defaultLimit: 15,
|
||||
filterableColumns,
|
||||
defaultSortBy: [['id', 'DESC']],
|
||||
maxLimit: 100,
|
||||
});
|
||||
|
||||
return AppResponse.toPagination<GenerateKey>(data, true, GenerateKey);
|
||||
}
|
||||
|
||||
async delete(id: Admin['id']) {
|
||||
const admin = await this.generateKeyRepo.findOne({ where: { id } });
|
||||
|
||||
if (!admin)
|
||||
throw new NotFoundException(
|
||||
AppResponse.toResponse(false, {
|
||||
message: 'Key is not found',
|
||||
status_code: HttpStatus.NOT_FOUND,
|
||||
}),
|
||||
);
|
||||
|
||||
await this.generateKeyRepo.delete({ id: admin.id });
|
||||
|
||||
return AppResponse.toResponse(true, { message: 'Delete success !' });
|
||||
}
|
||||
|
||||
async deletes(ids: Admin['id'][]) {
|
||||
const result = await this.generateKeyRepo.delete({
|
||||
id: In(ids),
|
||||
});
|
||||
|
||||
if (!result.affected) {
|
||||
throw new BadRequestException(
|
||||
AppResponse.toResponse(false, {
|
||||
message: 'No items have been deleted yet.',
|
||||
status_code: HttpStatus.BAD_REQUEST,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return AppResponse.toResponse(true, { message: 'Delete success !' });
|
||||
}
|
||||
|
||||
async create(request: Request) {
|
||||
const admin = request['admin'];
|
||||
|
||||
const clientKey = uuidV1();
|
||||
|
||||
const result = await this.generateKeyRepo.save({
|
||||
admin: { id: admin.id },
|
||||
client_key: `client-${clientKey}-${admin.id}`,
|
||||
});
|
||||
|
||||
if (!result)
|
||||
throw new BadRequestException(
|
||||
AppResponse.toResponse(null, { message: `Can't create key` }),
|
||||
);
|
||||
|
||||
return AppResponse.toResponse(result);
|
||||
}
|
||||
}
|
||||
|
|
@ -1,11 +1,16 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { ConfigModule } from '@nestjs/config';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
ConfigModule.forRoot({
|
||||
isGlobal: true,
|
||||
}),
|
||||
EventEmitterModule.forRoot({
|
||||
wildcard: true,
|
||||
global: true,
|
||||
}),
|
||||
],
|
||||
})
|
||||
export class AppConfigsModule {}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { JwtModule } from '@nestjs/jwt';
|
||||
import { AuthController } from './auth.controller';
|
||||
import { AuthController } from './controllers/auth.controller';
|
||||
import { AuthService } from './auth.service';
|
||||
import { AdminsModule } from '../admins/admins.module';
|
||||
|
||||
|
|
|
|||
|
|
@ -14,12 +14,14 @@ import { Request, Response } from 'express';
|
|||
import { Constant } from './ultils/constant';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import AppResponse from '@/response/app-response';
|
||||
import { GenerateKeysService } from '../admins/services/generate-key.service';
|
||||
|
||||
@Injectable()
|
||||
export class AuthService {
|
||||
constructor(
|
||||
private jwtService: JwtService,
|
||||
private readonly adminService: AdminsService,
|
||||
private readonly generateKeyService: GenerateKeysService,
|
||||
) {}
|
||||
|
||||
async validateAdmin(
|
||||
|
|
|
|||
|
|
@ -7,10 +7,13 @@ import {
|
|||
Res,
|
||||
Get,
|
||||
} from '@nestjs/common';
|
||||
import { AuthService } from './auth.service';
|
||||
import { LoginDto } from './dto/login.dto';
|
||||
import { AuthService } from '../auth.service';
|
||||
import { LoginDto } from '../dto/login.dto';
|
||||
import { Request, Response } from 'express';
|
||||
import { ChangePasswordDto } from './dto/change-password.dto';
|
||||
import { ChangePasswordDto } from '../dto/change-password.dto';
|
||||
import AppResponse from '@/response/app-response';
|
||||
import { Throttle } from '@nestjs/throttler';
|
||||
import { GenerateTokenDto } from '../dto/generate-token.dto';
|
||||
|
||||
@Controller('admin/auth')
|
||||
export class AuthController {
|
||||
|
|
@ -26,6 +29,12 @@ export class AuthController {
|
|||
.then((admin) => this.authService.login(admin, response));
|
||||
}
|
||||
|
||||
@Post('check-status')
|
||||
@Throttle({ default: { limit: 1, ttl: 30 * 1000 } })
|
||||
async checkStatus() {
|
||||
return AppResponse.toResponse(true);
|
||||
}
|
||||
|
||||
@Post('logout')
|
||||
async logout(@Res({ passthrough: true }) response: Response) {
|
||||
return await this.authService.logout(response);
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
import { IsString } from 'class-validator';
|
||||
|
||||
export class GenerateTokenDto {
|
||||
@IsString()
|
||||
client_key: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,44 @@
|
|||
// auth.middleware.ts
|
||||
|
||||
import { GenerateKeysService } from '@/modules/admins/services/generate-key.service';
|
||||
import AppResponse from '@/response/app-response';
|
||||
import {
|
||||
HttpStatus,
|
||||
Injectable,
|
||||
NestMiddleware,
|
||||
UnauthorizedException,
|
||||
} from '@nestjs/common';
|
||||
import { NextFunction, Request, Response } from 'express';
|
||||
|
||||
@Injectable()
|
||||
export class ClientAuthenticationMiddleware implements NestMiddleware {
|
||||
constructor(private readonly generateKeysService: GenerateKeysService) {}
|
||||
|
||||
async getKey(key: string) {
|
||||
if (!key) return null;
|
||||
|
||||
const data = await this.generateKeysService.generateKeyRepo.findOne({
|
||||
where: { client_key: key },
|
||||
});
|
||||
return data;
|
||||
}
|
||||
|
||||
async use(req: Request, res: Response, next: NextFunction) {
|
||||
const client_key: string = req.headers.authorization;
|
||||
|
||||
const key = await this.getKey(client_key);
|
||||
|
||||
if (!key) {
|
||||
return next(
|
||||
new UnauthorizedException(
|
||||
AppResponse.toResponse(null, {
|
||||
message: 'Unauthorized',
|
||||
status_code: HttpStatus.UNAUTHORIZED,
|
||||
}),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
next();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,25 @@
|
|||
import { GenerateKeysService } from '@/modules/admins/services/generate-key.service';
|
||||
import { Socket } from 'socket.io';
|
||||
|
||||
export async function clientGetWayMiddleware(
|
||||
client: Socket,
|
||||
generateKeysService: GenerateKeysService,
|
||||
): Promise<void> {
|
||||
const client_key = client.handshake.headers.authorization;
|
||||
|
||||
if (!client_key) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
const key = await generateKeysService.generateKeyRepo.findOne({
|
||||
where: { client_key },
|
||||
});
|
||||
|
||||
if (!key) {
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✅ WebSocket authenticated: Client ID ${key.client_key}`);
|
||||
}
|
||||
|
|
@ -0,0 +1,50 @@
|
|||
import { JwtService, TokenExpiredError } from '@nestjs/jwt';
|
||||
import { AdminsService } from '@/modules/admins/services/admins.service';
|
||||
import * as cookie from 'cookie';
|
||||
import { Socket } from 'socket.io';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import Admin from '@/modules/admins/entities/admin.entity';
|
||||
|
||||
export async function getWayMiddleware(
|
||||
client: Socket,
|
||||
jwtService: JwtService,
|
||||
adminService: AdminsService,
|
||||
): Promise<void> {
|
||||
try {
|
||||
const cookies = cookie.parse(client.handshake.headers.cookie || '');
|
||||
const accessToken = cookies['access_token'];
|
||||
const refreshToken = cookies['refresh_token'];
|
||||
|
||||
if (!accessToken && !refreshToken) {
|
||||
console.log('🚫 No tokens provided, disconnecting WebSocket');
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
let admin: Admin | null = null;
|
||||
|
||||
try {
|
||||
const result = await jwtService.verify(accessToken);
|
||||
|
||||
if (!result?._id || result.refresh_key) {
|
||||
throw new Error('Invalid access token');
|
||||
}
|
||||
|
||||
admin = await adminService.adminRepo.findOne({
|
||||
where: { id: result._id },
|
||||
});
|
||||
|
||||
if (!admin) throw new Error('Admin not found');
|
||||
} catch (error) {
|
||||
console.log('❗ Invalid access token, disconnecting WebSocket');
|
||||
client.disconnect();
|
||||
return;
|
||||
}
|
||||
|
||||
client.data.admin = plainToClass(Admin, admin);
|
||||
console.log(`✅ WebSocket authenticated: Admin ID ${admin.id}`);
|
||||
} catch (error) {
|
||||
console.log('❌ WebSocket authentication error:', error);
|
||||
client.disconnect();
|
||||
}
|
||||
}
|
||||
|
|
@ -1,10 +1,11 @@
|
|||
import { Injectable } from '@nestjs/common';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import axios from 'axios';
|
||||
import { escapeMarkdownV2 } from 'src/ultils';
|
||||
import { Bid } from '../entities/bid.entity';
|
||||
import * as dayjs from 'dayjs';
|
||||
import { SendMessageHistoriesService } from '../services/send-message-histories.service';
|
||||
import { existsSync, mkdirSync, readFileSync, writeFileSync } from 'fs';
|
||||
import { join } from 'path';
|
||||
import { Bid } from '../entities/bid.entity';
|
||||
import { Constant } from '../utils/constant';
|
||||
|
||||
@Injectable()
|
||||
export class BotTelegramApi {
|
||||
|
|
@ -64,6 +65,73 @@ export class BotTelegramApi {
|
|||
}
|
||||
}
|
||||
|
||||
async createFolderPath(): Promise<string> {
|
||||
const rootDir = process.cwd();
|
||||
const folderPath = join(rootDir, `${Constant.BOT_TELEGRAM_PATH}`);
|
||||
|
||||
if (!existsSync(folderPath)) {
|
||||
mkdirSync(folderPath, { recursive: true, mode: 0o777 });
|
||||
|
||||
// ✅ Lưu metadata lần đầu
|
||||
const metadataPath = join(folderPath, 'metadata.json');
|
||||
writeFileSync(
|
||||
metadataPath,
|
||||
JSON.stringify({ createdAt: Date.now() }),
|
||||
'utf-8',
|
||||
);
|
||||
}
|
||||
|
||||
return folderPath;
|
||||
}
|
||||
|
||||
async getGroupInfo(
|
||||
chatId: string = this.configService.get<string>('CHAT_ID'),
|
||||
): Promise<any> {
|
||||
try {
|
||||
const folderPath = await this.createFolderPath();
|
||||
const metadataPath = join(folderPath, 'metadata.json');
|
||||
const dataFilePath = join(folderPath, `group_${chatId}.json`);
|
||||
|
||||
// 10 minute
|
||||
const TIME_TO_REFRESH_DATA = 10;
|
||||
|
||||
if (existsSync(metadataPath)) {
|
||||
const metadata = JSON.parse(readFileSync(metadataPath, 'utf-8'));
|
||||
const createdAt = metadata?.createdAt || 0;
|
||||
const now = Date.now();
|
||||
const diffMinutes = (now - createdAt) / 60000;
|
||||
|
||||
if (diffMinutes < TIME_TO_REFRESH_DATA && existsSync(dataFilePath)) {
|
||||
return JSON.parse(readFileSync(dataFilePath, 'utf-8'));
|
||||
}
|
||||
}
|
||||
|
||||
const url = `${this.apiUrl}/getChat`;
|
||||
const { data } = await axios({
|
||||
url,
|
||||
params: { chat_id: chatId },
|
||||
family: 4,
|
||||
});
|
||||
|
||||
if (data?.ok) {
|
||||
writeFileSync(
|
||||
dataFilePath,
|
||||
JSON.stringify(data.result, null, 2),
|
||||
'utf-8',
|
||||
);
|
||||
writeFileSync(
|
||||
metadataPath,
|
||||
JSON.stringify({ createdAt: Date.now() }),
|
||||
'utf-8',
|
||||
);
|
||||
|
||||
return data.result;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(error || error.message);
|
||||
}
|
||||
}
|
||||
|
||||
async sendBidInfo(bid: Bid): Promise<boolean> {
|
||||
try {
|
||||
const text = this.formatBidMessage(bid);
|
||||
|
|
|
|||
|
|
@ -1,9 +1,13 @@
|
|||
import { Module } from '@nestjs/common';
|
||||
import { EventEmitterModule } from '@nestjs/event-emitter';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { AdminsModule } from '../admins/admins.module';
|
||||
import { NotificationModule } from '../notification/notification.module';
|
||||
import { BotTelegramApi } from './apis/bot-telegram.api';
|
||||
import { GraysApi } from './apis/grays.api';
|
||||
import { AdminBidHistoriesController } from './controllers/admin/admin-bid-histories.controller';
|
||||
import { AdminBidsController } from './controllers/admin/admin-bids.controller';
|
||||
import { AdminOutBidLogsController } from './controllers/admin/admin-out-bid-logs.controller';
|
||||
import { AdminSendMessageHistoriesController } from './controllers/admin/admin-send-message-histories.controller';
|
||||
import { AdminWebBidsController } from './controllers/admin/admin-web-bids.controller';
|
||||
import { BidHistoriesController } from './controllers/client/bid-histories.controller';
|
||||
import { BidsController } from './controllers/client/bids.controller';
|
||||
|
|
@ -11,17 +15,16 @@ import { OutBidLogsController } from './controllers/client/out-bid-logs.controll
|
|||
import { BidHistory } from './entities/bid-history.entity';
|
||||
import { Bid } from './entities/bid.entity';
|
||||
import { OutBidLog } from './entities/out-bid-log.entity';
|
||||
import { SendMessageHistory } from './entities/send-message-histories.entity';
|
||||
import { WebBid } from './entities/wed-bid.entity';
|
||||
import { AdminBidGateway } from './getways/admin-bid-getway';
|
||||
import { BidGateway } from './getways/bid-getway';
|
||||
import { BidHistoriesService } from './services/bid-histories.service';
|
||||
import { BidsService } from './services/bids.service';
|
||||
import { ImapService } from './services/mail/imap.service';
|
||||
import { OutBidLogsService } from './services/out-bid-logs.service';
|
||||
import { WebBidsService } from './services/web-bids.service';
|
||||
import { BotTelegramApi } from './apis/bot-telegram.api';
|
||||
import { GraysApi } from './apis/grays.api';
|
||||
import { SendMessageHistory } from './entities/send-message-histories.entity';
|
||||
import { SendMessageHistoriesService } from './services/send-message-histories.service';
|
||||
import { AdminSendMessageHistoriesController } from './controllers/admin/admin-send-message-histories.controller';
|
||||
import { WebBidsService } from './services/web-bids.service';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
|
|
@ -32,9 +35,9 @@ import { AdminSendMessageHistoriesController } from './controllers/admin/admin-s
|
|||
WebBid,
|
||||
SendMessageHistory,
|
||||
]),
|
||||
EventEmitterModule.forRoot({
|
||||
wildcard: true,
|
||||
}),
|
||||
// AuthModule,
|
||||
AdminsModule,
|
||||
NotificationModule,
|
||||
],
|
||||
controllers: [
|
||||
BidsController,
|
||||
|
|
@ -50,11 +53,14 @@ import { AdminSendMessageHistoriesController } from './controllers/admin/admin-s
|
|||
BidsService,
|
||||
BidHistoriesService,
|
||||
BidGateway,
|
||||
AdminBidGateway,
|
||||
OutBidLogsService,
|
||||
WebBidsService,
|
||||
BotTelegramApi,
|
||||
GraysApi,
|
||||
SendMessageHistoriesService,
|
||||
ImapService,
|
||||
],
|
||||
exports: [BotTelegramApi, SendMessageHistoriesService, BidsService],
|
||||
})
|
||||
export class BidsModule {}
|
||||
|
|
|
|||
|
|
@ -59,4 +59,12 @@ export class AdminBidsController {
|
|||
) {
|
||||
return await this.bidsService.getStatusWorkingImage(id, type, name, res);
|
||||
}
|
||||
|
||||
@Get('images-working/:type/:id')
|
||||
async getImagesWorking(
|
||||
@Param('id') id: Bid['id'],
|
||||
@Param('type') type: string,
|
||||
) {
|
||||
return await this.bidsService.getImagesWorking(id, type);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,22 +5,30 @@ import {
|
|||
Param,
|
||||
Post,
|
||||
Put,
|
||||
Res,
|
||||
UploadedFile,
|
||||
UseInterceptors,
|
||||
} from '@nestjs/common';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { ClientUpdateBidDto } from '../../dto/bid/client-update-bid.dto';
|
||||
import { CreateBidDto } from '../../dto/bid/create-bid.dto';
|
||||
import { Bid } from '../../entities/bid.entity';
|
||||
import { BidsService } from '../../services/bids.service';
|
||||
import { UpdateStatusByPriceDto } from '../../dto/bid/update-status-by-price.dto';
|
||||
import { FileInterceptor } from '@nestjs/platform-express';
|
||||
import { memoryStorage } from 'multer';
|
||||
import { Response } from 'express';
|
||||
import { Bid } from '../../entities/bid.entity';
|
||||
import { WebBid } from '../../entities/wed-bid.entity';
|
||||
import { BidsService } from '../../services/bids.service';
|
||||
import { WebBidsService } from '../../services/web-bids.service';
|
||||
import { Event } from '../../utils/events';
|
||||
import AppResponse from '@/response/app-response';
|
||||
|
||||
@Controller('bids')
|
||||
export class BidsController {
|
||||
constructor(private readonly bidsService: BidsService) {}
|
||||
constructor(
|
||||
private readonly bidsService: BidsService,
|
||||
private readonly eventEmitter: EventEmitter2,
|
||||
private readonly webBidService: WebBidsService,
|
||||
) {}
|
||||
|
||||
@Get()
|
||||
index() {
|
||||
|
|
@ -59,4 +67,21 @@ export class BidsController {
|
|||
) {
|
||||
return this.bidsService.updateStatusWork(id, type, image);
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
async test(@Body('code') code: string) {
|
||||
const webBid = await this.webBidService.webBidRepo.findOne({
|
||||
// where: { id: 9 },
|
||||
where: { id: 8 },
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(Event.verifyCode(webBid), {
|
||||
code,
|
||||
// name: 'LAWSONS',
|
||||
name: 'LANGTONS',
|
||||
web_bid: plainToClass(WebBid, webBid),
|
||||
});
|
||||
|
||||
return AppResponse.toResponse({ code });
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,7 @@
|
|||
import { Body, Controller, Post } from '@nestjs/common';
|
||||
import { CreateOutBidLogsDto } from '../../dto/out-bid-log/create-out-bid-logs.dto';
|
||||
import { OutBidLogsService } from '../../services/out-bid-logs.service';
|
||||
import { CreateOutBidLogDto } from '../../dto/out-bid-log/create-out-bid-log.dto';
|
||||
|
||||
@Controller('out-bid-logs')
|
||||
export class OutBidLogsController {
|
||||
|
|
@ -10,4 +11,9 @@ export class OutBidLogsController {
|
|||
create(@Body() data: CreateOutBidLogsDto) {
|
||||
return this.outBidLogsService.create(data.data);
|
||||
}
|
||||
|
||||
@Post('simple')
|
||||
createSimple(@Body() data: CreateOutBidLogDto) {
|
||||
return this.outBidLogsService.createSimple(data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,10 @@ export class ClientUpdateBidDto {
|
|||
@IsOptional()
|
||||
lot_id: string;
|
||||
|
||||
@IsString()
|
||||
@IsOptional()
|
||||
model: string;
|
||||
|
||||
@IsOptional()
|
||||
@IsString()
|
||||
close_time: string | null;
|
||||
|
|
|
|||
|
|
@ -16,6 +16,6 @@ export class OutBidLog extends Timestamp {
|
|||
@Column()
|
||||
out_price: number;
|
||||
|
||||
@Column({ default: null, nullable: true })
|
||||
@Column({ default: null, nullable: true, type: 'text' })
|
||||
raw_data: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,6 +10,9 @@ export class SendMessageHistory extends Timestamp {
|
|||
@Column({ default: null, nullable: true, type: 'text' })
|
||||
message: string;
|
||||
|
||||
@Column({ default: 'biding' })
|
||||
type: string;
|
||||
|
||||
@ManyToOne(() => Bid, (bid) => bid.sendMessageHistories, {
|
||||
onDelete: 'CASCADE',
|
||||
})
|
||||
|
|
|
|||
|
|
@ -0,0 +1,64 @@
|
|||
import { AdminsService } from '@/modules/admins/services/admins.service';
|
||||
import { getWayMiddleware } from '@/modules/auth/middlewares/get-way.middleware';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { JwtService } from '@nestjs/jwt';
|
||||
import {
|
||||
OnGatewayConnection,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
} from '@nestjs/websockets';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { WebBid } from '../entities/wed-bid.entity';
|
||||
import { WebBidsService } from '../services/web-bids.service';
|
||||
import * as Imap from 'imap';
|
||||
import { ImapService } from '../services/mail/imap.service';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { Event } from '../utils/events';
|
||||
|
||||
@WebSocketGateway({
|
||||
namespace: 'admin-bid-ws',
|
||||
cors: {
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST'],
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
export class AdminBidGateway implements OnGatewayConnection {
|
||||
@WebSocketServer()
|
||||
server: Server;
|
||||
|
||||
constructor(
|
||||
private eventEmitter: EventEmitter2,
|
||||
private readonly jwtService: JwtService,
|
||||
private webBidsService: WebBidsService,
|
||||
private readonly adminService: AdminsService,
|
||||
private readonly imapService: ImapService,
|
||||
) {
|
||||
this.imapService.server = this.server;
|
||||
}
|
||||
|
||||
async onModuleInit() {
|
||||
this.eventEmitter.on(Event.BIDS_UPDATED, (data) => {
|
||||
this.server.emit(Event.ADMIN_BIDS_UPDATED, plainToClass(WebBid, data));
|
||||
});
|
||||
|
||||
this.eventEmitter.on(Event.WORKING, (data) => {
|
||||
this.server.emit(Event.WORKING, data);
|
||||
});
|
||||
|
||||
// IMAP
|
||||
this.imapService.connectIMAP();
|
||||
}
|
||||
|
||||
async handleConnection(client: Socket) {
|
||||
await getWayMiddleware(client, this.jwtService, this.adminService);
|
||||
|
||||
console.log(`📢 Client connected: ${client.id}`);
|
||||
|
||||
const data = await this.webBidsService.getDataClient();
|
||||
|
||||
// Gửi dữ liệu bids ngay khi client kết nối
|
||||
client.emit(Event.ADMIN_BIDS_UPDATED, plainToClass(WebBid, data));
|
||||
}
|
||||
}
|
||||
|
|
@ -1,17 +1,19 @@
|
|||
import { GenerateKeysService } from '@/modules/admins/services/generate-key.service';
|
||||
import { clientGetWayMiddleware } from '@/modules/auth/middlewares/client-get-way.middleware copy';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import {
|
||||
OnGatewayConnection,
|
||||
WebSocketGateway,
|
||||
WebSocketServer,
|
||||
SubscribeMessage,
|
||||
OnGatewayConnection,
|
||||
} from '@nestjs/websockets';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { BidsService } from '../services/bids.service';
|
||||
import { WebBidsService } from '../services/web-bids.service';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import { Event } from '../utils/events';
|
||||
import { WebBid } from '../entities/wed-bid.entity';
|
||||
|
||||
@WebSocketGateway({
|
||||
namespace: 'bid-ws',
|
||||
cors: {
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST'],
|
||||
|
|
@ -25,30 +27,37 @@ export class BidGateway implements OnGatewayConnection {
|
|||
constructor(
|
||||
private eventEmitter: EventEmitter2,
|
||||
private webBidsService: WebBidsService,
|
||||
private bidsService: BidsService,
|
||||
private generateKeysService: GenerateKeysService,
|
||||
) {}
|
||||
|
||||
async onModuleInit() {
|
||||
this.eventEmitter.on('bids.updated', (data) => {
|
||||
this.server.emit('bidsUpdated', data);
|
||||
this.server.emit('adminBidsUpdated', plainToClass(WebBid, data));
|
||||
this.eventEmitter.on(Event.BIDS_UPDATED, (data) => {
|
||||
this.server.emit(Event.BIDS_UPDATED, data);
|
||||
});
|
||||
|
||||
this.eventEmitter.on('web.updated', (data) => {
|
||||
this.server.emit('webUpdated', data);
|
||||
this.eventEmitter.on(Event.WEB_UPDATED, (data) => {
|
||||
this.server.emit(Event.WEB_UPDATED, data);
|
||||
});
|
||||
|
||||
this.eventEmitter.on('working', (data) => {
|
||||
this.server.emit('working', data);
|
||||
});
|
||||
this.eventEmitter.onAny(
|
||||
(
|
||||
event: string,
|
||||
payload: { name: string; code: string; web_bid: WebBid },
|
||||
) => {
|
||||
if (!event.startsWith(Event.VERIFY_CODE)) return;
|
||||
|
||||
this.server.emit(Event.verifyCode(payload.web_bid), payload);
|
||||
},
|
||||
);
|
||||
}
|
||||
|
||||
async handleConnection(client: Socket) {
|
||||
await clientGetWayMiddleware(client, this.generateKeysService);
|
||||
|
||||
console.log(`📢 Client connected: ${client.id}`);
|
||||
|
||||
const data = await this.webBidsService.getDataClient();
|
||||
// Gửi dữ liệu bids ngay khi client kết nối
|
||||
client.emit('bidsUpdated', data);
|
||||
client.emit('adminBidsUpdated', plainToClass(WebBid, data));
|
||||
client.emit(Event.BIDS_UPDATED, data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -13,16 +13,21 @@ import { Bid } from '../entities/bid.entity';
|
|||
import { CreateBidHistoryDto } from '../dto/bid-history/create-bid-history.dto';
|
||||
import { BotTelegramApi } from '../apis/bot-telegram.api';
|
||||
import { SendMessageHistoriesService } from './send-message-histories.service';
|
||||
import { NotificationService } from '@/modules/notification/notification.service';
|
||||
import { isTimeReached } from '@/ultils';
|
||||
import { BidsService } from './bids.service';
|
||||
|
||||
@Injectable()
|
||||
export class BidHistoriesService {
|
||||
constructor(
|
||||
@InjectRepository(BidHistory)
|
||||
readonly bidHistoriesRepo: Repository<BidHistory>,
|
||||
@InjectRepository(Bid)
|
||||
readonly bidsRepo: Repository<Bid>,
|
||||
// @InjectRepository(Bid)
|
||||
// readonly bidsRepo: Repository<Bid>,
|
||||
private readonly botTelegramApi: BotTelegramApi,
|
||||
readonly sendMessageHistoriesService: SendMessageHistoriesService,
|
||||
private readonly notificationService: NotificationService,
|
||||
private readonly bidsService: BidsService,
|
||||
) {}
|
||||
|
||||
async index() {
|
||||
|
|
@ -30,8 +35,12 @@ export class BidHistoriesService {
|
|||
}
|
||||
|
||||
async create({ price, bid_id }: CreateBidHistoryDto) {
|
||||
const bid = await this.bidsRepo.findOne({ where: { id: bid_id } });
|
||||
// Tìm thông tin bid từ database
|
||||
const bid = await this.bidsService.bidsRepo.findOne({
|
||||
where: { id: bid_id },
|
||||
});
|
||||
|
||||
// Nếu không tìm thấy bid, trả về lỗi 404
|
||||
if (!bid)
|
||||
throw new NotFoundException(
|
||||
AppResponse.toResponse(null, {
|
||||
|
|
@ -40,20 +49,34 @@ export class BidHistoriesService {
|
|||
}),
|
||||
);
|
||||
|
||||
// Lấy lịch sử đặt giá cao nhất trước đó của bid hiện tại
|
||||
const lastHistory = await this.bidHistoriesRepo.findOne({
|
||||
where: { bid: { id: bid_id } },
|
||||
order: { price: 'desc' },
|
||||
});
|
||||
|
||||
// Nếu đã có lịch sử và giá mới giống với giá cao nhất hiện tại
|
||||
if (lastHistory && lastHistory.price === price) {
|
||||
// Nếu đã hết thời gian đấu giá, cập nhật trạng thái là 'win-bid'
|
||||
if (isTimeReached(bid.close_time)) {
|
||||
this.bidsService.bidsRepo.update(bid_id, { status: 'win-bid' });
|
||||
}
|
||||
|
||||
// Ném lỗi không cho đặt giá trùng lặp
|
||||
throw new BadRequestException(
|
||||
AppResponse.toResponse(null, { message: 'Duplicate place bid' }),
|
||||
);
|
||||
}
|
||||
|
||||
// Nếu tổng giá vượt quá mức tối đa cho phép
|
||||
if (price + bid.plus_price > bid.max_price) {
|
||||
this.bidsRepo.update(bid_id, { status: 'out-bid' });
|
||||
// Cập nhật trạng thái bid là 'out-bid'
|
||||
this.bidsService.bidsRepo.update(bid_id, { status: 'out-bid' });
|
||||
|
||||
// Gửi thông báo trạng thái mới qua service thông báo
|
||||
this.notificationService.emitBidStatus({ ...bid, status: 'out-bid' });
|
||||
|
||||
// Ném lỗi không cho đặt giá vượt mức
|
||||
throw new BadRequestException(
|
||||
AppResponse.toResponse(null, {
|
||||
message: 'Price is more than Max price ' + bid.max_price,
|
||||
|
|
@ -61,8 +84,10 @@ export class BidHistoriesService {
|
|||
);
|
||||
}
|
||||
|
||||
// Lưu lịch sử đặt giá mới
|
||||
await this.bidHistoriesRepo.save({ bid, price });
|
||||
|
||||
// Lấy danh sách tất cả lịch sử đặt giá theo thứ tự mới nhất
|
||||
const response = await this.bidHistoriesRepo.find({
|
||||
where: { bid: { id: bid_id } },
|
||||
order: {
|
||||
|
|
@ -70,19 +95,29 @@ export class BidHistoriesService {
|
|||
},
|
||||
});
|
||||
|
||||
// Nếu đây là lần đặt giá đầu tiên, cập nhật cờ `first_bid` thành false
|
||||
if (response.length === 1) {
|
||||
this.bidsRepo.update(bid_id, { first_bid: false });
|
||||
this.bidsService.bidsRepo.update(bid_id, { first_bid: false });
|
||||
}
|
||||
|
||||
// Gửi thông tin bid đến bot telegram
|
||||
const botData = { ...bid, histories: response };
|
||||
|
||||
this.botTelegramApi.sendBidInfo(botData);
|
||||
|
||||
// Lưu message đã gửi để theo dõi
|
||||
this.sendMessageHistoriesService.sendMessageRepo.save({
|
||||
message: this.botTelegramApi.formatBidMessage(botData),
|
||||
bid,
|
||||
});
|
||||
|
||||
// Kiểm tra nếu trạng thái bid thay đổi sau khi lưu, phát sự kiện cập nhật tất cả bid
|
||||
const bidUpdated = await this.bidsService.bidsRepo.findOne({
|
||||
where: { id: bid_id },
|
||||
});
|
||||
|
||||
this.bidsService.emitAllBidEvent();
|
||||
|
||||
// Trả về danh sách lịch sử đặt giá đã cập nhật
|
||||
return AppResponse.toResponse(plainToClass(BidHistory, response));
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -29,6 +29,8 @@ import { Bid } from '../entities/bid.entity';
|
|||
import { ImageCompressionPipe } from '../pipes/image-compression-pipe';
|
||||
import { Constant } from '../utils/constant';
|
||||
import { WebBidsService } from './web-bids.service';
|
||||
import { NotificationService } from '@/modules/notification/notification.service';
|
||||
import { Event } from '../utils/events';
|
||||
|
||||
@Injectable()
|
||||
export class BidsService {
|
||||
|
|
@ -39,6 +41,7 @@ export class BidsService {
|
|||
readonly bidHistoriesRepo: Repository<BidHistory>,
|
||||
private readonly webBidsService: WebBidsService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
private notificationService: NotificationService,
|
||||
) {}
|
||||
|
||||
async index(query: PaginateQuery) {
|
||||
|
|
@ -74,6 +77,7 @@ export class BidsService {
|
|||
maxLimit: 100,
|
||||
relations: {
|
||||
histories: true,
|
||||
web_bid: true,
|
||||
},
|
||||
});
|
||||
|
||||
|
|
@ -97,6 +101,7 @@ export class BidsService {
|
|||
const model = extractModelId(data.url);
|
||||
|
||||
const prev = await this.bidsRepo.findOne({ where: { model: model } });
|
||||
|
||||
if (prev) {
|
||||
throw new BadRequestException(
|
||||
AppResponse.toResponse(null, { message: 'Product is already exits' }),
|
||||
|
|
@ -159,6 +164,9 @@ export class BidsService {
|
|||
|
||||
this.emitAllBidEvent();
|
||||
|
||||
// send message event
|
||||
this.notificationService.emitBidStatus({ ...bid, status: 'out-bid' });
|
||||
|
||||
return AppResponse.toResponse(true);
|
||||
}
|
||||
|
||||
|
|
@ -183,16 +191,29 @@ export class BidsService {
|
|||
|
||||
await this.bidsRepo.update(id, { status: 'biding' });
|
||||
|
||||
// send message event
|
||||
this.notificationService.emitBidStatus({ ...bid, status: 'biding' });
|
||||
|
||||
this.emitAllBidEvent();
|
||||
return AppResponse.toResponse(true);
|
||||
}
|
||||
|
||||
async clientUpdate(
|
||||
id: Bid['id'],
|
||||
{ close_time, ...data }: ClientUpdateBidDto,
|
||||
{ close_time, model, ...data }: ClientUpdateBidDto, // Nhận dữ liệu cập nhật
|
||||
) {
|
||||
const bid = await this.bidsRepo.findOne({ where: { id } });
|
||||
// Tìm kiếm phiên đấu giá trong database theo id
|
||||
const bid = await this.bidsRepo.findOne({
|
||||
where: { id },
|
||||
relations: { histories: true },
|
||||
order: {
|
||||
histories: {
|
||||
price: 'DESC',
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
// Nếu không tìm thấy phiên đấu giá, trả về lỗi 404
|
||||
if (!bid)
|
||||
throw new NotFoundException(
|
||||
AppResponse.toResponse(null, {
|
||||
|
|
@ -201,24 +222,35 @@ export class BidsService {
|
|||
}),
|
||||
);
|
||||
|
||||
// Nếu phiên đấu giá chưa có thời gian bắt đầu và kết thúc
|
||||
if (!bid.close_time && !bid.start_bid_time) {
|
||||
// Thiết lập thời gian bắt đầu là 5 phút trước khi đóng
|
||||
// bid.start_bid_time = new Date().toUTCString();
|
||||
bid.start_bid_time = subtractMinutes(close_time, 5);
|
||||
}
|
||||
|
||||
if (
|
||||
data.current_price >= bid.max_price + bid.plus_price ||
|
||||
(bid.close_time && isTimeReached(bid.close_time))
|
||||
) {
|
||||
console.log({
|
||||
a: data.current_price >= bid.max_price + bid.plus_price,
|
||||
b: bid.close_time && !close_time,
|
||||
c: bid.close_time && isTimeReached(bid.close_time),
|
||||
});
|
||||
// Kiểm tra nếu thời gian đóng bid đã đạt tới (tức phiên đấu giá đã kết thúc)
|
||||
if (bid.close_time && isTimeReached(bid.close_time)) {
|
||||
const bidHistoriesItem = bid.histories[0]; // Lấy lịch sử bid gần nhất (mới nhất)
|
||||
|
||||
bid.status = 'out-bid';
|
||||
if (
|
||||
!bidHistoriesItem ||
|
||||
// Nếu giá cuối cùng không phải là giá của người dùng và giá hiện tại vượt quá mức người dùng đặt + bước giá
|
||||
(bidHistoriesItem.price !== data.current_price &&
|
||||
data.current_price > bid.max_price + bid.plus_price)
|
||||
) {
|
||||
bid.status = 'out-bid'; // Người dùng đã bị outbid khi đấu giá kết thúc
|
||||
} else {
|
||||
bid.status = 'win-bid'; // Người dùng là người thắng nếu không bị outbid
|
||||
}
|
||||
} else {
|
||||
// Nếu phiên đấu giá vẫn đang diễn ra và giá hiện tại vượt quá giới hạn đặt của người dùng
|
||||
if (data.current_price > bid.max_price + bid.plus_price) {
|
||||
bid.status = 'out-bid'; // Gán trạng thái là đã bị outbid
|
||||
}
|
||||
}
|
||||
|
||||
// Cập nhật thời gian kết thúc đấu giá nếu `close_time` mới lớn hơn `close_time` cũ
|
||||
if (
|
||||
close_time &&
|
||||
new Date(close_time).getTime() > new Date(bid.close_time).getTime()
|
||||
|
|
@ -226,24 +258,43 @@ export class BidsService {
|
|||
bid.close_time = close_time;
|
||||
}
|
||||
|
||||
// Nếu chưa có `model` nhưng dữ liệu mới có model, thì cập nhật model
|
||||
if (model && !bid.model) {
|
||||
bid.model = model;
|
||||
}
|
||||
|
||||
// Lưu cập nhật vào database
|
||||
const result = await this.bidsRepo.save({
|
||||
...bid,
|
||||
...data,
|
||||
updated_at: new Date(),
|
||||
updated_at: new Date(), // Cập nhật timestamp
|
||||
});
|
||||
|
||||
// Phát sự kiện cập nhật toàn bộ danh sách đấu giá
|
||||
this.emitAllBidEvent();
|
||||
|
||||
// send event message
|
||||
// Nếu trạng thái của bid là 'out-bid', gửi thông báo
|
||||
if (['out-bid', 'win-bid'].includes(result.status)) {
|
||||
this.notificationService.emitBidStatus(result);
|
||||
}
|
||||
|
||||
// Trả về kết quả cập nhật dưới dạng response chuẩn
|
||||
return AppResponse.toResponse(plainToClass(Bid, result));
|
||||
}
|
||||
|
||||
async outBid(id: Bid['id']) {
|
||||
const result = await this.bidsRepo.update(id, { status: 'out-bid' });
|
||||
|
||||
const bid = await this.bidsRepo.findOne({ where: { id } });
|
||||
|
||||
if (!result) throw new BadRequestException(AppResponse.toResponse(false));
|
||||
|
||||
await this.emitAllBidEvent();
|
||||
|
||||
// send message event
|
||||
this.notificationService.emitBidStatus(bid);
|
||||
|
||||
return AppResponse.toResponse(true);
|
||||
}
|
||||
|
||||
|
|
@ -309,9 +360,25 @@ export class BidsService {
|
|||
});
|
||||
|
||||
if (lastHistory && lastHistory.price === data.current_price) {
|
||||
await this.bidsRepo.update(bid.id, { status: 'win-bid' });
|
||||
if (bid.status !== 'win-bid') {
|
||||
await this.bidsRepo.update(bid.id, { status: 'win-bid' });
|
||||
|
||||
// send event message
|
||||
this.notificationService.emitBidStatus({
|
||||
...bid,
|
||||
status: 'win-bid',
|
||||
});
|
||||
}
|
||||
} else {
|
||||
await this.bidsRepo.update(bid.id, { status: 'out-bid' });
|
||||
if (bid.status !== 'out-bid') {
|
||||
await this.bidsRepo.update(bid.id, { status: 'out-bid' });
|
||||
|
||||
// send event message
|
||||
this.notificationService.emitBidStatus({
|
||||
...bid,
|
||||
status: 'out-bid',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
this.emitAllBidEvent();
|
||||
|
|
@ -380,7 +447,7 @@ export class BidsService {
|
|||
|
||||
let filePath: string;
|
||||
|
||||
if (name === 'working') {
|
||||
if (name === Event.WORKING) {
|
||||
const files = readdirSync(folderPath).filter((file) =>
|
||||
/\.(jpg|jpeg|png|webp)$/i.test(file),
|
||||
);
|
||||
|
|
@ -412,4 +479,26 @@ export class BidsService {
|
|||
|
||||
return res.sendFile(filePath);
|
||||
}
|
||||
|
||||
async getImagesWorking(id: Bid['id'], type: string) {
|
||||
const rootDir = process.cwd();
|
||||
const folderPath = join(
|
||||
rootDir,
|
||||
`${Constant.MEDIA_PATH}/${Constant.WORK_IMAGES_FOLDER}/${type}/${id}`,
|
||||
);
|
||||
|
||||
if (!existsSync(folderPath)) {
|
||||
throw new NotFoundException(
|
||||
AppResponse.toResponse(null, {
|
||||
message: 'Folder not found',
|
||||
status_code: HttpStatus.NOT_FOUND,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
// Lấy danh sách file trong folderPath
|
||||
const files = readdirSync(folderPath);
|
||||
|
||||
return AppResponse.toResponse(files);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,131 @@
|
|||
import { extractVerifyCodeLANGTONS, verifyCode } from '@/ultils';
|
||||
import { ConfigService } from '@nestjs/config';
|
||||
import { EventEmitter2 } from '@nestjs/event-emitter';
|
||||
import { WebSocketGateway } from '@nestjs/websockets';
|
||||
import * as Imap from 'imap';
|
||||
import { Server, Socket } from 'socket.io';
|
||||
import { WebBidsService } from '../web-bids.service';
|
||||
import { Like } from 'typeorm';
|
||||
import { Event } from '../../utils/events';
|
||||
import { plainToClass } from 'class-transformer';
|
||||
import { WebBid } from '../../entities/wed-bid.entity';
|
||||
@WebSocketGateway({
|
||||
namespace: 'admin-bid-ws',
|
||||
cors: {
|
||||
origin: '*',
|
||||
methods: ['GET', 'POST'],
|
||||
credentials: true,
|
||||
},
|
||||
})
|
||||
export class ImapService {
|
||||
private imap: Imap;
|
||||
|
||||
server: Server;
|
||||
|
||||
constructor(
|
||||
private readonly configService: ConfigService,
|
||||
private readonly webBidsService: WebBidsService,
|
||||
private eventEmitter: EventEmitter2,
|
||||
) {
|
||||
this.imap = new Imap({
|
||||
user: this.configService.get<string>('MAIL_USER'),
|
||||
password: this.configService.get<string>('MAIL_PASSWORD'),
|
||||
host: this.configService.get<string>('MAIL_SERVER'),
|
||||
port: 993,
|
||||
tls: true,
|
||||
tlsOptions: { rejectUnauthorized: false },
|
||||
});
|
||||
}
|
||||
|
||||
async connectIMAP() {
|
||||
this.imap.once('ready', () => {
|
||||
console.log('📥 IMAP Connected. Listening for new emails...');
|
||||
this.openInbox();
|
||||
});
|
||||
|
||||
this.imap.on('error', (err) => {
|
||||
console.error('❌ IMAP Error:', err);
|
||||
});
|
||||
|
||||
this.imap.on('end', () => {
|
||||
console.log('📴 IMAP Connection Ended');
|
||||
setTimeout(() => this.connectIMAP(), 5000);
|
||||
});
|
||||
|
||||
this.imap.connect();
|
||||
}
|
||||
|
||||
private openInbox() {
|
||||
this.imap.openBox('INBOX', false, (err, box) => {
|
||||
if (err) {
|
||||
console.error('❌ Error opening inbox:', err);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('📬 INBOX opened, waiting for new emails...');
|
||||
|
||||
// Lắng nghe email mới
|
||||
this.imap.on('mail', (numNewMsgs: number) => {
|
||||
console.log(`📩 New email received! (${numNewMsgs} new messages)`);
|
||||
this.fetchLatestEmail();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
private fetchLatestEmail() {
|
||||
this.imap.search(['UNSEEN'], (err, results) => {
|
||||
if (err || !results.length) {
|
||||
console.log('📭 No new unread emails.');
|
||||
return;
|
||||
}
|
||||
|
||||
const fetchOptions = { bodies: '', struct: true };
|
||||
const f = this.imap.fetch(results.slice(-1), fetchOptions); // Lấy email mới nhất
|
||||
|
||||
f.on('message', (msg, seqno) => {
|
||||
console.log(`📥 Fetching email #${seqno}`);
|
||||
let emailContent = '';
|
||||
let header = '';
|
||||
|
||||
msg.on('body', (stream) => {
|
||||
stream.on('data', (chunk) => {
|
||||
emailContent += chunk.toString();
|
||||
});
|
||||
|
||||
stream.on('end', async () => {
|
||||
const result = verifyCode(emailContent);
|
||||
|
||||
if (!result) {
|
||||
throw new Error('fetchLatestEmail: Name or Code is empty');
|
||||
}
|
||||
|
||||
const { code, name } = result;
|
||||
|
||||
const webBid = await this.webBidsService.webBidRepo.findOne({
|
||||
where: { origin_url: Like(`%${name.toLocaleLowerCase()}%`) },
|
||||
});
|
||||
|
||||
if (!webBid) {
|
||||
throw new Error('Not found web bid');
|
||||
}
|
||||
|
||||
// send message event
|
||||
this.eventEmitter.emit(Event.verifyCode(webBid), {
|
||||
code,
|
||||
name,
|
||||
web_bid: plainToClass(WebBid, webBid),
|
||||
});
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
f.on('error', (err) => {
|
||||
console.error('❌ Fetch error:', err);
|
||||
});
|
||||
|
||||
f.on('end', () => {
|
||||
console.log('✅ Done fetching emails.');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -53,11 +53,30 @@ export class OutBidLogsService {
|
|||
skipUpdateIfNoValuesChanged: true,
|
||||
});
|
||||
|
||||
if (!result) throw new BadRequestException(false);
|
||||
if (!result) throw new BadRequestException(AppResponse.toResponse(false));
|
||||
|
||||
return AppResponse.toResponse(true);
|
||||
} catch (error) {
|
||||
throw new BadRequestException(false);
|
||||
throw new BadRequestException(AppResponse.toResponse(false));
|
||||
}
|
||||
}
|
||||
|
||||
async createSimple({ model, lot_id, ...data }: CreateOutBidLogDto) {
|
||||
const prev = await this.outbidLogRepo.findOne({
|
||||
where: { lot_id: lot_id, model: model },
|
||||
});
|
||||
|
||||
if (!prev) {
|
||||
const result = await this.outbidLogRepo.save({ ...data, lot_id, model });
|
||||
|
||||
return AppResponse.toResponse(!!result);
|
||||
}
|
||||
|
||||
const result = await this.outbidLogRepo.update(
|
||||
{ model: model, lot_id: lot_id },
|
||||
{ ...data },
|
||||
);
|
||||
|
||||
return AppResponse.toResponse(!!result);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ import { extractDomain } from 'src/ultils';
|
|||
import { Bid } from '../entities/bid.entity';
|
||||
import { CreateWebBidDto } from '../dto/web-bid/create-web-bid';
|
||||
import { UpdateWebBidDto } from '../dto/web-bid/update-web-bid';
|
||||
import { Event } from '../utils/events';
|
||||
|
||||
@Injectable()
|
||||
export class WebBidsService {
|
||||
|
|
@ -73,13 +74,16 @@ export class WebBidsService {
|
|||
async emitAllBidEvent() {
|
||||
const data = await this.getDataClient();
|
||||
|
||||
this.eventEmitter.emit('bids.updated', data);
|
||||
this.eventEmitter.emit(Event.BIDS_UPDATED, data);
|
||||
}
|
||||
|
||||
async emitAccountUpdate(id: WebBid['id']) {
|
||||
const data = await this.webBidRepo.findOne({ where: { id } });
|
||||
const data = await this.webBidRepo.findOne({
|
||||
where: { id, children: { status: 'biding' } },
|
||||
relations: { children: true },
|
||||
});
|
||||
|
||||
this.eventEmitter.emit('web.updated', data || null);
|
||||
this.eventEmitter.emit(Event.WEB_UPDATED, data || null);
|
||||
}
|
||||
|
||||
async createByUrl(url: string) {
|
||||
|
|
|
|||
|
|
@ -1,5 +1,7 @@
|
|||
export class Constant {
|
||||
public static MEDIA_PATH = 'public';
|
||||
public static BOT_TELEGRAM_PATH = 'bot-data';
|
||||
|
||||
public static WORK_IMAGES_FOLDER = 'work-images';
|
||||
public static TMP_FOLDER = 'tmp';
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,13 @@
|
|||
import { WebBid } from '../entities/wed-bid.entity';
|
||||
|
||||
export class Event {
|
||||
public static VERIFY_CODE = 'verify-code';
|
||||
public static WORKING = 'working';
|
||||
public static BIDS_UPDATED = 'bidsUpdated';
|
||||
public static ADMIN_BIDS_UPDATED = 'adminBidsUpdated';
|
||||
public static WEB_UPDATED = 'webUpdated';
|
||||
|
||||
public static verifyCode(data: WebBid) {
|
||||
return `${this.VERIFY_CODE}.${data.origin_url}`;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
export const NAME_EVENTS = {
|
||||
BID_STATUS: 'notify.bid-status',
|
||||
};
|
||||
|
|
@ -0,0 +1,31 @@
|
|||
import { BotTelegramApi } from '@/modules/bids/apis/bot-telegram.api';
|
||||
import { Body, Controller, Get, Post } from '@nestjs/common';
|
||||
import { SendMessageDto } from '../dto/send-message.dto';
|
||||
import { NotificationService } from '../notification.service';
|
||||
import { BidsService } from '@/modules/bids/services/bids.service';
|
||||
|
||||
@Controller('notifications')
|
||||
export class ClientNotificationController {
|
||||
constructor(
|
||||
private botTelegramApi: BotTelegramApi,
|
||||
private readonly notifyService: NotificationService,
|
||||
private readonly bidsService: BidsService,
|
||||
) {}
|
||||
|
||||
@Post('send-messages')
|
||||
async sendMessage(@Body() data: SendMessageDto) {
|
||||
return await this.botTelegramApi.sendMessage(data.text, data.options);
|
||||
}
|
||||
|
||||
@Post('test')
|
||||
async test() {
|
||||
const bid = await this.bidsService.bidsRepo.findOne({
|
||||
where: { lot_id: '26077023' },
|
||||
});
|
||||
|
||||
return await this.notifyService.emitBidStatus({
|
||||
...bid,
|
||||
status: 'win-bid',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,12 @@
|
|||
import { Controller, Get } from '@nestjs/common';
|
||||
import { BotTelegramApi } from '../../bids/apis/bot-telegram.api';
|
||||
|
||||
@Controller('admin/notifications')
|
||||
export class NotificationController {
|
||||
constructor(private botTelegramApi: BotTelegramApi) {}
|
||||
|
||||
@Get('')
|
||||
async test() {
|
||||
return await this.botTelegramApi.getGroupInfo();
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1 @@
|
|||
export class CreateNotificationDto {}
|
||||
|
|
@ -0,0 +1,9 @@
|
|||
import { IsObject, IsString } from 'class-validator';
|
||||
|
||||
export class SendMessageDto {
|
||||
@IsString()
|
||||
text: string;
|
||||
|
||||
@IsObject()
|
||||
options: Record<string, string>;
|
||||
}
|
||||
|
|
@ -0,0 +1,4 @@
|
|||
import { PartialType } from '@nestjs/mapped-types';
|
||||
import { CreateNotificationDto } from './create-notification.dto';
|
||||
|
||||
export class UpdateNotificationDto extends PartialType(CreateNotificationDto) {}
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { Column, Entity, Index, PrimaryGeneratedColumn } from 'typeorm';
|
||||
import { Timestamp } from './timestamp';
|
||||
|
||||
@Entity('notifications')
|
||||
@Index(['message', 'raw_data'])
|
||||
export class Notification extends Timestamp {
|
||||
@PrimaryGeneratedColumn('increment')
|
||||
id: number;
|
||||
|
||||
@Column()
|
||||
message: string;
|
||||
|
||||
@Column({ type: 'varchar' })
|
||||
raw_data: string;
|
||||
|
||||
@Column({ default: null, nullable: true })
|
||||
read_at: Date | null;
|
||||
|
||||
@Column({ type: 'text' })
|
||||
send_to: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,8 @@
|
|||
import { CreateDateColumn, UpdateDateColumn } from 'typeorm';
|
||||
export abstract class Timestamp {
|
||||
@CreateDateColumn({ type: 'timestamp', name: 'created_at' })
|
||||
created_at: Date;
|
||||
|
||||
@UpdateDateColumn({ type: 'timestamp', name: 'updated_at' })
|
||||
updated_at: Date;
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
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';
|
||||
|
||||
@Injectable()
|
||||
export class AdminNotificationListener {
|
||||
constructor(private readonly botTelegramApi: BotTelegramApi) {}
|
||||
|
||||
@OnEvent(NAME_EVENTS.BID_STATUS)
|
||||
handleBidStatus({
|
||||
bid,
|
||||
notification,
|
||||
}: {
|
||||
bid: Bid;
|
||||
notification: Notification;
|
||||
}) {
|
||||
if (JSON.parse(notification.send_to).length <= 0) return;
|
||||
|
||||
this.botTelegramApi.sendMessage(notification.message);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,20 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotificationController } from './controllers/notification.controller';
|
||||
import { NotificationService } from './notification.service';
|
||||
|
||||
describe('NotificationController', () => {
|
||||
let controller: NotificationController;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
controllers: [NotificationController],
|
||||
providers: [NotificationService],
|
||||
}).compile();
|
||||
|
||||
controller = module.get<NotificationController>(NotificationController);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(controller).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
import { forwardRef, Module } from '@nestjs/common';
|
||||
import { TypeOrmModule } from '@nestjs/typeorm';
|
||||
import { BidsModule } from '../bids/bids.module';
|
||||
import { ClientNotificationController } from './controllers/client-notification.controller';
|
||||
import { NotificationController } from './controllers/notification.controller';
|
||||
import { Notification } from './entities/notification.entity';
|
||||
import { AdminNotificationListener } from './listeners/admin-notification.listener';
|
||||
import { NotificationService } from './notification.service';
|
||||
import { SendMessageHistoriesService } from '../bids/services/send-message-histories.service';
|
||||
import { SendMessageHistory } from '../bids/entities/send-message-histories.entity';
|
||||
|
||||
@Module({
|
||||
imports: [
|
||||
forwardRef(() => BidsModule),
|
||||
TypeOrmModule.forFeature([Notification, SendMessageHistory]),
|
||||
],
|
||||
controllers: [NotificationController, ClientNotificationController],
|
||||
providers: [NotificationService, AdminNotificationListener],
|
||||
exports: [NotificationService],
|
||||
})
|
||||
export class NotificationModule {}
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import { Test, TestingModule } from '@nestjs/testing';
|
||||
import { NotificationService } from './notification.service';
|
||||
|
||||
describe('NotificationService', () => {
|
||||
let service: NotificationService;
|
||||
|
||||
beforeEach(async () => {
|
||||
const module: TestingModule = await Test.createTestingModule({
|
||||
providers: [NotificationService],
|
||||
}).compile();
|
||||
|
||||
service = module.get<NotificationService>(NotificationService);
|
||||
});
|
||||
|
||||
it('should be defined', () => {
|
||||
expect(service).toBeDefined();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,126 @@
|
|||
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';
|
||||
import { LEGAL_TCP_SOCKET_OPTIONS, Repository } from 'typeorm';
|
||||
import { isTimeReached } from '@/ultils';
|
||||
import {
|
||||
FilterOperator,
|
||||
FilterSuffix,
|
||||
paginate,
|
||||
PaginateQuery,
|
||||
} from 'nestjs-paginate';
|
||||
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';
|
||||
|
||||
@Injectable()
|
||||
export class NotificationService {
|
||||
constructor(
|
||||
private eventEmitter: EventEmitter2,
|
||||
private readonly botTelegramApi: BotTelegramApi,
|
||||
@InjectRepository(Notification)
|
||||
readonly notificationRepo: Repository<Notification>,
|
||||
@InjectRepository(SendMessageHistory)
|
||||
readonly sendMessageRepo: Repository<SendMessageHistory>,
|
||||
) {}
|
||||
|
||||
async index(query: PaginateQuery) {
|
||||
const filterableColumns: {
|
||||
[key in Column<Bid> | (string & {})]?:
|
||||
| (FilterOperator | FilterSuffix)[]
|
||||
| true;
|
||||
} = {
|
||||
id: true,
|
||||
message: true,
|
||||
};
|
||||
|
||||
query.filter = AppResponse.processFilters(query.filter, filterableColumns);
|
||||
|
||||
const data = await paginate(query, this.notificationRepo, {
|
||||
sortableColumns: ['id'],
|
||||
searchableColumns: ['id'],
|
||||
defaultLimit: 15,
|
||||
filterableColumns,
|
||||
defaultSortBy: [['id', 'DESC']],
|
||||
maxLimit: 100,
|
||||
});
|
||||
|
||||
return AppResponse.toPagination<Notification>(data, true, Notification);
|
||||
}
|
||||
|
||||
getBidStatusMessage(bid: Bid): string | null {
|
||||
const itemName = `*${bid.name || 'the item'}*`;
|
||||
|
||||
switch (bid.status) {
|
||||
case 'biding':
|
||||
return !bid.name ? null : `✅ The item has been activated. ${bid.name}`;
|
||||
|
||||
case 'out-bid':
|
||||
if (isTimeReached(bid.close_time)) {
|
||||
return `⏳ The auction for *${bid.name || 'this item'}* has ended.`;
|
||||
}
|
||||
|
||||
if (
|
||||
bid.max_price + bid.plus_price <= bid.current_price ||
|
||||
bid.reserve_price > bid.max_price + bid.plus_price
|
||||
) {
|
||||
return `💰 The current bid for ${itemName} has exceeded your maximum bid.`;
|
||||
}
|
||||
|
||||
return `🛑 The auction for ${itemName} has been canceled.`;
|
||||
|
||||
case 'win-bid':
|
||||
return `🎉 Congratulations! You won the auction for ${itemName} at *${bid.current_price}*.`;
|
||||
|
||||
default:
|
||||
return '❓ Unknown auction status.';
|
||||
}
|
||||
}
|
||||
|
||||
async emitBidStatus(bid: Bid, sendTo: boolean = true) {
|
||||
const groupData = await this.botTelegramApi.getGroupInfo();
|
||||
const sendToData = groupData && sendTo ? [groupData?.title || 'None'] : [];
|
||||
|
||||
const message = this.getBidStatusMessage(bid);
|
||||
if (!message) return;
|
||||
|
||||
if (bid.status === 'win-bid') {
|
||||
const prevSendMessage = await this.sendMessageRepo.findOne({
|
||||
where: { bid: { id: bid.id }, type: 'win-bid' },
|
||||
});
|
||||
|
||||
if (prevSendMessage) return;
|
||||
}
|
||||
|
||||
const notification = await this.notificationRepo.save({
|
||||
message,
|
||||
raw_data: JSON.stringify({
|
||||
id: bid.id,
|
||||
status: bid.status,
|
||||
name: bid.name,
|
||||
close_time: bid.close_time,
|
||||
current_price: bid.current_price,
|
||||
}),
|
||||
send_to: JSON.stringify(sendToData),
|
||||
});
|
||||
|
||||
await this.sendMessageRepo.save({
|
||||
bid: { id: bid.id },
|
||||
message: notification.message,
|
||||
type: bid.status,
|
||||
});
|
||||
|
||||
this.eventEmitter.emit(NAME_EVENTS.BID_STATUS, {
|
||||
bid: {
|
||||
...bid,
|
||||
status: 'out-bid',
|
||||
},
|
||||
notification,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -12,6 +12,14 @@ export const excludeAuth = [
|
|||
},
|
||||
] as (string | RouteInfo)[];
|
||||
|
||||
export const clientExcludeAuth = [
|
||||
{ path: '/admin/(.*)', method: RequestMethod.ALL },
|
||||
{
|
||||
path: 'auth/generate-token',
|
||||
method: RequestMethod.POST,
|
||||
},
|
||||
] as (string | RouteInfo)[];
|
||||
|
||||
export const excludeAuthor = [
|
||||
...excludeAuth,
|
||||
{
|
||||
|
|
|
|||
|
|
@ -1,6 +1,18 @@
|
|||
export function extractModelId(url: string): string | null {
|
||||
const match = url.match(/\/lot\/([\d-]+)\//);
|
||||
return match ? match[1] : null;
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function subtractMinutes(timeStr: string, minutes: number) {
|
||||
|
|
@ -28,3 +40,60 @@ export function extractDomain(url: string): string | null {
|
|||
export function escapeMarkdownV2(text: string) {
|
||||
return text.replace(/[_*[\]()~`>#+\-=|{}.!\\]/g, '\\$&');
|
||||
}
|
||||
|
||||
export function extractVerifyCodeLANGTONS(content: string) {
|
||||
const match = content.match(/Your\s+(\w+)\s+verific.*?code\s+is:\s+(\d+)/i);
|
||||
|
||||
if (match) {
|
||||
const name = match[1]; // LANGTONS
|
||||
const code = match[2]; // 012152
|
||||
|
||||
return {
|
||||
name,
|
||||
code,
|
||||
};
|
||||
} else {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
export function extractVerifyCodeLAWSONS(content: string) {
|
||||
if (!content) return null;
|
||||
|
||||
// Loại bỏ các ký tự `=` không cần thiết do email encoding (quoted-printable)
|
||||
content = content.replace(/=\r\n/g, '').replace(/=3D/g, '=');
|
||||
|
||||
// Regex để tìm mã xác minh (6 chữ số)
|
||||
const codeMatch = content.match(/Verification code:\s*(\d{6})/);
|
||||
const code = codeMatch ? codeMatch[1] : null;
|
||||
|
||||
// Regex để tìm tên (trong đoạn "Lawsons | Sydney, AU")
|
||||
const nameMatch = content.match(/([\w\s]+)\s*\|\s*[\w\s,]+/);
|
||||
const name = nameMatch ? nameMatch[1].trim() : null;
|
||||
|
||||
// Nếu không tìm thấy cả hai giá trị, trả về null
|
||||
if (!code && !name) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return { code, name };
|
||||
}
|
||||
|
||||
export function verifyCode(content: string) {
|
||||
// Kiểm tra mã xác minh từ LANGTONS
|
||||
const langtonsResult = extractVerifyCodeLANGTONS(content);
|
||||
|
||||
if (langtonsResult) {
|
||||
return langtonsResult;
|
||||
}
|
||||
|
||||
// Nếu không có kết quả, tiếp tục kiểm tra mã xác minh từ LAWSONS
|
||||
const lawsonsResult = extractVerifyCodeLAWSONS(content);
|
||||
|
||||
if (lawsonsResult) {
|
||||
return lawsonsResult; // Trả về kết quả đúng thay vì langtonsResult
|
||||
}
|
||||
|
||||
// Nếu không tìm thấy mã xác minh, trả về null
|
||||
return null;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
ENVIRONMENT = 'prod'
|
||||
SOCKET_URL = 'http://localhost:4000'
|
||||
BASE_URL = 'http://localhost:4000/api/v1/'
|
||||
BASE_URL = 'http://localhost:4000/api/v1/'
|
||||
CLIENT_KEY = ''
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
{
|
||||
"cSpell.words": ["Langtons", "Lawsons"]
|
||||
}
|
||||
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"action": "Auction-LotsData",
|
||||
"queryString": "auctionBpa=18&auctionCatalogPrice=16&auctionId=7402&auctionUnitQuantity=1&includeQuickViewLotData=true&lotIds=26033882&pid=auc-var-26033882&productName=VICKERY%20WINES%20Riesling%2C%20Eden%20Valley%202016%20Bottle",
|
||||
"locale": "en_AU",
|
||||
"csrf": {
|
||||
"tokenName": "csrf_token",
|
||||
"token": "I0GEkb97Y1mbVWixUo2f7sXj4EChyVDMO-CT3I6vV90VEfRJFLio5eBbartpUOaiJUZci22TbjoAnR-UVxaIJ2Umj51lWikd3oUxsTGM_ObrtgFiQXQqxJjY4D98BWf7_tI4uAu-kG6uttjdwArabsiJMx6qDGG0wvH2FLBQ8fpK9IjIBXo="
|
||||
},
|
||||
"success": true,
|
||||
"lotData": {
|
||||
"lotId": "26033882",
|
||||
"currentMaxBid": 17,
|
||||
"minimumBid": 18,
|
||||
"numberOfBidders": 2,
|
||||
"numberOfWatchers": 1,
|
||||
"hasMetReserve": true,
|
||||
"bidStatus": "Winning",
|
||||
"timestamp": 49568786,
|
||||
"myBid": "20"
|
||||
},
|
||||
"productBPA": "18.00",
|
||||
"priceIncBPA": "$18.88",
|
||||
"auctionPrice": "$16.00",
|
||||
"totalAuctionPrice": "$16.00",
|
||||
"lotId": "26033882",
|
||||
"pid": "auc-var-26033882",
|
||||
"unitQuantity": "1",
|
||||
"auctionBpa": "18"
|
||||
}
|
||||
|
|
@ -1,11 +1,13 @@
|
|||
import 'dotenv/config';
|
||||
import _ from 'lodash';
|
||||
import pLimit from 'p-limit';
|
||||
import { io } from 'socket.io-client';
|
||||
import { createApiBid, createBidProduct, deleteProfile, shouldUpdateProductTab } from './service/app-service.js';
|
||||
import browser from './system/browser.js';
|
||||
import configs from './system/config.js';
|
||||
import { delay, isTimeReached, safeClosePage } from './system/utils.js';
|
||||
import pLimit from 'p-limit';
|
||||
|
||||
global.IS_CLEANING = true;
|
||||
|
||||
let MANAGER_BIDS = [];
|
||||
|
||||
|
|
@ -53,7 +55,6 @@ const tracking = async () => {
|
|||
console.log('🔍 Scanning active bids...');
|
||||
const productTabs = _.flatMap(MANAGER_BIDS, 'children');
|
||||
|
||||
// Lắng nghe sự kiện của API bids (chạy song song)
|
||||
await Promise.allSettled(
|
||||
MANAGER_BIDS.filter((bid) => !bid.page_context).map((apiBid) => {
|
||||
console.log(`🎧 Listening to events for API Bid ID: ${apiBid.id}`);
|
||||
|
|
@ -130,6 +131,11 @@ const tracking = async () => {
|
|||
};
|
||||
|
||||
const clearLazyTab = async () => {
|
||||
if (!global.IS_CLEANING) {
|
||||
console.log('🚀 Cleaning flag is OFF. Proceeding with operation.');
|
||||
return;
|
||||
}
|
||||
|
||||
if (!browser) {
|
||||
console.warn('⚠️ Browser is not available or disconnected.');
|
||||
return;
|
||||
|
|
@ -196,17 +202,28 @@ const workTracking = async () => {
|
|||
};
|
||||
|
||||
(async () => {
|
||||
const socket = io(configs.SOCKET_URL, {
|
||||
const socket = io(`${configs.SOCKET_URL}/bid-ws`, {
|
||||
transports: ['websocket'],
|
||||
reconnection: true,
|
||||
extraHeaders: {
|
||||
Authorization: process.env.CLIENT_KEY,
|
||||
},
|
||||
});
|
||||
|
||||
// set socket on global app
|
||||
global.socket = socket;
|
||||
|
||||
// listen connect
|
||||
socket.on('connect', () => {
|
||||
console.log('✅ Connected to WebSocket server');
|
||||
console.log('🔗 Socket ID:', socket.id);
|
||||
});
|
||||
|
||||
// listen connect
|
||||
socket.on('disconnect', () => {
|
||||
console.log('❌Client key is valid. Disconnected');
|
||||
});
|
||||
|
||||
// listen event
|
||||
socket.on('bidsUpdated', async (data) => {
|
||||
console.log('📢 Bids Data:', data);
|
||||
|
|
@ -222,11 +239,16 @@ const workTracking = async () => {
|
|||
if (isDeleted) {
|
||||
console.log('✅ Profile deleted successfully!');
|
||||
|
||||
const tabs = MANAGER_BIDS.filter((item) => item.url === data.url || item?.web_bid.url === data.url);
|
||||
const tab = MANAGER_BIDS.find((item) => item.url === data.url);
|
||||
|
||||
if (tabs.length <= 0) return;
|
||||
if (!tab) return;
|
||||
|
||||
await Promise.all(tabs.map((tab) => safeClosePage(tab)));
|
||||
global.IS_CLEANING = false;
|
||||
await Promise.all(tab.children.map((tab) => safeClosePage(tab)));
|
||||
|
||||
await safeClosePage(tab);
|
||||
|
||||
global.IS_CLEANING = true;
|
||||
} else {
|
||||
console.log('⚠️ No profile found to delete.');
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import BID_TYPE from '../system/bid-type.js';
|
||||
import CONSTANTS from '../system/constants.js';
|
||||
import { takeSnapshot } from '../system/utils.js';
|
||||
import _ from 'lodash';
|
||||
|
|
@ -20,7 +21,7 @@ export class Bid {
|
|||
|
||||
try {
|
||||
// await this.page_context.waitForSelector('#pageContainer', { timeout: 10000 });
|
||||
console.log(`✅ Page fully loaded. Taking snapshot for Product ID: ${this.id}`);
|
||||
console.log(`✅ Page fully loaded. Taking snapshot for ${this.type === BID_TYPE.PRODUCT_TAB ? 'Product ID' : 'Tracking ID'}: ${this.id}`);
|
||||
takeSnapshot(this.page_context, this, 'working', CONSTANTS.TYPE_IMAGE.WORK);
|
||||
} catch (error) {
|
||||
console.error(`❌ Error taking snapshot for Product ID: ${this.id}:`, error.message);
|
||||
|
|
|
|||
|
|
@ -39,44 +39,43 @@ export class GrayApiBid extends ApiBid {
|
|||
// }
|
||||
// });
|
||||
|
||||
console.log('🔄 Starting polling process...');
|
||||
console.log(`🔄 [${this.id}] Starting polling process...`);
|
||||
|
||||
await page.evaluateHandle(
|
||||
(apiUrl, interval) => {
|
||||
(apiUrl, interval, bidId) => {
|
||||
if (window._autoBidPollingStarted) {
|
||||
console.log('✅ Polling is already running. Skipping initialization.');
|
||||
console.log(`✅ [${bidId}] Polling is already running. Skipping initialization.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🚀 Initializing polling...');
|
||||
console.log(`🚀 [${bidId}] Initializing polling...`);
|
||||
window._autoBidPollingStarted = true;
|
||||
|
||||
function sendRequest() {
|
||||
console.log('📡 Sending request to track out-bid lots...');
|
||||
console.log(`📡 [${bidId}] Sending request to track out-bid lots...`);
|
||||
fetch(apiUrl, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/x-www-form-urlencoded' },
|
||||
body: JSON.stringify({ timeStamp: new Date().getTime() }),
|
||||
})
|
||||
.then((response) => console.log(`✅ Response received: ${response.status}`))
|
||||
.catch((err) => console.error('⚠️ Request error:', err));
|
||||
.then((response) => console.log(`✅ [${bidId}] Response received: ${response.status}`))
|
||||
.catch((err) => console.error(`⚠️ [${bidId}] Request error:`, err));
|
||||
}
|
||||
|
||||
window._pollingInterval = setInterval(sendRequest, interval);
|
||||
},
|
||||
configs.WEB_CONFIGS.GRAYS.API_CALL_TO_TRACKING,
|
||||
configs.WEB_CONFIGS.GRAYS.AUTO_CALL_API_TO_TRACKING,
|
||||
this.id,
|
||||
);
|
||||
|
||||
console.log('✅ Polling successfully started!');
|
||||
} catch (error) {
|
||||
if (error.message.includes('Execution context was destroyed')) {
|
||||
console.log('⚠️ Page reload detected, restarting polling...');
|
||||
console.log(`⚠️ [${this.id}] Page reload detected, restarting polling...`);
|
||||
await page.waitForNavigation({ waitUntil: 'networkidle2' }).catch(() => {});
|
||||
return await this.polling(page);
|
||||
}
|
||||
|
||||
console.error('🚨 Unexpected polling error:', error);
|
||||
console.error(`🚨 [${this.id}] Unexpected polling error:`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
|
@ -110,7 +109,7 @@ export class GrayApiBid extends ApiBid {
|
|||
|
||||
for (const product_tab of handleChildren) {
|
||||
if (!isTimeReached(product_tab.start_bid_time)) {
|
||||
console.log("❌ It's not time yet ID: " + product_tab.id + ' continue waiting...');
|
||||
console.log(`❌ [${this.id}] It's not time yet ID: ${product_tab.id} continue waiting...`);
|
||||
return;
|
||||
}
|
||||
|
||||
|
|
@ -129,17 +128,20 @@ export class GrayApiBid extends ApiBid {
|
|||
async handleLogin() {
|
||||
const page = this.page_context;
|
||||
|
||||
global.IS_CLEANING = false;
|
||||
|
||||
const filePath = getPathProfile(this.origin_url);
|
||||
|
||||
// 🔍 Check if already logged in (login input should not be visible)
|
||||
if (!(await page.$('input[name="username"]')) || fs.existsSync(filePath)) {
|
||||
console.log('✅ Already logged in, skipping login.');
|
||||
console.log(`✅ [${this.id}] Already logged in, skipping login.`);
|
||||
|
||||
global.IS_CLEANING = true;
|
||||
this.retry_login = 0; // Reset retry count
|
||||
return;
|
||||
}
|
||||
|
||||
console.log('🔑 Starting login process...');
|
||||
console.log(`🔑 [${this.id}] Starting login process...`);
|
||||
|
||||
try {
|
||||
await page.type('input[name="username"]', this.username, { delay: 100 });
|
||||
|
|
@ -152,18 +154,18 @@ export class GrayApiBid extends ApiBid {
|
|||
]);
|
||||
|
||||
if (!(await page.$('input[name="username"]'))) {
|
||||
console.log('✅ Login successful!');
|
||||
console.log(`✅ [${this.id}] Login successful!`);
|
||||
this.retry_login = 0; // Reset retry count after success
|
||||
return;
|
||||
}
|
||||
|
||||
throw new Error('Login failed, login input is still visible.');
|
||||
} catch (error) {
|
||||
console.log(`⚠️ Login error: ${error.message}. Retrying attempt ${this.retry_login + 1} ❌`);
|
||||
console.log(`⚠️ [${this.id}] Login error: ${error.message}. Retrying attempt ${this.retry_login + 1} ❌`);
|
||||
|
||||
this.retry_login++;
|
||||
if (this.retry_login > this.retry_login_count) {
|
||||
console.log('🚨 Maximum login attempts reached. Stopping login process.');
|
||||
console.log(`🚨 [${this.id}] Maximum login attempts reached. Stopping login process.`);
|
||||
safeClosePage(this);
|
||||
this.retry_login = 0; // Reset retry count
|
||||
return;
|
||||
|
|
@ -177,6 +179,8 @@ export class GrayApiBid extends ApiBid {
|
|||
}
|
||||
|
||||
return await this.action(); // Retry login
|
||||
} finally {
|
||||
global.IS_CLEANING = true;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -185,43 +189,38 @@ export class GrayApiBid extends ApiBid {
|
|||
const page = this.page_context;
|
||||
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2' });
|
||||
console.log(`🌍 [${this.id}] Navigated to URL: ${this.url}`);
|
||||
|
||||
await page.bringToFront();
|
||||
console.log(`🎯 [${this.id}] Brought page to front.`);
|
||||
|
||||
// Set userAgent
|
||||
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
|
||||
// await takeSnapshot(this.page_context, this, 'working', CONSTANTS.TYPE_IMAGE.WORK);
|
||||
console.log(`🛠️ [${this.id}] UserAgent set.`);
|
||||
|
||||
page.on('response', async (response) => {
|
||||
if (response.request().url().includes('api/Notifications/GetOutBidLots')) {
|
||||
console.log('🚀 API POST:', response.url());
|
||||
console.log(`🚀 [${this.id}] API POST detected: ${response.url()}`);
|
||||
|
||||
try {
|
||||
const responseBody = await response.json();
|
||||
|
||||
await this.listen_out_bids(responseBody.AuctionOutBidLots || []);
|
||||
} catch (error) {
|
||||
console.error('❌ Error get response', error?.message);
|
||||
console.error(`❌ [${this.id}] Error processing response:`, error?.message);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
page.on('load', async () => {
|
||||
console.log('🔄 The page has reloaded, restarting polling...');
|
||||
|
||||
// await takeSnapshot(this.page_context, this, 'working', CONSTANTS.TYPE_IMAGE.WORK);
|
||||
|
||||
console.log(`🔄 [${this.id}] Page has reloaded, restarting polling...`);
|
||||
await this.polling(page);
|
||||
|
||||
await this.handleLogin();
|
||||
});
|
||||
|
||||
await this.polling(page); // Call when fist load
|
||||
|
||||
await this.polling(page); // Call when first load
|
||||
await this.handleLogin();
|
||||
} catch (error) {
|
||||
console.log(error.message);
|
||||
console.log(`❌ [${this.id}] Action error: ${error.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -10,12 +10,12 @@ export class GraysProductBid extends ProductBid {
|
|||
|
||||
async validate({ page, price_value }) {
|
||||
if (!this.start_bid_time || !isTimeReached(this.start_bid_time)) {
|
||||
console.log("It's not time yet ❌");
|
||||
console.log(`❌ [${this.id}] It's not time yet`);
|
||||
return { result: false, bid_price: 0 };
|
||||
}
|
||||
|
||||
if (!isNumber(price_value)) {
|
||||
console.log("Can't get PRICE_VALUE ❌");
|
||||
console.log(`❌ [${this.id}] Can't get PRICE_VALUE`);
|
||||
await takeSnapshot(page, this, 'price-value-null');
|
||||
|
||||
return { result: false, bid_price: 0 };
|
||||
|
|
@ -24,7 +24,7 @@ export class GraysProductBid extends ProductBid {
|
|||
const bid_price = this.plus_price + Number(price_value);
|
||||
|
||||
if (bid_price > this.max_price) {
|
||||
console.log('PRICE BID is more than MAX_VALUE => STOP BID THIS PRODUCT ❌');
|
||||
console.log(`❌ ${this.id} PRICE BID is more than MAX_VALUE => STOP BID THIS PRODUCT`);
|
||||
await takeSnapshot(page, this, 'price-bid-more-than');
|
||||
|
||||
await outBid(this.id);
|
||||
|
|
@ -92,7 +92,7 @@ export class GraysProductBid extends ProductBid {
|
|||
await delay(500);
|
||||
|
||||
if (!close_time || new Date(close_time).getTime() <= new Date().getTime()) {
|
||||
console.log(`Product is close ${close_time} ❌`);
|
||||
console.log(`❌ [${this.id}] Product is close ${close_time}`);
|
||||
return { result: true, close_time };
|
||||
}
|
||||
|
||||
|
|
@ -123,7 +123,7 @@ export class GraysProductBid extends ProductBid {
|
|||
await takeSnapshot(page, this, 'bid-success', CONSTANTS.TYPE_IMAGE.SUCCESS);
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.log('❌ Timeout to loading');
|
||||
console.log(`❌ [${this.id}] Timeout to loading`);
|
||||
await takeSnapshot(page, this, 'timeout to loading');
|
||||
return false;
|
||||
}
|
||||
|
|
@ -169,7 +169,7 @@ export class GraysProductBid extends ProductBid {
|
|||
.$eval('#biddableLot > form > div > div:nth-child(1) > div:nth-child(1) > div:nth-child(2) > div > span > span', (el) => el.innerText)
|
||||
.catch(() => null);
|
||||
|
||||
console.log(`📌 Product Info: Lot ID: ${lot_id}, Name: ${name}, Current Price: ${current_price}, Reserve price: ${price_value}`);
|
||||
console.log(`📌 [${this.id}] Product Info: Lot ID: ${lot_id}, Name: ${name}, Current Price: ${current_price}, Reserve price: ${price_value}`);
|
||||
|
||||
const data = removeFalsyValues(
|
||||
{
|
||||
|
|
@ -196,47 +196,50 @@ export class GraysProductBid extends ProductBid {
|
|||
const page = this.page_context;
|
||||
|
||||
await this.gotoLink();
|
||||
console.log(`🌍 [${this.id}] Navigated to link.`);
|
||||
|
||||
await delay(1000);
|
||||
|
||||
const { close_time, ...isCloseProduct } = await this.isCloseProduct();
|
||||
if (isCloseProduct.result) {
|
||||
console.log('❌ The product is closed, cannot place a bid.');
|
||||
console.log(`❌ [${this.id}] The product is closed, cannot place a bid.`);
|
||||
return;
|
||||
}
|
||||
|
||||
await delay(500);
|
||||
|
||||
const { price_value } = await this.update();
|
||||
|
||||
if (!price_value) return;
|
||||
|
||||
const { result, bid_price } = await this.validate({ page, price_value });
|
||||
|
||||
if (!result) {
|
||||
console.log('❌ Validation failed. Unable to proceed with bidding.');
|
||||
console.log(`❌ [${this.id}] Validation failed. Unable to proceed with bidding.`);
|
||||
return;
|
||||
}
|
||||
|
||||
const bidHistoriesItem = _.maxBy(this.histories, 'price');
|
||||
if (bidHistoriesItem && bidHistoriesItem.price === this.current_price) {
|
||||
console.log(`🔄 [${this.id}] You have already bid on this item! (Bid Price: ${bidHistoriesItem.price})`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (price_value != bid_price) {
|
||||
console.log(`✍️ Updating bid price from ${price_value} → ${bid_price}`);
|
||||
console.log(`✍️ [${this.id}] Updating bid price from ${price_value} → ${bid_price}`);
|
||||
await this.handleWritePrice(page, bid_price);
|
||||
}
|
||||
|
||||
console.log('🚀 Placing the bid...');
|
||||
console.log(`🚀 [${this.id}] Placing the bid...`);
|
||||
const resultPlaceBid = await this.placeBid(page);
|
||||
|
||||
if (!resultPlaceBid) {
|
||||
console.log('❌ Error occurred while placing the bid.');
|
||||
console.log(`❌ [${this.id}] Error occurred while placing the bid.`);
|
||||
await takeSnapshot(page, this, 'place-bid-action');
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`✅ Bid placed successfully! 🏆 Bid Price: ${bid_price}, Closing Time: ${close_time}`);
|
||||
|
||||
console.log(`✅ [${this.id}] Bid placed successfully! 🏆 Bid Price: ${bid_price}, Closing Time: ${close_time}`);
|
||||
await this.handleReturnProductPage(page);
|
||||
} catch (error) {
|
||||
console.error(`🚨 Error navigating the page: ${error.message}`);
|
||||
console.error(`🚨 [${this.id}] Error navigating the page: ${error.message}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,225 @@
|
|||
import fs from 'fs';
|
||||
import configs from '../../system/config.js';
|
||||
import { getPathProfile, safeClosePage } from '../../system/utils.js';
|
||||
import { ApiBid } from '../api-bid.js';
|
||||
import _ from 'lodash';
|
||||
import { updateStatusByPrice } from '../../system/apis/bid.js';
|
||||
|
||||
export class LangtonsApiBid extends ApiBid {
|
||||
reloadInterval = null;
|
||||
constructor({ ...prev }) {
|
||||
super(prev);
|
||||
}
|
||||
|
||||
waitVerifyData = async () =>
|
||||
new Promise((rev, rej) => {
|
||||
// Tạo timeout để reject sau 1 phút nếu không có phản hồi
|
||||
const timeout = setTimeout(() => {
|
||||
global.socket.off(`verify-code.${this.origin_url}`); // Xóa listener tránh rò rỉ bộ nhớ
|
||||
rej(new Error(`[${this.id}] Timeout: No verification code received within 1 minute.`));
|
||||
}, 60 * 1000); // 60 giây
|
||||
|
||||
global.socket.on(`verify-code.${this.origin_url}`, async (data) => {
|
||||
console.log(`📢 [${this.id}] VERIFY CODE:`, data);
|
||||
clearTimeout(timeout); // Hủy timeout vì đã nhận được mã
|
||||
global.socket.off(`verify-code.${this.origin_url}`); // Xóa listener tránh lặp lại
|
||||
rev(data); // Resolve với dữ liệu nhận được
|
||||
});
|
||||
});
|
||||
|
||||
async isLogin() {
|
||||
if (!this.page_context) return false;
|
||||
|
||||
const filePath = getPathProfile(this.origin_url);
|
||||
|
||||
return !(await this.page_context.$('input[name="loginEmail"]')) && fs.existsSync(filePath);
|
||||
}
|
||||
|
||||
async handleLogin() {
|
||||
const page = this.page_context;
|
||||
|
||||
global.IS_CLEANING = false;
|
||||
|
||||
const filePath = getPathProfile(this.origin_url);
|
||||
|
||||
await page.waitForNavigation({ waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 🛠 Check if already logged in (login input should not be visible or profile exists)
|
||||
if (!(await page.$('input[name="loginEmail"]')) && fs.existsSync(filePath)) {
|
||||
console.log(`✅ [${this.id}] Already logged in, skipping login process.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
console.log(`🗑 [${this.id}] Deleting existing file: ${filePath}`);
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
const children = this.children.filter((item) => item.page_context);
|
||||
console.log(`🔍 [${this.id}] Found ${children.length} child pages to close.`);
|
||||
|
||||
if (children.length > 0) {
|
||||
console.log(`🛑 [${this.id}] Closing child pages...`);
|
||||
await Promise.all(
|
||||
children.map((item) => {
|
||||
console.log(`➡ [${this.id}] Closing child page with context: ${item.page_context}`);
|
||||
return safeClosePage(item);
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(`➡ [${this.id}] Closing main page context: ${this.page_context}`);
|
||||
await safeClosePage(this);
|
||||
}
|
||||
|
||||
console.log(`🔑 [${this.id}] Starting login process...`);
|
||||
|
||||
try {
|
||||
// ⌨ Enter email
|
||||
console.log(`✍ [${this.id}] Entering email:`, this.username);
|
||||
await page.type('input[name="loginEmail"]', this.username, { delay: 100 });
|
||||
|
||||
// ⌨ Enter password
|
||||
console.log(`✍ [${this.id}] Entering password...`);
|
||||
await page.type('input[name="loginPassword"]', this.password, { delay: 150 });
|
||||
|
||||
// ✅ Click the "Remember Me" checkbox
|
||||
console.log(`🔘 [${this.id}] Clicking the "Remember Me" checkbox`);
|
||||
await page.click('#rememberMe', { delay: 80 });
|
||||
|
||||
// 🚀 Click the login button
|
||||
console.log(`🔘 [${this.id}] Clicking the "Login" button`);
|
||||
await page.click('#loginFormSubmitButton', { delay: 92 });
|
||||
|
||||
// ⏳ Wait for navigation after login
|
||||
console.log(`⏳ [${this.id}] Waiting for navigation after login...`);
|
||||
await page.waitForNavigation({ timeout: 8000, waitUntil: 'domcontentloaded' });
|
||||
|
||||
console.log(`🌍 [${this.id}] Current page after login:`, page.url());
|
||||
|
||||
// 📢 Listen for verification code event
|
||||
console.log(`👂 [${this.id}] Listening for event: verify-code.${this.origin_url}`);
|
||||
|
||||
// ⏳ Wait for verification code from socket event
|
||||
const { name, code } = await this.waitVerifyData();
|
||||
console.log(`✅ [${this.id}] Verification code received:`, { name, code });
|
||||
|
||||
// ⌨ Enter verification code
|
||||
console.log(`✍ [${this.id}] Entering verification code...`);
|
||||
await page.type('#code', code, { delay: 120 });
|
||||
|
||||
// 🚀 Click the verification confirmation button
|
||||
console.log(`🔘 [${this.id}] Clicking the verification confirmation button`);
|
||||
await page.click('.btn.btn-block.btn-primary', { delay: 90 });
|
||||
|
||||
// ⏳ Wait for navigation after verification
|
||||
console.log(`⏳ [${this.id}] Waiting for navigation after verification...`);
|
||||
await page.waitForNavigation({ timeout: 8000, waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 📂 Save session context to avoid re-login
|
||||
await this.saveContext();
|
||||
console.log(`✅ [${this.id}] Login successful!`);
|
||||
|
||||
await page.goto(this.url);
|
||||
console.log(`✅ [${this.id}] Navigation successful!`);
|
||||
} catch (error) {
|
||||
console.error(`❌ [${this.id}] Error during login process:`, error.message);
|
||||
} finally {
|
||||
global.IS_CLEANING = true;
|
||||
}
|
||||
}
|
||||
|
||||
async getWonList() {
|
||||
try {
|
||||
await page.waitForSelector('.row.account-product-list', { timeout: 30000 });
|
||||
|
||||
const items = await page.evaluate(() => {
|
||||
return Array.from(document.querySelectorAll('.row.account-product-list')).map((item) => item.getAttribute('data-lotid') || null);
|
||||
});
|
||||
|
||||
return _.compact(items);
|
||||
} catch (error) {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
async handleUpdateWonItem() {
|
||||
console.log(`🔄 [${this.id}] Starting to update the won list...`);
|
||||
|
||||
// Lấy danh sách các lot_id thắng
|
||||
const items = await this.getWonList();
|
||||
console.log(`📌 [${this.id}] List of won lot_ids:`, items);
|
||||
|
||||
// Nếu không có item nào, thoát ra
|
||||
if (items.length === 0) {
|
||||
console.log(`⚠️ [${this.id}] No items to update.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Lọc danh sách `this.children` chỉ giữ lại những item có trong danh sách thắng
|
||||
const result = _.filter(this.children, (item) => _.includes(items, item.lot_id));
|
||||
console.log(`✅ [${this.id}] ${result.length} items need to be updated:`, result);
|
||||
|
||||
// Gọi API updateStatusByPrice cho mỗi item và đợi tất cả hoàn thành
|
||||
const responses = await Promise.allSettled(result.map((i) => updateStatusByPrice(i.id, i.current_price)));
|
||||
|
||||
// Log kết quả của mỗi request
|
||||
responses.forEach((response, index) => {
|
||||
if (response.status === 'fulfilled') {
|
||||
console.log(`✔️ [${this.id}] Successfully updated:`, result[index]);
|
||||
} else {
|
||||
console.error(`❌ [${this.id}] Update failed:`, result[index], response.reason);
|
||||
}
|
||||
});
|
||||
|
||||
console.log(`🏁 [${this.id}] Finished updating the won list.`);
|
||||
return responses;
|
||||
}
|
||||
|
||||
action = async () => {
|
||||
try {
|
||||
const page = this.page_context;
|
||||
|
||||
page.on('response', async (response) => {
|
||||
const request = response.request();
|
||||
if (request.redirectChain().length > 0) {
|
||||
if (response.url().includes(configs.WEB_CONFIGS.LANGTONS.LOGIN_URL)) {
|
||||
await this.handleLogin();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2' });
|
||||
|
||||
await page.bringToFront();
|
||||
|
||||
// Set userAgent
|
||||
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
} catch (error) {
|
||||
console.log('Error [action]: ', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
listen_events = async () => {
|
||||
if (this.page_context) return;
|
||||
|
||||
await this.puppeteer_connect();
|
||||
await this.action();
|
||||
|
||||
this.reloadInterval = setInterval(async () => {
|
||||
try {
|
||||
if (this.page_context && !this.page_context.isClosed()) {
|
||||
console.log(`🔄 [${this.id}] Reloading page...`);
|
||||
await this.page_context.reload({ waitUntil: 'networkidle2' });
|
||||
console.log(`✅ [${this.id}] Page reloaded successfully.`);
|
||||
|
||||
// this.handleUpdateWonItem();
|
||||
} else {
|
||||
console.log(`❌ [${this.id}] Page context is closed. Stopping reload.`);
|
||||
clearInterval(this.reloadInterval);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`🚨 [${this.id}] Error reloading page:`, error.message);
|
||||
}
|
||||
}, 60000);
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,395 @@
|
|||
import _ from 'lodash';
|
||||
import { outBid, pushPrice, updateBid } from '../../system/apis/bid.js';
|
||||
import { sendMessage } from '../../system/apis/notification.js';
|
||||
import { createOutBidLog } from '../../system/apis/out-bid-log.js';
|
||||
import configs from '../../system/config.js';
|
||||
import CONSTANTS from '../../system/constants.js';
|
||||
import { convertAETtoUTC, removeFalsyValues, takeSnapshot } from '../../system/utils.js';
|
||||
import { ProductBid } from '../product-bid.js';
|
||||
|
||||
export class LangtonsProductBid extends ProductBid {
|
||||
constructor({ ...prev }) {
|
||||
super(prev);
|
||||
}
|
||||
|
||||
// Hàm lấy thời gian kết thúc từ trang web
|
||||
async getCloseTime() {
|
||||
try {
|
||||
// Kiểm tra xem có context của trang web không, nếu không thì trả về null
|
||||
if (!this.page_context) return null;
|
||||
|
||||
await this.page_context.waitForSelector('.site-timezone', { timeout: 2000 });
|
||||
const time = await this.page_context.evaluate(() => {
|
||||
const el = document.querySelector('.site-timezone');
|
||||
return el ? el.innerText : null;
|
||||
});
|
||||
|
||||
return time ? convertAETtoUTC(time) : null;
|
||||
|
||||
// return new Date(Date.now() + 2 * 60 * 1000).toUTCString();
|
||||
} catch (error) {
|
||||
// Nếu có lỗi xảy ra trong quá trình lấy thời gian, trả về null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async waitForApiResponse(timeout = 10000) {
|
||||
if (!this.page_context) {
|
||||
console.error(`❌ [${this.id}] Error: page_context is undefined.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const onResponse = async (response) => {
|
||||
try {
|
||||
if (!response || !response.request().url().includes(configs.WEB_CONFIGS.LANGTONS.API_CALL_TO_TRACKING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
clearTimeout(timer); // Hủy timeout nếu có phản hồi
|
||||
this.page_context.off('response', onResponse); // Gỡ bỏ listener
|
||||
|
||||
const data = await response.json();
|
||||
resolve(data);
|
||||
} catch (error) {
|
||||
console.error(`❌ [${this.id}] Error while parsing response:`, error?.message);
|
||||
resolve(null);
|
||||
}
|
||||
};
|
||||
|
||||
const timer = setTimeout(() => {
|
||||
console.log(`⏳ [${this.id}] Timeout: No response received within 10s`);
|
||||
this.page_context.off('response', onResponse); // Gỡ bỏ listener khi timeout
|
||||
resolve(null);
|
||||
}, timeout);
|
||||
|
||||
this.page_context.on('response', onResponse);
|
||||
});
|
||||
}
|
||||
|
||||
async getName() {
|
||||
try {
|
||||
if (!this.page_context) return null;
|
||||
|
||||
await this.page_context.waitForSelector('.product-name', { timeout: 3000 });
|
||||
|
||||
return await this.page_context.evaluate(() => {
|
||||
const el = document.querySelector('.product-name');
|
||||
return el ? el.innerText : null;
|
||||
});
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async handleUpdateBid({ lot_id, close_time, name, current_price, reserve_price, model }) {
|
||||
const response = await updateBid(this.id, { lot_id, close_time, name, current_price, reserve_price: Number(reserve_price) || 0, model });
|
||||
|
||||
if (response) {
|
||||
this.lot_id = response.lot_id;
|
||||
this.close_time = response.close_time;
|
||||
this.start_bid_time = response.start_bid_time;
|
||||
}
|
||||
}
|
||||
|
||||
update = async () => {
|
||||
if (!this.page_context) return;
|
||||
|
||||
console.log(`🔄 [${this.id}] Call update for ID: ${this.id}`);
|
||||
|
||||
// 📌 Lấy thời gian kết thúc đấu giá từ giao diện
|
||||
const close_time = await this.getCloseTime();
|
||||
console.log(`⏳ [${this.id}] Retrieved close time: ${close_time}`);
|
||||
|
||||
// 📌 Lấy tên sản phẩm hoặc thông tin liên quan
|
||||
const name = await this.getName();
|
||||
console.log(`📌 [${this.id}] Retrieved name: ${name}`);
|
||||
|
||||
// 📌 Chờ phản hồi API từ trang, tối đa 10 giây
|
||||
const result = await this.waitForApiResponse();
|
||||
|
||||
// 📌 Nếu không có dữ liệu trả về thì dừng
|
||||
if (!result) {
|
||||
console.log(`⚠️ [${this.id}] No valid data received, skipping update.`);
|
||||
return;
|
||||
}
|
||||
|
||||
// 📌 Loại bỏ các giá trị không hợp lệ và bổ sung thông tin cần thiết
|
||||
const data = removeFalsyValues(
|
||||
{
|
||||
model: result?.pid || null,
|
||||
lot_id: result?.lotId || null,
|
||||
reserve_price: result.lotData?.minimumBid || null,
|
||||
current_price: result.lotData?.currentMaxBid || null,
|
||||
// close_time: close_time && !this.close_time ? String(close_time) : null,
|
||||
close_time: close_time ? String(close_time) : null,
|
||||
name,
|
||||
},
|
||||
// [],
|
||||
['close_time'],
|
||||
);
|
||||
|
||||
console.log(`🚀 [${this.id}] Processed data ready for update`);
|
||||
|
||||
// 📌 Gửi dữ liệu cập nhật lên hệ thống
|
||||
await this.handleUpdateBid(data);
|
||||
|
||||
console.log('✅ Update successful!');
|
||||
|
||||
return { ...response, name, close_time };
|
||||
};
|
||||
|
||||
async getContinueShopButton() {
|
||||
try {
|
||||
if (!this.page_context) return null;
|
||||
|
||||
await this.page_context.waitForSelector('.btn.btn-block.btn-primary.error.continue-shopping', { timeout: 3000 });
|
||||
|
||||
return await this.page_context.evaluate(() => {
|
||||
const el = document.querySelector('.btn.btn-block.btn-primary.error.continue-shopping');
|
||||
|
||||
return el;
|
||||
});
|
||||
} catch (error) {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async handlePlaceBid() {
|
||||
if (!this.page_context) {
|
||||
console.log(`⚠️ [${this.id}] No page context found, aborting bid process.`);
|
||||
return;
|
||||
}
|
||||
const page = this.page_context;
|
||||
|
||||
if (global[`IS_PLACE_BID-${this.id}`]) {
|
||||
console.log(`⚠️ [${this.id}] Bid is already in progress, skipping.`);
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
console.log(`🔄 [${this.id}] Starting bid process...`);
|
||||
global[`IS_PLACE_BID-${this.id}`] = true;
|
||||
|
||||
const continueShopBtn = await this.getContinueShopButton();
|
||||
if (continueShopBtn) {
|
||||
console.log(`⚠️ [${this.id}] Outbid detected, calling outBid function.`);
|
||||
await outBid(this.id);
|
||||
return;
|
||||
}
|
||||
|
||||
// Kiểm tra nếu giá hiện tại lớn hơn giá tối đa cộng thêm giá cộng thêm
|
||||
if (this.current_price > this.max_price + this.plus_price) {
|
||||
console.log(`⚠️ [${this.id}] Outbid bid`); // Ghi log cảnh báo nếu giá hiện tại vượt quá mức tối đa cho phép
|
||||
return; // Dừng hàm nếu giá đã vượt qua giới hạn
|
||||
}
|
||||
|
||||
// Đợi phản hồi từ API
|
||||
const response = await this.waitForApiResponse();
|
||||
|
||||
// Kiểm tra nếu phản hồi không tồn tại hoặc nếu giá đấu của người dùng bằng với giá tối đa hiện tại
|
||||
if (!response || (response?.lotData?.myBid && response.lotData.myBid == this.max_price) || response?.lotData?.minimumBid > this.max_price) {
|
||||
console.log(`⚠️ [${this.id}] No response or myBid equals max_price:`, response); // Ghi log nếu không có phản hồi hoặc giá đấu của người dùng bằng giá tối đa
|
||||
return; // Nếu không có phản hồi hoặc giá đấu bằng giá tối đa thì dừng hàm
|
||||
}
|
||||
|
||||
// Kiểm tra nếu dữ liệu trong response có tồn tại và trạng thái đấu giá (bidStatus) không phải là 'None'
|
||||
if (response.lotData && response.lotData?.bidStatus !== 'None' && this.max_price == response?.lotData.myBid) {
|
||||
console.log(`✔️ [${this.id}] Bid status is not 'None'. Current bid status:`, response.lotData?.bidStatus); // Ghi log nếu trạng thái đấu giá không phải 'None'
|
||||
return; // Nếu trạng thái đấu giá không phải là 'None', dừng hàm
|
||||
}
|
||||
|
||||
const bidHistoriesItem = _.maxBy(this.histories, 'price');
|
||||
console.log(`📜 [${this.id}] Current bid history:`, this.histories);
|
||||
|
||||
if (bidHistoriesItem && bidHistoriesItem.price === this.current_price && this.max_price == response?.lotData.myBid) {
|
||||
console.log(`🔄 [${this.id}] You have already bid on this item! (Bid Price: ${bidHistoriesItem.price})`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`💰 [${this.id}] Placing a bid with amount: ${this.reserve_price}`);
|
||||
|
||||
// 📌 Làm rỗng ô input trước khi nhập giá đấu
|
||||
await page.evaluate(() => {
|
||||
document.querySelector('#place-bid').value = '';
|
||||
});
|
||||
|
||||
console.log(`📝 [${this.id}] Cleared bid input field.`);
|
||||
|
||||
// 📌 Nhập giá đấu vào ô input
|
||||
await page.type('#place-bid', String(this.max_price), { delay: 800 });
|
||||
console.log(`✅ [${this.id}] Entered bid amount: ${this.max_price}`);
|
||||
|
||||
// 📌 Lấy giá trị thực tế từ ô input sau khi nhập
|
||||
const bidValue = await page.evaluate(() => document.querySelector('#place-bid').value);
|
||||
console.log(`🔍 Entered bid value: ${bidValue}`);
|
||||
|
||||
// 📌 Kiểm tra nếu giá trị nhập vào không khớp với giá trị mong muốn
|
||||
if (!bidValue || bidValue !== String(this.max_price)) {
|
||||
console.log(`❌ Incorrect bid amount! Received: ${bidValue}`);
|
||||
return; // Dừng thực hiện nếu giá trị nhập sai
|
||||
}
|
||||
|
||||
// 📌 Nhấn nút "Place Bid"
|
||||
await page.click('.place-bid-submit .btn.btn-primary.btn-block.place-bid-btn', { delay: 5000 });
|
||||
console.log(`🖱️ [${this.id}] Clicked "Place Bid" button.`);
|
||||
|
||||
console.log(`📩 [${this.id}] Bid submitted, waiting for navigation...`);
|
||||
|
||||
// 📌 Chờ trang load lại để cập nhật trạng thái đấu giá
|
||||
await page.waitForNavigation({ timeout: 8000, waitUntil: 'domcontentloaded' });
|
||||
|
||||
console.log(`🔄 [${this.id}] Page reloaded, checking bid status...`);
|
||||
|
||||
const { lotData } = await this.waitForApiResponse();
|
||||
console.log(`📡 [${this.id}] API Response received:`, lotData);
|
||||
|
||||
// 📌 Kiểm tra trạng thái đấu giá từ API
|
||||
if (lotData?.myBid == this.max_price) {
|
||||
console.log(`📸 [${this.id}] Taking bid success snapshot...`);
|
||||
await takeSnapshot(page, this, 'bid-success', CONSTANTS.TYPE_IMAGE.SUCCESS);
|
||||
|
||||
sendMessage(this);
|
||||
|
||||
console.log(`✅ [${this.id}] Bid placed successfully!`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`⚠️ [${this.id}] Bid action completed, but status is still "None".`);
|
||||
} catch (error) {
|
||||
console.log(`🚨 [${this.id}] Error placing bid: ${error.message}`);
|
||||
} finally {
|
||||
console.log(`🔚 [${this.id}] Resetting bid flag.`);
|
||||
global[`IS_PLACE_BID-${this.id}`] = false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleCreateLogsOnServer(data) {
|
||||
const values = data.map((item) => {
|
||||
return {
|
||||
model: item.pid,
|
||||
lot_id: item.lotId,
|
||||
out_price: item.lotData.minimumBid || 0,
|
||||
raw_data: JSON.stringify(item),
|
||||
};
|
||||
});
|
||||
|
||||
await createOutBidLog(values);
|
||||
}
|
||||
|
||||
async gotoLink() {
|
||||
const page = this.page_context;
|
||||
|
||||
if (page.isClosed()) {
|
||||
console.error(`❌ [${this.id}] Page has been closed, cannot navigate.`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔄 [${this.id}] Starting the bidding process...`);
|
||||
|
||||
try {
|
||||
console.log(`🌐 [${this.id}] Navigating to: ${this.url} ...`);
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2' });
|
||||
console.log(`✅ [${this.id}] Successfully navigated to: ${this.url}`);
|
||||
|
||||
console.log(`🖥️ [${this.id}] Bringing tab to the foreground...`);
|
||||
await page.bringToFront();
|
||||
|
||||
console.log(`🛠️ [${this.id}] Setting custom user agent...`);
|
||||
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
|
||||
console.log(`🎯 [${this.id}] Listening for API responses...`);
|
||||
|
||||
// // 🔥 Xóa tất cả event chặn request trước khi thêm mới
|
||||
// page.removeAllListeners('request');
|
||||
|
||||
// await page.setRequestInterception(true);
|
||||
|
||||
// page.on('request', (request) => {
|
||||
// if (request.url().includes(configs.WEB_CONFIGS.LANGTONS.API_CALL_TO_TRACKING)) {
|
||||
// console.log('🚀 Fake response cho request:', request.url());
|
||||
|
||||
// const fakeData = fs.readFileSync('./data/fake-out-lot-langtons.json', 'utf8');
|
||||
|
||||
// request.respond({
|
||||
// status: 200,
|
||||
// contentType: 'application/json',
|
||||
// body: fakeData,
|
||||
// });
|
||||
// } else {
|
||||
// try {
|
||||
// request.continue(); // ⚠️ Chỉ tiếp tục nếu request chưa bị chặn
|
||||
// } catch (error) {
|
||||
// console.error('⚠️ Lỗi khi tiếp tục request:', error.message);
|
||||
// }
|
||||
// }
|
||||
// });
|
||||
|
||||
const onResponse = async (response) => {
|
||||
const url = response?.request()?.url();
|
||||
if (!url || !url.includes(configs.WEB_CONFIGS.LANGTONS.API_CALL_TO_TRACKING)) {
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const { lotData, ...prev } = await response.json();
|
||||
console.log(`📜 [${this.id}] Received lotData:`, lotData);
|
||||
|
||||
if (!lotData || lotData.lotId !== this.lot_id) {
|
||||
console.log(`⚠️ [${this.id}] Ignored response for lotId: ${lotData?.lotId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
console.log(`🔍 [${this.id}] Checking bid status...`);
|
||||
|
||||
if (['Outbid'].includes(lotData?.bidStatus)) {
|
||||
console.log(`⚠️ [${this.id}] Outbid detected, attempting to place a new bid...`);
|
||||
|
||||
this.handleCreateLogsOnServer([{ lotData, ...prev }]);
|
||||
} else if (['Winning'].includes(lotData?.bidStatus)) {
|
||||
const bidHistoriesItem = _.maxBy(this.histories, 'price');
|
||||
|
||||
if (!bidHistoriesItem || bidHistoriesItem?.price != lotData?.currentMaxBid) {
|
||||
pushPrice({
|
||||
bid_id: this.id,
|
||||
price: lotData?.currentMaxBid,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
if (lotData.myBid && this.max_price != lotData.myBid) {
|
||||
this.handlePlaceBid();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`🚨 [${this.id}] Error parsing API response:`, error);
|
||||
}
|
||||
};
|
||||
|
||||
console.log(`🔄 [${this.id}] Removing previous response listeners...`);
|
||||
this.page_context.off('response', onResponse);
|
||||
|
||||
console.log(`📡 [${this.id}] Attaching new response listener...`);
|
||||
this.page_context.on('response', onResponse);
|
||||
|
||||
console.log(`✅ [${this.id}] Navigation setup complete.`);
|
||||
} catch (error) {
|
||||
console.error(`❌ [${this.id}] Error during navigation:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
action = async () => {
|
||||
try {
|
||||
const page = this.page_context;
|
||||
|
||||
// 📌 Kiểm tra nếu trang chưa tải đúng URL thì điều hướng đến URL mục tiêu
|
||||
if (!page.url() || !page.url().includes(this.url)) {
|
||||
console.log(`🔄 [${this.id}] Navigating to target URL: ${this.url}`);
|
||||
await this.gotoLink();
|
||||
}
|
||||
|
||||
await this.handlePlaceBid();
|
||||
} catch (error) {
|
||||
console.error(`🚨 [${this.id}] Error navigating the page: ${error}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,260 @@
|
|||
import fs from 'fs';
|
||||
import configs from '../../system/config.js';
|
||||
import { delay, getPathProfile, safeClosePage } from '../../system/utils.js';
|
||||
import { ApiBid } from '../api-bid.js';
|
||||
|
||||
export class LawsonsApiBid extends ApiBid {
|
||||
reloadInterval = null;
|
||||
constructor({ ...prev }) {
|
||||
super(prev);
|
||||
}
|
||||
|
||||
waitVerifyData = async () =>
|
||||
new Promise((rev, rej) => {
|
||||
// Tạo timeout để reject sau 1 phút nếu không có phản hồi
|
||||
const timeout = setTimeout(() => {
|
||||
global.socket.off(`verify-code.${this.origin_url}`); // Xóa listener tránh rò rỉ bộ nhớ
|
||||
rej(new Error(`[${this.id}] Timeout: No verification code received within 1 minute.`));
|
||||
}, 60 * 1000); // 60 giây
|
||||
|
||||
global.socket.on(`verify-code.${this.origin_url}`, async (data) => {
|
||||
console.log(`📢 [${this.id}] VERIFY CODE:`, data);
|
||||
clearTimeout(timeout); // Hủy timeout vì đã nhận được mã
|
||||
global.socket.off(`verify-code.${this.origin_url}`); // Xóa listener tránh lặp lại
|
||||
rev(data); // Resolve với dữ liệu nhận được
|
||||
});
|
||||
});
|
||||
|
||||
async isLogin() {
|
||||
if (!this.page_context) return false;
|
||||
|
||||
const filePath = getPathProfile(this.origin_url);
|
||||
|
||||
return !(await this.page_context.$('#emailLogin')) && fs.existsSync(filePath);
|
||||
}
|
||||
|
||||
waitVerifyData = async () =>
|
||||
new Promise((rev, rej) => {
|
||||
// Tạo timeout để reject sau 1 phút nếu không có phản hồi
|
||||
const timeout = setTimeout(() => {
|
||||
global.socket.off(`verify-code.${this.origin_url}`); // Xóa listener tránh rò rỉ bộ nhớ
|
||||
rej(new Error(`[${this.id}] Timeout: No verification code received within 1 minute.`));
|
||||
}, 60 * 1000); // 60 giây
|
||||
|
||||
global.socket.on(`verify-code.${this.origin_url}`, async (data) => {
|
||||
console.log(`📢 [${this.id}] VERIFY CODE:`, data);
|
||||
clearTimeout(timeout); // Hủy timeout vì đã nhận được mã
|
||||
global.socket.off(`verify-code.${this.origin_url}`); // Xóa listener tránh lặp lại
|
||||
rev(data); // Resolve với dữ liệu nhận được
|
||||
});
|
||||
});
|
||||
|
||||
async enterOTP(otp) {
|
||||
try {
|
||||
// Selector cho tất cả các input OTP
|
||||
const inputSelector = '.MuiDialog-container .container input';
|
||||
|
||||
// Chờ cho các input OTP xuất hiện
|
||||
await this.page_context.waitForSelector(inputSelector, { timeout: 8000 });
|
||||
|
||||
// Lấy tất cả các input OTP
|
||||
const inputs = await this.page_context.$$(inputSelector);
|
||||
|
||||
// Kiểm tra nếu có đúng 6 trường input
|
||||
if (inputs.length === 6 && otp.length === 6) {
|
||||
// Nhập mỗi ký tự của OTP vào các input tương ứng
|
||||
for (let i = 0; i < 6; i++) {
|
||||
await inputs[i].type(otp[i], { delay: 100 });
|
||||
}
|
||||
console.log(`✅ OTP entered successfully: ${otp}`);
|
||||
} else {
|
||||
console.error('❌ Invalid OTP or input fields count');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('❌ Error entering OTP:', error);
|
||||
}
|
||||
}
|
||||
|
||||
async waitToTwoVerify() {
|
||||
try {
|
||||
if (!this.page_context) return false;
|
||||
|
||||
// Selector của các phần tử trên trang
|
||||
const button = '.form-input-wrapper.form-group > .btn.btn-primary'; // Nút để tiếp tục quá trình xác minh
|
||||
const remember = '.PrivateSwitchBase-input'; // Checkbox "Remember me"
|
||||
const continueButton =
|
||||
'.MuiButtonBase-root.MuiButton-root.MuiButton-contained.MuiButton-containedPrimary.MuiButton-sizeMedium.MuiButton-containedSizeMedium.MuiButton-colorPrimary.MuiButton-root'; // Nút "Continue"
|
||||
|
||||
// Chờ cho nút xác minh xuất hiện
|
||||
console.log(`🔎 [${this.id}] Waiting for the button with selector: ${button}`);
|
||||
await this.page_context.waitForSelector(button, { timeout: 8000 });
|
||||
|
||||
console.log(`✅ [${this.id}] Button found, clicking the first button.`);
|
||||
|
||||
// Lấy phần tử của nút và log nội dung của nó
|
||||
const firstButton = await this.page_context.$(button); // Lấy phần tử đầu tiên
|
||||
const buttonContent = await firstButton.evaluate((el) => el.textContent); // Lấy nội dung của nút
|
||||
console.log(`🔎 [${this.id}] Button content: ${buttonContent}`);
|
||||
|
||||
// Chờ 2s cho button sẵn sàn
|
||||
await delay(2000);
|
||||
// Click vào nút xác minh
|
||||
await firstButton.click();
|
||||
console.log(`✅ [${this.id}] Button clicked.`);
|
||||
|
||||
// Nhận mã OTP để nhập vào form
|
||||
const { name, code } = await this.waitVerifyData();
|
||||
console.log(`🔎 [${this.id}] Waiting for OTP input, received code: ${code}`);
|
||||
|
||||
// Nhập mã OTP vào form
|
||||
await this.enterOTP(code);
|
||||
console.log(`✅ [${this.id}] OTP entered successfully.`);
|
||||
|
||||
// Chờ cho checkbox "Remember me" xuất hiện
|
||||
await this.page_context.waitForSelector(remember, { timeout: 8000 });
|
||||
console.log(`🔎 [${this.id}] Waiting for remember me checkbox with selector: ${remember}`);
|
||||
|
||||
// Click vào checkbox "Remember me"
|
||||
await this.page_context.click(remember, { delay: 92 });
|
||||
console.log(`✅ [${this.id}] Remember me checkbox clicked.`);
|
||||
|
||||
// Chờ cho nút "Continue" xuất hiện
|
||||
await this.page_context.waitForSelector(continueButton, { timeout: 8000 });
|
||||
console.log(`🔎 [${this.id}] Waiting for continue button with selector: ${continueButton}`);
|
||||
|
||||
// Click vào nút "Continue"
|
||||
await this.page_context.click(continueButton, { delay: 100 });
|
||||
console.log(`✅ [${this.id}] Continue button clicked.`);
|
||||
|
||||
// Chờ cho trang tải hoàn tất sau khi click "Continue"
|
||||
await this.page_context.waitForNavigation({ waitUntil: 'domcontentloaded' });
|
||||
console.log(`✅ [${this.id}] Navigation completed.`);
|
||||
|
||||
return true;
|
||||
} catch (error) {
|
||||
console.error(`❌ [${this.id}] Error:`, error);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
async handleLogin() {
|
||||
const page = this.page_context;
|
||||
|
||||
global.IS_CLEANING = false;
|
||||
|
||||
const filePath = getPathProfile(this.origin_url);
|
||||
|
||||
await page.waitForNavigation({ waitUntil: 'domcontentloaded' });
|
||||
|
||||
// 🛠 Check if already logged in (login input should not be visible or profile exists)
|
||||
if (!(await page.$('#emailLogin')) && fs.existsSync(filePath)) {
|
||||
console.log(`✅ [${this.id}] Already logged in, skipping login process.`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (fs.existsSync(filePath)) {
|
||||
console.log(`🗑 [${this.id}] Deleting existing file: ${filePath}`);
|
||||
fs.unlinkSync(filePath);
|
||||
}
|
||||
|
||||
const children = this.children.filter((item) => item.page_context);
|
||||
console.log(`🔍 [${this.id}] Found ${children.length} child pages to close.`);
|
||||
|
||||
if (children.length > 0) {
|
||||
console.log(`🛑 [${this.id}] Closing child pages...`);
|
||||
await Promise.all(
|
||||
children.map((item) => {
|
||||
console.log(`➡ [${this.id}] Closing child page with context: ${item.page_context}`);
|
||||
return safeClosePage(item);
|
||||
}),
|
||||
);
|
||||
|
||||
console.log(`➡ [${this.id}] Closing main page context: ${this.page_context}`);
|
||||
await safeClosePage(this);
|
||||
}
|
||||
|
||||
console.log(`🔑 [${this.id}] Starting login process...`);
|
||||
|
||||
try {
|
||||
// ⌨ Enter email
|
||||
console.log(`✍ [${this.id}] Entering email:`, this.username);
|
||||
await page.type('#emailLogin', this.username, { delay: 100 });
|
||||
|
||||
// ⌨ Enter password
|
||||
console.log(`✍ [${this.id}] Entering password...`);
|
||||
await page.type('#passwordLogin', this.password, { delay: 150 });
|
||||
|
||||
// 🚀 Click the login button
|
||||
console.log(`🔘 [${this.id}] Clicking the "Login" button`);
|
||||
await page.click('#signInBtn', { delay: 92 });
|
||||
|
||||
const result = await this.waitToTwoVerify();
|
||||
|
||||
// ⏳ Wait for navigation after login
|
||||
if (!result) {
|
||||
console.log(`⏳ [${this.id}] Waiting for navigation after login...`);
|
||||
await page.waitForNavigation({ timeout: 8000, waitUntil: 'domcontentloaded' });
|
||||
}
|
||||
|
||||
if (this.page_context.url() == this.url) {
|
||||
// 📂 Save session context to avoid re-login
|
||||
await this.saveContext();
|
||||
console.log(`✅ [${this.id}] Login successful!`);
|
||||
} else {
|
||||
console.log(`❌ [${this.id}] Login Failure!`);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`❌ [${this.id}] Error during login process:`, error.message);
|
||||
} finally {
|
||||
global.IS_CLEANING = true;
|
||||
}
|
||||
}
|
||||
|
||||
action = async () => {
|
||||
try {
|
||||
const page = this.page_context;
|
||||
|
||||
page.on('response', async (response) => {
|
||||
const request = response.request();
|
||||
if (request.redirectChain().length > 0) {
|
||||
if (response.url().includes(configs.WEB_CONFIGS.LAWSONS.LOGIN_URL)) {
|
||||
await this.handleLogin();
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
await page.goto(this.url, { waitUntil: 'networkidle2' });
|
||||
|
||||
await page.bringToFront();
|
||||
|
||||
// Set userAgent
|
||||
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
} catch (error) {
|
||||
console.log('Error [action]: ', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
listen_events = async () => {
|
||||
if (this.page_context) return;
|
||||
|
||||
await this.puppeteer_connect();
|
||||
await this.action();
|
||||
|
||||
this.reloadInterval = setInterval(async () => {
|
||||
try {
|
||||
if (this.page_context && !this.page_context.isClosed()) {
|
||||
console.log(`🔄 [${this.id}] Reloading page...`);
|
||||
await this.page_context.reload({ waitUntil: 'networkidle2' });
|
||||
console.log(`✅ [${this.id}] Page reloaded successfully.`);
|
||||
|
||||
// this.handleUpdateWonItem();
|
||||
} else {
|
||||
console.log(`❌ [${this.id}] Page context is closed. Stopping reload.`);
|
||||
clearInterval(this.reloadInterval);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`🚨 [${this.id}] Error reloading page:`, error.message);
|
||||
}
|
||||
}, 60000);
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,114 @@
|
|||
import { updateBid } from '../../system/apis/bid.js';
|
||||
import configs from '../../system/config.js';
|
||||
import { extractPriceNumber, removeFalsyValues } from '../../system/utils.js';
|
||||
import { ProductBid } from '../product-bid.js';
|
||||
|
||||
export class LawsonsProductBid extends ProductBid {
|
||||
constructor({ ...prev }) {
|
||||
super(prev);
|
||||
}
|
||||
|
||||
async handleUpdateBid({ lot_id, close_time, name, current_price, reserve_price }) {
|
||||
const response = await updateBid(this.id, { lot_id, close_time, name, current_price, reserve_price: Number(reserve_price) || 0 });
|
||||
|
||||
if (response) {
|
||||
this.lot_id = response.lot_id;
|
||||
this.close_time = response.close_time;
|
||||
this.start_bid_time = response.start_bid_time;
|
||||
}
|
||||
}
|
||||
|
||||
async getReversePrice() {
|
||||
try {
|
||||
// Kiểm tra xem có context của trang web không, nếu không thì trả về null
|
||||
if (!this.page_context) return null;
|
||||
|
||||
await this.page_context.waitForSelector('.select-dropdown-value.text-truncate', { timeout: 2000 });
|
||||
const price = await this.page_context.evaluate(() => {
|
||||
const el = document.querySelector('.select-dropdown-value.text-truncate');
|
||||
return el ? el.innerText : null;
|
||||
});
|
||||
|
||||
return price ? extractPriceNumber(price) : null;
|
||||
} catch (error) {
|
||||
console.log(error);
|
||||
// Nếu có lỗi xảy ra trong quá trình lấy thời gian, trả về null
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
update = async () => {
|
||||
try {
|
||||
if (!this.page_context) return;
|
||||
|
||||
const result = await this.waitApiInfo();
|
||||
|
||||
const reservePrice = await this.getReversePrice();
|
||||
|
||||
console.log({ reservePrice });
|
||||
if (!result) return;
|
||||
|
||||
// 📌 Loại bỏ các giá trị không hợp lệ và bổ sung thông tin cần thiết
|
||||
const data = removeFalsyValues(
|
||||
{
|
||||
lot_id: String(result?.itemView.lotId) || null,
|
||||
reserve_price: reservePrice,
|
||||
current_price: result?.currentBidAmount || null,
|
||||
close_time: new Date(result.endTime).toUTCString() || null,
|
||||
name: result?.itemView?.title || null,
|
||||
},
|
||||
['close_time'],
|
||||
);
|
||||
|
||||
console.log(`🚀 [${this.id}] Processed data ready for update`);
|
||||
|
||||
// 📌 Gửi dữ liệu cập nhật lên hệ thống
|
||||
await this.handleUpdateBid(data);
|
||||
} catch (error) {
|
||||
console.log('Error Update', error.message);
|
||||
}
|
||||
};
|
||||
|
||||
// Hàm con để fetch trong context trình duyệt
|
||||
fetchFromPage = async (url) => {
|
||||
return await this.page_context.evaluate(async (url) => {
|
||||
try {
|
||||
const res = await fetch(url, {
|
||||
method: 'GET',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
});
|
||||
return await res.json();
|
||||
} catch (err) {
|
||||
return { error: err.message };
|
||||
}
|
||||
}, url);
|
||||
};
|
||||
|
||||
async waitApiInfo() {
|
||||
if (!this.page_context) {
|
||||
console.error(`❌ [${this.id}] Error: page_context is undefined.`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const infoUrl = configs.WEB_CONFIGS.LAWSONS.API_DETAIL_INFO(this.model);
|
||||
const detailUrl = configs.WEB_CONFIGS.LAWSONS.API_DETAIL_PRODUCT(this.model);
|
||||
|
||||
const [info, detailData] = await Promise.all([this.fetchFromPage(infoUrl), this.fetchFromPage(detailUrl)]);
|
||||
|
||||
return { ...info, ...detailData };
|
||||
}
|
||||
|
||||
action = async () => {
|
||||
try {
|
||||
const page = this.page_context;
|
||||
|
||||
// 📌 Kiểm tra nếu trang chưa tải đúng URL thì điều hướng đến URL mục tiêu
|
||||
if (!page.url() || !page.url().includes(this.url)) {
|
||||
console.log(`🔄 [${this.id}] Navigating to target URL: ${this.url}`);
|
||||
await this.gotoLink();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error(`🚨 [${this.id}] Error navigating the page: ${error}`);
|
||||
}
|
||||
};
|
||||
}
|
||||
|
|
@ -135,16 +135,6 @@ export class ProductBid extends Bid {
|
|||
return true;
|
||||
}
|
||||
|
||||
// async gotoLink() {
|
||||
// const page = this.page_context;
|
||||
// console.log('🔄 Starting the bidding process...');
|
||||
|
||||
// await page.goto(this.url, { waitUntil: 'networkidle2' });
|
||||
// console.log(`✅ Navigated to: ${this.url}`);
|
||||
|
||||
// await page.bringToFront();
|
||||
// console.log('👀 Brought the tab to the foreground.');
|
||||
// }
|
||||
async gotoLink() {
|
||||
const page = this.page_context;
|
||||
|
||||
|
|
@ -160,6 +150,7 @@ export class ProductBid extends Bid {
|
|||
console.log(`✅ Navigated to: ${this.url}`);
|
||||
|
||||
await page.bringToFront();
|
||||
await page.setUserAgent('Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 (KHTML, like Gecko) Chrome/120.0.0.0 Safari/537.36');
|
||||
console.log('👀 Brought the tab to the foreground.');
|
||||
} catch (error) {
|
||||
console.error('❌ Error during navigation:', error);
|
||||
|
|
|
|||
|
|
@ -1,13 +1,16 @@
|
|||
import * as fs from 'fs';
|
||||
import path from 'path';
|
||||
import { GrayApiBid } from '../models/grays.com/grays-api-bid.js';
|
||||
import { GraysProductBid } from '../models/grays.com/grays-product-bid.js';
|
||||
import { LangtonsApiBid } from '../models/langtons.com.au/langtons-api-bid.js';
|
||||
import { LangtonsProductBid } from '../models/langtons.com.au/langtons-product-bid.js';
|
||||
import configs from '../system/config.js';
|
||||
import CONSTANTS from '../system/constants.js';
|
||||
import { sanitizeFileName } from '../system/utils.js';
|
||||
import * as fs from 'fs';
|
||||
import _ from 'lodash';
|
||||
import { LawsonsApiBid } from '../models/lawsons.com.au/lawsons-api-bid.js';
|
||||
import { LawsonsProductBid } from '../models/lawsons.com.au/lawsons-product-bid.js';
|
||||
|
||||
const ONE_MINUTE = 60 * 1000;
|
||||
const TIME = 30 * 1000;
|
||||
|
||||
export const handleCloseRemoveProduct = (data) => {
|
||||
if (!Array.isArray(data)) return;
|
||||
|
|
@ -24,6 +27,12 @@ export const createBidProduct = (web, data) => {
|
|||
case configs.WEB_URLS.GRAYS: {
|
||||
return new GraysProductBid({ ...data });
|
||||
}
|
||||
case configs.WEB_URLS.LANGTONS: {
|
||||
return new LangtonsProductBid({ ...data });
|
||||
}
|
||||
case configs.WEB_URLS.LAWSONS: {
|
||||
return new LawsonsProductBid({ ...data });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -32,6 +41,12 @@ export const createApiBid = (web) => {
|
|||
case configs.WEB_URLS.GRAYS: {
|
||||
return new GrayApiBid({ ...web });
|
||||
}
|
||||
case configs.WEB_URLS.LANGTONS: {
|
||||
return new LangtonsApiBid({ ...web });
|
||||
}
|
||||
case configs.WEB_URLS.LAWSONS: {
|
||||
return new LawsonsApiBid({ ...web });
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
|
|
@ -49,5 +64,6 @@ export const deleteProfile = (data) => {
|
|||
export const shouldUpdateProductTab = (productTab) => {
|
||||
const updatedAt = new Date(productTab.updated_at).getTime();
|
||||
const now = Date.now();
|
||||
return now - updatedAt >= ONE_MINUTE;
|
||||
|
||||
return now - updatedAt >= TIME;
|
||||
};
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export const getBids = async () => {
|
|||
|
||||
return data.data;
|
||||
} catch (error) {
|
||||
console.log('❌ ERROR IN SERVER: ', error);
|
||||
console.log('❌ ERROR IN SERVER (GET BIDS): ', error);
|
||||
return [];
|
||||
}
|
||||
};
|
||||
|
|
@ -34,7 +34,7 @@ export const updateBid = async (id, values) => {
|
|||
|
||||
return data.data;
|
||||
} catch (error) {
|
||||
console.log('❌ ERROR IN SERVER: (UPDATE BID) ', error.response);
|
||||
console.log('❌ ERROR IN SERVER: (UPDATE BID) ', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
|
@ -73,7 +73,7 @@ export const pushPrice = async (values) => {
|
|||
|
||||
return { status: true, data: data.data };
|
||||
} catch (error) {
|
||||
console.log('❌ ERROR IN SERVER: ', error);
|
||||
console.log('❌ ERROR IN SERVER (PUSH PRICE): ', error.response.data);
|
||||
return { status: false, data: [] };
|
||||
}
|
||||
};
|
||||
|
|
@ -95,7 +95,10 @@ export const updateStatusByPrice = async (id, current_price) => {
|
|||
|
||||
return { status: true, data: data.data };
|
||||
} catch (error) {
|
||||
console.log('❌ ERROR IN SERVER:(UPDATE STATUS BY PRICE) ', error.message);
|
||||
console.log('❌ ERROR IN SERVER:(UPDATE STATUS BY PRICE) ', {
|
||||
// response: error.response,
|
||||
message: error.message,
|
||||
});
|
||||
return { status: false, data: [] };
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -0,0 +1,21 @@
|
|||
import axios from '../axios.js';
|
||||
|
||||
export const sendMessage = async (values) => {
|
||||
try {
|
||||
const { data } = await axios({
|
||||
method: 'POST',
|
||||
url: 'notifications/send-messages',
|
||||
data: values,
|
||||
});
|
||||
|
||||
if (!data || !data?.data) {
|
||||
console.log('❌ UPDATE FAILURE (UPDATE Noti)');
|
||||
return null;
|
||||
}
|
||||
|
||||
return data.data;
|
||||
} catch (error) {
|
||||
console.log('❌ ERROR IN SERVER: (UPDATE Noti) ', error);
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
|
@ -3,6 +3,9 @@ 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;
|
||||
|
|
|
|||
|
|
@ -4,12 +4,29 @@ const configs = {
|
|||
SOCKET_URL: process.env.SOCKET_URL,
|
||||
WEB_URLS: {
|
||||
GRAYS: `https://www.grays.com`,
|
||||
LANGTONS: `https://www.langtons.com.au`,
|
||||
LAWSONS: `https://www.lawsons.com.au`,
|
||||
},
|
||||
WEB_CONFIGS: {
|
||||
GRAYS: {
|
||||
AUTO_CALL_API_TO_TRACKING: 3000,
|
||||
API_CALL_TO_TRACKING: 'https://www.grays.com/api/Notifications/GetOutBidLots',
|
||||
},
|
||||
LANGTONS: {
|
||||
AUTO_CALL_API_TO_TRACKING: 5000,
|
||||
LOGIN_URL: 'https://www.langtons.com.au/account/login',
|
||||
API_CALL_TO_TRACKING: 'https://www.langtons.com.au/on/demandware.store/Sites-langtons-Site/en_AU/Auction-LotsData',
|
||||
},
|
||||
LAWSONS: {
|
||||
LOGIN_URL: 'https://www.lawsons.com.au/login?redirectUrl=/my-account/current-bids',
|
||||
// API_CALL_TO_TRACKING: 'https://www.langtons.com.au/on/demandware.store/Sites-langtons-Site/en_AU/Auction-LotsData',
|
||||
API_DETAIL_INFO: (model) => {
|
||||
return `https://www.lawsons.com.au/api/auctions/lot/v2/liveInfo/${model}`;
|
||||
},
|
||||
API_DETAIL_PRODUCT: (model) => {
|
||||
return `https://www.lawsons.com.au/api/auctions/lot/${model}`;
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ export const takeSnapshot = async (page, item, imageName, type = CONSTANTS.TYPE_
|
|||
if (!page || page.isClosed()) return;
|
||||
|
||||
try {
|
||||
global.IS_CLEANING = false;
|
||||
const baseDir = path.join(CONSTANTS.ERROR_IMAGES_PATH, item.type, String(item.id)); // Thư mục theo lot_id
|
||||
const typeDir = path.join(baseDir, type); // Thư mục con theo type
|
||||
|
||||
|
|
@ -101,3 +100,81 @@ export function removeFalsyValues(obj, excludeKeys = []) {
|
|||
return acc;
|
||||
}, {});
|
||||
}
|
||||
|
||||
export const enableAutoBidMessage = (data) => {
|
||||
return `
|
||||
<b>⭉ Activate Auto Bid</b><br>
|
||||
📌 Product: <b>${data.name}</b><br>
|
||||
🔗 Link: <a href="${data.url}">Click here</a><br>
|
||||
💰 Max Price: <b>$${data.max_price}</b><br>
|
||||
🌐 Platform: <a href="${data.web_bid.origin_url}">Langtons</a>
|
||||
`;
|
||||
};
|
||||
|
||||
export function convertAETtoUTC(dateString) {
|
||||
// Bảng ánh xạ tên tháng sang số (0-11, theo chuẩn JavaScript)
|
||||
const monthMap = {
|
||||
Jan: 0,
|
||||
Feb: 1,
|
||||
Mar: 2,
|
||||
Apr: 3,
|
||||
May: 4,
|
||||
Jun: 5,
|
||||
Jul: 6,
|
||||
Aug: 7,
|
||||
Sep: 8,
|
||||
Oct: 9,
|
||||
Nov: 10,
|
||||
Dec: 11,
|
||||
};
|
||||
|
||||
// Tách chuỗi đầu vào
|
||||
const parts = dateString.match(/(\w+)\s(\d+)\s(\w+)\s(\d+),\s(\d+)\s(PM|AM)\sAET/);
|
||||
if (!parts) {
|
||||
throw new Error("Error format: 'Sun 6 Apr 2025, 9 PM AET'");
|
||||
}
|
||||
|
||||
const [, , day, month, year, hour, period] = parts;
|
||||
|
||||
// Chuyển đổi giờ sang định dạng 24h
|
||||
let hours = parseInt(hour, 10);
|
||||
if (period === 'PM' && hours !== 12) hours += 12;
|
||||
if (period === 'AM' && hours === 12) hours = 0;
|
||||
|
||||
// Tạo đối tượng Date ban đầu (chưa điều chỉnh múi giờ)
|
||||
const date = new Date(Date.UTC(parseInt(year, 10), monthMap[month], parseInt(day, 10), hours, 0, 0));
|
||||
|
||||
// Hàm kiểm tra DST cho AET
|
||||
function isDST(date) {
|
||||
const year = date.getUTCFullYear();
|
||||
const month = date.getUTCMonth();
|
||||
const day = date.getUTCDate();
|
||||
|
||||
// DST bắt đầu: Chủ nhật đầu tiên của tháng 10 (2:00 AM AEST -> 3:00 AM AEDT)
|
||||
const dstStart = new Date(Date.UTC(year, 9, 1, 0, 0, 0)); // 1/10
|
||||
dstStart.setUTCDate(1 + ((7 - dstStart.getUTCDay()) % 7)); // Chủ nhật đầu tiên
|
||||
const dstStartTime = dstStart.getTime() + 2 * 60 * 60 * 1000; // 2:00 AM UTC+10
|
||||
|
||||
// DST kết thúc: Chủ nhật đầu tiên của tháng 4 (3:00 AM AEDT -> 2:00 AM AEST)
|
||||
const dstEnd = new Date(Date.UTC(year, 3, 1, 0, 0, 0)); // 1/4
|
||||
dstEnd.setUTCDate(1 + ((7 - dstEnd.getUTCDay()) % 7)); // Chủ nhật đầu tiên
|
||||
const dstEndTime = dstEnd.getTime() + 3 * 60 * 60 * 1000; // 3:00 AM UTC+11
|
||||
|
||||
const currentTime = date.getTime() + 10 * 60 * 60 * 1000; // Thời gian AET (giả định ban đầu UTC+10)
|
||||
return currentTime >= dstStartTime && currentTime < dstEndTime;
|
||||
}
|
||||
|
||||
// Xác định offset dựa trên DST
|
||||
const offset = isDST(date) ? 11 : 10; // UTC+11 nếu DST, UTC+10 nếu không
|
||||
|
||||
// Điều chỉnh thời gian về UTC
|
||||
const utcDate = new Date(date.getTime() - offset * 60 * 60 * 1000);
|
||||
|
||||
// Trả về chuỗi UTC
|
||||
return utcDate.toUTCString();
|
||||
}
|
||||
|
||||
export function extractPriceNumber(priceString) {
|
||||
const cleaned = priceString.replace(/[^\d.]/g, '');
|
||||
return parseFloat(cleaned);
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue