@mohasinac/appkit 2.7.32 → 2.7.34

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (50) hide show
  1. package/dist/_internal/server/features/faqs/index.d.ts +1 -0
  2. package/dist/_internal/server/features/faqs/index.js +1 -0
  3. package/dist/_internal/server/features/faqs/og.d.ts +9 -0
  4. package/dist/_internal/server/features/faqs/og.js +50 -0
  5. package/dist/_internal/server/features/reviews/index.d.ts +2 -0
  6. package/dist/_internal/server/features/reviews/index.js +2 -0
  7. package/dist/_internal/server/features/reviews/og-data.d.ts +1 -0
  8. package/dist/_internal/server/features/reviews/og-data.js +7 -0
  9. package/dist/_internal/server/features/reviews/og.d.ts +19 -0
  10. package/dist/_internal/server/features/reviews/og.js +70 -0
  11. package/dist/_internal/server/features/scams/index.d.ts +1 -0
  12. package/dist/_internal/server/features/scams/index.js +1 -0
  13. package/dist/_internal/server/features/scams/og.d.ts +18 -0
  14. package/dist/_internal/server/features/scams/og.js +59 -0
  15. package/dist/_internal/server/jobs/core/auctionSettlement.js +14 -14
  16. package/dist/_internal/server/jobs/core/offerExpiry.js +3 -2
  17. package/dist/_internal/server/jobs/core/onBidPlaced.js +14 -12
  18. package/dist/_internal/server/jobs/core/onOrderStatusChange.js +6 -71
  19. package/dist/_internal/server/jobs/core/onSupportTicketCreate.js +6 -8
  20. package/dist/_internal/server/jobs/core/onSupportTicketUpdate.js +6 -7
  21. package/dist/_internal/server/jobs/core/pendingOrderTimeout.js +11 -10
  22. package/dist/_internal/server/jobs/core/prizeRevealExpiry.js +2 -2
  23. package/dist/_internal/server/jobs/core/prizeRevealOpen.js +2 -2
  24. package/dist/_internal/server/jobs/core/prizeRevealReminder.js +2 -2
  25. package/dist/features/admin/actions/notification-actions.js +18 -8
  26. package/dist/features/admin/schemas/firestore.d.ts +6 -2
  27. package/dist/features/admin/schemas/firestore.js +4 -0
  28. package/dist/features/orders/actions/refund-actions.js +2 -2
  29. package/dist/features/seller/actions/offer-actions.js +5 -5
  30. package/dist/index.d.ts +1 -0
  31. package/dist/index.js +1 -0
  32. package/dist/seed/actions/demo-seed-actions.d.ts +1 -1
  33. package/dist/seed/conversations-seed-data.js +52 -0
  34. package/dist/seed/index.d.ts +1 -0
  35. package/dist/seed/index.js +1 -0
  36. package/dist/seed/manifest.js +5 -0
  37. package/dist/seed/notifications-seed-data.js +10 -0
  38. package/dist/seed/offers-seed-data.d.ts +10 -0
  39. package/dist/seed/offers-seed-data.js +264 -0
  40. package/dist/seed/orders-seed-data.js +116 -2
  41. package/dist/seed/sessions-seed-data.js +35 -1
  42. package/dist/seed/support-tickets-seed-data.js +43 -0
  43. package/dist/seed/users-seed-data.js +1 -1
  44. package/dist/seed/wishlists-seed-data.js +5 -1
  45. package/dist/server-entry.d.ts +3 -1
  46. package/dist/server-entry.js +3 -1
  47. package/dist/server.d.ts +5 -0
  48. package/dist/server.js +5 -0
  49. package/dist/ui/components/Input.style.css +42 -4
  50. package/package.json +1 -1
