More control panel updates
This commit is contained in:
@@ -30,6 +30,9 @@ COPY --from=build /app/node_modules ./node_modules
|
||||
COPY --from=build /app/package.json ./
|
||||
COPY --from=build /app/prisma ./prisma
|
||||
COPY --from=build /app/docker-entrypoint.sh /usr/local/bin/
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh
|
||||
RUN chmod +x /usr/local/bin/docker-entrypoint.sh \
|
||||
&& mkdir -p /app/uploads && chown -R node:node /app/uploads
|
||||
|
||||
USER node
|
||||
ENTRYPOINT ["docker-entrypoint.sh"]
|
||||
CMD ["npm", "start"]
|
||||
|
||||
@@ -1,13 +1,31 @@
|
||||
#!/bin/sh
|
||||
set -e
|
||||
|
||||
echo "Running Prisma schema sync..."
|
||||
npx prisma db push --skip-generate 2>&1
|
||||
echo "Schema sync complete."
|
||||
# Block NODE_TLS_REJECT_UNAUTHORIZED=0 in production
|
||||
if [ "$NODE_ENV" = "production" ] && [ "$NODE_TLS_REJECT_UNAUTHORIZED" = "0" ]; then
|
||||
echo "FATAL: NODE_TLS_REJECT_UNAUTHORIZED=0 is not allowed in production"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
echo "Running Prisma migrations..."
|
||||
npx prisma migrate deploy 2>&1 || {
|
||||
echo "Migration failed, falling back to schema push..."
|
||||
npx prisma db push --skip-generate 2>&1
|
||||
}
|
||||
echo "Database sync complete."
|
||||
|
||||
echo "Running database seed..."
|
||||
npx prisma db seed 2>&1
|
||||
echo "Seed complete."
|
||||
|
||||
# If running production mode (node dist/server.js) and dist is stale, recompile
|
||||
if [ -f "src/server.ts" ] && echo "$@" | grep -q "npm.*start\|node.*dist"; then
|
||||
if [ ! -f "dist/server.js" ] || [ "src/server.ts" -nt "dist/server.js" ]; then
|
||||
echo "Compiling TypeScript (dist/ is missing or stale)..."
|
||||
npx tsc 2>&1 || echo "WARNING: TypeScript compilation had errors"
|
||||
echo "Compilation complete."
|
||||
fi
|
||||
fi
|
||||
|
||||
echo "Starting server..."
|
||||
exec "$@"
|
||||
|
||||
@@ -189,6 +189,7 @@ model Campaign {
|
||||
emailBody String @db.Text
|
||||
callToAction String? @db.Text
|
||||
coverPhoto String?
|
||||
coverVideoId Int?
|
||||
status CampaignStatus @default(DRAFT)
|
||||
|
||||
// Feature flags
|
||||
|
||||
@@ -28,7 +28,7 @@ async function main() {
|
||||
console.warn('⚠️ INITIAL_ADMIN_PASSWORD contains placeholder value');
|
||||
console.warn('⚠️ Skipping admin user creation. Please set a real password in .env');
|
||||
} else {
|
||||
const hashedPassword = await bcrypt.hash(initialAdminPassword, 10);
|
||||
const hashedPassword = await bcrypt.hash(initialAdminPassword, 12);
|
||||
|
||||
admin = await prisma.user.upsert({
|
||||
where: { email: initialAdminEmail },
|
||||
@@ -311,6 +311,21 @@ async function main() {
|
||||
buttonText: 'Buy Now',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'default-campaign-form',
|
||||
type: 'campaign-form',
|
||||
label: 'Campaign Email Form',
|
||||
category: 'Influence',
|
||||
sortOrder: 13,
|
||||
schema: {
|
||||
campaignSlug: { type: 'string', label: 'Campaign Slug', required: true },
|
||||
compact: { type: 'boolean', label: 'Compact Mode', default: false },
|
||||
},
|
||||
defaults: {
|
||||
campaignSlug: '',
|
||||
compact: false,
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'default-gancio-events',
|
||||
type: 'gancio-events',
|
||||
|
||||
@@ -29,7 +29,7 @@ const envSchema = z.object({
|
||||
JWT_REFRESH_EXPIRY: z.string().default('7d'),
|
||||
|
||||
// Encryption (for DB-stored secrets like SMTP password; falls back to JWT_ACCESS_SECRET)
|
||||
ENCRYPTION_KEY: z.string().optional(),
|
||||
ENCRYPTION_KEY: z.string().min(32, 'ENCRYPTION_KEY must be at least 32 characters').optional(),
|
||||
|
||||
// Initial Super Admin (auto-created during database seeding)
|
||||
INITIAL_ADMIN_EMAIL: z.string().email().default('admin@cmlite.org'),
|
||||
|
||||
@@ -196,17 +196,19 @@ router.post(
|
||||
|
||||
const hashedPassword = await bcrypt.hash(password, 12);
|
||||
|
||||
// Update password, mark token used, invalidate all refresh tokens
|
||||
// Update password, mark token used, invalidate all refresh tokens — all in one transaction
|
||||
await prisma.$transaction(async (tx) => {
|
||||
await tx.user.update({
|
||||
where: { id: result.userId },
|
||||
data: { password: hashedPassword },
|
||||
});
|
||||
await tx.refreshToken.deleteMany({ where: { userId: result.userId } });
|
||||
await tx.passwordResetToken.update({
|
||||
where: { token },
|
||||
data: { usedAt: new Date() },
|
||||
});
|
||||
});
|
||||
|
||||
await passwordResetTokenService.markTokenUsed(token);
|
||||
|
||||
res.json({ message: 'Password has been reset. You can now log in with your new password.' });
|
||||
} catch (err) {
|
||||
next(err);
|
||||
|
||||
@@ -13,8 +13,11 @@ const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN,
|
||||
export const docsAnalyticsPublicRouter = Router();
|
||||
|
||||
// Per-route CORS override: MkDocs runs on a different origin (root domain vs API subdomain)
|
||||
import { env } from '../../config/env';
|
||||
const DOCS_ORIGIN = env.ADMIN_URL || `https://docs.${env.DOMAIN}`;
|
||||
docsAnalyticsPublicRouter.use((_req, res, next) => {
|
||||
res.setHeader('Access-Control-Allow-Origin', '*');
|
||||
res.setHeader('Access-Control-Allow-Origin', DOCS_ORIGIN);
|
||||
res.setHeader('Vary', 'Origin');
|
||||
res.setHeader('Access-Control-Allow-Methods', 'POST, OPTIONS');
|
||||
res.setHeader('Access-Control-Allow-Headers', 'Content-Type');
|
||||
next();
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { Router, Request, Response, NextFunction } from 'express';
|
||||
import multer from 'multer';
|
||||
import { rm } from 'fs/promises';
|
||||
import { extname } from 'path';
|
||||
import { extname, basename } from 'path';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireNonTemp, requireRole } from '../../middleware/rbac.middleware';
|
||||
import { env } from '../../config/env';
|
||||
@@ -172,6 +172,7 @@ const upload = multer({
|
||||
// POST /api/docs/upload — upload binary file (image, pdf, etc.)
|
||||
router.post(
|
||||
'/upload',
|
||||
requireRole('SUPER_ADMIN'),
|
||||
upload.single('file'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
const tempPath = req.file?.path;
|
||||
@@ -183,7 +184,7 @@ router.post(
|
||||
}
|
||||
|
||||
const targetDir = (req.body as { path?: string }).path || '';
|
||||
const fileName = req.file.originalname;
|
||||
const fileName = basename(req.file.originalname).replace(/[^a-zA-Z0-9._-]/g, '_');
|
||||
const relativePath = targetDir ? `${targetDir}/${fileName}` : fileName;
|
||||
|
||||
await docsFilesService.uploadFile(relativePath, req.file.path);
|
||||
@@ -223,6 +224,7 @@ router.get(
|
||||
// POST /api/docs/files/rename — rename/move file
|
||||
router.post(
|
||||
'/files/rename',
|
||||
requireRole('SUPER_ADMIN'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
cm_docs_operations.inc({ operation: 'rename' });
|
||||
@@ -261,6 +263,7 @@ router.get(
|
||||
// PUT /api/docs/files/* — write/update file content
|
||||
router.put(
|
||||
'/files/*',
|
||||
requireRole('SUPER_ADMIN'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
cm_docs_operations.inc({ operation: 'write' });
|
||||
@@ -285,6 +288,7 @@ router.put(
|
||||
// POST /api/docs/files/* — create new file or folder
|
||||
router.post(
|
||||
'/files/*',
|
||||
requireRole('SUPER_ADMIN'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
cm_docs_operations.inc({ operation: 'create' });
|
||||
@@ -305,6 +309,7 @@ router.post(
|
||||
// DELETE /api/docs/files/* — delete file or empty folder
|
||||
router.delete(
|
||||
'/files/*',
|
||||
requireRole('SUPER_ADMIN'),
|
||||
async (req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
cm_docs_operations.inc({ operation: 'delete' });
|
||||
|
||||
@@ -315,16 +315,26 @@ router.post(
|
||||
try {
|
||||
// This is a placeholder - the actual seeding is done via the script
|
||||
// But we keep this endpoint for manual triggering if needed
|
||||
const { exec } = require('child_process');
|
||||
const { promisify } = require('util');
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
const result = await execAsync('npx tsx src/scripts/seed-email-templates.ts', {
|
||||
const { spawn } = require('child_process');
|
||||
const child = spawn('npx', ['tsx', 'src/scripts/seed-email-templates.ts'], {
|
||||
cwd: '/app',
|
||||
shell: false,
|
||||
});
|
||||
|
||||
let exitCode = 0;
|
||||
await new Promise<void>((resolve) => {
|
||||
child.on('close', (code: number) => {
|
||||
exitCode = code;
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
|
||||
if (exitCode !== 0) {
|
||||
throw new Error(`Seed script exited with code ${exitCode}`);
|
||||
}
|
||||
|
||||
logger.info('Email templates seeded via API');
|
||||
res.json({ success: true, output: result.stdout });
|
||||
res.json({ success: true, message: 'Templates seeded successfully' });
|
||||
} catch (error) {
|
||||
logger.error('Error seeding templates:', error);
|
||||
res.status(500).json({ error: 'Failed to seed templates' });
|
||||
|
||||
@@ -2,11 +2,11 @@ import { z } from 'zod';
|
||||
import { CampaignStatus, CampaignModerationStatus, GovernmentLevel } from '@prisma/client';
|
||||
|
||||
export const createCampaignSchema = z.object({
|
||||
title: z.string().min(1, 'Title is required'),
|
||||
description: z.string().optional(),
|
||||
emailSubject: z.string().min(1, 'Email subject is required'),
|
||||
emailBody: z.string().min(1, 'Email body is required'),
|
||||
callToAction: z.string().optional(),
|
||||
title: z.string().min(1, 'Title is required').max(200),
|
||||
description: z.string().max(2000).optional(),
|
||||
emailSubject: z.string().min(1, 'Email subject is required').max(200),
|
||||
emailBody: z.string().min(1, 'Email body is required').max(10000),
|
||||
callToAction: z.string().max(500).optional(),
|
||||
status: z.nativeEnum(CampaignStatus).optional().default(CampaignStatus.DRAFT),
|
||||
targetGovernmentLevels: z.array(z.nativeEnum(GovernmentLevel)).optional().default([]),
|
||||
allowSmtpEmail: z.boolean().optional().default(true),
|
||||
@@ -18,15 +18,16 @@ export const createCampaignSchema = z.object({
|
||||
allowCustomRecipients: z.boolean().optional().default(false),
|
||||
showResponseWall: z.boolean().optional().default(false),
|
||||
highlightCampaign: z.boolean().optional().default(false),
|
||||
coverPhoto: z.string().optional(),
|
||||
coverPhoto: z.string().url().max(500).optional(),
|
||||
coverVideoId: z.number().int().positive().nullable().optional(),
|
||||
});
|
||||
|
||||
export const updateCampaignSchema = z.object({
|
||||
title: z.string().min(1).optional(),
|
||||
description: z.string().nullable().optional(),
|
||||
emailSubject: z.string().min(1).optional(),
|
||||
emailBody: z.string().min(1).optional(),
|
||||
callToAction: z.string().nullable().optional(),
|
||||
title: z.string().min(1).max(200).optional(),
|
||||
description: z.string().max(2000).nullable().optional(),
|
||||
emailSubject: z.string().min(1).max(200).optional(),
|
||||
emailBody: z.string().min(1).max(10000).optional(),
|
||||
callToAction: z.string().max(500).nullable().optional(),
|
||||
status: z.nativeEnum(CampaignStatus).optional(),
|
||||
targetGovernmentLevels: z.array(z.nativeEnum(GovernmentLevel)).optional(),
|
||||
allowSmtpEmail: z.boolean().optional(),
|
||||
@@ -38,7 +39,8 @@ export const updateCampaignSchema = z.object({
|
||||
allowCustomRecipients: z.boolean().optional(),
|
||||
showResponseWall: z.boolean().optional(),
|
||||
highlightCampaign: z.boolean().optional(),
|
||||
coverPhoto: z.string().nullable().optional(),
|
||||
coverPhoto: z.string().url().max(500).nullable().optional(),
|
||||
coverVideoId: z.number().int().positive().nullable().optional(),
|
||||
});
|
||||
|
||||
export const listCampaignsSchema = z.object({
|
||||
|
||||
@@ -25,6 +25,7 @@ const campaignSelect = {
|
||||
emailBody: true,
|
||||
callToAction: true,
|
||||
coverPhoto: true,
|
||||
coverVideoId: true,
|
||||
status: true,
|
||||
allowSmtpEmail: true,
|
||||
allowMailtoLink: true,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
export const effectivenessQuerySchema = z.object({
|
||||
campaignId: z.string().optional(),
|
||||
campaignId: z.string().uuid().optional(),
|
||||
dateFrom: z.string().datetime({ offset: true }).optional(),
|
||||
dateTo: z.string().datetime({ offset: true }).optional(),
|
||||
});
|
||||
|
||||
@@ -286,27 +286,30 @@ export const effectivenessService = {
|
||||
}
|
||||
|
||||
// For city/province grouping, we need to join with postal_code_cache
|
||||
const groupCol = query.groupBy === 'province' ? 'pcc.province' : 'pcc.city';
|
||||
const dateClause = dateFilter
|
||||
? `AND ce."sentAt" ${dateFilter.gte ? `>= '${dateFilter.gte.toISOString()}'` : ''} ${dateFilter.lte ? `AND ce."sentAt" <= '${dateFilter.lte.toISOString()}'` : ''}`
|
||||
: '';
|
||||
const campaignClause = query.campaignId
|
||||
? `AND ce."campaignId" = '${query.campaignId}'`
|
||||
: '';
|
||||
const groupCol = query.groupBy === 'province' ? Prisma.raw('pcc.province') : Prisma.raw('pcc.city');
|
||||
const campaignFilter = query.campaignId
|
||||
? Prisma.sql`AND ce."campaignId" = ${query.campaignId}`
|
||||
: Prisma.sql``;
|
||||
const dateGteFilter = dateFilter?.gte
|
||||
? Prisma.sql`AND ce."sentAt" >= ${dateFilter.gte}`
|
||||
: Prisma.sql``;
|
||||
const dateLteFilter = dateFilter?.lte
|
||||
? Prisma.sql`AND ce."sentAt" <= ${dateFilter.lte}`
|
||||
: Prisma.sql``;
|
||||
|
||||
const rawResults = await prisma.$queryRawUnsafe<Array<{ key: string; email_count: bigint }>>(
|
||||
`SELECT ${groupCol} as key, COUNT(*) as email_count
|
||||
FROM campaign_emails ce
|
||||
LEFT JOIN postal_code_cache pcc ON ce."userPostalCode" = pcc."postalCode"
|
||||
WHERE ce."userPostalCode" IS NOT NULL
|
||||
AND ${groupCol} IS NOT NULL
|
||||
${campaignClause}
|
||||
${dateClause}
|
||||
GROUP BY ${groupCol}
|
||||
ORDER BY email_count DESC
|
||||
LIMIT $1`,
|
||||
query.limit,
|
||||
);
|
||||
const rawResults = await prisma.$queryRaw<Array<{ key: string; email_count: bigint }>>`
|
||||
SELECT ${groupCol} as key, COUNT(*) as email_count
|
||||
FROM campaign_emails ce
|
||||
LEFT JOIN postal_code_cache pcc ON ce."userPostalCode" = pcc."postalCode"
|
||||
WHERE ce."userPostalCode" IS NOT NULL
|
||||
AND ${groupCol} IS NOT NULL
|
||||
${campaignFilter}
|
||||
${dateGteFilter}
|
||||
${dateLteFilter}
|
||||
GROUP BY ${groupCol}
|
||||
ORDER BY email_count DESC
|
||||
LIMIT ${query.limit}
|
||||
`;
|
||||
|
||||
return {
|
||||
groupBy: query.groupBy,
|
||||
@@ -337,20 +340,23 @@ export const effectivenessService = {
|
||||
if (query.campaignId) callWhere.campaignId = query.campaignId;
|
||||
if (dateFilter) callWhere.calledAt = dateFilter;
|
||||
|
||||
// Build date clause for raw SQL
|
||||
const dateClauseParts: string[] = [];
|
||||
if (query.campaignId) dateClauseParts.push(`"campaignId" = '${query.campaignId}'`);
|
||||
if (dateFilter?.gte) dateClauseParts.push(`"sentAt" >= '${dateFilter.gte.toISOString()}'`);
|
||||
if (dateFilter?.lte) dateClauseParts.push(`"sentAt" <= '${dateFilter.lte.toISOString()}'`);
|
||||
const rawWhereClause = dateClauseParts.length > 0
|
||||
? `WHERE ${dateClauseParts.join(' AND ')}`
|
||||
: '';
|
||||
// Build parameterized conditions for unique participant count
|
||||
const campaignFilter = query.campaignId
|
||||
? Prisma.sql`AND "campaignId" = ${query.campaignId}`
|
||||
: Prisma.sql``;
|
||||
const dateGteFilter = dateFilter?.gte
|
||||
? Prisma.sql`AND "sentAt" >= ${dateFilter.gte}`
|
||||
: Prisma.sql``;
|
||||
const dateLteFilter = dateFilter?.lte
|
||||
? Prisma.sql`AND "sentAt" <= ${dateFilter.lte}`
|
||||
: Prisma.sql``;
|
||||
|
||||
const [emailsSent, uniqueParticipants, approvedResponses, verifiedResponses, callsMade] = await Promise.all([
|
||||
prisma.campaignEmail.count({ where: emailWhere }),
|
||||
prisma.$queryRawUnsafe<[{ count: bigint }]>(
|
||||
`SELECT COUNT(DISTINCT "userEmail") as count FROM campaign_emails ${rawWhereClause}`,
|
||||
),
|
||||
prisma.$queryRaw<[{ count: bigint }]>`
|
||||
SELECT COUNT(DISTINCT "userEmail") as count FROM campaign_emails
|
||||
WHERE 1=1 ${campaignFilter} ${dateGteFilter} ${dateLteFilter}
|
||||
`,
|
||||
prisma.representativeResponse.count({
|
||||
where: { ...responseWhere, status: ResponseStatus.APPROVED },
|
||||
}),
|
||||
@@ -397,32 +403,29 @@ export const effectivenessService = {
|
||||
const from = dateFilter?.gte || defaultFrom;
|
||||
const to = dateFilter?.lte || new Date();
|
||||
|
||||
const campaignClause = query.campaignId
|
||||
? `AND "campaignId" = '${query.campaignId}'`
|
||||
: '';
|
||||
const truncFnSql = Prisma.raw(`'${truncFn}'`);
|
||||
const campaignFilter = query.campaignId
|
||||
? Prisma.sql`AND "campaignId" = ${query.campaignId}`
|
||||
: Prisma.sql``;
|
||||
|
||||
const [emailTrends, responseTrends] = await Promise.all([
|
||||
prisma.$queryRawUnsafe<Array<{ period: Date; count: bigint }>>(
|
||||
`SELECT DATE_TRUNC('${truncFn}', "sentAt") as period, COUNT(*) as count
|
||||
FROM campaign_emails
|
||||
WHERE "sentAt" >= $1 AND "sentAt" <= $2
|
||||
${campaignClause}
|
||||
GROUP BY period
|
||||
ORDER BY period ASC`,
|
||||
from,
|
||||
to,
|
||||
),
|
||||
prisma.$queryRawUnsafe<Array<{ period: Date; count: bigint }>>(
|
||||
`SELECT DATE_TRUNC('${truncFn}', "createdAt") as period, COUNT(*) as count
|
||||
FROM representative_responses
|
||||
WHERE "createdAt" >= $1 AND "createdAt" <= $2
|
||||
AND status = 'APPROVED'
|
||||
${campaignClause}
|
||||
GROUP BY period
|
||||
ORDER BY period ASC`,
|
||||
from,
|
||||
to,
|
||||
),
|
||||
prisma.$queryRaw<Array<{ period: Date; count: bigint }>>`
|
||||
SELECT DATE_TRUNC(${truncFnSql}, "sentAt") as period, COUNT(*) as count
|
||||
FROM campaign_emails
|
||||
WHERE "sentAt" >= ${from} AND "sentAt" <= ${to}
|
||||
${campaignFilter}
|
||||
GROUP BY period
|
||||
ORDER BY period ASC
|
||||
`,
|
||||
prisma.$queryRaw<Array<{ period: Date; count: bigint }>>`
|
||||
SELECT DATE_TRUNC(${truncFnSql}, "createdAt") as period, COUNT(*) as count
|
||||
FROM representative_responses
|
||||
WHERE "createdAt" >= ${from} AND "createdAt" <= ${to}
|
||||
AND status = 'APPROVED'
|
||||
${campaignFilter}
|
||||
GROUP BY period
|
||||
ORDER BY period ASC
|
||||
`,
|
||||
]);
|
||||
|
||||
// Merge into a single series with both email and response counts
|
||||
|
||||
@@ -2,14 +2,14 @@ import { z } from 'zod';
|
||||
import { GovernmentLevel, ResponseType, ResponseStatus } from '@prisma/client';
|
||||
|
||||
export const submitResponseSchema = z.object({
|
||||
representativeName: z.string().min(1, 'Representative name is required'),
|
||||
representativeName: z.string().min(1, 'Representative name is required').max(200),
|
||||
representativeLevel: z.nativeEnum(GovernmentLevel),
|
||||
responseType: z.nativeEnum(ResponseType),
|
||||
responseText: z.string().min(1, 'Response text is required'),
|
||||
representativeTitle: z.string().optional(),
|
||||
responseText: z.string().min(1, 'Response text is required').max(5000),
|
||||
representativeTitle: z.string().max(200).optional(),
|
||||
representativeEmail: z.string().email().optional(),
|
||||
userComment: z.string().optional(),
|
||||
submittedByName: z.string().optional(),
|
||||
userComment: z.string().max(1000).optional(),
|
||||
submittedByName: z.string().max(200).optional(),
|
||||
submittedByEmail: z.string().email().optional(),
|
||||
isAnonymous: z.boolean().optional().default(false),
|
||||
sendVerification: z.boolean().optional().default(false),
|
||||
|
||||
@@ -152,8 +152,8 @@ export async function commentsRoutes(fastify: FastifyInstance) {
|
||||
},
|
||||
});
|
||||
|
||||
// Rate limiting check
|
||||
const rateLimitKey = userId || sessionId;
|
||||
// Rate limiting check — use IP for anonymous users to prevent header-based bypass
|
||||
const rateLimitKey = userId || `ip:${request.ip}`;
|
||||
const now = Date.now();
|
||||
const timestamps = commentRateLimitMap.get(rateLimitKey) || [];
|
||||
const recentTimestamps = timestamps.filter(
|
||||
|
||||
@@ -380,12 +380,15 @@ export async function userProfileRoutes(fastify: FastifyInstance) {
|
||||
return reply.code(401).send({ message: 'Current password is incorrect' });
|
||||
}
|
||||
|
||||
// Hash and save new password
|
||||
// Hash and save new password, invalidate all sessions
|
||||
const hashedPassword = await bcrypt.hash(newPassword, 12);
|
||||
await prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { password: hashedPassword },
|
||||
});
|
||||
await prisma.$transaction([
|
||||
prisma.user.update({
|
||||
where: { id: userId },
|
||||
data: { password: hashedPassword },
|
||||
}),
|
||||
prisma.refreshToken.deleteMany({ where: { userId } }),
|
||||
]);
|
||||
|
||||
return reply.send({ message: 'Password updated successfully' });
|
||||
}
|
||||
|
||||
@@ -6,7 +6,7 @@ import { logger } from '../../../utils/logger';
|
||||
import { sign } from 'jsonwebtoken';
|
||||
import { env } from '../../../config/env';
|
||||
import { copyFile } from 'fs/promises';
|
||||
import { join, dirname, basename, extname } from 'path';
|
||||
import { join, dirname, basename, extname, normalize } from 'path';
|
||||
import { z } from 'zod';
|
||||
|
||||
const UpdateVideoSchema = z.object({
|
||||
@@ -149,16 +149,18 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
* Replace video file while keeping metadata and URL
|
||||
* Note: This endpoint accepts a new file path - actual file upload should go through upload routes
|
||||
*/
|
||||
const ReplaceVideoSchema = z.object({
|
||||
newPath: z.string().min(1).max(500),
|
||||
newFilename: z.string().min(1).max(255),
|
||||
durationSeconds: z.number().optional(),
|
||||
width: z.number().int().optional(),
|
||||
height: z.number().int().optional(),
|
||||
fileSize: z.number().optional(),
|
||||
});
|
||||
|
||||
fastify.post<{
|
||||
Params: { id: string };
|
||||
Body: {
|
||||
newPath: string;
|
||||
newFilename: string;
|
||||
durationSeconds?: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
fileSize?: number;
|
||||
};
|
||||
Body: z.infer<typeof ReplaceVideoSchema>;
|
||||
}>(
|
||||
'/:id/replace',
|
||||
{
|
||||
@@ -166,7 +168,23 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
},
|
||||
async (request, reply) => {
|
||||
const videoId = parseInt(request.params.id);
|
||||
const { newPath, newFilename, durationSeconds, width, height, fileSize } = request.body;
|
||||
|
||||
// Validate input with Zod
|
||||
const parseResult = ReplaceVideoSchema.safeParse(request.body);
|
||||
if (!parseResult.success) {
|
||||
return reply.code(400).send({ message: 'Invalid input' });
|
||||
}
|
||||
const { newPath, newFilename, durationSeconds, width, height, fileSize } = parseResult.data;
|
||||
|
||||
// Path traversal protection
|
||||
if (newPath.includes('\0') || newFilename.includes('\0')) {
|
||||
return reply.code(400).send({ message: 'Invalid file path' });
|
||||
}
|
||||
const normalizedPath = normalize(newPath);
|
||||
if (normalizedPath.includes('..') || normalizedPath.startsWith('/') || normalizedPath.startsWith('\\')) {
|
||||
return reply.code(400).send({ message: 'Invalid file path: must be relative with no traversal' });
|
||||
}
|
||||
const sanitizedFilename = basename(newFilename);
|
||||
|
||||
try {
|
||||
const existingVideo = await prisma.video.findUnique({
|
||||
@@ -181,8 +199,8 @@ export async function videoActionsRoutes(fastify: FastifyInstance) {
|
||||
const updatedVideo = await prisma.video.update({
|
||||
where: { id: videoId },
|
||||
data: {
|
||||
path: newPath,
|
||||
filename: newFilename,
|
||||
path: normalizedPath,
|
||||
filename: sanitizedFilename,
|
||||
originalPath: existingVideo.path, // Save old path for reference
|
||||
originalFilename: existingVideo.filename,
|
||||
durationSeconds: durationSeconds || existingVideo.durationSeconds,
|
||||
|
||||
@@ -5,6 +5,7 @@ import { createLandingPageSchema, updateLandingPageSchema, listLandingPagesSchem
|
||||
import { validate } from '../../middleware/validate';
|
||||
import { authenticate } from '../../middleware/auth.middleware';
|
||||
import { requireRole } from '../../middleware/rbac.middleware';
|
||||
import { prisma } from '../../config/database';
|
||||
|
||||
const ADMIN_ROLES: UserRole[] = [UserRole.SUPER_ADMIN, UserRole.INFLUENCE_ADMIN, UserRole.MAP_ADMIN];
|
||||
|
||||
@@ -13,6 +14,34 @@ const router = Router();
|
||||
router.use(authenticate);
|
||||
router.use(requireRole(...ADMIN_ROLES));
|
||||
|
||||
// GET /api/pages/view-counts — landing page view counts (last 30d)
|
||||
router.get(
|
||||
'/view-counts',
|
||||
async (_req: Request, res: Response, next: NextFunction) => {
|
||||
try {
|
||||
const since = new Date();
|
||||
since.setDate(since.getDate() - 30);
|
||||
const rows = await prisma.docsPageView.groupBy({
|
||||
by: ['path'],
|
||||
where: {
|
||||
path: { startsWith: '/p/' },
|
||||
createdAt: { gte: since },
|
||||
},
|
||||
_count: { id: true },
|
||||
});
|
||||
const counts: Record<string, number> = {};
|
||||
for (const row of rows) {
|
||||
// Extract slug from /p/:slug
|
||||
const slug = row.path.replace(/^\/p\//, '');
|
||||
counts[slug] = row._count.id;
|
||||
}
|
||||
res.json(counts);
|
||||
} catch (err) {
|
||||
next(err);
|
||||
}
|
||||
}
|
||||
);
|
||||
|
||||
// POST /api/pages/sync — sync MkDocs overrides (must be before /:id routes)
|
||||
router.post(
|
||||
'/sync',
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { Router, type Request, type Response } from 'express';
|
||||
import { z } from 'zod';
|
||||
import rateLimit from 'express-rate-limit';
|
||||
import RedisStore from 'rate-limit-redis';
|
||||
import { readFileSync } from 'fs';
|
||||
@@ -954,10 +955,24 @@ router.post('/test-2step', pangolinSetupLimiter, async (req: Request, res: Respo
|
||||
});
|
||||
|
||||
// PUT /api/pangolin/resource/:id — Update a resource
|
||||
const updateResourceSchema = z.object({
|
||||
name: z.string().max(200).optional(),
|
||||
subdomain: z.string().max(200).optional(),
|
||||
ssl: z.boolean().optional(),
|
||||
blockAccess: z.boolean().optional(),
|
||||
proxyPort: z.number().int().optional(),
|
||||
protocol: z.string().max(20).optional(),
|
||||
domainId: z.string().max(200).optional(),
|
||||
isBaseDomain: z.boolean().optional(),
|
||||
http: z.boolean().optional(),
|
||||
https: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
router.put('/resource/:id', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const resourceId = req.params.id as string;
|
||||
const resource = await pangolinClient.updateResource(resourceId, req.body);
|
||||
const body = updateResourceSchema.parse(req.body);
|
||||
const resource = await pangolinClient.updateResource(resourceId, body);
|
||||
res.json({ success: true, resource });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
@@ -979,10 +994,16 @@ router.get('/certificate/:domainId/:domain', async (req: Request, res: Response)
|
||||
});
|
||||
|
||||
// POST /api/pangolin/certificate/:certId — Update certificate
|
||||
const updateCertificateSchema = z.object({
|
||||
autoRenew: z.boolean().optional(),
|
||||
isWildcard: z.boolean().optional(),
|
||||
}).passthrough();
|
||||
|
||||
router.post('/certificate/:certId', async (req: Request, res: Response) => {
|
||||
try {
|
||||
const certId = req.params.certId as string;
|
||||
const certificate = await pangolinClient.updateCertificate(certId, req.body);
|
||||
const body = updateCertificateSchema.parse(req.body);
|
||||
const certificate = await pangolinClient.updateCertificate(certId, body);
|
||||
res.json({ success: true, certificate });
|
||||
} catch (err) {
|
||||
const msg = err instanceof Error ? err.message : 'Unknown error';
|
||||
|
||||
@@ -3,6 +3,7 @@ import { prisma } from '../../config/database';
|
||||
import { getStripe, getWebhookSecret } from '../../services/stripe.client';
|
||||
import { logger } from '../../utils/logger';
|
||||
import { paymentEmailService } from './payment-email.service';
|
||||
import { listmonkEventSyncService } from '../../services/listmonk-event-sync.service';
|
||||
|
||||
// Helper to extract subscription ID from invoice (may be string, object, or missing in newer types)
|
||||
function getSubscriptionId(invoice: Stripe.Invoice): string | null {
|
||||
@@ -130,6 +131,18 @@ export const webhookService = {
|
||||
stripeSubscriptionId: subscriptionId,
|
||||
currentPeriodEnd,
|
||||
});
|
||||
|
||||
// Sync to Listmonk Subscribers list (fire-and-forget)
|
||||
const subUser = await prisma.user.findUnique({ where: { id: userId }, select: { email: true, name: true } });
|
||||
const plan = await prisma.subscriptionPlan.findUnique({ where: { id: parseInt(planId, 10) }, select: { name: true } });
|
||||
if (subUser) {
|
||||
listmonkEventSyncService.onSubscriptionActivated({
|
||||
email: subUser.email,
|
||||
name: subUser.name || '',
|
||||
planName: plan?.name || `Plan ${planId}`,
|
||||
subscriptionId,
|
||||
}).catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
async handleProductCheckout(session: Stripe.Checkout.Session) {
|
||||
@@ -185,6 +198,17 @@ export const webhookService = {
|
||||
completedAt: updatedOrder.completedAt,
|
||||
product: updatedOrder.product,
|
||||
});
|
||||
|
||||
// Sync to Listmonk Donors list (fire-and-forget)
|
||||
if (updatedOrder.buyerEmail) {
|
||||
listmonkEventSyncService.onProductPurchased({
|
||||
email: updatedOrder.buyerEmail,
|
||||
name: updatedOrder.buyerName || '',
|
||||
productTitle: updatedOrder.product?.title || 'Product',
|
||||
amountCents: updatedOrder.amountCAD,
|
||||
orderId: updatedOrder.id,
|
||||
}).catch(() => {});
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
@@ -228,6 +252,16 @@ export const webhookService = {
|
||||
isAnonymous: order.isAnonymous,
|
||||
completedAt: new Date(),
|
||||
});
|
||||
|
||||
// Sync to Listmonk Donors list (fire-and-forget)
|
||||
if (order.buyerEmail) {
|
||||
listmonkEventSyncService.onDonationCompleted({
|
||||
email: order.buyerEmail,
|
||||
name: order.buyerName || '',
|
||||
amountCents: order.amountCAD,
|
||||
orderId: order.id,
|
||||
}).catch(() => {});
|
||||
}
|
||||
},
|
||||
|
||||
async handleInvoicePaid(invoice: Stripe.Invoice) {
|
||||
|
||||
@@ -145,6 +145,12 @@ export const usersService = {
|
||||
select: userSelect,
|
||||
});
|
||||
|
||||
// Invalidate sessions when user is deactivated
|
||||
const deactivatedStatuses = ['INACTIVE', 'PENDING_APPROVAL', 'PENDING_VERIFICATION'];
|
||||
if (data.status && deactivatedStatuses.includes(data.status)) {
|
||||
await prisma.refreshToken.deleteMany({ where: { userId: id } });
|
||||
}
|
||||
|
||||
return user;
|
||||
},
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export const volunteerInviteService = {
|
||||
|
||||
// 4. Create new TEMP user with random password (never shown to user)
|
||||
const randomPassword = crypto.randomBytes(16).toString('hex');
|
||||
const hashedPassword = await bcrypt.hash(randomPassword, 10);
|
||||
const hashedPassword = await bcrypt.hash(randomPassword, 12);
|
||||
|
||||
const newUser = await prisma.user.create({
|
||||
data: {
|
||||
|
||||
@@ -8,6 +8,8 @@ import { prisma } from './config/database';
|
||||
import { redis } from './config/redis';
|
||||
import { register, httpRequestDuration, httpRequestsTotal } from './utils/metrics';
|
||||
import { errorHandler } from './middleware/error-handler';
|
||||
import { authenticate } from './middleware/auth.middleware';
|
||||
import { requireRole } from './middleware/rbac.middleware';
|
||||
import { globalRateLimit, healthMetricsRateLimit } from './middleware/rate-limit';
|
||||
import { authRouter } from './modules/auth/auth.routes';
|
||||
import { usersRouter } from './modules/users/users.routes';
|
||||
@@ -139,8 +141,8 @@ app.get('/api/health', healthMetricsRateLimit, async (_req, res) => {
|
||||
res.status(healthy ? 200 : 503).json({ status: healthy ? 'healthy' : 'degraded', checks });
|
||||
});
|
||||
|
||||
// --- Metrics Endpoint ---
|
||||
app.get('/api/metrics', healthMetricsRateLimit, async (_req, res) => {
|
||||
// --- Metrics Endpoint (authenticated - SUPER_ADMIN only) ---
|
||||
app.get('/api/metrics', authenticate, requireRole('SUPER_ADMIN'), healthMetricsRateLimit, async (_req, res) => {
|
||||
res.set('Content-Type', register.contentType);
|
||||
res.end(await register.metrics());
|
||||
});
|
||||
@@ -214,8 +216,16 @@ async function start() {
|
||||
if (env.NODE_ENV === 'production' && !env.ENCRYPTION_KEY) {
|
||||
throw new Error('ENCRYPTION_KEY must be set in production (do not reuse JWT_ACCESS_SECRET)');
|
||||
}
|
||||
if (!env.ENCRYPTION_KEY) {
|
||||
logger.warn('ENCRYPTION_KEY not set — falling back to JWT_ACCESS_SECRET for encryption. Set ENCRYPTION_KEY in production.');
|
||||
}
|
||||
initEncryption(env.ENCRYPTION_KEY || env.JWT_ACCESS_SECRET);
|
||||
|
||||
// Warn if Listmonk sync is enabled but webhook secret is not configured
|
||||
if (env.LISTMONK_SYNC_ENABLED === 'true' && !env.LISTMONK_WEBHOOK_SECRET) {
|
||||
logger.warn('LISTMONK_SYNC_ENABLED is true but LISTMONK_WEBHOOK_SECRET is not set. Unsubscribe events from Listmonk will not be processed.');
|
||||
}
|
||||
|
||||
// Rebuild SMTP transporter from DB settings (env fallback for empty fields)
|
||||
await emailService.rebuildTransporter();
|
||||
|
||||
|
||||
@@ -137,6 +137,113 @@ class ListmonkEventSyncService {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync an activated subscription to Listmonk "All Contacts" + "Subscribers" lists.
|
||||
*/
|
||||
async onSubscriptionActivated(data: {
|
||||
email: string;
|
||||
name: string;
|
||||
planName: string;
|
||||
subscriptionId: string;
|
||||
}): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
|
||||
try {
|
||||
await listmonkSyncService.ensureInitialized();
|
||||
const allContactsId = listmonkSyncService.getListId('All Contacts');
|
||||
const subscribersId = listmonkSyncService.getListId('Subscribers');
|
||||
if (!allContactsId || !subscribersId) return;
|
||||
|
||||
await listmonkClient.upsertSubscriber(
|
||||
data.email,
|
||||
data.name,
|
||||
[allContactsId, subscribersId],
|
||||
{
|
||||
source: 'subscription',
|
||||
plan_name: data.planName,
|
||||
subscription_id: data.subscriptionId,
|
||||
last_synced: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
this.incrementCounter();
|
||||
logger.debug(`Listmonk event sync: subscription activated for ${data.email}`);
|
||||
} catch (err) {
|
||||
logger.debug('Listmonk event sync failed (onSubscriptionActivated):', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a completed donation to Listmonk "All Contacts" + "Donors" lists.
|
||||
*/
|
||||
async onDonationCompleted(data: {
|
||||
email: string;
|
||||
name: string;
|
||||
amountCents: number;
|
||||
orderId: string;
|
||||
}): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
|
||||
try {
|
||||
await listmonkSyncService.ensureInitialized();
|
||||
const allContactsId = listmonkSyncService.getListId('All Contacts');
|
||||
const donorsId = listmonkSyncService.getListId('Donors');
|
||||
if (!allContactsId || !donorsId) return;
|
||||
|
||||
await listmonkClient.upsertSubscriber(
|
||||
data.email,
|
||||
data.name,
|
||||
[allContactsId, donorsId],
|
||||
{
|
||||
source: 'donation',
|
||||
last_donation_amount: data.amountCents,
|
||||
last_order_id: data.orderId,
|
||||
last_synced: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
this.incrementCounter();
|
||||
logger.debug(`Listmonk event sync: donation completed for ${data.email}`);
|
||||
} catch (err) {
|
||||
logger.debug('Listmonk event sync failed (onDonationCompleted):', err);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sync a product purchase to Listmonk "All Contacts" + "Donors" lists.
|
||||
*/
|
||||
async onProductPurchased(data: {
|
||||
email: string;
|
||||
name: string;
|
||||
productTitle: string;
|
||||
amountCents: number;
|
||||
orderId: string;
|
||||
}): Promise<void> {
|
||||
if (!this.enabled) return;
|
||||
|
||||
try {
|
||||
await listmonkSyncService.ensureInitialized();
|
||||
const allContactsId = listmonkSyncService.getListId('All Contacts');
|
||||
const donorsId = listmonkSyncService.getListId('Donors');
|
||||
if (!allContactsId || !donorsId) return;
|
||||
|
||||
await listmonkClient.upsertSubscriber(
|
||||
data.email,
|
||||
data.name,
|
||||
[allContactsId, donorsId],
|
||||
{
|
||||
source: 'product_purchase',
|
||||
last_product: data.productTitle,
|
||||
last_purchase_amount: data.amountCents,
|
||||
last_order_id: data.orderId,
|
||||
last_synced: new Date().toISOString(),
|
||||
},
|
||||
);
|
||||
this.incrementCounter();
|
||||
logger.debug(`Listmonk event sync: product purchased for ${data.email}`);
|
||||
} catch (err) {
|
||||
logger.debug('Listmonk event sync failed (onProductPurchased):', err);
|
||||
}
|
||||
}
|
||||
|
||||
getStats(): {
|
||||
enabled: boolean;
|
||||
lastSyncAt: string | null;
|
||||
|
||||
@@ -17,6 +17,8 @@ const LIST_DEFINITIONS: Array<{ name: string; tags: string[] }> = [
|
||||
{ name: 'Users', tags: ['v2', 'users'] },
|
||||
{ name: 'Volunteers', tags: ['v2', 'map', 'shifts'] },
|
||||
{ name: 'Canvassers', tags: ['v2', 'map', 'canvass'] },
|
||||
{ name: 'Subscribers', tags: ['v2', 'payments'] },
|
||||
{ name: 'Donors', tags: ['v2', 'payments'] },
|
||||
];
|
||||
|
||||
const SUPPORT_LEVEL_LIST_MAP: Record<string, string> = {
|
||||
|
||||
@@ -131,7 +131,8 @@ class ListmonkClient {
|
||||
async findSubscriberByEmail(email: string): Promise<ListmonkSubscriber | null> {
|
||||
this.assertEnabled();
|
||||
try {
|
||||
const query = encodeURIComponent(`subscribers.email='${email}'`);
|
||||
const safeEmail = email.replace(/'/g, "''");
|
||||
const query = encodeURIComponent(`subscribers.email='${safeEmail}'`);
|
||||
const res = await this.request<{ data: { results: ListmonkSubscriber[] } }>(
|
||||
'GET',
|
||||
`/api/subscribers?query=${query}&per_page=1`,
|
||||
|
||||
Reference in New Issue
Block a user