update sync
This commit is contained in:
parent
80309efde2
commit
58dde4b304
|
|
@ -1 +0,0 @@
|
|||
function n(e){return new Promise(t=>setTimeout(t,e))}export{n as d};
|
||||
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
File diff suppressed because one or more lines are too long
|
|
@ -6,11 +6,17 @@
|
|||
|
||||
"permissions": ["storage", "tabs", "alarms"],
|
||||
|
||||
"host_permissions": ["https://www.facebook.com/marketplace/*"],
|
||||
"host_permissions": [
|
||||
"https://www.facebook.com/marketplace/*",
|
||||
"https://web.facebook.com/*"
|
||||
],
|
||||
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://www.facebook.com/marketplace/*"],
|
||||
"matches": [
|
||||
"https://www.facebook.com/marketplace/*",
|
||||
"https://web.facebook.com/marketplace/*"
|
||||
],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle",
|
||||
"type": "module"
|
||||
|
|
|
|||
|
|
@ -6,11 +6,17 @@
|
|||
|
||||
"permissions": ["storage", "tabs", "alarms"],
|
||||
|
||||
"host_permissions": ["https://www.facebook.com/marketplace/*"],
|
||||
"host_permissions": [
|
||||
"https://www.facebook.com/marketplace/*",
|
||||
"https://web.facebook.com/*"
|
||||
],
|
||||
|
||||
"content_scripts": [
|
||||
{
|
||||
"matches": ["https://www.facebook.com/marketplace/*"],
|
||||
"matches": [
|
||||
"https://www.facebook.com/marketplace/*",
|
||||
"https://web.facebook.com/marketplace/*"
|
||||
],
|
||||
"js": ["content.js"],
|
||||
"run_at": "document_idle",
|
||||
"type": "module"
|
||||
|
|
|
|||
|
|
@ -1,27 +0,0 @@
|
|||
import axios from "@/lib/axios";
|
||||
|
||||
class MessageApiService {
|
||||
async sendSingleMessage(message: IMessage) {
|
||||
try {
|
||||
const { data } = await axios.post("/messages", message);
|
||||
console.log("[NestJS] Response (single):", data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error("[NestJS] Error (single):", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
async sendBulkMessages(messages: IMessage[]) {
|
||||
try {
|
||||
const { data } = await axios.post("/messages/bulk", { data: messages });
|
||||
console.log("[NestJS] Response (bulk):", data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error("[NestJS] Error (bulk):", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const messageApi = new MessageApiService();
|
||||
|
|
@ -0,0 +1,18 @@
|
|||
import axios from "@/lib/axios";
|
||||
|
||||
class ProductApiService {
|
||||
async sync(items: ISyncItem[]) {
|
||||
try {
|
||||
const { data } = await axios.post("/products/sync", {
|
||||
items,
|
||||
});
|
||||
console.log("[NestJS] Response (bulk):", data);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error("[NestJS] Error (bulk):", err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export const productApi = new ProductApiService();
|
||||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
import PQueue from "p-queue";
|
||||
|
||||
let ports: chrome.runtime.Port[] = [];
|
||||
|
|
@ -10,22 +11,6 @@ chrome.runtime.onConnect.addListener((port) => {
|
|||
});
|
||||
});
|
||||
|
||||
(() => {
|
||||
const evtSource = new EventSource(
|
||||
`${import.meta.env.VITE_API_URL}/products/publist-stream`
|
||||
);
|
||||
|
||||
evtSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log("New event:", data);
|
||||
queue.add(() => handlePublish(data, 30000));
|
||||
};
|
||||
|
||||
evtSource.onerror = (err) => {
|
||||
console.error("EventSource failed:", err);
|
||||
};
|
||||
})();
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
async function handlePublish(data: any, timeoutMs = 5 * 60 * 1000) {
|
||||
return new Promise<void>((resolve) => {
|
||||
|
|
@ -43,6 +28,7 @@ async function handlePublish(data: any, timeoutMs = 5 * 60 * 1000) {
|
|||
chrome.tabs.onRemoved.removeListener(onTabClosed);
|
||||
clearTimeout(timeoutId);
|
||||
resolve();
|
||||
ensureMarketplaceSellingTab();
|
||||
};
|
||||
|
||||
const onConnectListener = (port: chrome.runtime.Port) => {
|
||||
|
|
@ -71,9 +57,101 @@ async function handlePublish(data: any, timeoutMs = 5 * 60 * 1000) {
|
|||
});
|
||||
}
|
||||
|
||||
const handleListenPublists = () => {
|
||||
const evtSource = new EventSource(
|
||||
`${import.meta.env.VITE_API_URL}/products/publist-stream`
|
||||
);
|
||||
|
||||
evtSource.onmessage = (event) => {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log("New event:", data);
|
||||
queue.add(() => handlePublish(data, 30000));
|
||||
};
|
||||
|
||||
evtSource.onerror = (err) => {
|
||||
console.error("EventSource failed:", err);
|
||||
};
|
||||
};
|
||||
|
||||
const handleListenDeletes = () => {
|
||||
const evtSource = new EventSource(
|
||||
`${import.meta.env.VITE_API_URL}/products/delete-stream`
|
||||
);
|
||||
|
||||
evtSource.onmessage = (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log("[SSE] New event:", data);
|
||||
|
||||
queue.add(() => {
|
||||
return new Promise<void>((resolve) => {
|
||||
const listener = (msg: any) => {
|
||||
if (msg?.type === "delete-done") {
|
||||
console.log("[QUEUE] Delete done signal received.");
|
||||
chrome.runtime.onMessage.removeListener(listener);
|
||||
resolve();
|
||||
}
|
||||
};
|
||||
chrome.runtime.onMessage.addListener(listener);
|
||||
|
||||
// Gửi tới tab có URL khớp
|
||||
chrome.tabs.query(
|
||||
{ url: "https://www.facebook.com/marketplace/you/selling" },
|
||||
(tabs) => {
|
||||
for (const tab of tabs) {
|
||||
if (tab.id) {
|
||||
chrome.tabs.sendMessage(tab.id, {
|
||||
type: "DELETE_STREAM_DATA",
|
||||
payload: data,
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
);
|
||||
});
|
||||
});
|
||||
} catch (err) {
|
||||
console.error("[SSE] Parse error:", err);
|
||||
}
|
||||
};
|
||||
|
||||
evtSource.onerror = (err) => {
|
||||
console.error("[SSE] Connection failed:", err);
|
||||
};
|
||||
|
||||
console.log("[SSE] Listening for delete-stream events...");
|
||||
};
|
||||
|
||||
function ensureMarketplaceSellingTab() {
|
||||
const targetUrl = "https://www.facebook.com/marketplace/you/selling";
|
||||
|
||||
console.log({ targetUrl });
|
||||
chrome.tabs.query({}, (tabs) => {
|
||||
const existingTab = tabs.find(
|
||||
(tab) => tab.url && tab.url.startsWith(targetUrl)
|
||||
);
|
||||
|
||||
if (existingTab) {
|
||||
// Nếu đã có thì refresh lại
|
||||
chrome.tabs.reload((existingTab as any).id);
|
||||
} else {
|
||||
// Nếu chưa có thì mở mới
|
||||
chrome.tabs.create({ url: targetUrl });
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
const init = async () => {
|
||||
handleListenPublists();
|
||||
handleListenDeletes();
|
||||
ensureMarketplaceSellingTab();
|
||||
};
|
||||
|
||||
chrome.runtime.onMessage.addListener((message, sender) => {
|
||||
if (message.type === "close-tab") {
|
||||
const tabId = sender.tab?.id;
|
||||
if (tabId) chrome.tabs.remove(tabId);
|
||||
}
|
||||
});
|
||||
|
||||
init();
|
||||
|
|
|
|||
|
|
@ -1,8 +1,9 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
// content.ts
|
||||
import { productApi } from "./api/product-api.service";
|
||||
import { delay } from "./features/app";
|
||||
import axios from "./lib/axios";
|
||||
import { thiefService } from "./services/thief.service";
|
||||
|
||||
const selectors = {
|
||||
file__image_input: 'input[type="file"]',
|
||||
title_input:
|
||||
|
|
@ -58,23 +59,10 @@ const selectors = {
|
|||
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[5]/div/div/div",
|
||||
publish_btn:
|
||||
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[1]/div/div[4]/div[2]/div/div",
|
||||
products:
|
||||
"/html/body/div[1]/div/div[1]/div/div[3]/div/div/div[1]/div[1]/div[2]/div/div/div[2]/div[1]/div/div[2]/div/div/span/div/div",
|
||||
};
|
||||
|
||||
// const getData = async () => {
|
||||
// return new Promise((rev, rej) => {
|
||||
// const url = chrome.runtime.getURL("data.json");
|
||||
|
||||
// fetch(url)
|
||||
// .then((res) => res.json())
|
||||
// .then((data) => {
|
||||
// rev(data as IItem[]);
|
||||
// })
|
||||
// .catch((err) => {
|
||||
// rej(err);
|
||||
// });
|
||||
// });
|
||||
// };
|
||||
|
||||
const uploadImages = async (item: IItem) => {
|
||||
// Tạo DataTransfer để giả lập FileList
|
||||
const dt: DataTransfer = new DataTransfer();
|
||||
|
|
@ -214,6 +202,19 @@ const finistPublist = async (
|
|||
return data;
|
||||
};
|
||||
|
||||
const finistDelete = async (
|
||||
item: IItem,
|
||||
values: { error?: string; published: boolean }
|
||||
) => {
|
||||
const { data } = await axios({
|
||||
url: "products/delete-finish/" + item.id,
|
||||
method: "POST",
|
||||
data: values,
|
||||
});
|
||||
|
||||
return data;
|
||||
};
|
||||
|
||||
const clickNext = async () => {
|
||||
const btn = await thiefService.getElementByXPath(selectors.next_btn);
|
||||
|
||||
|
|
@ -230,6 +231,28 @@ const clickPublist = async () => {
|
|||
thiefService.clickByPoint(btn);
|
||||
};
|
||||
|
||||
function extractListings(productsEl: HTMLElement) {
|
||||
const children = Array.from(productsEl.children);
|
||||
|
||||
return children.map((child) => {
|
||||
// Lấy title (thường là span hoặc div có dir="auto")
|
||||
const titleEl = child.querySelector('span[dir="auto"], div[dir="auto"]');
|
||||
const title = titleEl?.textContent?.trim() || "";
|
||||
|
||||
// Lấy giá (span có attribute dir="auto")
|
||||
const priceEl = Array.from(child.querySelectorAll('span[dir="auto"]')).find(
|
||||
(el) =>
|
||||
/\d/.test(el.textContent || "") && /[AU$]/.test(el.textContent || "")
|
||||
);
|
||||
|
||||
// Tách lấy số, ví dụ: "AU$20" -> "20"
|
||||
const priceMatch = priceEl?.textContent?.match(/\d+(?:\.\d+)?/);
|
||||
const price = priceMatch ? parseFloat(priceMatch[0]) : 0;
|
||||
|
||||
return { title, price, el: productsEl };
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* B1. Upload images
|
||||
* B2. Write title
|
||||
|
|
@ -308,6 +331,31 @@ const handle = async (item: IItem) => {
|
|||
return true;
|
||||
};
|
||||
|
||||
const getProducts = async () => {
|
||||
const products = await thiefService.getElementByXPath(selectors.products);
|
||||
if (!products) return [];
|
||||
|
||||
return extractListings(products) as ISyncItem[];
|
||||
};
|
||||
|
||||
const syncListing = async () => {
|
||||
const url = window.location.href;
|
||||
if (!url.includes("https://www.facebook.com/marketplace/you/selling")) return;
|
||||
|
||||
const products = await getProducts();
|
||||
|
||||
if (!products.length) return;
|
||||
|
||||
const response = await productApi.sync(
|
||||
products.map((item) => ({
|
||||
title: item.title,
|
||||
price: item.price,
|
||||
})) as ISyncItem[]
|
||||
);
|
||||
|
||||
console.log({ response });
|
||||
};
|
||||
|
||||
const closeTab = async (data: IItem) => {
|
||||
chrome.runtime.sendMessage({
|
||||
type: "close-tab",
|
||||
|
|
@ -315,6 +363,64 @@ const closeTab = async (data: IItem) => {
|
|||
});
|
||||
};
|
||||
|
||||
const handleDelete = async (payload: IItem) => {
|
||||
const products = await getProducts();
|
||||
|
||||
const product = products.find((product) => {
|
||||
return product.title == payload.title && product.price == payload.price;
|
||||
});
|
||||
|
||||
console.log({ payload, product, products });
|
||||
|
||||
if (!product) return;
|
||||
|
||||
const el = product.el;
|
||||
|
||||
const optionEl = el.querySelector(
|
||||
`[aria-label="More options for ${product.title}"]`
|
||||
);
|
||||
|
||||
console.log({ optionEl });
|
||||
if (!optionEl) return;
|
||||
|
||||
(optionEl as any).click?.();
|
||||
|
||||
await delay(2000);
|
||||
|
||||
const items = Array.from(document.querySelectorAll('[role="menuitem"]'));
|
||||
|
||||
console.log({ items });
|
||||
const deleteItem = items.find((item) =>
|
||||
item.textContent.toLocaleLowerCase().includes("delete")
|
||||
);
|
||||
|
||||
(deleteItem as any).click?.();
|
||||
|
||||
await delay(1000);
|
||||
|
||||
const btnDelete = await thiefService.getElementByXPath(
|
||||
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div/div/div/div[3]/div/div/div/div/div[1]/div",
|
||||
{
|
||||
xpathFallback:
|
||||
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div[3]/div[2]/div/div[2]/div[1]",
|
||||
}
|
||||
);
|
||||
|
||||
console.log({ btnDelete });
|
||||
|
||||
btnDelete?.click();
|
||||
|
||||
const closeModal = await thiefService.getElementByXPath(
|
||||
"/html/body/div[1]/div/div[1]/div/div[4]/div/div/div[1]/div/div[2]/div/div/div/div[2]/div"
|
||||
);
|
||||
|
||||
closeModal?.click();
|
||||
|
||||
await finistDelete(payload, { published: false });
|
||||
|
||||
chrome.runtime.sendMessage({ type: "delete-done" });
|
||||
};
|
||||
|
||||
const port = chrome.runtime.connect();
|
||||
|
||||
port.onMessage.addListener(async (message) => {
|
||||
|
|
@ -345,14 +451,25 @@ port.onMessage.addListener(async (message) => {
|
|||
}
|
||||
});
|
||||
|
||||
// content.js
|
||||
chrome.runtime.onMessage.addListener((message) => {
|
||||
if (message.type === "DELETE_STREAM_DATA") {
|
||||
console.log("Nhận dữ liệu từ background:", message.payload);
|
||||
|
||||
handleDelete(message.payload);
|
||||
}
|
||||
});
|
||||
|
||||
async function init() {
|
||||
const { data } = await axios.get("products/27");
|
||||
// const { data } = await axios.get("products/33");
|
||||
|
||||
if (!data.data) return;
|
||||
// if (!data.data) return;
|
||||
|
||||
const item = data.data as IItem;
|
||||
// const item = data.data as IItem;
|
||||
|
||||
await handle(item);
|
||||
// await handle(item);
|
||||
|
||||
await syncListing();
|
||||
}
|
||||
|
||||
init();
|
||||
|
|
|
|||
|
|
@ -15,3 +15,8 @@ export function removeFalsyValues<T extends Record<string, any>>(
|
|||
export function delay(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
}
|
||||
|
||||
export function randomDelay(minMs: number, maxMs: number): Promise<void> {
|
||||
const time = Math.floor(Math.random() * (maxMs - minMs + 1)) + minMs;
|
||||
return delay(time);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
/* eslint-disable @typescript-eslint/no-explicit-any */
|
||||
interface IItem {
|
||||
images: string[];
|
||||
title: string;
|
||||
|
|
@ -11,3 +12,9 @@ interface IItem {
|
|||
location?: string;
|
||||
id: number;
|
||||
}
|
||||
|
||||
interface ISyncItem {
|
||||
title: string;
|
||||
price: number;
|
||||
el: HTMLElement;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -80,6 +80,18 @@ export function HistoryModal({ data, open, setOpen }: HistoryModalProps) {
|
|||
</div>
|
||||
)}
|
||||
|
||||
{item.message && (
|
||||
<div className=" border border-gray-200 rounded-md p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<AlertCircle className="h-4 w-4 mt-0.5 flex-shrink-0" />
|
||||
<div>
|
||||
<p className="text-sm font-medium ">Message:</p>
|
||||
<p className="text-sm mt-1">{item.message}</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex justify-between text-sm text-muted-foreground">
|
||||
<span></span>
|
||||
<span>
|
||||
|
|
|
|||
|
|
@ -150,12 +150,12 @@ export default function List() {
|
|||
},
|
||||
{
|
||||
key: "histories",
|
||||
label: "Published",
|
||||
label: "Status",
|
||||
displayType: "custom",
|
||||
render(value, row) {
|
||||
return (
|
||||
<div className="w-full flex items-center justify-center">
|
||||
{row.histories.some((item) => item.published) ? (
|
||||
{row.status ? (
|
||||
<Button size={"xs"} className="w-fit" variant={"ghost"}>
|
||||
<Check size={14} className="text-green-500" />
|
||||
</Button>
|
||||
|
|
@ -267,6 +267,7 @@ export default function List() {
|
|||
},
|
||||
onSuccess: (data) => {
|
||||
console.log({ data });
|
||||
refetch();
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -80,10 +80,12 @@ interface IProduct extends ITimestamp {
|
|||
location: string;
|
||||
id: number;
|
||||
histories: IHistory[];
|
||||
status: boolean;
|
||||
}
|
||||
|
||||
interface IHistory extends ITimestamp {
|
||||
id: number;
|
||||
error?: string;
|
||||
published: boolean;
|
||||
message?: string;
|
||||
}
|
||||
|
|
|
|||
Binary file not shown.
|
Before Width: | Height: | Size: 527 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 22 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.9 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 1.4 MiB |
|
|
@ -57,6 +57,9 @@ export class Product extends CoreEntity {
|
|||
@Column({ type: 'varchar', nullable: true })
|
||||
location?: string;
|
||||
|
||||
@Column({ type: 'bool', default: false })
|
||||
status: boolean;
|
||||
|
||||
@OneToMany(() => PublistHistory, (publistHistory) => publistHistory.product)
|
||||
histories: PublistHistory[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,6 +7,9 @@ export class PublistHistory extends CoreEntity {
|
|||
@Column({ type: 'varchar', nullable: true, default: null })
|
||||
error: string;
|
||||
|
||||
@Column({ type: 'varchar', nullable: true, default: null })
|
||||
message: string;
|
||||
|
||||
@Column({ type: 'bool' })
|
||||
published: boolean;
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,9 @@
|
|||
import { IsNumber, IsString } from 'class-validator';
|
||||
|
||||
export class SyncItemDto {
|
||||
@IsString()
|
||||
title: string;
|
||||
|
||||
@IsNumber()
|
||||
price: number;
|
||||
}
|
||||
|
|
@ -0,0 +1,10 @@
|
|||
import { IsArray, ValidateNested } from 'class-validator';
|
||||
import { Type } from 'class-transformer';
|
||||
import { SyncItemDto } from './sync-item.dto';
|
||||
|
||||
export class SyncDto {
|
||||
@IsArray()
|
||||
@ValidateNested({ each: true })
|
||||
@Type(() => SyncItemDto)
|
||||
items: SyncItemDto[];
|
||||
}
|
||||
|
|
@ -4,6 +4,7 @@ import { Body, Controller, Param, Post, Sse } from '@nestjs/common';
|
|||
import { ProductsService } from './products.service';
|
||||
import { CreateProductDto } from './dtos/create-product.dto';
|
||||
import { PublistFinishDto } from './dtos/publist-finish.dto';
|
||||
import { SyncDto } from './dtos/syncs.dto';
|
||||
|
||||
@Controller('products')
|
||||
export class ProductsController extends CoreController<
|
||||
|
|
@ -15,10 +16,15 @@ export class ProductsController extends CoreController<
|
|||
}
|
||||
|
||||
@Post('')
|
||||
async create(data: CreateProductDto): Promise<any> {
|
||||
async create(@Body() data: CreateProductDto): Promise<any> {
|
||||
return this.service.create(data);
|
||||
}
|
||||
|
||||
@Post('sync')
|
||||
async sync(@Body() data: SyncDto): Promise<any> {
|
||||
return this.service.sync(data);
|
||||
}
|
||||
|
||||
@Post('publist/:id')
|
||||
async publist(@Param('id') id: Product['id']): Promise<any> {
|
||||
return this.service.publist(id);
|
||||
|
|
@ -31,9 +37,21 @@ export class ProductsController extends CoreController<
|
|||
): Promise<any> {
|
||||
return this.service.publistFinish(id, data);
|
||||
}
|
||||
@Post('delete-finish/:id')
|
||||
async deleteFinish(
|
||||
@Param('id') id: Product['id'],
|
||||
@Body() data: PublistFinishDto,
|
||||
): Promise<any> {
|
||||
return this.service.deleteFinish(id, data);
|
||||
}
|
||||
|
||||
@Sse('publist-stream')
|
||||
async publistStream() {
|
||||
return this.service.sendEvents();
|
||||
return this.service.sendPublistEvents();
|
||||
}
|
||||
|
||||
@Sse('delete-stream')
|
||||
async deleteStream() {
|
||||
return this.service.sendDeleteEvents();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -19,12 +19,15 @@ import { MediasService } from '../medias/medias.service';
|
|||
import { CreateProductDto } from './dtos/create-product.dto';
|
||||
import { PublistFinishDto } from './dtos/publist-finish.dto';
|
||||
import { Request } from 'express';
|
||||
import { SyncDto } from './dtos/syncs.dto';
|
||||
|
||||
@Injectable()
|
||||
export class ProductsService extends CoreService<Product> {
|
||||
public static EVENTS = {
|
||||
SEND_PUBLIST: 'send-publist',
|
||||
SEND_DELETE: 'send-delete',
|
||||
PUBLIST_FINISH: 'publist-finish',
|
||||
DElETE_FINISH: 'delete-finish',
|
||||
};
|
||||
|
||||
constructor(
|
||||
|
|
@ -60,7 +63,7 @@ export class ProductsService extends CoreService<Product> {
|
|||
data: Partial<Product>,
|
||||
req: Request,
|
||||
): Promise<void> {
|
||||
// Xóa avatar nếu có
|
||||
// Xóa images nếu có
|
||||
if (data.images?.length) {
|
||||
for (const image of data.images) {
|
||||
await this.mediasService.removeMediasFolder(
|
||||
|
|
@ -71,6 +74,29 @@ export class ProductsService extends CoreService<Product> {
|
|||
}
|
||||
}
|
||||
|
||||
protected async beforeDelete(
|
||||
id: number,
|
||||
data: Partial<Product>,
|
||||
req: Request,
|
||||
): Promise<boolean> {
|
||||
// Emit sự kiện để bắt đầu publish
|
||||
this.eventService.sendEvent(ProductsService.EVENTS.SEND_DELETE, {
|
||||
...data,
|
||||
});
|
||||
|
||||
// Đợi phản hồi từ client
|
||||
const result = await this.waitForDeleteResult({ ...data, id });
|
||||
|
||||
if (!result)
|
||||
throw new BadRequestException(
|
||||
AppResponse.toResponse(false, {
|
||||
message: SystemLang.getText('messages', 'try_again'),
|
||||
}),
|
||||
);
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
async create(data: CreateProductDto): Promise<any> {
|
||||
const product = this.repo.create(data);
|
||||
|
||||
|
|
@ -187,6 +213,23 @@ export class ProductsService extends CoreService<Product> {
|
|||
}
|
||||
}
|
||||
|
||||
private async waitForDeleteResult(
|
||||
data: Partial<Product>,
|
||||
): Promise<PublistHistory> {
|
||||
try {
|
||||
return await this.eventService.waitForEvent<PublistHistory>(
|
||||
`${ProductsService.EVENTS.DElETE_FINISH}_${data.id}`,
|
||||
30000,
|
||||
);
|
||||
} catch {
|
||||
throw new BadRequestException(
|
||||
AppResponse.toResponse(null, {
|
||||
message: SystemLang.getText('messages', 'try_again'),
|
||||
}),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
async publistFinish(id: Product['id'], data: PublistFinishDto) {
|
||||
const product = await this.repo.findOne({ where: { id } });
|
||||
|
||||
|
|
@ -209,7 +252,29 @@ export class ProductsService extends CoreService<Product> {
|
|||
return AppResponse.toResponse(plainData);
|
||||
}
|
||||
|
||||
sendEvents(): Observable<{ data: any }> {
|
||||
async deleteFinish(id: Product['id'], data: PublistFinishDto) {
|
||||
const product = await this.repo.findOne({ where: { id } });
|
||||
|
||||
if (!product)
|
||||
throw new NotFoundException(
|
||||
AppResponse.toResponse(null, {
|
||||
message: SystemLang.getText('messages', 'not_found'),
|
||||
}),
|
||||
);
|
||||
|
||||
const plainData = plainToClass(Product, product);
|
||||
|
||||
const result = await this.historiesRepo.save({ ...data, product });
|
||||
|
||||
this.eventService.sendEvent(
|
||||
`${ProductsService.EVENTS.DElETE_FINISH}_${plainData.id}`,
|
||||
plainToClass(PublistHistory, result),
|
||||
);
|
||||
|
||||
return AppResponse.toResponse(plainData);
|
||||
}
|
||||
|
||||
sendPublistEvents(): Observable<{ data: any }> {
|
||||
// Tạo Observable dựa trên sự kiện 'send publist' của eventEmitter
|
||||
return fromEvent(
|
||||
this.eventService.event,
|
||||
|
|
@ -220,4 +285,89 @@ export class ProductsService extends CoreService<Product> {
|
|||
})),
|
||||
);
|
||||
}
|
||||
|
||||
sendDeleteEvents(): Observable<{ data: any }> {
|
||||
// Tạo Observable dựa trên sự kiện 'send publist' của eventEmitter
|
||||
return fromEvent(
|
||||
this.eventService.event,
|
||||
ProductsService.EVENTS.SEND_DELETE,
|
||||
).pipe(
|
||||
map((data) => ({
|
||||
data, // gửi data về client
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
async sync({ items }: SyncDto) {
|
||||
const incomingTitles = new Set(items.map((d) => d.title));
|
||||
|
||||
// Lấy danh sách sản phẩm hiện tại trong DB
|
||||
const products = await this.repo.find({
|
||||
select: ['id', 'title', 'status', 'price'],
|
||||
});
|
||||
|
||||
const toDisable: Product[] = [];
|
||||
const toEnable: { product: Product; price: number }[] = [];
|
||||
const toInsert: { title: string; price: number }[] = [];
|
||||
|
||||
const existingMap = new Map(products.map((p) => [p.title, p]));
|
||||
|
||||
// Tìm sản phẩm cần disable
|
||||
for (const product of products) {
|
||||
if (!incomingTitles.has(product.title) && product.status) {
|
||||
toDisable.push(product);
|
||||
}
|
||||
}
|
||||
|
||||
// Tìm sản phẩm cần enable hoặc insert mới
|
||||
for (const item of items) {
|
||||
const existing = existingMap.get(item.title);
|
||||
if (!existing) {
|
||||
toInsert.push(item);
|
||||
} else if (!existing.status || existing.price !== item.price) {
|
||||
toEnable.push({ product: existing, price: item.price });
|
||||
}
|
||||
}
|
||||
|
||||
// Batch update disable
|
||||
if (toDisable.length) {
|
||||
await this.repo
|
||||
.createQueryBuilder()
|
||||
.update(Product)
|
||||
.set({ status: false })
|
||||
.whereInIds(toDisable.map((p) => p.id))
|
||||
.execute();
|
||||
|
||||
await this.historiesRepo.save(
|
||||
toDisable.map((product) => ({
|
||||
message: `Product was disabled at: ${new Date().toISOString()}`,
|
||||
product,
|
||||
published: false,
|
||||
})),
|
||||
);
|
||||
}
|
||||
|
||||
// Update enable + update giá + log
|
||||
for (const { product, price } of toEnable) {
|
||||
await this.repo.update(product.id, { status: true, price });
|
||||
}
|
||||
|
||||
// Insert sản phẩm mới + log
|
||||
if (toInsert.length) {
|
||||
const newProducts = this.repo.create(
|
||||
toInsert.map((item) => ({
|
||||
title: item.title,
|
||||
price: item.price,
|
||||
status: true,
|
||||
})),
|
||||
);
|
||||
await this.repo.save(newProducts);
|
||||
}
|
||||
|
||||
return {
|
||||
disabled: toDisable.length,
|
||||
enabled: toEnable.length,
|
||||
inserted: toInsert.length,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in New Issue