@@ -0,0 +1 @@
1
+ export { renderFaqOg, renderFaqOgImage, type FaqOgData } from "./og";
@@ -0,0 +1 @@
1
+ export { renderFaqOg, renderFaqOgImage } from "./og";
@@ -0,0 +1,9 @@
1
+ import type { ReactElement } from "react";
2
+ export interface FaqOgData {
3
+ categoryLabel: string;
4
+ siteName: string;
5
+ }
6
+ export declare function renderFaqOg(category: string, opts: {
7
+ siteName: string;
8
+ }): ReactElement;
9
+ export declare function renderFaqOgImage(data: FaqOgData): ReactElement;
@@ -0,0 +1,50 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function renderFaqOg(category, opts) {
3
+ const label = category
4
+ .replace(/-/g, " ")
5
+ .replace(/\b\w/g, (c) => c.toUpperCase());
6
+ return renderFaqOgImage({ categoryLabel: label, siteName: opts.siteName });
7
+ }
8
+ export function renderFaqOgImage(data) {
9
+ const { categoryLabel, siteName } = data;
10
+ return (_jsxs("div", { style: {
11
+ display: "flex",
12
+ width: "100%",
13
+ height: "100%",
14
+ background: "linear-gradient(135deg, #3570fc 0%, #1343de 100%)",
15
+ fontFamily: "sans-serif",
16
+ alignItems: "center",
17
+ justifyContent: "center",
18
+ padding: "80px",
19
+ position: "relative",
20
+ overflow: "hidden",
21
+ }, children: [_jsx("div", { style: {
22
+ position: "absolute",
23
+ inset: 0,
24
+ opacity: 0.06,
25
+ backgroundImage: "radial-gradient(circle at 25% 25%, white 1px, transparent 0), radial-gradient(circle at 75% 75%, white 1px, transparent 0)",
26
+ backgroundSize: "64px 64px",
27
+ } }), _jsxs("div", { style: {
28
+ position: "relative",
29
+ display: "flex",
30
+ flexDirection: "column",
31
+ alignItems: "center",
32
+ gap: 24,
33
+ textAlign: "center",
34
+ }, children: [_jsxs("div", { style: {
35
+ fontSize: 20,
36
+ color: "rgba(255,255,255,0.7)",
37
+ letterSpacing: 3,
38
+ textTransform: "uppercase",
39
+ }, children: [siteName, " \u00B7 Help Centre"] }), _jsx("div", { style: {
40
+ fontSize: 72,
41
+ fontWeight: 800,
42
+ color: "white",
43
+ lineHeight: 1.1,
44
+ letterSpacing: "-1px",
45
+ }, children: categoryLabel }), _jsx("div", { style: {
46
+ fontSize: 32,
47
+ color: "rgba(255,255,255,0.8)",
48
+ fontWeight: 400,
49
+ }, children: "Frequently Asked Questions" })] })] }));
50
+ }
@@ -1,3 +1,5 @@
1
1
  export { getReviewsForProduct, getReviewsForStore, hasUserPurchasedProduct } from "./data";
2
+ export { getReviewById } from "./og-data";
3
+ export { renderReviewOg, renderReviewOgImage, type ReviewOgData } from "./og";
2
4
  export { createReviewAction, replyToReviewAction, deleteReviewAction, markReviewHelpfulAction, } from "./actions";
3
5
  export { REVIEWS_PAGE_SIZE, REVIEW_MAX_RATING, REVIEW_MIN_RATING, REVIEW_IMAGES_MAX } from "../../../shared/features/reviews/config";
@@ -1,3 +1,5 @@
1
1
  export { getReviewsForProduct, getReviewsForStore, hasUserPurchasedProduct } from "./data";
2
+ export { getReviewById } from "./og-data";
3
+ export { renderReviewOg, renderReviewOgImage } from "./og";
2
4
  export { createReviewAction, replyToReviewAction, deleteReviewAction, markReviewHelpfulAction, } from "./actions";
3
5
  export { REVIEWS_PAGE_SIZE, REVIEW_MAX_RATING, REVIEW_MIN_RATING, REVIEW_IMAGES_MAX } from "../../../shared/features/reviews/config";
