changemaker.lite/api/src/modules/payments/products.service.ts
bunker-admin d010993994 Add pagination to public endpoints, Pangolin site picker, and docs editor toolbar
- Paginate public APIs: campaigns, petitions, shifts, products, pages, shop
- Add safety caps (take limits) to gallery ads, cuts, plans, donation pages
- Add Pangolin connect-site endpoint with .env writer and site ID validation
- Add formatting toolbar + keyboard shortcuts to shared doc editor
- Fix Dockerfile to support su-exec privilege dropping for mounted volumes
- Fix duplicate WebSocket headers in nginx API location block
- Update MkDocs site build and social card assets

Bunker Admin
2026-04-07 16:50:20 -06:00

398 lines
13 KiB
TypeScript

import { prisma } from '../../config/database';
import { getStripe } from '../../services/stripe.client';
import { env } from '../../config/env';
import type { Prisma, OrderStatus, ProductType } from '@prisma/client';
import { logger } from '../../utils/logger';
/** Resolve media IDs to public-facing URLs on a product */
function resolveMediaUrls<T extends { imageUrl: string | null; photoId: number | null; videoId: number | null; galleryPhotoIds: unknown }>(product: T) {
const photoIds = Array.isArray(product.galleryPhotoIds) ? product.galleryPhotoIds as number[] : null;
return {
...product,
// Prefer gallery photo over external URL
resolvedImageUrl: product.photoId
? `/media/public/photos/${product.photoId}/image?size=medium`
: product.imageUrl ?? null,
thumbnailUrl: product.photoId
? `/media/public/photos/${product.photoId}/thumbnail`
: null,
// Promotional video
videoThumbnailUrl: product.videoId
? `/media/videos/${product.videoId}/thumbnail`
: null,
videoStreamUrl: product.videoId
? `/media/videos/${product.videoId}/stream`
: null,
// Gallery photo array
galleryImages: photoIds
? photoIds.map((id: number) => ({
photoId: id,
thumbnailUrl: `/media/public/photos/${id}/thumbnail`,
imageUrl: `/media/public/photos/${id}/image?size=medium`,
}))
: null,
};
}
/** Map product type to gallery ad defaults */
function productAdDefaults(product: { title: string; description: string | null; type: ProductType; slug: string; imageUrl: string | null; photoId: number | null; priceCAD: number }) {
const priceStr = `$${(product.priceCAD / 100).toFixed(2)}`;
// Prefer gallery photo URL for ad image
const imagePath = product.photoId
? `/media/public/photos/${product.photoId}/image?size=medium`
: product.imageUrl ?? null;
const base = {
title: product.title,
subtitle: product.description
? product.description.slice(0, 120) + (product.description.length > 120 ? '...' : '')
: null,
imagePath,
variant: 'standard',
visibility: 'everyone',
isActive: false, // admin enables manually
isSystemAd: false,
frequency: 12,
position: 10,
};
switch (product.type) {
case 'DONATION':
return {
...base,
type: 'payment_donate',
linkUrl: '/donate',
ctaText: 'Donate Now',
ctaStyle: 'primary',
iconEmoji: null,
};
case 'EVENT':
return {
...base,
type: 'payment_shop',
linkUrl: `/shop/${product.slug}`,
ctaText: `Get Tickets \u2022 ${priceStr}`,
ctaStyle: 'primary',
iconEmoji: null,
};
default: // DIGITAL
return {
...base,
type: 'payment_shop',
linkUrl: `/shop/${product.slug}`,
ctaText: `Buy Now \u2022 ${priceStr}`,
ctaStyle: 'primary',
iconEmoji: null,
};
}
}
export const productsService = {
/** List active products (public, paginated) */
async listActive(type?: string, page: number = 1, limit: number = 20) {
const where: Prisma.ProductWhereInput = { isActive: true };
if (type) where.type = type as Prisma.EnumProductTypeFilter['equals'];
const skip = (page - 1) * limit;
const [products, total] = await Promise.all([
prisma.product.findMany({
where,
orderBy: { createdAt: 'desc' },
skip,
take: limit,
}),
prisma.product.count({ where }),
]);
return {
products: products.map(resolveMediaUrls),
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
/** List all products (admin) */
async listAll(filters: { page: number; limit: number; type?: string; search?: string }) {
const { page, limit, type, search } = filters;
const where: Prisma.ProductWhereInput = {};
if (type) where.type = type as Prisma.EnumProductTypeFilter['equals'];
if (search) {
where.OR = [
{ title: { contains: search, mode: 'insensitive' } },
{ slug: { contains: search, mode: 'insensitive' } },
];
}
const [products, total] = await Promise.all([
prisma.product.findMany({
where,
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.product.count({ where }),
]);
return {
products: products.map(resolveMediaUrls),
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
async getById(id: string) {
const product = await prisma.product.findUnique({ where: { id } });
return product ? resolveMediaUrls(product) : null;
},
/** Get a single active product by slug (public detail page) */
async getBySlug(slug: string) {
const product = await prisma.product.findUnique({ where: { slug } });
if (!product || !product.isActive) return null;
return resolveMediaUrls(product);
},
async create(data: Prisma.ProductUncheckedCreateInput) {
const product = await prisma.product.create({ data });
// Auto-create a linked gallery ad only if the feature is enabled
try {
const settings = await prisma.siteSettings.findFirst();
if (settings?.enableGalleryAds) {
const adData = productAdDefaults(product);
await prisma.ad.create({
data: { ...adData, productId: product.id },
});
logger.info(`Auto-created gallery ad for product "${product.title}" (${product.id})`);
}
} catch (err) {
// Non-critical — log but don't fail the product creation
logger.warn(`Failed to auto-create gallery ad for product ${product.id}: ${err}`);
}
return resolveMediaUrls(product);
},
async update(id: string, data: Prisma.ProductUncheckedUpdateInput) {
const product = await prisma.product.update({ where: { id }, data });
// Sync linked gallery ad with updated product info (race-safe: updateMany is a no-op if no ad exists)
try {
const updates: Prisma.AdUncheckedUpdateManyInput = { updatedAt: new Date() };
if (data.title !== undefined) updates.title = data.title;
if (data.description !== undefined) {
const desc = typeof data.description === 'string' ? data.description : null;
updates.subtitle = desc ? desc.slice(0, 120) + (desc.length > 120 ? '...' : '') : null;
}
// Prefer photoId URL for ad image, fall back to imageUrl
if (data.photoId !== undefined || data.imageUrl !== undefined) {
const pid = data.photoId !== undefined ? data.photoId as number | null : product.photoId;
updates.imagePath = pid
? `/media/public/photos/${pid}/image?size=medium`
: (data.imageUrl !== undefined ? data.imageUrl as string | null : product.imageUrl);
}
if (data.isActive === false) updates.isActive = false;
if (data.slug !== undefined) {
// Update link URL for non-donation products
updates.linkUrl = `/shop/${data.slug}`;
}
if (data.priceCAD !== undefined) {
const price = typeof data.priceCAD === 'number' ? data.priceCAD : product.priceCAD;
const priceStr = `$${(price / 100).toFixed(2)}`;
const verb = product.type === 'EVENT' ? 'Get Tickets' : 'Buy Now';
updates.ctaText = `${verb} \u2022 ${priceStr}`;
}
// updateMany with productId filter: no-op if no linked ad exists (no error thrown)
// Avoids race condition of find-then-update pattern
await prisma.ad.updateMany({
where: { productId: id },
data: updates,
});
} catch (err) {
logger.warn(`Failed to sync gallery ad for product ${id}: ${err}`);
}
return resolveMediaUrls(product);
},
async delete(id: string) {
// Deactivate linked gallery ad
try {
const linkedAd = await prisma.ad.findUnique({ where: { productId: id } });
if (linkedAd) {
await prisma.ad.update({
where: { productId: id },
data: { isActive: false, updatedAt: new Date() },
});
}
} catch (err) {
logger.warn(`Failed to deactivate gallery ad for product ${id}: ${err}`);
}
const orders = await prisma.order.count({ where: { productId: id, status: 'COMPLETED' } });
if (orders > 0) {
// Soft delete by deactivating
return prisma.product.update({ where: { id }, data: { isActive: false } });
}
return prisma.product.delete({ where: { id } });
},
/** Create Stripe Checkout for a product purchase */
async createProductCheckout(productId: string, buyerEmail: string, buyerName?: string, userId?: string) {
const stripe = await getStripe();
// Atomic availability check to prevent overselling under concurrency
const product = await prisma.$transaction(async (tx) => {
const p = await tx.product.findUnique({ where: { id: productId } });
if (!p || !p.isActive) throw new Error('Product not found or inactive');
if (p.maxPurchases && p.purchaseCount >= p.maxPurchases) {
throw new Error('Product is sold out');
}
return p;
});
const session = await stripe.checkout.sessions.create({
mode: 'payment',
line_items: [{
price_data: {
currency: 'cad',
product_data: {
name: product.title,
description: product.description || undefined,
},
unit_amount: product.priceCAD,
},
quantity: 1,
}],
customer_email: buyerEmail,
success_url: `${env.ADMIN_URL}/payments/success?session_id={CHECKOUT_SESSION_ID}`,
cancel_url: `${env.ADMIN_URL}/shop`,
metadata: {
type: 'product',
productId: product.id,
userId: userId || '',
buyerEmail,
buyerName: buyerName || '',
},
});
// Create pending order
await prisma.order.create({
data: {
userId: userId || null,
productId: product.id,
amountCAD: product.priceCAD,
status: 'PENDING',
stripeCheckoutSessionId: session.id,
type: 'product',
buyerEmail,
buyerName: buyerName || null,
},
});
return { sessionId: session.id, url: session.url };
},
/** Sync product to Stripe */
async syncProductToStripe(id: string) {
const stripe = await getStripe();
const product = await prisma.product.findUnique({ where: { id } });
if (!product) throw new Error('Product not found');
let stripeProductId = product.stripeProductId;
if (stripeProductId) {
await stripe.products.update(stripeProductId, {
name: product.title,
description: product.description || undefined,
active: product.isActive,
});
} else {
const sp = await stripe.products.create({
name: product.title,
description: product.description || undefined,
active: product.isActive,
metadata: { productId: product.id },
});
stripeProductId = sp.id;
}
let stripePriceId = product.stripePriceId;
if (!stripePriceId) {
const price = await stripe.prices.create({
product: stripeProductId,
unit_amount: product.priceCAD,
currency: 'cad',
});
stripePriceId = price.id;
}
return prisma.product.update({
where: { id },
data: { stripeProductId, stripePriceId },
});
},
/** List orders (admin) */
async listOrders(filters: {
page: number;
limit: number;
status?: OrderStatus;
type?: string;
search?: string;
}) {
const { page, limit, status, type, search } = filters;
const where: Prisma.OrderWhereInput = {};
if (status) where.status = status;
if (type) where.type = type;
if (search) {
where.OR = [
{ buyerEmail: { contains: search, mode: 'insensitive' } },
{ buyerName: { contains: search, mode: 'insensitive' } },
];
}
const [orders, total] = await Promise.all([
prisma.order.findMany({
where,
include: {
product: { select: { id: true, title: true, slug: true, type: true } },
user: { select: { id: true, email: true, name: true } },
},
skip: (page - 1) * limit,
take: limit,
orderBy: { createdAt: 'desc' },
}),
prisma.order.count({ where }),
]);
return {
orders,
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
};
},
/** Refund an order via Stripe */
async refundOrder(orderId: string) {
const order = await prisma.order.findUnique({ where: { id: orderId } });
if (!order) throw new Error('Order not found');
if (order.status !== 'COMPLETED') throw new Error('Can only refund completed orders');
if (order.stripePaymentIntentId) {
const stripe = await getStripe();
await stripe.refunds.create({ payment_intent: order.stripePaymentIntentId });
}
// Stripe refund succeeded — update DB. If this fails, the charge.refunded
// webhook will reconcile the status as a fallback.
try {
return await prisma.order.update({
where: { id: orderId },
data: { status: 'REFUNDED' },
});
} catch (dbErr) {
logger.error(`Stripe refund succeeded but DB update failed for order ${orderId}. Webhook will reconcile.`, dbErr);
throw new Error('Refund processed by Stripe but local status update failed. It will be reconciled shortly.');
}
},
};