Merge branch 'next' into dependabot/npm_and_yarn/qs-6.14.1
This commit is contained in:
@@ -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
|
||||||
|
|||||||
@@ -156,4 +156,10 @@ Required for builds and deployment (see turbo.json and .env.example):
|
|||||||
allows the same Docker image to be used across different environments by simply changing environment variables at
|
allows the same Docker image to be used across different environments by simply changing environment variables at
|
||||||
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.
|
||||||
@@ -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');
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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,12 +333,7 @@ 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) {
|
||||||
process.env.REQUEST_LOGGING = originalEnv;
|
process.env.REQUEST_LOGGING = originalEnv;
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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};
|
||||||
|
|||||||
@@ -27,8 +27,7 @@ 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 {
|
||||||
if (cursor) {
|
if (cursor) {
|
||||||
|
|||||||
@@ -50,8 +50,7 @@ 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,
|
||||||
isLoading: statsLoading,
|
isLoading: statsLoading,
|
||||||
@@ -61,8 +60,7 @@ 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,
|
||||||
isLoading: timeSeriesLoading,
|
isLoading: timeSeriesLoading,
|
||||||
|
|||||||
@@ -46,8 +46,7 @@ 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 {
|
||||||
fields: fieldNames,
|
fields: fieldNames,
|
||||||
|
|||||||
@@ -72,8 +72,7 @@ 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;
|
||||||
completed: number;
|
completed: number;
|
||||||
@@ -85,8 +84,7 @@ 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;
|
||||||
count: number;
|
count: number;
|
||||||
@@ -98,8 +96,7 @@ 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;
|
||||||
subject: string;
|
subject: string;
|
||||||
@@ -115,8 +112,7 @@ 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 => ({
|
||||||
date: new Date(point.date).toLocaleDateString('en-US', {month: 'short', day: 'numeric'}),
|
date: new Date(point.date).toLocaleDateString('en-US', {month: 'short', day: 'numeric'}),
|
||||||
@@ -131,13 +127,11 @@ 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) => ({
|
||||||
emails: acc.emails + (day.emails || 0),
|
emails: acc.emails + (day.emails || 0),
|
||||||
|
|||||||
@@ -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 && (
|
||||||
|
|||||||
@@ -67,8 +67,7 @@ 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);
|
||||||
if (!cursor) {
|
if (!cursor) {
|
||||||
|
|||||||
@@ -33,8 +33,7 @@ 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);
|
||||||
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|||||||
@@ -57,8 +57,7 @@ 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);
|
||||||
setDescription(segment.description || '');
|
setDescription(segment.description || '');
|
||||||
|
|||||||
@@ -1556,8 +1556,7 @@ 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';
|
||||||
|
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|||||||
Reference in New Issue
Block a user