@@ -0,0 +1 @@
1
+ export declare const getReviewById: (id: string) => Promise<import("../../../..").ReviewDocument | null>;
@@ -0,0 +1,7 @@
1
+ import { cache } from "react";
2
+ import { reviewRepository } from "../../../../repositories";
3
+ export const getReviewById = cache(async (id) => {
4
+ if (!id)
5
+ return null;
6
+ return reviewRepository.findById(id).catch(() => null);
7
+ });
@@ -0,0 +1,19 @@
1
+ import type { ReactElement } from "react";
2
+ export interface ReviewOgData {
3
+ productTitle: string;
4
+ rating: number;
5
+ reviewerInitial: string;
6
+ reviewExcerpt?: string | null;
7
+ }
8
+ interface ReviewDocLike {
9
+ title?: string | null;
10
+ productName?: string | null;
11
+ rating?: number | null;
12
+ userName?: string | null;
13
+ body?: string | null;
14
+ }
15
+ export declare function renderReviewOg(doc: ReviewDocLike | null | undefined, opts: {
16
+ siteName: string;
17
+ }): ReactElement;
18
+ export declare function renderReviewOgImage(data: ReviewOgData, siteName: string): ReactElement;
19
+ export {};
@@ -0,0 +1,70 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ function stars(n) {
3
+ return "★".repeat(Math.min(5, Math.max(1, Math.round(n))));
4
+ }
5
+ export function renderReviewOg(doc, opts) {
6
+ return renderReviewOgImage({
7
+ productTitle: doc?.productName ?? doc?.title ?? "Collectible",
8
+ rating: doc?.rating ?? 5,
9
+ reviewerInitial: (doc?.userName?.[0] ?? "?").toUpperCase(),
10
+ reviewExcerpt: doc?.body ? doc.body.slice(0, 120) : null,
11
+ }, opts.siteName);
12
+ }
13
+ export function renderReviewOgImage(data, siteName) {
14
+ const { productTitle, rating, reviewerInitial, reviewExcerpt } = data;
15
+ return (_jsxs("div", { style: {
16
+ display: "flex",
17
+ width: "100%",
18
+ height: "100%",
19
+ background: "#0f172a",
20
+ fontFamily: "sans-serif",
21
+ alignItems: "center",
22
+ justifyContent: "center",
23
+ padding: "60px",
24
+ position: "relative",
25
+ }, children: [_jsx("div", { style: {
26
+ position: "absolute",
27
+ inset: 0,
28
+ background: "linear-gradient(135deg, #0f172a 0%, #1e293b 100%)",
29
+ } }), _jsxs("div", { style: {
30
+ position: "relative",
31
+ display: "flex",
32
+ flexDirection: "column",
33
+ width: "100%",
34
+ maxWidth: 1080,
35
+ gap: 32,
36
+ }, children: [_jsxs("div", { style: { fontSize: 18, color: "#94a3b8", letterSpacing: 2, textTransform: "uppercase" }, children: [siteName, " \u00B7 Customer Review"] }), _jsxs("div", { style: {
37
+ display: "flex",
38
+ alignItems: "center",
39
+ gap: 24,
40
+ }, children: [_jsx("div", { style: {
41
+ width: 80,
42
+ height: 80,
43
+ borderRadius: 40,
44
+ background: "linear-gradient(135deg, #3570fc, #e91e8c)",
45
+ display: "flex",
46
+ alignItems: "center",
47
+ justifyContent: "center",
48
+ fontSize: 36,
49
+ fontWeight: 700,
50
+ color: "white",
51
+ flexShrink: 0,
52
+ }, children: reviewerInitial }), _jsxs("div", { style: { display: "flex", flexDirection: "column", gap: 4 }, children: [_jsx("div", { style: { fontSize: 32, color: "#f59e0b", letterSpacing: 2 }, children: stars(rating) }), _jsxs("div", { style: { fontSize: 20, color: "#64748b" }, children: [rating, "/5"] })] })] }), _jsx("div", { style: {
53
+ fontSize: 48,
54
+ fontWeight: 700,
55
+ color: "#f1f5f9",
56
+ lineHeight: 1.2,
57
+ display: "-webkit-box",
58
+ WebkitLineClamp: 2,
59
+ WebkitBoxOrient: "vertical",
60
+ overflow: "hidden",
61
+ }, children: productTitle }), reviewExcerpt && (_jsxs("div", { style: {
62
+ fontSize: 24,
63
+ color: "#94a3b8",
64
+ lineHeight: 1.5,
65
+ display: "-webkit-box",
66
+ WebkitLineClamp: 2,
67
+ WebkitBoxOrient: "vertical",
68
+ overflow: "hidden",
69
+ }, children: ["\u201C", reviewExcerpt, "\u201D"] }))] })] }));
70
+ }
@@ -1 +1,2 @@
1
1
  export * from "./data";
2
+ export { renderScamOg, renderScamOgImage, type ScamOgData } from "./og";
@@ -1 +1,2 @@
1
1
  export * from "./data";
