15e48c8704
Bekraftade kvittovaror far sitt netto-pris (efter rabatt) i lagret. Prisupp- skattningar anvander nu ett robust marknadssnitt: median av observerade pris/kg fran lagret, klampat mot schablonen och viktat efter antal observationer -> ett 'realtidsnara' pris dar en extremt dyr/billig butik inte drar ivag snittet.
582 lines
20 KiB
TypeScript
582 lines
20 KiB
TypeScript
import type { FastifyInstance } from "fastify";
|
||
import { and, eq, gt, isNull } from "drizzle-orm";
|
||
import { markMilestone, schema, trackProductAnalytics } from "@app/database";
|
||
import { scanStarted } from "@app/analytics";
|
||
import type { JobType, ScanType } from "@app/shared-types";
|
||
import { confirmScanInputSchema, createScanInputSchema, idParamSchema } from "@app/validation";
|
||
import { errors, parse } from "../lib/errors.js";
|
||
import { emitEvent, requireActiveHousehold } from "../lib/helpers.js";
|
||
import { consumeAiScan } from "../lib/entitlements.js";
|
||
import { findDuplicateCandidates, normalizeItemName } from "@app/inventory-engine";
|
||
|
||
/**
|
||
* Skanningsflödet (spec §50):
|
||
* App → POST /v1/scans (kvotkontroll + presignade upload-URL:er)
|
||
* → PUT bild(er) till storage
|
||
* → POST /v1/scans/:id/start (läggs på kö → worker → AAMOS)
|
||
* → GET /v1/scans/:id (poll: status + resultat)
|
||
* → POST /v1/scans/:id/confirm (användaren godkänner → lagret uppdateras)
|
||
*
|
||
* Användarbekräftelse är obligatorisk innan något skrivs till Food Twin
|
||
* (spec §10, §61.5). Korrigeringar sparas som ai_corrections (spec §33).
|
||
*/
|
||
|
||
const SCAN_TO_JOB: Record<ScanType, JobType> = {
|
||
fridge: "ANALYZE_FRIDGE_IMAGE",
|
||
freezer: "ANALYZE_FRIDGE_IMAGE",
|
||
pantry: "ANALYZE_PANTRY_IMAGE",
|
||
ingredients: "ANALYZE_PANTRY_IMAGE",
|
||
plate: "ANALYZE_MEAL_IMAGE",
|
||
receipt: "READ_RECEIPT",
|
||
barcode: "NORMALIZE_PRODUCTS",
|
||
expiry_date: "READ_EXPIRY_DATE",
|
||
nutrition_label: "READ_NUTRITION_LABEL",
|
||
product_package: "READ_NUTRITION_LABEL",
|
||
};
|
||
|
||
const S3_PREFIX: Partial<Record<ScanType, string>> = {
|
||
fridge: "fridge-scans",
|
||
freezer: "fridge-scans",
|
||
pantry: "pantry-scans",
|
||
ingredients: "pantry-scans",
|
||
plate: "meal-scans",
|
||
receipt: "receipts",
|
||
expiry_date: "product-images",
|
||
nutrition_label: "product-images",
|
||
product_package: "product-images",
|
||
};
|
||
|
||
export async function scanRoutes(app: FastifyInstance) {
|
||
const auth = { preHandler: [app.authenticate] };
|
||
|
||
app.post("/v1/scans", auth, async (req, reply) => {
|
||
const input = parse(createScanInputSchema, req.body);
|
||
const householdId = await requireActiveHousehold(app.db, req.userId);
|
||
|
||
// Streckkod är gratis uppslag utan AI – hanteras direkt (spec §11: lokalt + databas).
|
||
if (input.scanType === "barcode") {
|
||
if (!input.barcode) throw errors.badRequest("barcode krävs för streckkodsskanning.");
|
||
const product = await lookupBarcode(app, input.barcode);
|
||
const [job] = await app.db
|
||
.insert(schema.scanJobs)
|
||
.values({
|
||
userId: req.userId,
|
||
householdId,
|
||
scanType: "barcode",
|
||
jobType: "NORMALIZE_PRODUCTS",
|
||
status: product ? "completed" : "failed",
|
||
result: product ? { product } : null,
|
||
error: product
|
||
? null
|
||
: "Produkten hittades inte. Fota framsida + näringsdeklaration så lägger vi till den.",
|
||
completedAt: new Date(),
|
||
})
|
||
.returning();
|
||
return reply.status(201).send({ scan: job, product });
|
||
}
|
||
|
||
// AI-skanning: kvotkontroll (fair use, spec §45–46). Debiteras per bild –
|
||
// 6 foton = 6 skanningar. Streckkod (ovan) är gratis och drar inget.
|
||
await consumeAiScan(app.db, req.userId, Math.max(1, input.imageCount));
|
||
|
||
const prefix = `${S3_PREFIX[input.scanType] ?? "temporary"}/${householdId}`;
|
||
const uploads = [];
|
||
for (let i = 0; i < Math.max(1, input.imageCount); i++) {
|
||
uploads.push(await app.storage.presignUpload(prefix, input.contentType));
|
||
}
|
||
|
||
const [job] = await app.db
|
||
.insert(schema.scanJobs)
|
||
.values({
|
||
userId: req.userId,
|
||
householdId,
|
||
scanType: input.scanType,
|
||
jobType: SCAN_TO_JOB[input.scanType],
|
||
status: "queued",
|
||
s3Keys: uploads.map((u) => u.key),
|
||
context: input.context ?? null,
|
||
})
|
||
.returning();
|
||
|
||
return reply.status(201).send({ scan: job, uploads });
|
||
});
|
||
|
||
app.post("/v1/scans/:id/start", auth, async (req) => {
|
||
const { id } = parse(idParamSchema, req.params);
|
||
const job = await getOwnedScan(app, id, req.userId);
|
||
if (job.status !== "queued") throw errors.conflict(`Jobbet är redan ${job.status}.`);
|
||
|
||
await app.jobQueue.add(job.jobType, {
|
||
scanJobId: job.id,
|
||
jobType: job.jobType,
|
||
correlationId: req.correlationId,
|
||
});
|
||
|
||
await trackProductAnalytics(app.db, req.userId, {
|
||
...scanStarted(),
|
||
householdId: job.householdId ?? undefined,
|
||
properties: {
|
||
scanType: job.scanType,
|
||
jobType: job.jobType,
|
||
},
|
||
});
|
||
|
||
return { ok: true, status: "queued" };
|
||
});
|
||
|
||
app.get("/v1/scans/:id", auth, async (req) => {
|
||
const { id } = parse(idParamSchema, req.params);
|
||
const job = await getOwnedScan(app, id, req.userId);
|
||
return annotateScanDuplicates(app, job);
|
||
});
|
||
|
||
app.post("/v1/scans/:id/confirm", auth, async (req) => {
|
||
const { id } = parse(idParamSchema, req.params);
|
||
const input = parse(confirmScanInputSchema, req.body);
|
||
const job = await getOwnedScan(app, id, req.userId);
|
||
if (job.status !== "awaiting_confirmation" && job.status !== "completed") {
|
||
throw errors.conflict("Jobbet har inget resultat att bekräfta ännu.");
|
||
}
|
||
const householdId = job.householdId ?? (await requireActiveHousehold(app.db, req.userId));
|
||
|
||
const fallbackLocation =
|
||
input.storageLocationId ?? (await defaultLocation(app, householdId, job.scanType));
|
||
|
||
const created: string[] = [];
|
||
const proposals = extractProposals(job);
|
||
|
||
// Aktiva varor i hushållet, för dubblett-hopslagning vid bekräftelse.
|
||
const activeRows = await app.db
|
||
.select({
|
||
id: schema.inventoryItems.id,
|
||
canonicalIngredientId: schema.inventoryItems.canonicalIngredientId,
|
||
displayName: schema.inventoryItems.displayName,
|
||
})
|
||
.from(schema.inventoryItems)
|
||
.where(
|
||
and(
|
||
eq(schema.inventoryItems.householdId, householdId),
|
||
gt(schema.inventoryItems.quantity, 0),
|
||
),
|
||
);
|
||
const activeItems = activeRows.map((r) => ({ ...r, norm: normalizeItemName(r.displayName) }));
|
||
|
||
for (const item of input.items) {
|
||
const proposal = findProposal(proposals, item.tempId);
|
||
await recordCorrection(app, job, item, proposal);
|
||
if (item.action === "reject") continue;
|
||
|
||
const locationId = item.storageLocationId ?? fallbackLocation;
|
||
if (!locationId)
|
||
throw errors.badRequest("storageLocationId saknas och ingen standardplats finns.");
|
||
|
||
// Dedup (#1): slå ihop om varan redan finns aktiv i hushållet, så att
|
||
// överlappande foton / omfotografering av samma hylla inte dubbellagras.
|
||
// Matcha på katalog-id ELLER normaliserat namn — Vision skriver sällan
|
||
// exakt samma namn två gånger ("Mjölk" vs "mjölk" vs "Mjölk 1L").
|
||
const wantNorm = normalizeItemName(item.displayName);
|
||
const dupe = activeItems.find(
|
||
(r) =>
|
||
(item.canonicalIngredientId != null &&
|
||
r.canonicalIngredientId === item.canonicalIngredientId) ||
|
||
r.norm === wantNorm,
|
||
);
|
||
if (dupe) {
|
||
await app.db
|
||
.update(schema.inventoryItems)
|
||
.set({
|
||
quantity: item.quantity,
|
||
unit: item.unit,
|
||
brand: item.brand ?? null,
|
||
bestBeforeDate: item.bestBeforeDate ?? null,
|
||
useByDate: item.useByDate ?? null,
|
||
lastVerifiedAt: new Date(),
|
||
verifiedByUser: true,
|
||
updatedAt: new Date(),
|
||
})
|
||
.where(eq(schema.inventoryItems.id, dupe.id));
|
||
created.push(dupe.id);
|
||
continue;
|
||
}
|
||
|
||
const [inv] = await app.db
|
||
.insert(schema.inventoryItems)
|
||
.values({
|
||
householdId,
|
||
canonicalIngredientId: item.canonicalIngredientId ?? null,
|
||
displayName: item.displayName,
|
||
brand: item.brand ?? null,
|
||
quantity: item.quantity,
|
||
unit: item.unit,
|
||
storageLocationId: locationId,
|
||
sublocation: item.sublocation ?? null,
|
||
bestBeforeDate: item.bestBeforeDate ?? null,
|
||
useByDate: item.useByDate ?? null,
|
||
priceMinor: item.priceMinor ?? proposal?.priceMinor ?? null,
|
||
purchasedAt: new Date().toISOString().slice(0, 10),
|
||
source: scanSource(job.scanType),
|
||
confidence: item.action === "accept" ? 0.9 : 1,
|
||
verifiedByUser: true,
|
||
lastVerifiedAt: new Date(),
|
||
modelVersion: job.modelVersion,
|
||
promptVersion: job.promptVersion,
|
||
})
|
||
.returning();
|
||
|
||
// Gör den nyss inlagda varan sökbar för resten av SAMMA batch, så att
|
||
// två träffar på samma vara i ett och samma foto (t.ex. "helmjölk 4dl"
|
||
// + "helmjölk 8dl") slås ihop i stället för att dubbellagras.
|
||
activeItems.push({
|
||
id: inv!.id,
|
||
canonicalIngredientId: item.canonicalIngredientId ?? null,
|
||
displayName: item.displayName,
|
||
norm: wantNorm,
|
||
});
|
||
|
||
await app.db.insert(schema.inventoryTransactions).values({
|
||
householdId,
|
||
inventoryItemId: inv!.id,
|
||
type: "purchase",
|
||
quantityDelta: item.quantity,
|
||
unit: item.unit,
|
||
refType: "scan",
|
||
refId: job.id,
|
||
actorUserId: req.userId,
|
||
valueMinor: item.priceMinor ?? proposal?.priceMinor ?? null,
|
||
});
|
||
await emitEvent(app.db, {
|
||
type: "PRODUCT_ADDED",
|
||
payload: {
|
||
inventoryItemId: inv!.id,
|
||
canonicalIngredientId: item.canonicalIngredientId ?? null,
|
||
quantity: item.quantity,
|
||
unit: item.unit,
|
||
source: scanSource(job.scanType),
|
||
},
|
||
userId: req.userId,
|
||
householdId,
|
||
correlationId: req.correlationId,
|
||
});
|
||
created.push(inv!.id);
|
||
}
|
||
|
||
await app.db
|
||
.update(schema.scanJobs)
|
||
.set({ status: "completed", updatedAt: new Date() })
|
||
.where(eq(schema.scanJobs.id, id));
|
||
|
||
const acceptedCount = input.items.filter((i) => i.action !== "reject").length;
|
||
await markMilestone(app.db, householdId, "firstScanCompletedAt");
|
||
if (acceptedCount >= 5) {
|
||
await markMilestone(app.db, householdId, "fifthItemConfirmedAt");
|
||
}
|
||
|
||
return { ok: true, createdItemIds: created };
|
||
});
|
||
|
||
app.get("/v1/scans", auth, async (req) => {
|
||
const jobs = await app.db
|
||
.select()
|
||
.from(schema.scanJobs)
|
||
.where(eq(schema.scanJobs.userId, req.userId))
|
||
.orderBy((await import("drizzle-orm")).desc(schema.scanJobs.createdAt))
|
||
.limit(30);
|
||
return { scans: jobs };
|
||
});
|
||
}
|
||
|
||
async function getOwnedScan(app: FastifyInstance, id: string, userId: string) {
|
||
const [job] = await app.db
|
||
.select()
|
||
.from(schema.scanJobs)
|
||
.where(eq(schema.scanJobs.id, id))
|
||
.limit(1);
|
||
if (!job || job.userId !== userId) throw errors.notFound("Skanningen finns inte.");
|
||
return job;
|
||
}
|
||
|
||
/**
|
||
* Flaggar troliga dubbletter i ett skann-resultat INNAN användaren bekräftar:
|
||
* jämför varje föreslagen vara mot aktivt lager OCH mot tidigare varor i samma
|
||
* skanning (så samma hylla från två vinklar fångas). Ren hint – appen slår
|
||
* aldrig ihop utan bekräftelse (spec §9); användaren väljer i granskningen.
|
||
*/
|
||
async function annotateScanDuplicates(
|
||
app: FastifyInstance,
|
||
job: Awaited<ReturnType<typeof getOwnedScan>>,
|
||
) {
|
||
const result = job.result as { items?: Array<Record<string, unknown>> } | null;
|
||
if (!result?.items?.length || !job.householdId) return job;
|
||
|
||
const rows = await app.db
|
||
.select({
|
||
id: schema.inventoryItems.id,
|
||
canonicalIngredientId: schema.inventoryItems.canonicalIngredientId,
|
||
displayName: schema.inventoryItems.displayName,
|
||
brand: schema.inventoryItems.brand,
|
||
quantity: schema.inventoryItems.quantity,
|
||
source: schema.inventoryItems.source,
|
||
createdAt: schema.inventoryItems.createdAt,
|
||
})
|
||
.from(schema.inventoryItems)
|
||
.where(
|
||
and(
|
||
eq(schema.inventoryItems.householdId, job.householdId),
|
||
isNull(schema.inventoryItems.depletedAt),
|
||
gt(schema.inventoryItems.quantity, 0),
|
||
),
|
||
);
|
||
|
||
const existing = rows.map((r) => ({
|
||
id: r.id,
|
||
canonicalIngredientId: r.canonicalIngredientId,
|
||
displayName: r.displayName,
|
||
brand: r.brand,
|
||
quantity: Number(r.quantity) || 0,
|
||
source: r.source,
|
||
createdAt: r.createdAt.toISOString(),
|
||
}));
|
||
|
||
const source = scanSource(job.scanType);
|
||
const now = new Date().toISOString();
|
||
// Jämför bara mot TIDIGARE bilder i samma skanning när det finns FLERA foton
|
||
// (överlappande vinklar). I ett enda foto är två liknande behållare oftast
|
||
// två riktiga varor – flagga dem aldrig mot varandra.
|
||
const multiImage = (job.s3Keys?.length ?? 0) > 1;
|
||
const seen: typeof existing = [];
|
||
|
||
const items = result.items.map((it, idx) => {
|
||
const input = {
|
||
canonicalIngredientId: (it.canonicalIngredientId as string | null) ?? null,
|
||
displayName: String(it.detectedName ?? ""),
|
||
brand: (it.brand as string | null) ?? null,
|
||
quantity: typeof it.estimatedQuantity === "number" ? it.estimatedQuantity : 1,
|
||
source,
|
||
createdAt: now,
|
||
};
|
||
const pool = multiImage ? [...existing, ...seen] : existing;
|
||
const [top] = findDuplicateCandidates(input, pool);
|
||
if (multiImage) seen.push({ id: String(it.tempId ?? idx), ...input });
|
||
if (!top) return { ...it, possibleDuplicate: null };
|
||
const match = pool.find((p) => p.id === top.itemId);
|
||
return {
|
||
...it,
|
||
possibleDuplicate: {
|
||
name: match?.displayName ?? "",
|
||
reason: top.reasons[0] ?? "liknande vara",
|
||
score: top.score,
|
||
},
|
||
};
|
||
});
|
||
|
||
return { ...job, result: { ...result, items } };
|
||
}
|
||
|
||
async function lookupBarcode(app: FastifyInstance, gtin: string) {
|
||
// 1. Egen produktdatabas (aktuell version)
|
||
const [own] = await app.db
|
||
.select()
|
||
.from(schema.products)
|
||
.where(and(eq(schema.products.gtin, gtin), isNull(schema.products.validTo)))
|
||
.limit(1);
|
||
if (own) return own;
|
||
|
||
// 2. Open Food Facts (laglig öppen källa, spec §11)
|
||
const off = app.connectors.get("open-food-facts");
|
||
if (off && "lookupBarcode" in off) {
|
||
try {
|
||
const result = await (off as { lookupBarcode(g: string): Promise<unknown> }).lookupBarcode(
|
||
gtin,
|
||
);
|
||
if (result && typeof result === "object") {
|
||
const p = result as {
|
||
gtin: string;
|
||
name?: string;
|
||
brand?: string;
|
||
ingredientsText?: string;
|
||
nutrimentsPer100g: Record<string, number | undefined>;
|
||
imageUrl?: string;
|
||
};
|
||
if (!p.name) return null;
|
||
const n = p.nutrimentsPer100g;
|
||
const [saved] = await app.db
|
||
.insert(schema.products)
|
||
.values({
|
||
gtin: p.gtin,
|
||
name: p.name,
|
||
brand: p.brand ?? null,
|
||
ingredientsText: p.ingredientsText ?? null,
|
||
nutrition:
|
||
n.kcal != null
|
||
? {
|
||
basis: "per_100_g",
|
||
values: {
|
||
kcal: n.kcal ?? 0,
|
||
proteinG: n.proteinG ?? 0,
|
||
carbsG: n.carbsG ?? 0,
|
||
fatG: n.fatG ?? 0,
|
||
saturatedFatG: n.saturatedFatG ?? 0,
|
||
fiberG: n.fiberG ?? 0,
|
||
sugarG: n.sugarG ?? 0,
|
||
saltG: n.saltG ?? 0,
|
||
},
|
||
}
|
||
: null,
|
||
imageUrls: p.imageUrl ? [p.imageUrl] : [],
|
||
dataSource: "open_food_facts",
|
||
verificationStatus: "unverified",
|
||
})
|
||
.onConflictDoNothing()
|
||
.returning();
|
||
return saved ?? null;
|
||
}
|
||
} catch (err) {
|
||
app.log.warn({ err, gtin }, "OFF-uppslag misslyckades");
|
||
}
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function scanSource(scanType: ScanType) {
|
||
switch (scanType) {
|
||
case "fridge":
|
||
return "fridge_photo" as const;
|
||
case "freezer":
|
||
return "freezer_photo" as const;
|
||
case "pantry":
|
||
return "pantry_photo" as const;
|
||
case "ingredients":
|
||
return "ingredient_photo" as const;
|
||
case "receipt":
|
||
return "receipt" as const;
|
||
case "barcode":
|
||
return "barcode" as const;
|
||
default:
|
||
return "label_photo" as const;
|
||
}
|
||
}
|
||
|
||
async function defaultLocation(app: FastifyInstance, householdId: string, scanType: ScanType) {
|
||
const wanted =
|
||
scanType === "freezer"
|
||
? "freezer"
|
||
: scanType === "pantry" || scanType === "ingredients"
|
||
? "pantry"
|
||
: "fridge";
|
||
const [loc] = await app.db
|
||
.select({ id: schema.storageLocations.id })
|
||
.from(schema.storageLocations)
|
||
.where(
|
||
and(
|
||
eq(schema.storageLocations.householdId, householdId),
|
||
eq(schema.storageLocations.type, wanted),
|
||
),
|
||
)
|
||
.limit(1);
|
||
return loc?.id ?? null;
|
||
}
|
||
|
||
type ProposalItem = {
|
||
tempId?: string;
|
||
detectedName?: string;
|
||
canonicalIngredientId?: string | null;
|
||
brand?: string | null;
|
||
estimatedQuantity?: number | null;
|
||
unit?: string | null;
|
||
bestBeforeDate?: string | null;
|
||
confidence?: number;
|
||
requiresConfirmation?: boolean;
|
||
priceMinor?: number | null;
|
||
};
|
||
|
||
function extractProposals(job: { result: unknown }): ProposalItem[] {
|
||
const result = job.result as Record<string, unknown> | null;
|
||
if (!result || !Array.isArray(result.items)) return [];
|
||
return result.items.map((it, idx) => ({
|
||
tempId: String(it.tempId ?? idx),
|
||
detectedName: String(it.detectedName ?? ""),
|
||
canonicalIngredientId: it.canonicalIngredientId ?? null,
|
||
brand: it.brand ?? null,
|
||
estimatedQuantity: it.estimatedQuantity ?? null,
|
||
unit: it.unit ?? null,
|
||
bestBeforeDate: it.bestBeforeDate ?? null,
|
||
confidence: typeof it.confidence === "number" ? it.confidence : null,
|
||
requiresConfirmation:
|
||
typeof it.requiresConfirmation === "boolean" ? it.requiresConfirmation : null,
|
||
priceMinor: typeof it.priceMinor === "number" ? it.priceMinor : null,
|
||
}));
|
||
}
|
||
|
||
function findProposal(proposals: ProposalItem[], tempId: string | undefined): ProposalItem | null {
|
||
if (tempId == null) return null;
|
||
return proposals.find((p) => p.tempId === tempId) ?? null;
|
||
}
|
||
|
||
async function recordCorrection(
|
||
app: FastifyInstance,
|
||
job: {
|
||
id: string;
|
||
userId: string;
|
||
jobType: string;
|
||
result: unknown;
|
||
s3Keys: string[];
|
||
modelVersion: string | null;
|
||
promptVersion: string | null;
|
||
},
|
||
item: {
|
||
tempId?: string;
|
||
action: "accept" | "edit" | "reject" | "add";
|
||
displayName: string;
|
||
quantity: number;
|
||
unit: string;
|
||
brand?: string;
|
||
canonicalIngredientId?: string;
|
||
bestBeforeDate?: string;
|
||
useByDate?: string;
|
||
},
|
||
proposal: ProposalItem | null,
|
||
) {
|
||
const consents = await app.db
|
||
.select()
|
||
.from(schema.userConsents)
|
||
.where(eq(schema.userConsents.userId, job.userId));
|
||
const snapshot = Object.fromEntries(consents.map((c) => [c.kind, c.status]));
|
||
const hasImageConsent = snapshot.image_training === "granted";
|
||
|
||
const userCorrection: Record<string, unknown> = { action: item.action };
|
||
if (item.action !== "reject") {
|
||
userCorrection.corrected = {
|
||
displayName: item.displayName,
|
||
canonicalIngredientId: item.canonicalIngredientId ?? null,
|
||
brand: item.brand ?? null,
|
||
quantity: item.quantity,
|
||
unit: item.unit,
|
||
bestBeforeDate: item.bestBeforeDate ?? null,
|
||
useByDate: item.useByDate ?? null,
|
||
};
|
||
}
|
||
|
||
await app.db.insert(schema.aiCorrections).values({
|
||
scanJobId: job.id,
|
||
userId: job.userId,
|
||
taskType: job.jobType,
|
||
aiOutput: { raw: job.result },
|
||
proposal: proposal,
|
||
userCorrection,
|
||
imageS3Key: hasImageConsent && job.s3Keys.length > 0 ? job.s3Keys[0] : null,
|
||
modelVersion: job.modelVersion,
|
||
promptVersion: job.promptVersion,
|
||
consentSnapshot: snapshot,
|
||
});
|
||
|
||
await emitEvent(app.db, {
|
||
type: "AI_CORRECTED",
|
||
payload: {
|
||
scanJobId: job.id,
|
||
taskType: job.jobType,
|
||
field: item.action,
|
||
},
|
||
userId: job.userId,
|
||
});
|
||
}
|