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(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) */ async listActive(type?: string) { const where: Prisma.ProductWhereInput = { isActive: true }; if (type) where.type = type as Prisma.EnumProductTypeFilter['equals']; const products = await prisma.product.findMany({ where, orderBy: { createdAt: 'desc' }, }); return products.map(resolveMediaUrls); }, /** 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(); const product = await prisma.product.findUnique({ where: { id: productId } }); if (!product || !product.isActive) throw new Error('Product not found or inactive'); if (product.maxPurchases && product.purchaseCount >= product.maxPurchases) { throw new Error('Product is sold out'); } 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 }); } return prisma.order.update({ where: { id: orderId }, data: { status: 'REFUNDED' }, }); }, };