2
+ export { renderScamOg, renderScamOgImage } from "./og";
@@ -0,0 +1,18 @@
1
+ import type { ReactElement } from "react";
2
+ export interface ScamOgData {
3
+ displayName: string;
4
+ scamTypeLabel: string;
5
+ reportCount: number;
6
+ }
7
+ interface ScammerDocLike {
8
+ displayNames?: string[];
9
+ scamType?: string;
10
+ incidents?: unknown[];
11
+ totalReports?: number;
12
+ }
13
+ export declare function renderScamOg(doc: ScammerDocLike | null | undefined, opts: {
14
+ siteName: string;
15
+ scamTypeLabel?: string;
16
+ }): ReactElement;
17
+ export declare function renderScamOgImage(data: ScamOgData, siteName: string): ReactElement;
18
+ export {};
@@ -0,0 +1,59 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ export function renderScamOg(doc, opts) {
3
+ return renderScamOgImage({
4
+ displayName: doc?.displayNames?.[0] ?? "Unknown",
5
+ scamTypeLabel: opts.scamTypeLabel ?? doc?.scamType ?? "Scammer",
6
+ reportCount: doc?.totalReports ?? 0,
7
+ }, opts.siteName);
8
+ }
9
+ export function renderScamOgImage(data, siteName) {
10
+ const { displayName, scamTypeLabel, reportCount } = data;
11
+ return (_jsxs("div", { style: {
12
+ display: "flex",
13
+ width: "100%",
14
+ height: "100%",
15
+ background: "#0f172a",
16
+ fontFamily: "sans-serif",
17
+ alignItems: "center",
18
+ padding: "60px 80px",
19
+ position: "relative",
20
+ overflow: "hidden",
21
+ }, children: [_jsx("div", { style: {
22
+ position: "absolute",
23
+ inset: 0,
24
+ background: "linear-gradient(135deg, #1e0505 0%, #0f172a 60%)",
25
+ } }), _jsxs("div", { style: {
26
+ position: "relative",
27
+ display: "flex",
28
+ flexDirection: "column",
29
+ gap: 28,
30
+ width: "100%",
31
+ }, children: [_jsxs("div", { style: {
32
+ display: "flex",
33
+ alignItems: "center",
34
+ gap: 12,
35
+ }, children: [_jsx("div", { style: {
36
+ background: "#dc2626",
37
+ color: "white",
38
+ padding: "6px 18px",
39
+ borderRadius: 6,
40
+ fontSize: 16,
41
+ fontWeight: 700,
42
+ letterSpacing: 2,
43
+ textTransform: "uppercase",
44
+ }, children: "\u26A0 Verified Scammer" }), _jsxs("div", { style: { fontSize: 18, color: "#94a3b8", letterSpacing: 1 }, children: [siteName, " \u00B7 Scam Registry"] })] }), _jsx("div", { style: {
45
+ fontSize: 72,
46
+ fontWeight: 800,
47
+ color: "#f1f5f9",
48
+ lineHeight: 1.1,
49
+ letterSpacing: "-1px",
50
+ }, children: displayName }), _jsxs("div", { style: { display: "flex", gap: 24, alignItems: "center" }, children: [_jsx("div", { style: {
51
+ background: "#1e293b",
52
+ border: "1px solid #334155",
53
+ borderRadius: 8,
54
+ padding: "10px 20px",
55
+ fontSize: 22,
56
+ color: "#f87171",
57
+ fontWeight: 600,
58
+ }, children: scamTypeLabel }), reportCount > 0 && (_jsxs("div", { style: { fontSize: 22, color: "#64748b" }, children: [reportCount, " ", reportCount === 1 ? "report" : "reports"] }))] }), _jsx("div", { style: { fontSize: 20, color: "#64748b", marginTop: 8 }, children: "Do not transact with this individual. Verified by the LetItRip community." })] })] }));
59
+ }
@@ -1,4 +1,5 @@
1
- import { bidRepository, notificationRepository, orderRepository, productRepository, } from "../../../../repositories";
1
+ import { bidRepository, orderRepository, productRepository, } from "../../../../repositories";
2
+ import { sendNotification } from "../../../../features/admin/actions/notification-actions";
2
3
  import { ProductStatusValues } from "../../../../features/products/schemas/firestore";
3
4
  import { AUCTION_MESSAGES } from "../handlers/messages";
4
5
  async function settleAuction(ctx, product) {
@@ -25,7 +26,9 @@ async function settleAuction(ctx, product) {
25
26
  auctionProductId: product.id,
26
27
  });
27
28
  productRepository.updateStatusInBatch(batch, product.id, ProductStatusValues.SOLD);
