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
This commit is contained in:
@@ -25,6 +25,8 @@ RUN npm run build
|
||||
# Production stage
|
||||
FROM node:22-alpine AS production
|
||||
WORKDIR /app
|
||||
# su-exec for dropping privileges after fixing mounted volume permissions
|
||||
RUN apk add --no-cache su-exec
|
||||
# Copy compiled output and manifests
|
||||
COPY --from=build /app/dist ./dist
|
||||
COPY --from=build /app/package.json ./
|
||||
@@ -37,8 +39,10 @@ COPY --from=build /app/tsconfig.json ./
|
||||
RUN npm ci --omit=dev && npm install tsx && npx prisma generate
|
||||
COPY --from=build /app/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh \
|
||||
&& mkdir -p /app/uploads && chown -R node:node /app/uploads
|
||||
&& mkdir -p /app/uploads /app/logs /data/geoip \
|
||||
&& chown -R node:node /app/uploads /app/logs /data/geoip
|
||||
|
||||
USER node
|
||||
# Note: USER node is NOT set here — entrypoint runs as root to fix
|
||||
# mounted volume permissions, then drops to node via su-exec
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["npm", "start"]
|
||||
|
||||
@@ -0,0 +1,3 @@
|
||||
-- Change default for public map toggles so fresh installs start with map disabled
|
||||
ALTER TABLE "map_settings" ALTER COLUMN "publicMapEnabled" SET DEFAULT false;
|
||||
ALTER TABLE "site_settings" ALTER COLUMN "enableMap" SET DEFAULT false;
|
||||
@@ -142,6 +142,7 @@ class GalleryAdsService {
|
||||
],
|
||||
},
|
||||
orderBy: { position: 'asc' },
|
||||
take: 100, // Safety cap: post-fetch filter may reduce further
|
||||
});
|
||||
|
||||
// Filter in application layer for complex logic
|
||||
|
||||
@@ -5,13 +5,15 @@ import { redis } from '../../../config/redis';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/campaigns/public — list all active campaigns (public)
|
||||
// GET /api/campaigns/public?page=1&limit=20 — list active campaigns (public, paginated)
|
||||
router.get(
|
||||
'/public',
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const campaigns = await campaignsService.findActiveCampaigns();
|
||||
res.json(campaigns);
|
||||
const page = Math.max(parseInt(req.query.page as string) || 1, 1);
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
|
||||
const result = await campaignsService.findActiveCampaigns(page, limit);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
@@ -256,15 +256,28 @@ export const campaignsService = {
|
||||
return campaign;
|
||||
},
|
||||
|
||||
async findActiveCampaigns() {
|
||||
return prisma.campaign.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
select: publicCampaignSelect,
|
||||
orderBy: [
|
||||
{ highlightCampaign: 'desc' },
|
||||
{ createdAt: 'desc' },
|
||||
],
|
||||
});
|
||||
async findActiveCampaigns(page: number = 1, limit: number = 20) {
|
||||
const where = { status: 'ACTIVE' as const };
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [campaigns, total] = await Promise.all([
|
||||
prisma.campaign.findMany({
|
||||
where,
|
||||
select: publicCampaignSelect,
|
||||
orderBy: [
|
||||
{ highlightCampaign: 'desc' },
|
||||
{ createdAt: 'desc' },
|
||||
],
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.campaign.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
campaigns,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
};
|
||||
},
|
||||
|
||||
async findBySlugPublic(slug: string) {
|
||||
|
||||
@@ -6,13 +6,15 @@ import { petitionSignRateLimit } from '../../../middleware/rate-limit';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/petitions/public — list active petitions
|
||||
// GET /api/petitions/public?page=1&limit=20 — list active petitions (paginated)
|
||||
router.get(
|
||||
'/public',
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const petitions = await petitionsService.findActivePetitions();
|
||||
res.json(petitions);
|
||||
const page = Math.max(parseInt(req.query.page as string) || 1, 1);
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
|
||||
const result = await petitionsService.findActivePetitions(page, limit);
|
||||
res.json(result);
|
||||
} catch (err) { next(err); }
|
||||
}
|
||||
);
|
||||
|
||||
@@ -251,21 +251,34 @@ export const petitionsService = {
|
||||
|
||||
// ─── Public Routes ───────────────────────────────────────────────────
|
||||
|
||||
async findActivePetitions() {
|
||||
return prisma.petition.findMany({
|
||||
where: {
|
||||
status: 'ACTIVE',
|
||||
OR: [
|
||||
{ isUserGenerated: false },
|
||||
{ isUserGenerated: true, moderationStatus: 'APPROVED' },
|
||||
],
|
||||
},
|
||||
select: publicPetitionSelect,
|
||||
orderBy: [
|
||||
{ highlightPetition: 'desc' },
|
||||
{ createdAt: 'desc' },
|
||||
async findActivePetitions(page: number = 1, limit: number = 20) {
|
||||
const where = {
|
||||
status: 'ACTIVE' as const,
|
||||
OR: [
|
||||
{ isUserGenerated: false },
|
||||
{ isUserGenerated: true, moderationStatus: 'APPROVED' as const },
|
||||
],
|
||||
});
|
||||
};
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [petitions, total] = await Promise.all([
|
||||
prisma.petition.findMany({
|
||||
where,
|
||||
select: publicPetitionSelect,
|
||||
orderBy: [
|
||||
{ highlightPetition: 'desc' },
|
||||
{ createdAt: 'desc' },
|
||||
],
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.petition.count({ where }),
|
||||
]);
|
||||
|
||||
return {
|
||||
petitions,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
};
|
||||
},
|
||||
|
||||
async findBySlugPublic(slug: string) {
|
||||
|
||||
@@ -175,12 +175,13 @@ adminRouter.get(
|
||||
// --- Public Router ---
|
||||
const publicRouter = Router();
|
||||
|
||||
// GET /api/map/cuts/public — all public cuts for map display
|
||||
// GET /api/map/cuts/public?limit=50 — public cuts for map display
|
||||
publicRouter.get(
|
||||
'/public',
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const cuts = await cutsService.getPublicCuts();
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const cuts = await cutsService.getPublicCuts(limit);
|
||||
res.json(cuts);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@@ -125,7 +125,7 @@ export const cutsService = {
|
||||
await prisma.cut.delete({ where: { id } });
|
||||
},
|
||||
|
||||
async getPublicCuts() {
|
||||
async getPublicCuts(limit: number = 50) {
|
||||
const cuts = await prisma.cut.findMany({
|
||||
where: { isPublic: true },
|
||||
select: {
|
||||
@@ -139,6 +139,7 @@ export const cutsService = {
|
||||
bounds: true,
|
||||
},
|
||||
orderBy: { name: 'asc' },
|
||||
take: limit,
|
||||
});
|
||||
return cuts;
|
||||
},
|
||||
|
||||
@@ -260,13 +260,15 @@ volunteerRouter.delete(
|
||||
// --- Public Router ---
|
||||
const publicRouter = Router();
|
||||
|
||||
// GET /api/map/shifts/public — list upcoming public shifts
|
||||
// GET /api/map/shifts/public?page=1&limit=20 — list upcoming public shifts (paginated)
|
||||
publicRouter.get(
|
||||
'/public',
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const shifts = await shiftsService.getPublicShifts();
|
||||
res.json(shifts);
|
||||
const page = Math.max(parseInt(req.query.page as string) || 1, 1);
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
|
||||
const result = await shiftsService.getPublicShifts(page, limit);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
@@ -1121,30 +1121,41 @@ export const shiftsService = {
|
||||
return signups;
|
||||
},
|
||||
|
||||
async getPublicShifts() {
|
||||
const shifts = await prisma.shift.findMany({
|
||||
where: {
|
||||
isPublic: true,
|
||||
status: { not: ShiftStatus.CANCELLED },
|
||||
date: { gte: new Date(new Date().toISOString().split('T')[0]) },
|
||||
},
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
date: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
location: true,
|
||||
maxVolunteers: true,
|
||||
currentVolunteers: true,
|
||||
status: true,
|
||||
meeting: { select: { id: true, slug: true, isActive: true } },
|
||||
},
|
||||
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
|
||||
});
|
||||
async getPublicShifts(page: number = 1, limit: number = 20) {
|
||||
const where = {
|
||||
isPublic: true,
|
||||
status: { not: ShiftStatus.CANCELLED },
|
||||
date: { gte: new Date(new Date().toISOString().split('T')[0]) },
|
||||
};
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
return shifts;
|
||||
const [shifts, total] = await Promise.all([
|
||||
prisma.shift.findMany({
|
||||
where,
|
||||
select: {
|
||||
id: true,
|
||||
title: true,
|
||||
description: true,
|
||||
date: true,
|
||||
startTime: true,
|
||||
endTime: true,
|
||||
location: true,
|
||||
maxVolunteers: true,
|
||||
currentVolunteers: true,
|
||||
status: true,
|
||||
meeting: { select: { id: true, slug: true, isActive: true } },
|
||||
},
|
||||
orderBy: [{ date: 'asc' }, { startTime: 'asc' }],
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.shift.count({ where: { isPublic: true, status: { not: ShiftStatus.CANCELLED }, date: { gte: new Date(new Date().toISOString().split('T')[0]) } } }),
|
||||
]);
|
||||
|
||||
return {
|
||||
shifts,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
};
|
||||
},
|
||||
|
||||
async emailShiftDetails(shiftId: string) {
|
||||
|
||||
@@ -4,23 +4,37 @@ import { pagesService } from './pages.service';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/pages/listed — get published + listed pages for public index (no auth)
|
||||
// GET /api/pages/listed?page=1&limit=20 — get published + listed pages for public index (no auth, paginated)
|
||||
router.get(
|
||||
'/listed',
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const pages = await prisma.landingPage.findMany({
|
||||
where: { published: true, listed: true },
|
||||
select: {
|
||||
slug: true,
|
||||
title: true,
|
||||
description: true,
|
||||
seoImage: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
const page = Math.max(parseInt(req.query.page as string) || 1, 1);
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
|
||||
const where = { published: true, listed: true };
|
||||
const skip = (page - 1) * limit;
|
||||
|
||||
const [pages, total] = await Promise.all([
|
||||
prisma.landingPage.findMany({
|
||||
where,
|
||||
select: {
|
||||
slug: true,
|
||||
title: true,
|
||||
description: true,
|
||||
seoImage: true,
|
||||
updatedAt: true,
|
||||
},
|
||||
orderBy: { updatedAt: 'desc' },
|
||||
skip,
|
||||
take: limit,
|
||||
}),
|
||||
prisma.landingPage.count({ where }),
|
||||
]);
|
||||
|
||||
res.json({
|
||||
pages,
|
||||
pagination: { page, limit, total, totalPages: Math.ceil(total / limit) },
|
||||
});
|
||||
res.json(pages);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
@@ -172,14 +172,44 @@ const router = Router();
|
||||
router.use(authenticate);
|
||||
router.use(requireRole('SUPER_ADMIN'));
|
||||
|
||||
// GET /api/pangolin/status — Health + connection info
|
||||
// GET /api/pangolin/status — Health + connection info + site ID validation
|
||||
router.get('/status', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const configured = pangolinClient.configured;
|
||||
let healthy = false;
|
||||
let siteIdValid: boolean | null = null;
|
||||
let resolvedSiteId: string | null = null;
|
||||
let siteIdMismatch = false;
|
||||
|
||||
if (configured) {
|
||||
healthy = await pangolinClient.healthCheck();
|
||||
|
||||
// Validate site ID by checking if it exists in the org
|
||||
// Pangolin returns siteId as a number; env stores it as a string — compare with String()
|
||||
if (healthy) {
|
||||
const envSiteId = env.PANGOLIN_SITE_ID;
|
||||
if (envSiteId) {
|
||||
try {
|
||||
const sites = await pangolinClient.listSites();
|
||||
|
||||
// Match env siteId against org sites (coerce types for comparison)
|
||||
const match = sites.find(s =>
|
||||
String(s.siteId) === envSiteId || s.niceId === envSiteId
|
||||
);
|
||||
|
||||
if (match) {
|
||||
siteIdValid = true;
|
||||
resolvedSiteId = String(match.siteId);
|
||||
} else {
|
||||
siteIdValid = false;
|
||||
siteIdMismatch = true;
|
||||
logger.warn(`PANGOLIN_SITE_ID "${envSiteId}" not found in org (${sites.length} sites available)`);
|
||||
}
|
||||
} catch (err) {
|
||||
logger.warn('Could not validate site ID (non-critical):', err instanceof Error ? err.message : err);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
res.json({
|
||||
@@ -189,6 +219,9 @@ router.get('/status', async (_req: Request, res: Response) => {
|
||||
orgId: env.PANGOLIN_ORG_ID || null,
|
||||
siteId: env.PANGOLIN_SITE_ID || null,
|
||||
newtConfigured: !!(env.PANGOLIN_NEWT_ID && env.PANGOLIN_NEWT_SECRET),
|
||||
siteIdValid,
|
||||
resolvedSiteId,
|
||||
siteIdMismatch,
|
||||
});
|
||||
} catch (err) {
|
||||
logger.error('Pangolin status check failed:', err);
|
||||
@@ -265,17 +298,95 @@ router.post('/newt-restart', async (_req: Request, res: Response) => {
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/pangolin/sites — List sites
|
||||
// GET /api/pangolin/sites — List sites (with newtId matching for site picker)
|
||||
router.get('/sites', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
const sites = await pangolinClient.listSites();
|
||||
res.json({ sites });
|
||||
const currentNewtId = env.PANGOLIN_NEWT_ID || null;
|
||||
const currentSiteId = env.PANGOLIN_SITE_ID || null;
|
||||
|
||||
// Annotate each site with whether it matches current env config
|
||||
// Pangolin returns siteId as a number; env stores it as a string — compare with String()
|
||||
const annotatedSites = sites.map(s => ({
|
||||
...s,
|
||||
isCurrentSite: currentSiteId
|
||||
? (String(s.siteId) === currentSiteId || s.niceId === currentSiteId)
|
||||
: false,
|
||||
}));
|
||||
|
||||
res.json({ sites: annotatedSites, currentNewtId, currentSiteId });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
// POST /api/pangolin/connect-site — Connect to an existing site (write site ID + newt creds to .env)
|
||||
const connectSiteSchema = z.object({
|
||||
siteId: z.union([z.string().min(1).max(200), z.number().int().positive()]).transform(String),
|
||||
});
|
||||
|
||||
router.post('/connect-site', pangolinSetupLimiter, async (req: Request, res: Response) => {
|
||||
try {
|
||||
if (!pangolinClient.configured) {
|
||||
res.status(400).json({
|
||||
error: { message: 'Pangolin not configured', code: 'NOT_CONFIGURED' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const { siteId } = connectSiteSchema.parse(req.body);
|
||||
|
||||
// Verify the site exists in the org
|
||||
const sites = await pangolinClient.listSites();
|
||||
const site = sites.find(s => String(s.siteId) === siteId || s.niceId === siteId);
|
||||
|
||||
if (!site) {
|
||||
res.status(404).json({
|
||||
error: { message: `Site "${siteId}" not found in organization`, code: 'SITE_NOT_FOUND' },
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
// Build env updates — always write the site ID (coerce to string for .env)
|
||||
const envUpdates: Record<string, string> = {
|
||||
PANGOLIN_SITE_ID: String(site.siteId),
|
||||
};
|
||||
|
||||
// If the site has a Pangolin endpoint, write that too
|
||||
if (env.PANGOLIN_API_URL) {
|
||||
// Derive the endpoint from the API URL (strip /v1 path)
|
||||
const endpoint = env.PANGOLIN_API_URL.replace(/\/v1\/?$/, '');
|
||||
envUpdates.PANGOLIN_ENDPOINT = endpoint;
|
||||
}
|
||||
|
||||
// Write to .env
|
||||
const envResult = updateEnvFile(envUpdates);
|
||||
|
||||
logger.info(`Connected to Pangolin site: ${site.siteId} (name: ${site.name})`);
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
site: {
|
||||
siteId: site.siteId,
|
||||
name: site.name,
|
||||
niceId: site.niceId,
|
||||
online: site.online,
|
||||
},
|
||||
envUpdate: envResult,
|
||||
message: `Connected to site "${site.name}". Restart the API container to apply the new PANGOLIN_SITE_ID.`,
|
||||
});
|
||||
} catch (err) {
|
||||
if (err instanceof z.ZodError) {
|
||||
res.status(400).json({ error: { message: 'Invalid request body', code: 'VALIDATION_ERROR' } });
|
||||
return;
|
||||
}
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
logger.error('Connect site failed:', err);
|
||||
res.status(500).json({ error: { message: msg, code: 'PANGOLIN_ERROR' } });
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/pangolin/exit-nodes — List available exit nodes
|
||||
router.get('/exit-nodes', async (_req: Request, res: Response) => {
|
||||
try {
|
||||
|
||||
@@ -8,10 +8,11 @@ import { donationPageCheckoutSchema } from './donation-pages.schemas';
|
||||
|
||||
const router = Router();
|
||||
|
||||
// GET /api/donation-pages — list active pages (with stats)
|
||||
router.get('/', async (_req: Request, res: Response, next: NextFunction) => {
|
||||
// GET /api/donation-pages?limit=20 — list active pages (with stats)
|
||||
router.get('/', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const pages = await donationPagesService.findActivePages();
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
|
||||
const pages = await donationPagesService.findActivePages(limit);
|
||||
res.json(pages);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@@ -193,13 +193,14 @@ export const donationPagesService = {
|
||||
},
|
||||
|
||||
/** Public: list active donation pages with stats */
|
||||
async findActivePages() {
|
||||
async findActivePages(limit: number = 20) {
|
||||
const pages = await prisma.donationPage.findMany({
|
||||
where: { status: 'ACTIVE' },
|
||||
orderBy: [
|
||||
{ highlightPage: 'desc' },
|
||||
{ createdAt: 'desc' },
|
||||
],
|
||||
take: limit,
|
||||
});
|
||||
|
||||
return Promise.all(
|
||||
|
||||
@@ -36,10 +36,11 @@ router.get('/config', async (_req: Request, res: Response, next: NextFunction) =
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/payments/plans — list active subscription plans
|
||||
router.get('/plans', async (_req: Request, res: Response, next: NextFunction) => {
|
||||
// GET /api/payments/plans?limit=50 — list active subscription plans
|
||||
router.get('/plans', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const plans = await plansService.listActivePlans();
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 50, 100);
|
||||
const plans = await plansService.listActivePlans(limit);
|
||||
res.json(plans);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
@@ -57,12 +58,14 @@ router.get('/plans/:slug', async (req: Request, res: Response, next: NextFunctio
|
||||
}
|
||||
});
|
||||
|
||||
// GET /api/payments/products — list active products
|
||||
// GET /api/payments/products?page=1&limit=20 — list active products (paginated)
|
||||
router.get('/products', async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const type = req.query.type as string | undefined;
|
||||
const products = await productsService.listActive(type);
|
||||
res.json(products);
|
||||
const page = Math.max(parseInt(req.query.page as string) || 1, 1);
|
||||
const limit = Math.min(parseInt(req.query.limit as string) || 20, 50);
|
||||
const result = await productsService.listActive(type, page, limit);
|
||||
res.json(result);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
|
||||
@@ -152,13 +152,14 @@ export const plansService = {
|
||||
},
|
||||
|
||||
/** Public: list active plans for pricing page */
|
||||
async listActivePlans() {
|
||||
async listActivePlans(limit: number = 50) {
|
||||
return prisma.subscriptionPlan.findMany({
|
||||
where: { isActive: true },
|
||||
orderBy: [
|
||||
{ highlightPlan: 'desc' },
|
||||
{ displayOrder: 'asc' },
|
||||
],
|
||||
take: limit,
|
||||
});
|
||||
},
|
||||
|
||||
|
||||
@@ -88,15 +88,26 @@ function productAdDefaults(product: { title: string; description: string | null;
|
||||
}
|
||||
|
||||
export const productsService = {
|
||||
/** List active products (public) */
|
||||
async listActive(type?: string) {
|
||||
/** 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 products = await prisma.product.findMany({
|
||||
where,
|
||||
orderBy: { createdAt: 'desc' },
|
||||
});
|
||||
return products.map(resolveMediaUrls);
|
||||
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) */
|
||||
|
||||
Reference in New Issue
Block a user