- 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
398 lines
13 KiB
TypeScript
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.');
|
|
}
|
|
},
|
|
};
|