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:
2026-04-07 16:50:20 -06:00
parent 513b8cfea5
commit d010993994
338 changed files with 6579 additions and 11740 deletions

View File

@@ -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"]

View File

@@ -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;

View File

@@ -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

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -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); }
}
);

View File

@@ -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) {

View File

@@ -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);

View File

@@ -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;
},

View File

@@ -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);
}

View File

@@ -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) {

View File

@@ -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);
}

View File

@@ -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 {

View File

@@ -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);

View File

@@ -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(

View File

@@ -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);
}

View File

@@ -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,
});
},

View File

@@ -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) */