28
- notificationRepository.createInBatch(batch, {
29
+ await batch.commit();
30
+ // Fan-out notifications after batch commits.
31
+ await sendNotification({
29
32
  userId: winnerEntry.data.userId,
30
33
  type: "bid_won",
31
34
  priority: "high",
@@ -34,18 +37,15 @@ async function settleAuction(ctx, product) {
34
37
  relatedId: product.id,
35
38
  relatedType: "product",
36
39
  });
37
- loserEntries.slice(0, 50).forEach(({ data: bid }) => {
38
- notificationRepository.createInBatch(batch, {
39
- userId: bid.userId,
40
- type: "bid_lost",
41
- priority: "normal",
42
- title: AUCTION_MESSAGES.LOST_TITLE,
43
- message: AUCTION_MESSAGES.LOST_MESSAGE(product.title),
44
- relatedId: product.id,
45
- relatedType: "product",
46
- });
47
- });
48
- await batch.commit();
40
+ await Promise.allSettled(loserEntries.slice(0, 50).map(({ data: bid }) => sendNotification({
41
+ userId: bid.userId,
42
+ type: "bid_lost",
43
+ priority: "normal",
44
+ title: AUCTION_MESSAGES.LOST_TITLE,
45
+ message: AUCTION_MESSAGES.LOST_MESSAGE(product.title),
46
+ relatedId: product.id,
47
+ relatedType: "product",
48
+ })));
49
49
  ctx.logger.info(`Settled auction ${product.id}`, {
50
50
  winner: winnerEntry.data.userId,
51
51
  winningBid: winnerEntry.data.bidAmount,
@@ -1,4 +1,5 @@
1
- import { offerRepository, notificationRepository } from "../../../../repositories";
1
+ import { offerRepository } from "../../../../repositories";
2
+ import { sendNotification } from "../../../../features/admin/actions/notification-actions";
2
3
  export async function runOfferExpiry(ctx) {
3
4
  ctx.logger.info("Starting offer expiry sweep");
4
5
  let expiredOffers;
@@ -18,7 +19,7 @@ export async function runOfferExpiry(ctx) {
18
19
  for (const offer of expiredOffers) {
19
20
  try {
20
21
  expiredIds.push(offer.id);
21
- await notificationRepository.create({
22
+ await sendNotification({
22
23
  userId: offer.buyerUid,
23
24
  type: "offer_expired",
24
25
  priority: "normal",
@@ -1,5 +1,6 @@
1
- import { bidRepository, notificationRepository, productRepository, } from "../../../../repositories";
1
+ import { bidRepository, productRepository, } from "../../../../repositories";
2
2
  import { decryptPii } from "../../../../security/index";
3
+ import { sendNotification } from "../../../../features/admin/actions/notification-actions";
3
4
  import { getAdminRealtimeDb } from "../../../../providers/db-firebase";
4
5
  import { BID_MESSAGES } from "../handlers/messages";
5
6
  const BIDS_COLLECTION = "bids";
@@ -15,33 +16,34 @@ export async function handleBidPlaced(input, ctx) {
15
16
  if (prevWinning && prevWinning.data.userId !== newBid.userId) {
16
17
  outbidUserId = prevWinning.data.userId;
17
18
  bidRepository.markOutbid(batch, prevWinning.ref);
18
- notificationRepository.createInBatch(batch, {
19
+ }
20
+ productRepository.incrementBidCountInBatch(batch, newBid.productId, newBid.bidAmount);
21
+ await batch.commit();
22
+ if (outbidUserId) {
23
+ const outbidMessage = BID_MESSAGES.OUTBID_MESSAGE(newBid.productTitle, newBid.currency, newBid.bidAmount);
24
+ await sendNotification({
19
25
  userId: outbidUserId,
20
26
  type: "bid_outbid",
21
27
  priority: "high",
22
28
  title: BID_MESSAGES.OUTBID_TITLE,
23
- message: BID_MESSAGES.OUTBID_MESSAGE(newBid.productTitle, newBid.currency, newBid.bidAmount),
29
+ message: outbidMessage,
24
30
  relatedId: newBid.productId,
25
31
  relatedType: "product",
26
32
  });
27
- }
28
- productRepository.incrementBidCountInBatch(batch, newBid.productId, newBid.bidAmount);
29
- await batch.commit();
30
- try {
31
- if (outbidUserId) {
33
+ try {
32
34
  await getAdminRealtimeDb()
33
35
  .ref(`notifications/${outbidUserId}`)
34
36
  .push({
35
37
  type: "bid_outbid",
36
38
  title: BID_MESSAGES.OUTBID_TITLE,
37
- message: BID_MESSAGES.OUTBID_MESSAGE(newBid.productTitle, newBid.currency, newBid.bidAmount),
39
+ message: outbidMessage,
38
40
  timestamp: Date.now(),
39
41
  read: false,
40
42
  });
41
43
  }
42
- }
43
- catch (rtdbError) {
44
- ctx.logger.error("Realtime DB push failed (non-fatal)", rtdbError);
44
+ catch (rtdbError) {
45
+ ctx.logger.error("Realtime DB push failed (non-fatal)", rtdbError);
46
+ }
45
47
  }
46
48
  ctx.logger.info(`Bid placed on ${newBid.productId}`, {
47
49
  bidId,
@@ -1,87 +1,33 @@
1
- import { notificationRepository } from "../../../../repositories";
2
- import { decryptPii } from "../../../../security/index";
3
1
  import { getAdminRealtimeDb } from "../../../../providers/db-firebase";
4
- import { ORDER_MESSAGES, EMAIL_SUBJECTS, JOB_ERROR_MESSAGES } from "../handlers/messages";
2
+ import { decryptPii } from "../../../../security/index";
3
+ import { sendNotification } from "../../../../features/admin/actions/notification-actions";
4
+ import { ORDER_MESSAGES } from "../handlers/messages";
5
5
  const STATUS_CONFIG = {
6
6
  confirmed: {
7
7
  type: "order_confirmed",
8
8
  title: ORDER_MESSAGES.CONFIRMED_TITLE,
9
9
  message: (o) => ORDER_MESSAGES.CONFIRMED_MESSAGE(o.productTitle),
10
10
  priority: "normal",
11
- sendEmail: true,
12
11
  },
13
12
  shipped: {
14
13
  type: "order_shipped",
15
14
  title: ORDER_MESSAGES.SHIPPED_TITLE,
16
15
  message: (o) => ORDER_MESSAGES.SHIPPED_MESSAGE(o.productTitle, o.trackingNumber),
17
16
  priority: "high",
18
- sendEmail: true,
19
17
  },
20
18
  delivered: {
21
19
  type: "order_delivered",
22
20
  title: ORDER_MESSAGES.DELIVERED_TITLE,
23
21
  message: (o) => ORDER_MESSAGES.DELIVERED_MESSAGE(o.productTitle),
24
22
  priority: "normal",
25
- sendEmail: true,
26
23
  },
27
24
  cancelled: {
28
25
  type: "order_cancelled",
29
26
  title: ORDER_MESSAGES.CANCELLED_TITLE,
30
27
  message: (o) => `Your order for "${o.productTitle}" has been cancelled.`,
31
28
  priority: "normal",
32
- sendEmail: false,
33
29
  },
34
30
  };
35
- async function sendResendEmail(params) {
36
- const subject = params.status === "confirmed"
37
- ? EMAIL_SUBJECTS.ORDER_CONFIRMED(params.productTitle)
38
- : params.status === "shipped"
39
- ? EMAIL_SUBJECTS.ORDER_SHIPPED(params.productTitle)
40
- : params.status === "delivered"
41
- ? EMAIL_SUBJECTS.ORDER_DELIVERED(params.productTitle)
42
- : EMAIL_SUBJECTS.ORDER_UPDATE_FALLBACK(params.productTitle);
43
- const response = await fetch("https://api.resend.com/emails", {
44
- method: "POST",
45
- headers: {
46
- Authorization: `Bearer ${params.apiKey}`,
47
- "Content-Type": "application/json",
48
- },
49
- body: JSON.stringify({
50
- from: params.fromAddress,
51
- to: [params.to],
52
- subject,
53
- html: `<p>Hi,</p><p>Your order for <strong>${params.productTitle}</strong> is now <strong>${params.status}</strong>.</p>${params.trackingNumber ? `<p>Tracking number: ${params.trackingNumber}</p>` : ""}<p>Thanks,<br/>${params.brandName} Team</p>`,
54
- }),
55
- });
56
- if (!response.ok) {
57
- const body = await response.text();
58
- throw new Error(JOB_ERROR_MESSAGES.RESEND_API_ERROR(response.status, body));
59
- }
60
- }
61
- async function sendStatusChangeEmail(ctx, userEmail, newStatus, orderId, after, apiKey) {
62
- try {
63
- const fromAddress = ctx.env("ORDER_EMAIL_FROM") ?? ctx.env("RESEND_FROM_ADDRESS") ?? "";
64
- const brandName = ctx.env("APPKIT_BRAND_NAME") ?? "";
65
- if (!fromAddress || !brandName) {
66
- ctx.logger.error("ORDER_EMAIL_FROM / APPKIT_BRAND_NAME not configured — skipping order status email", null, { orderId });
67
- }
68
- else {
69
- await sendResendEmail({
70
- to: userEmail,
71
- status: newStatus,
72
- orderId,
73
- productTitle: after.productTitle,
74
- trackingNumber: after.trackingNumber,
75
- apiKey,
76
- fromAddress,
77
- brandName,
78
- });
79
- }
80
- }
81
- catch (emailError) {
82
- ctx.logger.error("Email send failed (non-fatal)", emailError, { orderId });
83
- }
84
- }
85
31
  export async function handleOrderStatusChange(input, ctx) {
86
32
  const { orderId, before, after } = input;
87
33
  if (!before || !after)
@@ -100,7 +46,7 @@ export async function handleOrderStatusChange(input, ctx) {
100
46
  productTitle: after.productTitle,
101
47
  trackingNumber: after.trackingNumber,
102
48
  });
103
- await notificationRepository.create({
49
+ await sendNotification({
104
50
  userId: after.userId,
105
51
  type: config.type,
106
52
  priority: config.priority,
@@ -108,6 +54,7 @@ export async function handleOrderStatusChange(input, ctx) {
108
54
  message: messageText,
109
55
  relatedId: orderId,
110
56
  relatedType: "order",
57
+ userEmail,
111
58
  });
112
59
  try {
113
60
  await getAdminRealtimeDb().ref(`notifications/${after.userId}`).push({
@@ -121,19 +68,7 @@ export async function handleOrderStatusChange(input, ctx) {
121
68
  catch (rtdbError) {
122
69
  ctx.logger.error("Realtime DB push failed (non-fatal)", rtdbError);
123
70
  }
124
- if (config.sendEmail) {
125
- const apiKey = ctx.env("RESEND_API_KEY") ?? "";
126
- if (!apiKey) {
127
- ctx.logger.error(JOB_ERROR_MESSAGES.RESEND_KEY_MISSING, null);
128
- }
129
- else {
130
- await sendStatusChangeEmail(ctx, userEmail, newStatus, orderId, after, apiKey);
131
- }
132
- }
133
- ctx.logger.info(`Order ${orderId} status → ${newStatus}`, {
134
- userId: after.userId,
135
- emailSent: config.sendEmail,
136
- });
71
+ ctx.logger.info(`Order ${orderId} status → ${newStatus}`, { userId: after.userId });
137
72
  }
138
73
  catch (error) {
139
74
  ctx.logger.error("Error handling order status change", error, { orderId });
@@ -1,4 +1,4 @@
1
- import { notificationRepository } from "../../../../repositories";
1
+ import { sendNotification } from "../../../../features/admin/actions/notification-actions";
2
2
  export async function handleSupportTicketCreate(input, ctx) {
3
3
  const { ticketId, ticket } = input;
4
4
  const userId = ticket.userId;
@@ -6,17 +6,15 @@ export async function handleSupportTicketCreate(input, ctx) {
6
6
  ctx.logger.warn("onSupportTicketCreate: no userId on ticket", { ticketId });
7
7
  return;
8
8
  }
9
- // Confirm to the user their ticket was received
10
9
  try {
11
- await notificationRepository.create({
10
+ await sendNotification({
12
11
  userId,
13
12
  type: "account_action",
13
+ priority: "normal",
14
14
  title: "Support ticket received",
15
- body: `We received your support request: "${ticket.subject ?? "your ticket"}". We'll get back to you soon.`,
16
- isRead: false,
17
- entityId: ticketId,
18
- entityType: "support_ticket",
19
- createdAt: new Date(),
15
+ message: `We received your support request: "${ticket.subject ?? "your ticket"}". We'll get back to you soon.`,
16
+ relatedId: ticketId,
17
+ relatedType: "support_ticket",
20
18
  });
21
19
  }
22
20
  catch (err) {
@@ -1,4 +1,4 @@
1
- import { notificationRepository } from "../../../../repositories";
1
+ import { sendNotification } from "../../../../features/admin/actions/notification-actions";
2
2
  const USER_NOTIFY_STATUSES = new Set(["resolved", "closed", "waiting_on_user", "in_progress"]);
3
3
  const STATUS_MESSAGES = {
4
4
  resolved: {
@@ -32,15 +32,14 @@ export async function handleSupportTicketUpdate(input, ctx) {
32
32
  if (!msg)
33
33
  return;
34
34
  try {
35
- await notificationRepository.create({
35
+ await sendNotification({
36
36
  userId,
37
37
  type: "account_action",
38
+ priority: "normal",
38
39
  title: msg.title,
39
- body: msg.body(subject),
40
- isRead: false,
41
- entityId: ticketId,
42
- entityType: "support_ticket",
43
- createdAt: new Date(),
40
+ message: msg.body(subject),
41
+ relatedId: ticketId,
42
+ relatedType: "support_ticket",
44
43
  });
45
44
  }
46
45
  catch (err) {
@@ -1,4 +1,5 @@
1
- import { notificationRepository, orderRepository } from "../../../../repositories";
1
+ import { orderRepository } from "../../../../repositories";
2
+ import { sendNotification } from "../../../../features/admin/actions/notification-actions";
2
3
  import { ORDER_MESSAGES } from "../handlers/messages";
3
4
  const DEFAULT_TIMEOUT_HOURS = 24;
4
5
  export async function runPendingOrderTimeout(ctx) {
@@ -12,16 +13,16 @@ export async function runPendingOrderTimeout(ctx) {
12
13
  const batch = ctx.db.batch();
13
14
  for (const entry of timedOut) {
14
15
  orderRepository.cancelInBatch(batch, entry.ref);
15
- notificationRepository.createInBatch(batch, {
16
- userId: entry.data.userId,
17
- type: "order_cancelled",
18
- priority: "normal",
19
- title: ORDER_MESSAGES.CANCELLED_TITLE,
20
- message: ORDER_MESSAGES.CANCELLED_TIMEOUT_MESSAGE(entry.data.productTitle, timeoutHours),
21
- relatedId: entry.data.id,
22
- relatedType: "order",
23
- });
24
16
  }
25
17
  await batch.commit();
18
+ await Promise.allSettled(timedOut.map((entry) => sendNotification({
19
+ userId: entry.data.userId,
20
+ type: "order_cancelled",
21
+ priority: "normal",
22
+ title: ORDER_MESSAGES.CANCELLED_TITLE,
23
+ message: ORDER_MESSAGES.CANCELLED_TIMEOUT_MESSAGE(entry.data.productTitle, timeoutHours),
24
+ relatedId: entry.data.id,
25
+ relatedType: "order",
26
+ })));
26
27
  ctx.logger.info("Pending order timeout complete", { cancelled: timedOut.length });
27
28
  }
@@ -1,4 +1,4 @@
1
- import { notificationRepository } from "../../../../repositories";
1
+ import { sendNotification } from "../../../../features/admin/actions/notification-actions";
2
2
  import { ORDER_FIELDS, PRODUCT_FIELDS, COMMON_FIELDS } from "../../../../constants/field-names";
3
3
  const ORDER_COLLECTION = "orders";
4
4
  export async function runPrizeRevealExpiry(ctx) {
@@ -34,7 +34,7 @@ export async function runPrizeRevealExpiry(ctx) {
34
34
  });
35
35
  if (order.userId) {
36
36
  try {
37
- await notificationRepository.create({
37
+ await sendNotification({
38
38
  userId: order.userId,
39
39
  type: "prize_reveal_expired",
40
40
  priority: "high",
@@ -1,4 +1,4 @@
1
- import { notificationRepository } from "../../../../repositories";
1
+ import { sendNotification } from "../../../../features/admin/actions/notification-actions";
2
2
  import { PRODUCT_FIELDS, ORDER_FIELDS, COMMON_FIELDS } from "../../../../constants/field-names";
3
3
  const PRODUCT_COLLECTION = "products";
4
4
  const ORDER_COLLECTION = "orders";
@@ -38,7 +38,7 @@ export async function runPrizeRevealOpen(ctx) {
38
38
  if (!order.userId || order.prizeWon)
39
39
  continue;
40
40
  try {
41
- await notificationRepository.create({
41
+ await sendNotification({
42
42
  userId: order.userId,
43
43
  type: "prize_reveal_ready",
44
44
  priority: "high",