Merge branch 'next' into dependabot/npm_and_yarn/qs-6.14.1

This commit is contained in:
Dries Augustyns
2026-01-01 14:28:29 +01:00
committed by GitHub
24 changed files with 84 additions and 104 deletions
+2 -2
View File
@@ -103,7 +103,7 @@ jobs:
EOF EOF
- name: Build shared packages - name: Build shared packages
run: yarn build --filter="@plunk/shared" --filter="@plunk/db" run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
- name: Generate Prisma Client - name: Generate Prisma Client
run: yarn workspace @plunk/db db:generate run: yarn workspace @plunk/db db:generate
@@ -188,7 +188,7 @@ jobs:
EOF EOF
- name: Build shared packages - name: Build shared packages
run: yarn build --filter="@plunk/shared" --filter="@plunk/db" run: yarn build --filter="@plunk/db" --filter="@plunk/types" --filter="@plunk/shared"
- name: Run linter - name: Run linter
run: yarn lint run: yarn lint
+6
View File
@@ -157,3 +157,9 @@ Required for builds and deployment (see turbo.json and .env.example):
runtime runtime
- **Frontend Variables**: Next.js apps use `NEXT_PUBLIC_*` prefixed variables that are embedded at build time for - **Frontend Variables**: Next.js apps use `NEXT_PUBLIC_*` prefixed variables that are embedded at build time for
client-side access client-side access
## Plugins
There are two plugins installed for you to use.
- frontend-design: This plugin can help you to create polished user interfaces. Use it when working on design-related tasks.
- superpowers: This plugin can help you with advanced tasks such as refactorings, new features or architectural changes. Use it when you need extra assistance beyond basic coding.
-1
View File
@@ -23,7 +23,6 @@ export class Campaigns {
const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} = const {name, description, subject, body, from, fromName, replyTo, audienceType, audienceCondition, segmentId} =
CampaignSchemas.create.parse(req.body); CampaignSchemas.create.parse(req.body);
// Validate audience-specific fields
if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) { if (audienceType === CampaignAudienceType.SEGMENT && !segmentId) {
throw new HttpException(400, 'Segment ID is required for SEGMENT audience type'); throw new HttpException(400, 'Segment ID is required for SEGMENT audience type');
} }
-1
View File
@@ -14,7 +14,6 @@ const upload = multer({
fileSize: 5 * 1024 * 1024, // 5MB max file size fileSize: 5 * 1024 * 1024, // 5MB max file size
}, },
fileFilter: (_req, file, cb) => { fileFilter: (_req, file, cb) => {
// Only accept CSV files
if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) { if (file.mimetype === 'text/csv' || file.originalname.endsWith('.csv')) {
cb(null, true); cb(null, true);
} else { } else {
-1
View File
@@ -13,7 +13,6 @@ const upload = multer({
fileSize: 10 * 1024 * 1024, // 10MB max file size fileSize: 10 * 1024 * 1024, // 10MB max file size
}, },
fileFilter: (_req, file, cb) => { fileFilter: (_req, file, cb) => {
// Only accept image files
const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml']; const allowedMimeTypes = ['image/jpeg', 'image/jpg', 'image/png', 'image/gif', 'image/webp', 'image/svg+xml'];
if (allowedMimeTypes.includes(file.mimetype)) { if (allowedMimeTypes.includes(file.mimetype)) {
@@ -29,12 +29,6 @@ describe('Email Processor', () => {
status: EmailStatus.PENDING, status: EmailStatus.PENDING,
}); });
// Mock the email processor logic
// In a real implementation, you would:
// 1. Create job tester
// 2. Mock SES service
// 3. Process the job
// 4. Verify status changes
// Simulate processing // Simulate processing
await prisma.email.update({ await prisma.email.update({
@@ -16,7 +16,6 @@ describe('Request Logger Middleware', () => {
projectId = project.id; projectId = project.id;
userId = user.id; userId = user.id;
// Mock request object
req = { req = {
method: 'POST', method: 'POST',
path: '/v1/send', path: '/v1/send',
@@ -334,11 +333,6 @@ describe('Request Logger Middleware', () => {
await res.json!({success: true}); await res.json!({success: true});
await new Promise(resolve => setTimeout(resolve, 100)); await new Promise(resolve => setTimeout(resolve, 100));
// TODO: Add assertion to verify request was NOT logged when disabled
// const loggedRequest = await prisma.apiRequest.findUnique({
// where: {id: 'test-request-id-123'},
// });
// expect(loggedRequest).toBeNull();
// Restore original value // Restore original value
if (originalEnv !== undefined) { if (originalEnv !== undefined) {
@@ -68,8 +68,6 @@ export class AnalyticsService {
return JSON.parse(cached); return JSON.parse(cached);
} }
// Raw SQL query for efficient daily aggregation
// Using raw SQL because Prisma's groupBy is less efficient for date truncation
const result = await prisma.$queryRaw< const result = await prisma.$queryRaw<
{ {
date: Date; date: Date;
-1
View File
@@ -46,7 +46,6 @@ export async function initializeBucket(): Promise<void> {
let bucketExists = true; let bucketExists = true;
try { try {
// Check if bucket exists
await s3Client.send( await s3Client.send(
new HeadBucketCommand({ new HeadBucketCommand({
Bucket: S3_BUCKET, Bucket: S3_BUCKET,
-1
View File
@@ -9,7 +9,6 @@ import {HttpException} from '../exceptions/index.js';
import {EventService} from './EventService.js'; import {EventService} from './EventService.js';
import {NtfyService} from './NtfyService.js'; import {NtfyService} from './NtfyService.js';
// Re-export types for use in other services
export type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types'; export type {FilterCondition, FilterGroup, SegmentFilter} from '@plunk/types';
/** /**
@@ -20,7 +20,6 @@ import {EmailService} from './EmailService.js';
import {NtfyService} from './NtfyService.js'; import {NtfyService} from './NtfyService.js';
import {QueueService} from './QueueService.js'; import {QueueService} from './QueueService.js';
// Type aliases for workflow execution context
type StepConfig = Prisma.JsonValue; type StepConfig = Prisma.JsonValue;
type StepResult = Record<string, unknown>; type StepResult = Record<string, unknown>;
type WorkflowExecutionWithRelations = WorkflowExecution & {contact: Contact; workflow: Workflow}; type WorkflowExecutionWithRelations = WorkflowExecution & {contact: Contact; workflow: Workflow};
-1
View File
@@ -27,7 +27,6 @@ export function ActivityFeed({typeFilter, dateRangeDays = 30, contactId}: Activi
return date.toISOString(); return date.toISOString();
}, [dateRangeDays]); }, [dateRangeDays]);
// Fetch activities
const fetchActivities = useCallback( const fetchActivities = useCallback(
async (cursor?: string) => { async (cursor?: string) => {
try { try {
-2
View File
@@ -50,7 +50,6 @@ export function useAnalytics(options: UseAnalyticsOptions = {}): AnalyticsData {
}, [days, options.startDate, options.endDate]); }, [days, options.startDate, options.endDate]);
/* eslint-enable react-hooks/purity */ /* eslint-enable react-hooks/purity */
// Fetch activity stats
const { const {
data: stats, data: stats,
error: statsError, error: statsError,
@@ -61,7 +60,6 @@ export function useAnalytics(options: UseAnalyticsOptions = {}): AnalyticsData {
dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds dedupingInterval: 10000, // Prevent duplicate requests within 10 seconds
}); });
// Fetch time series data (if endpoint exists)
const { const {
data: timeSeries, data: timeSeries,
error: timeSeriesError, error: timeSeriesError,
-1
View File
@@ -46,7 +46,6 @@ export function useContactFields() {
}, },
); );
// Extract just the field names as strings
const fieldNames = (data?.fields || []).map(f => f.field); const fieldNames = (data?.fields || []).map(f => f.field);
return { return {
-6
View File
@@ -72,7 +72,6 @@ export default function AnalyticsPage() {
return {startDate: start.toISOString(), endDate: end.toISOString()}; return {startDate: start.toISOString(), endDate: end.toISOString()};
}, [days]); }, [days]);
// Fetch campaign stats from API
const {data: campaignStats} = useSWR<{ const {data: campaignStats} = useSWR<{
total: number; total: number;
active: number; active: number;
@@ -85,7 +84,6 @@ export default function AnalyticsPage() {
dedupingInterval: 10000, dedupingInterval: 10000,
}); });
// Fetch top events from API
const {data: topEvents} = useSWR< const {data: topEvents} = useSWR<
{ {
name: string; name: string;
@@ -98,7 +96,6 @@ export default function AnalyticsPage() {
dedupingInterval: 10000, dedupingInterval: 10000,
}); });
// Fetch top campaigns from API
const {data: topCampaigns} = useSWR< const {data: topCampaigns} = useSWR<
{ {
id: string; id: string;
@@ -115,7 +112,6 @@ export default function AnalyticsPage() {
dedupingInterval: 10000, dedupingInterval: 10000,
}); });
// Process time series data for charts
const chartData = useMemo(() => { const chartData = useMemo(() => {
if (timeSeries && timeSeries.length > 0) { if (timeSeries && timeSeries.length > 0) {
return timeSeries.map(point => ({ return timeSeries.map(point => ({
@@ -131,12 +127,10 @@ export default function AnalyticsPage() {
return []; return [];
}, [timeSeries]); }, [timeSeries]);
// Check if we have any real data
const hasData = useMemo(() => { const hasData = useMemo(() => {
return chartData.some(point => point.emails > 0 || point.opens > 0 || point.clicks > 0); return chartData.some(point => point.emails > 0 || point.opens > 0 || point.clicks > 0);
}, [chartData]); }, [chartData]);
// Calculate cumulative totals
const cumulativeTotals = useMemo(() => { const cumulativeTotals = useMemo(() => {
return chartData.reduce( return chartData.reduce(
(acc, day) => ({ (acc, day) => ({
+56 -6
View File
@@ -16,6 +16,8 @@ export default function VerifyEmail() {
const [errorMessage, setErrorMessage] = useState<string>(''); const [errorMessage, setErrorMessage] = useState<string>('');
const [isResending, setIsResending] = useState(false); const [isResending, setIsResending] = useState(false);
const [resendMessage, setResendMessage] = useState<string>(''); const [resendMessage, setResendMessage] = useState<string>('');
const [cooldownExpiry, setCooldownExpiry] = useState<number | null>(null);
const [remainingSeconds, setRemainingSeconds] = useState(0);
const processedToken = useRef<string | undefined>(undefined); const processedToken = useRef<string | undefined>(undefined);
useEffect(() => { useEffect(() => {
@@ -67,6 +69,45 @@ export default function VerifyEmail() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [router.isReady, token]); }, [router.isReady, token]);
// Initialize cooldown from localStorage on mount
useEffect(() => {
const storedExpiry = localStorage.getItem('plunk:email-verification-cooldown');
if (storedExpiry) {
const expiryTime = parseInt(storedExpiry, 10);
// Validate: not NaN, in the future, and within reasonable range (< 1 hour from now)
if (!isNaN(expiryTime) && expiryTime > Date.now() && expiryTime < Date.now() + 3600000) {
setCooldownExpiry(expiryTime);
} else {
// Clean up invalid/expired cooldown
localStorage.removeItem('plunk:email-verification-cooldown');
}
}
}, []);
// Countdown timer effect
useEffect(() => {
if (!cooldownExpiry) {
setRemainingSeconds(0);
return;
}
// Update immediately
const updateRemaining = () => {
const remaining = Math.max(0, Math.ceil((cooldownExpiry - Date.now()) / 1000));
setRemainingSeconds(remaining);
if (remaining === 0) {
setCooldownExpiry(null);
localStorage.removeItem('plunk:email-verification-cooldown');
}
};
updateRemaining();
const interval = setInterval(updateRemaining, 1000);
return () => clearInterval(interval);
}, [cooldownExpiry]);
async function handleResend() { async function handleResend() {
setIsResending(true); setIsResending(true);
setResendMessage(''); setResendMessage('');
@@ -75,11 +116,20 @@ export default function VerifyEmail() {
if (response.success) { if (response.success) {
setResendMessage('Verification email sent! Please check your inbox.'); setResendMessage('Verification email sent! Please check your inbox.');
// Set 60-second cooldown
const expiryTime = Date.now() + 60000;
setCooldownExpiry(expiryTime);
localStorage.setItem('plunk:email-verification-cooldown', expiryTime.toString());
} else { } else {
setResendMessage('Failed to send verification email. Please try again.'); setResendMessage('Failed to send verification email. Please try again.');
} }
} catch { } catch (error) {
setResendMessage('Failed to send verification email. Please try again.'); // Show error message but still apply cooldown to prevent spam
setResendMessage(error instanceof Error ? error.message : 'Failed to send verification email. Please try again.');
// Apply cooldown even on error to prevent retry spam
const expiryTime = Date.now() + 60000;
setCooldownExpiry(expiryTime);
localStorage.setItem('plunk:email-verification-cooldown', expiryTime.toString());
} finally { } finally {
setIsResending(false); setIsResending(false);
} }
@@ -119,8 +169,8 @@ export default function VerifyEmail() {
</p> </p>
<div className="flex flex-col gap-3 w-full mt-4"> <div className="flex flex-col gap-3 w-full mt-4">
<Button onClick={handleResend} disabled={isResending} className="w-full"> <Button onClick={handleResend} disabled={isResending || cooldownExpiry !== null} className="w-full">
{isResending ? 'Sending...' : 'Resend verification email'} {isResending ? 'Sending...' : cooldownExpiry !== null ? `Resend in ${remainingSeconds}s` : 'Resend verification email'}
</Button> </Button>
{resendMessage && ( {resendMessage && (
@@ -205,8 +255,8 @@ export default function VerifyEmail() {
<p className="text-neutral-600">{errorMessage}</p> <p className="text-neutral-600">{errorMessage}</p>
<div className="flex flex-col gap-3 w-full mt-4"> <div className="flex flex-col gap-3 w-full mt-4">
<Button onClick={handleResend} disabled={isResending} className="w-full"> <Button onClick={handleResend} disabled={isResending || cooldownExpiry !== null} className="w-full">
{isResending ? 'Sending...' : 'Resend verification email'} {isResending ? 'Sending...' : cooldownExpiry !== null ? `Resend in ${remainingSeconds}s` : 'Resend verification email'}
</Button> </Button>
{resendMessage && ( {resendMessage && (
-1
View File
@@ -67,7 +67,6 @@ export default function ContactsPage() {
{revalidateOnFocus: false}, {revalidateOnFocus: false},
); );
// Update contacts when data changes
useEffect(() => { useEffect(() => {
if (data) { if (data) {
setContacts(data.data); setContacts(data.data);
-1
View File
@@ -33,7 +33,6 @@ export default function Manage() {
const data = await network.fetch<ContactInfo>('GET', `/contacts/public/${id}`); const data = await network.fetch<ContactInfo>('GET', `/contacts/public/${id}`);
setContact(data); setContact(data);
// Load translations for the project's language
const t = await createTranslator(data.language || 'en'); const t = await createTranslator(data.language || 'en');
setTranslator(t); setTranslator(t);
-1
View File
@@ -57,7 +57,6 @@ export default function SegmentDetailPage() {
const [isComputing, setIsComputing] = useState(false); const [isComputing, setIsComputing] = useState(false);
const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [showDeleteDialog, setShowDeleteDialog] = useState(false);
// Initialize form when segment loads
useEffect(() => { useEffect(() => {
if (segment) { if (segment) {
setName(segment.name); setName(segment.name);
-1
View File
@@ -1556,7 +1556,6 @@ function EditStepDialog({step, workflowId, open, onOpenChange, onSuccess}: EditS
const [name, setName] = useState(step.name); const [name, setName] = useState(step.name);
const [isSubmitting, setIsSubmitting] = useState(false); const [isSubmitting, setIsSubmitting] = useState(false);
// Get icon and colors for this step type
const Icon = STEP_TYPE_ICONS[step.type as keyof typeof STEP_TYPE_ICONS] || GitBranch; const Icon = STEP_TYPE_ICONS[step.type as keyof typeof STEP_TYPE_ICONS] || GitBranch;
const color = STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] || '#6b7280'; const color = STEP_TYPE_COLORS[step.type as keyof typeof STEP_TYPE_COLORS] || '#6b7280';
const bgColor = STEP_TYPE_BG[step.type as keyof typeof STEP_TYPE_BG] || '#f3f4f6'; const bgColor = STEP_TYPE_BG[step.type as keyof typeof STEP_TYPE_BG] || '#f3f4f6';
+1
View File
@@ -14,6 +14,7 @@
"typescript": "^5.7.2" "typescript": "^5.7.2"
}, },
"dependencies": { "dependencies": {
"@plunk/types": "*",
"zod": "^3.23.8" "zod": "^3.23.8"
}, },
"exports": { "exports": {
+1
View File
@@ -8,6 +8,7 @@
"build": "tsc" "build": "tsc"
}, },
"devDependencies": { "devDependencies": {
"@plunk/db": "*",
"@plunk/typescript-config": "*", "@plunk/typescript-config": "*",
"@types/node": "^24.10.0", "@types/node": "^24.10.0",
"@types/react": "^19.2.7", "@types/react": "^19.2.7",
-46
View File
@@ -7,58 +7,12 @@
import {Prisma} from '@plunk/db'; import {Prisma} from '@plunk/db';
/**
* Safely convert a value to Prisma.InputJsonValue for storing in JSON fields
*
* This helper provides better type safety than direct casting while acknowledging
* that Prisma cannot validate the JSON structure at compile time.
*
* @template T - The type being stored (for documentation purposes)
* @param value - The value to convert to Prisma JSON format
* @returns The value as Prisma.InputJsonValue
*
* @example
* ```typescript
* // Filter condition (complex nested object)
* const condition: FilterCondition = { logic: 'AND', groups: [...] };
* await prisma.segment.create({
* data: {
* condition: toPrismaJson(condition)
* }
* });
*
* // Simple object
* const headers = { 'X-Custom': 'value' };
* await prisma.email.create({
* data: {
* headers: toPrismaJson(headers)
* }
* });
* ```
*/
export function toPrismaJson<T>(value: T | null | undefined): Prisma.InputJsonValue { export function toPrismaJson<T>(value: T | null | undefined): Prisma.InputJsonValue {
// Prisma.InputJsonValue accepts: string | number | boolean | null | JsonObject | JsonArray // Prisma.InputJsonValue accepts: string | number | boolean | null | JsonObject | JsonArray
// We trust that T is JSON-serializable at runtime (including null) // We trust that T is JSON-serializable at runtime (including null)
return value as unknown as Prisma.InputJsonValue; return value as unknown as Prisma.InputJsonValue;
} }
/**
* Safely convert Prisma.JsonValue to a typed value when reading from JSON fields
*
* IMPORTANT: This does NOT perform runtime validation. It's a type-safe way to
* document what type you expect, but the caller must validate if needed.
*
* @template T - The expected type
* @param value - The JSON value from Prisma
* @returns The value as type T
*
* @example
* ```typescript
* const segment = await prisma.segment.findUnique({ where: { id } });
* const condition = fromPrismaJson<FilterCondition>(segment.condition);
* // condition is now typed as FilterCondition (but not validated)
* ```
*/
export function fromPrismaJson<T>(value: Prisma.JsonValue): T { export function fromPrismaJson<T>(value: Prisma.JsonValue): T {
return value as unknown as T; return value as unknown as T;
} }
+2
View File
@@ -3104,6 +3104,7 @@ __metadata:
resolution: "@plunk/shared@workspace:packages/shared" resolution: "@plunk/shared@workspace:packages/shared"
dependencies: dependencies:
"@plunk/db": "npm:*" "@plunk/db": "npm:*"
"@plunk/types": "npm:*"
"@plunk/typescript-config": "npm:*" "@plunk/typescript-config": "npm:*"
"@types/node": "npm:^24.10.0" "@types/node": "npm:^24.10.0"
typescript: "npm:^5.7.2" typescript: "npm:^5.7.2"
@@ -3115,6 +3116,7 @@ __metadata:
version: 0.0.0-use.local version: 0.0.0-use.local
resolution: "@plunk/types@workspace:packages/types" resolution: "@plunk/types@workspace:packages/types"
dependencies: dependencies:
"@plunk/db": "npm:*"
"@plunk/typescript-config": "npm:*" "@plunk/typescript-config": "npm:*"
"@types/node": "npm:^24.10.0" "@types/node": "npm:^24.10.0"
"@types/react": "npm:^19.2.7" "@types/react": "npm:^19.2.7"