feat: convert to nextjs
This commit is contained in:
47
utils/anthropicClient.ts
Normal file
47
utils/anthropicClient.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import 'dotenv/config';
|
||||
import Anthropic from '@anthropic-ai/sdk';
|
||||
|
||||
export interface BaseTool {
|
||||
readonly name: string;
|
||||
readonly input_schema: {
|
||||
readonly type: 'object';
|
||||
readonly properties: Record<string, unknown>;
|
||||
readonly required?: readonly string[];
|
||||
readonly description?: string;
|
||||
};
|
||||
}
|
||||
|
||||
export async function makeRequest<T extends BaseTool>(prompt: string, tool: T) {
|
||||
if (!process.env.ANTHROPIC_API_KEY) {
|
||||
throw Error('No Anthropic API key found.');
|
||||
}
|
||||
|
||||
const client = new Anthropic({ apiKey: process.env.ANTHROPIC_API_KEY });
|
||||
|
||||
try {
|
||||
const response = await client.messages.create({
|
||||
model: 'claude-3-5-sonnet-20241022',
|
||||
max_tokens: 8192,
|
||||
temperature: 1,
|
||||
tools: [tool],
|
||||
messages: [{ role: 'user', content: prompt }]
|
||||
});
|
||||
|
||||
if (response.stop_reason && response.stop_reason !== 'tool_use') {
|
||||
throw Error(JSON.stringify(response));
|
||||
}
|
||||
|
||||
if (response.content.length != 2) {
|
||||
throw Error(JSON.stringify(response));
|
||||
}
|
||||
|
||||
const content = response.content as [
|
||||
{ type: string; text: string },
|
||||
{ type: string; input: object }
|
||||
];
|
||||
|
||||
return content[1].input;
|
||||
} catch (error) {
|
||||
throw Error('Anthropic client error.');
|
||||
}
|
||||
}
|
||||
75
utils/consumer/prompt.ts
Normal file
75
utils/consumer/prompt.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { Moment } from 'moment';
|
||||
|
||||
export function generatePrompt(
|
||||
letters: string,
|
||||
birthday: Moment,
|
||||
zipCode: string
|
||||
): string {
|
||||
return `You are tasked with creating a detailed consumer of an Italian individual based on the following seed information:
|
||||
|
||||
<consumer_seed>
|
||||
<name_letters>${letters}</name_letters>
|
||||
<birthday>${birthday.format('YYYY-MM-DD')}</birthday>
|
||||
<zip_code>${zipCode}</zip_code>
|
||||
</consumer_seed>
|
||||
|
||||
Your goal is to generate a realistic and diverse consumer that reflects the complexity of Italian society. Follow these steps to create the consumer:
|
||||
|
||||
1. Analyze the demographic data:
|
||||
- Create a full name that includes ALL the letters provided in <name_letters>, though it may contain additional letters.
|
||||
- Consider the implications of the birth year and postal code on the person's background and lifestyle.
|
||||
|
||||
2. Generate core demographics:
|
||||
- Name and age (derived from the seed information)
|
||||
- Occupation (including title, level, approximate income, location, and schedule)
|
||||
- Home situation (residence type, ownership status, location)
|
||||
- Household (relationship status, family members, pets if any)
|
||||
|
||||
3. Describe daily patterns:
|
||||
- Detailed weekday schedule with approximate times and locations
|
||||
- Typical weekend activities
|
||||
- Commute details (transportation method, route, regular stops if applicable)
|
||||
|
||||
4. Define preferences and behaviors:
|
||||
- Financial management style
|
||||
- Brand relationships (with loyalty scores from 1 to 10)
|
||||
- Preferred payment methods
|
||||
|
||||
5. Outline a financial profile:
|
||||
- Fixed monthly expenses:
|
||||
* Housing: rent/mortgage payments
|
||||
* Utilities: electricity, gas, water, waste management
|
||||
* Internet and phone services
|
||||
* Insurance payments (home, car, health, life)
|
||||
* Property taxes (if applicable)
|
||||
- Regular subscriptions:
|
||||
* Digital services (streaming, apps, etc.)
|
||||
* Memberships (gym, clubs, etc.)
|
||||
* Regular services (cleaning, maintenance, etc.)
|
||||
- Category-specific spending patterns
|
||||
- Impulse buying tendency (score from 1 to 10)
|
||||
|
||||
6. Describe regular activities:
|
||||
- Exercise routines or lack thereof
|
||||
- Social activities
|
||||
|
||||
7. Add consumerl context:
|
||||
- Key stress triggers
|
||||
- Reward behaviors
|
||||
- Upcoming significant events
|
||||
|
||||
Throughout this process, consider the following:
|
||||
- Ensure a diverse representation of technological aptitudes, not focusing solely on tech-savvy individuals.
|
||||
- Use inclusive language and avoid stereotypes or discriminatory assumptions.
|
||||
- Align numerical scores (1-10) and monetary values (in EUR) with:
|
||||
a) Regional economic indicators
|
||||
b) Generational trends
|
||||
c) Professional sector norms
|
||||
d) Local cost of living
|
||||
|
||||
Before providing the final consumer, wrap your analysis in <consumer_creation_process> tags. For each major section:
|
||||
1. Break down the postal code implications on the person's background and lifestyle.
|
||||
2. Consider multiple options for each aspect (at least 2-3 choices).
|
||||
3. Explain your reasoning for the final choice.
|
||||
This will help ensure a thorough and well-reasoned consumer creation.`;
|
||||
}
|
||||
54
utils/consumer/store.ts
Normal file
54
utils/consumer/store.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
import 'dotenv/config';
|
||||
import { Consumer, consumerSchema } from './types';
|
||||
import { Tool } from './tool';
|
||||
import { BaseTool, makeRequest } from '../anthropicClient';
|
||||
import { generatePrompt } from './prompt';
|
||||
import { generateConsumerSeed } from '@utils/generateConsumerSeed';
|
||||
import prisma from '../../prisma/prisma';
|
||||
|
||||
export async function generate() {
|
||||
const { letters, birthday, zipCode } = generateConsumerSeed();
|
||||
|
||||
const newConsumer = await prisma.consumer.create({
|
||||
data: {
|
||||
letters,
|
||||
birthday: birthday.toDate(),
|
||||
zipCode
|
||||
}
|
||||
});
|
||||
|
||||
console.info(`New consumer being generated with id ${newConsumer.id}`);
|
||||
|
||||
const prompt = generatePrompt(letters, birthday, zipCode);
|
||||
|
||||
try {
|
||||
const result = (await makeRequest(prompt, Tool as BaseTool)) as Consumer;
|
||||
|
||||
const validConsumer = consumerSchema.safeParse(result);
|
||||
|
||||
if (validConsumer.error) {
|
||||
throw Error(`Invalid consumer generated: ${validConsumer.error.message}`);
|
||||
}
|
||||
|
||||
console.info('Generated consumer by Anthropic', validConsumer.data);
|
||||
|
||||
await prisma.consumer.update({
|
||||
where: {
|
||||
id: newConsumer.id
|
||||
},
|
||||
data: {
|
||||
value: validConsumer.data
|
||||
}
|
||||
});
|
||||
|
||||
console.info(`Consumer with id ${newConsumer.id} stored in database.`);
|
||||
|
||||
return {
|
||||
id: newConsumer.id,
|
||||
consumer: validConsumer.data
|
||||
};
|
||||
} catch (error) {
|
||||
console.error('Error generating purchases:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
408
utils/consumer/tool.ts
Normal file
408
utils/consumer/tool.ts
Normal file
@@ -0,0 +1,408 @@
|
||||
export const Tool = {
|
||||
name: 'ConsumerSchema' as const,
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
description: 'User consumer',
|
||||
properties: {
|
||||
core: {
|
||||
type: 'object' as const,
|
||||
description: 'Core user information and demographics',
|
||||
properties: {
|
||||
age: {
|
||||
type: 'number' as const,
|
||||
description: "User's age in years"
|
||||
},
|
||||
name: {
|
||||
type: 'string' as const,
|
||||
description: "User's full name"
|
||||
},
|
||||
occupation: {
|
||||
type: 'object' as const,
|
||||
description: 'Employment details',
|
||||
properties: {
|
||||
title: {
|
||||
type: 'string' as const,
|
||||
description: 'Job title'
|
||||
},
|
||||
level: {
|
||||
type: 'string' as const,
|
||||
description: 'Career level (e.g., entry, senior, manager)'
|
||||
},
|
||||
income: {
|
||||
type: 'number' as const,
|
||||
description: 'Annual income'
|
||||
},
|
||||
location: {
|
||||
type: 'string' as const,
|
||||
description: 'Work location'
|
||||
},
|
||||
schedule: {
|
||||
type: 'array' as const,
|
||||
description: 'Working days/hours',
|
||||
items: {
|
||||
type: 'string' as const
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
home: {
|
||||
type: 'object' as const,
|
||||
description: 'Housing information',
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string' as const,
|
||||
description: 'Type of residence (e.g., apartment, house)'
|
||||
},
|
||||
ownership: {
|
||||
type: 'string' as const,
|
||||
description: 'Ownership status (e.g., owned, rented)'
|
||||
},
|
||||
location: {
|
||||
type: 'string' as const,
|
||||
description: 'Home address or area'
|
||||
},
|
||||
commute_distance_km: {
|
||||
type: 'number' as const,
|
||||
description: 'Distance to work in kilometers'
|
||||
}
|
||||
}
|
||||
},
|
||||
household: {
|
||||
type: 'object' as const,
|
||||
description: 'Household composition',
|
||||
properties: {
|
||||
status: {
|
||||
type: 'string' as const,
|
||||
description: 'Marital/living status'
|
||||
},
|
||||
members: {
|
||||
type: 'array' as const,
|
||||
description: 'Other household members',
|
||||
items: {
|
||||
type: 'string' as const
|
||||
}
|
||||
},
|
||||
pets: {
|
||||
type: 'array' as const,
|
||||
description: 'Household pets',
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
type: {
|
||||
type: 'string' as const,
|
||||
description: 'Type of pet'
|
||||
},
|
||||
name: {
|
||||
type: 'string' as const,
|
||||
description: "Pet's name"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
routines: {
|
||||
type: 'object' as const,
|
||||
description: 'Daily and weekly routines',
|
||||
properties: {
|
||||
weekday: {
|
||||
type: 'object' as const,
|
||||
description: 'Typical weekday schedule',
|
||||
additionalProperties: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
activity: {
|
||||
type: 'string' as const,
|
||||
description: 'Activity description'
|
||||
},
|
||||
location: {
|
||||
type: 'string' as const,
|
||||
description: 'Location of activity'
|
||||
},
|
||||
duration_minutes: {
|
||||
type: 'number' as const,
|
||||
description: 'Duration in minutes'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
weekend: {
|
||||
type: 'array' as const,
|
||||
description: 'Regular weekend activities',
|
||||
items: {
|
||||
type: 'string' as const
|
||||
}
|
||||
},
|
||||
commute: {
|
||||
type: 'object' as const,
|
||||
description: 'Commute details',
|
||||
properties: {
|
||||
method: {
|
||||
type: 'string' as const,
|
||||
description: 'Primary mode of transportation'
|
||||
},
|
||||
route: {
|
||||
type: 'array' as const,
|
||||
description: 'Regular route points',
|
||||
items: {
|
||||
type: 'string' as const
|
||||
}
|
||||
},
|
||||
regular_stops: {
|
||||
type: 'array' as const,
|
||||
description: 'Regular stops during commute',
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
location: {
|
||||
type: 'string' as const,
|
||||
description: 'Stop location'
|
||||
},
|
||||
purpose: {
|
||||
type: 'string' as const,
|
||||
description: 'Purpose of stop'
|
||||
},
|
||||
frequency: {
|
||||
type: 'string' as const,
|
||||
description: 'How often this stop is made'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
preferences: {
|
||||
type: 'object' as const,
|
||||
description: 'User preferences and habits',
|
||||
properties: {
|
||||
diet: {
|
||||
type: 'array' as const,
|
||||
description: 'Dietary preferences and restrictions',
|
||||
items: {
|
||||
type: 'string' as const
|
||||
}
|
||||
},
|
||||
brands: {
|
||||
type: 'array' as const,
|
||||
description: 'Brand preferences',
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string' as const,
|
||||
description: 'Brand name'
|
||||
},
|
||||
loyalty_score: {
|
||||
type: 'number' as const,
|
||||
description: 'Brand loyalty score (1-10)',
|
||||
minimum: 1,
|
||||
maximum: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
price_sensitivity: {
|
||||
type: 'number' as const,
|
||||
description: 'Price sensitivity score (1-10)',
|
||||
minimum: 1,
|
||||
maximum: 10
|
||||
},
|
||||
payment_methods: {
|
||||
type: 'array' as const,
|
||||
description: 'Preferred payment methods',
|
||||
items: {
|
||||
type: 'string' as const
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
finances: {
|
||||
type: 'object' as const,
|
||||
description: 'Financial information',
|
||||
properties: {
|
||||
subscriptions: {
|
||||
type: 'array' as const,
|
||||
description: 'Regular subscriptions and fixed expenses',
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string' as const,
|
||||
description: 'Subscription or expense name'
|
||||
},
|
||||
amount: {
|
||||
type: 'number' as const,
|
||||
description: 'Monthly cost'
|
||||
},
|
||||
frequency: {
|
||||
type: 'string' as const,
|
||||
description: 'Billing frequency'
|
||||
},
|
||||
next_due_date: {
|
||||
type: 'string' as const,
|
||||
description: 'Next payment date',
|
||||
format: 'date'
|
||||
},
|
||||
category: {
|
||||
type: 'string' as const,
|
||||
description: 'Expense category',
|
||||
enum: [
|
||||
'housing',
|
||||
'utilities',
|
||||
'insurance',
|
||||
'services',
|
||||
'memberships',
|
||||
'digital',
|
||||
'taxes',
|
||||
'other'
|
||||
]
|
||||
},
|
||||
is_fixed_expense: {
|
||||
type: 'boolean' as const,
|
||||
description:
|
||||
'Whether this is a fixed expense (utilities, rent) or optional subscription'
|
||||
},
|
||||
auto_payment: {
|
||||
type: 'boolean' as const,
|
||||
description: 'Whether payment is automated'
|
||||
}
|
||||
},
|
||||
required: [
|
||||
'name',
|
||||
'amount',
|
||||
'frequency',
|
||||
'next_due_date',
|
||||
'category',
|
||||
'is_fixed_expense',
|
||||
'auto_payment'
|
||||
]
|
||||
}
|
||||
},
|
||||
spending_patterns: {
|
||||
type: 'object' as const,
|
||||
description: 'Spending behavior',
|
||||
properties: {
|
||||
impulsive_score: {
|
||||
type: 'number' as const,
|
||||
description: 'Impulse buying tendency (1-10)',
|
||||
minimum: 1,
|
||||
maximum: 10
|
||||
},
|
||||
categories: {
|
||||
type: 'object' as const,
|
||||
description: 'Spending categories',
|
||||
additionalProperties: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
preference_score: {
|
||||
type: 'number' as const,
|
||||
description: 'Category preference (1-10)',
|
||||
minimum: 1,
|
||||
maximum: 10
|
||||
},
|
||||
monthly_budget: {
|
||||
type: 'number' as const,
|
||||
description: 'Typical monthly spend'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
habits: {
|
||||
type: 'object' as const,
|
||||
description: 'Regular activities and habits',
|
||||
properties: {
|
||||
exercise: {
|
||||
type: 'array' as const,
|
||||
description: 'Exercise routines',
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
activity: {
|
||||
type: 'string' as const,
|
||||
description: 'Type of exercise'
|
||||
},
|
||||
frequency: {
|
||||
type: 'string' as const,
|
||||
description: 'How often performed'
|
||||
},
|
||||
duration_minutes: {
|
||||
type: 'number' as const,
|
||||
description: 'Typical duration'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
social: {
|
||||
type: 'array' as const,
|
||||
description: 'Social activities',
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
activity: {
|
||||
type: 'string' as const,
|
||||
description: 'Type of social activity'
|
||||
},
|
||||
frequency: {
|
||||
type: 'string' as const,
|
||||
description: 'How often performed'
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
context: {
|
||||
type: 'object' as const,
|
||||
description: 'Contextual information',
|
||||
properties: {
|
||||
stress_triggers: {
|
||||
type: 'array' as const,
|
||||
description: 'Known stress factors',
|
||||
items: {
|
||||
type: 'string' as const
|
||||
}
|
||||
},
|
||||
reward_behaviors: {
|
||||
type: 'array' as const,
|
||||
description: 'Activities used as rewards',
|
||||
items: {
|
||||
type: 'string' as const
|
||||
}
|
||||
},
|
||||
upcoming_events: {
|
||||
type: 'array' as const,
|
||||
description: 'Planned future events',
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string' as const,
|
||||
description: 'Event name'
|
||||
},
|
||||
date: {
|
||||
type: 'string' as const,
|
||||
description: 'Event date',
|
||||
format: 'date'
|
||||
},
|
||||
importance: {
|
||||
type: 'number' as const,
|
||||
description: 'Event importance (1-10)',
|
||||
minimum: 1,
|
||||
maximum: 10
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} as const;
|
||||
138
utils/consumer/types.ts
Normal file
138
utils/consumer/types.ts
Normal file
@@ -0,0 +1,138 @@
|
||||
import { z } from 'zod';
|
||||
|
||||
const petSchema = z.object({
|
||||
type: z.string(),
|
||||
name: z.string()
|
||||
});
|
||||
|
||||
const regularStopSchema = z.object({
|
||||
location: z.string(),
|
||||
purpose: z.string(),
|
||||
frequency: z.string()
|
||||
});
|
||||
|
||||
const weekdayActivitySchema = z.object({
|
||||
activity: z.string(),
|
||||
location: z.string(),
|
||||
duration_minutes: z.number()
|
||||
});
|
||||
|
||||
const brandPreferenceSchema = z.object({
|
||||
name: z.string(),
|
||||
loyalty_score: z.number().min(1).max(10)
|
||||
});
|
||||
|
||||
const subscriptionSchema = z.object({
|
||||
name: z.string(),
|
||||
amount: z.number(),
|
||||
frequency: z.string(),
|
||||
next_due_date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
category: z.enum([
|
||||
'housing',
|
||||
'utilities',
|
||||
'insurance',
|
||||
'services',
|
||||
'memberships',
|
||||
'digital',
|
||||
'taxes',
|
||||
'other'
|
||||
]),
|
||||
is_fixed_expense: z.boolean(),
|
||||
auto_payment: z.boolean()
|
||||
});
|
||||
|
||||
const spendingCategorySchema = z.object({
|
||||
preference_score: z.number().min(1).max(10),
|
||||
monthly_budget: z.number()
|
||||
});
|
||||
|
||||
const exerciseActivitySchema = z.object({
|
||||
activity: z.string(),
|
||||
frequency: z.string(),
|
||||
duration_minutes: z.number()
|
||||
});
|
||||
|
||||
export type ExerciseActivity = z.infer<typeof exerciseActivitySchema>;
|
||||
|
||||
const socialActivitySchema = z.object({
|
||||
activity: z.string(),
|
||||
frequency: z.string()
|
||||
});
|
||||
|
||||
export type SocialActivity = z.infer<typeof socialActivitySchema>;
|
||||
|
||||
const upcomingEventSchema = z.object({
|
||||
name: z.string(),
|
||||
date: z.string().regex(/^\d{4}-\d{2}-\d{2}$/),
|
||||
importance: z.number().min(1).max(10)
|
||||
});
|
||||
|
||||
const coreSchema = z.object({
|
||||
age: z.number(),
|
||||
name: z.string(),
|
||||
occupation: z.object({
|
||||
title: z.string(),
|
||||
level: z.string(),
|
||||
income: z.number(),
|
||||
location: z.string(),
|
||||
schedule: z.array(z.string())
|
||||
}),
|
||||
home: z.object({
|
||||
type: z.string(),
|
||||
ownership: z.string(),
|
||||
location: z.string(),
|
||||
commute_distance_km: z.number()
|
||||
}),
|
||||
household: z.object({
|
||||
status: z.string(),
|
||||
members: z.array(z.string()),
|
||||
pets: z.array(petSchema)
|
||||
})
|
||||
});
|
||||
|
||||
const routinesSchema = z.object({
|
||||
weekday: z.record(weekdayActivitySchema),
|
||||
weekend: z.array(z.string()),
|
||||
commute: z.object({
|
||||
method: z.string(),
|
||||
route: z.array(z.string()),
|
||||
regular_stops: z.array(regularStopSchema)
|
||||
})
|
||||
});
|
||||
|
||||
const preferencesSchema = z.object({
|
||||
diet: z.array(z.string()),
|
||||
brands: z.array(brandPreferenceSchema),
|
||||
price_sensitivity: z.number().min(1).max(10),
|
||||
payment_methods: z.array(z.string())
|
||||
});
|
||||
|
||||
const financesSchema = z.object({
|
||||
subscriptions: z.array(subscriptionSchema),
|
||||
spending_patterns: z.object({
|
||||
impulsive_score: z.number().min(1).max(10),
|
||||
categories: z.record(spendingCategorySchema)
|
||||
})
|
||||
});
|
||||
|
||||
const habitsSchema = z.object({
|
||||
exercise: z.array(exerciseActivitySchema),
|
||||
social: z.array(socialActivitySchema)
|
||||
});
|
||||
|
||||
const contextSchema = z.object({
|
||||
stress_triggers: z.array(z.string()),
|
||||
reward_behaviors: z.array(z.string()),
|
||||
upcoming_events: z.array(upcomingEventSchema)
|
||||
});
|
||||
|
||||
export const consumerSchema = z.object({
|
||||
core: coreSchema,
|
||||
routines: routinesSchema,
|
||||
preferences: preferencesSchema,
|
||||
finances: financesSchema,
|
||||
habits: habitsSchema,
|
||||
context: contextSchema
|
||||
});
|
||||
|
||||
export type Consumer = z.infer<typeof consumerSchema>;
|
||||
33
utils/dateFunctions.ts
Normal file
33
utils/dateFunctions.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
export function getWeekRanges(
|
||||
targetDate: Date,
|
||||
numWeeks: number
|
||||
): Array<{ start: Date; end: Date }> {
|
||||
if (numWeeks < 1 || numWeeks > 8) {
|
||||
throw new Error('Number of weeks must be between 1 and 8');
|
||||
}
|
||||
|
||||
const ranges = [];
|
||||
const firstDay = new Date(targetDate);
|
||||
firstDay.setUTCHours(0, 0, 0, 0);
|
||||
|
||||
const dayOfWeek = firstDay.getUTCDay();
|
||||
const diff = dayOfWeek === 0 ? -6 : 1 - dayOfWeek;
|
||||
firstDay.setUTCDate(firstDay.getUTCDate() + diff);
|
||||
|
||||
for (let i = 0; i < numWeeks; i++) {
|
||||
const weekStart = new Date(firstDay);
|
||||
weekStart.setUTCDate(weekStart.getUTCDate() + i * 7);
|
||||
|
||||
const weekEnd = new Date(weekStart);
|
||||
weekEnd.setUTCDate(weekEnd.getUTCDate() + 6);
|
||||
weekEnd.setUTCHours(23, 59, 59, 999);
|
||||
|
||||
ranges.push({ start: weekStart, end: weekEnd });
|
||||
}
|
||||
|
||||
return ranges;
|
||||
}
|
||||
|
||||
export function isDateInRange(date: Date, start: Date, end: Date): boolean {
|
||||
return date >= start && date <= end;
|
||||
}
|
||||
115
utils/generateConsumerSeed.ts
Normal file
115
utils/generateConsumerSeed.ts
Normal file
@@ -0,0 +1,115 @@
|
||||
import moment, { Moment } from 'moment';
|
||||
|
||||
const PROVINCE_CODES = [
|
||||
'00', // Roma
|
||||
'04', // Latina
|
||||
'10', // Torino
|
||||
'12', // Cuneo
|
||||
'16', // Genova
|
||||
'20', // Milano
|
||||
'24', // Bergamo
|
||||
'25', // Brescia
|
||||
'30', // Venezia
|
||||
'31', // Treviso
|
||||
'35', // Padova
|
||||
'40', // Bologna
|
||||
'45', // Rovigo
|
||||
'47', // Forli-Cesena
|
||||
'48', // Ravenna
|
||||
'50', // Firenze
|
||||
'51', // Pistoia
|
||||
'52', // Arezzo
|
||||
'53', // Siena
|
||||
'54', // Massa-Carrara
|
||||
'55', // Lucca
|
||||
'56', // Pisa
|
||||
'57', // Livorno
|
||||
'58', // Grosseto
|
||||
'60', // Ancona
|
||||
'61', // Pesaro
|
||||
'63', // Ascoli Piceno
|
||||
'65', // Pescara
|
||||
'66', // Chieti
|
||||
'67', // L'Aquila
|
||||
'70', // Bari
|
||||
'71', // Foggia
|
||||
'72', // Brindisi
|
||||
'73', // Lecce
|
||||
'74', // Taranto
|
||||
'75', // Matera
|
||||
'80', // Napoli
|
||||
'81', // Caserta
|
||||
'82', // Benevento
|
||||
'83', // Avellino
|
||||
'84', // Salerno
|
||||
'87', // Cosenza
|
||||
'88', // Catanzaro
|
||||
'89', // Reggio Calabria
|
||||
'90', // Palermo
|
||||
'91', // Trapani
|
||||
'92', // Agrigento
|
||||
'93', // Caltanissetta
|
||||
'94', // Enna
|
||||
'95', // Catania
|
||||
'96', // Siracusa
|
||||
'97', // Ragusa
|
||||
'98' // Messina
|
||||
];
|
||||
|
||||
export function generateRandomCAP(): string {
|
||||
const provinceCode =
|
||||
PROVINCE_CODES[Math.floor(Math.random() * PROVINCE_CODES.length)];
|
||||
|
||||
const lastThreeDigits = Math.floor(Math.random() * 1000)
|
||||
.toString()
|
||||
.padStart(3, '0');
|
||||
|
||||
return `${provinceCode}${lastThreeDigits}`;
|
||||
}
|
||||
|
||||
function generateLetters(): string {
|
||||
const consonants = 'BCDFGLMNPRSTVZ';
|
||||
const vowels = 'AEIOU';
|
||||
|
||||
let result = '';
|
||||
|
||||
const consonantCount = 4 + Math.floor(Math.random() * 2);
|
||||
for (let i = 0; i < consonantCount; i++) {
|
||||
result += consonants[Math.floor(Math.random() * consonants.length)];
|
||||
}
|
||||
|
||||
const extraVowels = 3 + Math.floor(Math.random() * 2);
|
||||
for (let i = 0; i < extraVowels; i++) {
|
||||
result += vowels[Math.floor(Math.random() * vowels.length)];
|
||||
}
|
||||
|
||||
return result
|
||||
.split('')
|
||||
.sort(() => Math.random() - 0.5)
|
||||
.join('');
|
||||
}
|
||||
|
||||
function generateBirthYear(): Moment {
|
||||
const currentYear = moment().year();
|
||||
const minYear = currentYear - 50;
|
||||
const maxYear = currentYear - 20;
|
||||
const year = Math.floor(Math.random() * (maxYear - minYear) + minYear);
|
||||
|
||||
const startDate = moment([year, 0, 1]); // January 1st
|
||||
const endDate = moment([year, 11, 31]); // December 31st
|
||||
|
||||
const startTimestamp = startDate.valueOf();
|
||||
const endTimestamp = endDate.valueOf();
|
||||
const randomTimestamp =
|
||||
startTimestamp + Math.random() * (endTimestamp - startTimestamp);
|
||||
|
||||
return moment(randomTimestamp).startOf('day');
|
||||
}
|
||||
|
||||
export function generateConsumerSeed() {
|
||||
const letters = generateLetters();
|
||||
const birthday = generateBirthYear();
|
||||
const zipCode = generateRandomCAP();
|
||||
|
||||
return { letters, birthday, zipCode };
|
||||
}
|
||||
296
utils/purchases/prompt.ts
Normal file
296
utils/purchases/prompt.ts
Normal file
@@ -0,0 +1,296 @@
|
||||
import { ExerciseActivity, Consumer, SocialActivity } from '../consumer/types';
|
||||
import { getWeekRanges, isDateInRange } from '../dateFunctions';
|
||||
|
||||
function formatCategories(
|
||||
categories?: Record<
|
||||
string,
|
||||
{ preference_score: number; monthly_budget: number }
|
||||
>
|
||||
): string {
|
||||
if (!categories || Object.keys(categories).length === 0) {
|
||||
return '(No spending categories defined)';
|
||||
}
|
||||
|
||||
return Object.entries(categories)
|
||||
.map(([category, data]) => {
|
||||
const weeklyBudget = Math.round(data.monthly_budget / 4.33);
|
||||
const preferenceLevel =
|
||||
data.preference_score >= 8
|
||||
? 'high'
|
||||
: data.preference_score >= 5
|
||||
? 'moderate'
|
||||
: 'low';
|
||||
return `- ${category}: €${weeklyBudget}/week (${preferenceLevel} preference)`;
|
||||
})
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatHabits(habits: {
|
||||
exercise: ExerciseActivity[];
|
||||
social: SocialActivity[];
|
||||
}): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
if (habits.exercise?.length) {
|
||||
sections.push(
|
||||
'Activity Pattern:\n' +
|
||||
habits.exercise
|
||||
.map(
|
||||
ex =>
|
||||
`- ${ex.frequency} ${ex.activity} sessions (${ex.duration_minutes}min)`
|
||||
)
|
||||
.join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
if (habits.social?.length) {
|
||||
sections.push(
|
||||
'Social Pattern:\n' +
|
||||
habits.social
|
||||
.map(soc => `- ${soc.frequency} ${soc.activity}`)
|
||||
.join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
function formatContext(context: {
|
||||
stress_triggers: string[];
|
||||
reward_behaviors: string[];
|
||||
upcoming_events: Array<{ name: string; date: string; importance: number }>;
|
||||
}): string {
|
||||
const sections: string[] = [];
|
||||
|
||||
if (context.upcoming_events?.length) {
|
||||
sections.push(
|
||||
'Key Events:\n' +
|
||||
context.upcoming_events
|
||||
.map(event => {
|
||||
const impact =
|
||||
event.importance >= 8
|
||||
? 'significant'
|
||||
: event.importance >= 5
|
||||
? 'moderate'
|
||||
: 'minor';
|
||||
return `- ${event.name} (${event.date}) - Priority: ${event.importance}/10\n Expected spending impact: ${impact}`;
|
||||
})
|
||||
.join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
if (context.stress_triggers?.length) {
|
||||
sections.push(
|
||||
'Stress Factors:\n' +
|
||||
context.stress_triggers.map(trigger => `- ${trigger}`).join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
if (context.reward_behaviors?.length) {
|
||||
sections.push(
|
||||
'Reward Activities:\n' +
|
||||
context.reward_behaviors.map(behavior => `- ${behavior}`).join('\n')
|
||||
);
|
||||
}
|
||||
|
||||
return sections.join('\n\n');
|
||||
}
|
||||
|
||||
function formatConsumerCore(core: Consumer['core']): string {
|
||||
const sections = [
|
||||
`${core.name} is a ${core.age}-year-old ${core.occupation.title} at ${core.occupation.location}`,
|
||||
`Living: ${core.household.status}, ${core.home.ownership} ${core.home.type} in ${core.home.location}`,
|
||||
formatHousehold(core.household)
|
||||
];
|
||||
|
||||
return sections.filter(Boolean).join('\n');
|
||||
}
|
||||
|
||||
function formatHousehold(household: Consumer['core']['household']): string {
|
||||
const sections = [];
|
||||
|
||||
if (household.members.length) {
|
||||
sections.push(`Household Members: ${household.members.join(', ')}`);
|
||||
}
|
||||
|
||||
if (household.pets.length) {
|
||||
sections.push(
|
||||
`Pets: ${household.pets.map(pet => `${pet.name} (${pet.type})`).join(', ')}`
|
||||
);
|
||||
}
|
||||
|
||||
return sections.join('\n');
|
||||
}
|
||||
|
||||
function formatDailySchedule(
|
||||
weekday: Record<
|
||||
string,
|
||||
{
|
||||
activity: string;
|
||||
location: string;
|
||||
duration_minutes: number;
|
||||
}
|
||||
>
|
||||
): string {
|
||||
return Object.entries(weekday)
|
||||
.map(
|
||||
([time, details]) =>
|
||||
`- ${time}: ${details.activity} (${details.location})`
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatCommute(commute: Consumer['routines']['commute']): string {
|
||||
return `${commute.method} (${commute.route.join(' → ')})
|
||||
Regular stops: ${commute.regular_stops
|
||||
.map(stop => `${stop.frequency} ${stop.purpose} at ${stop.location}`)
|
||||
.join(', ')}`;
|
||||
}
|
||||
|
||||
function formatPurchasingStyle(impulsiveScore: number): string {
|
||||
if (impulsiveScore <= 3) return 'Highly planned, rarely impulsive';
|
||||
if (impulsiveScore <= 5)
|
||||
return 'Mostly planned, occasional impulse purchases';
|
||||
if (impulsiveScore <= 7) return 'Balance of planned and impulse purchases';
|
||||
return 'Frequently makes impulse purchases';
|
||||
}
|
||||
|
||||
function formatSubscriptions(
|
||||
subscriptions: Consumer['finances']['subscriptions']
|
||||
): string {
|
||||
return subscriptions
|
||||
.map(
|
||||
sub =>
|
||||
`- ${sub.name}: €${sub.amount} (${sub.frequency})${sub.auto_payment ? ' [automatic]' : ''}`
|
||||
)
|
||||
.join('\n');
|
||||
}
|
||||
|
||||
function formatBrands(brands: Consumer['preferences']['brands']): string {
|
||||
return brands
|
||||
.map(brand => `${brand.name} (loyalty: ${brand.loyalty_score}/10)`)
|
||||
.join(', ');
|
||||
}
|
||||
|
||||
function formatWeekSection(
|
||||
range: { start: Date; end: Date },
|
||||
weekNum: number,
|
||||
consumer: Consumer
|
||||
): string {
|
||||
return `=== WEEK ${weekNum}: ${range.start.toISOString().split('T')[0]} to ${range.end.toISOString().split('T')[0]} ===
|
||||
|
||||
Context:
|
||||
${formatWeekContext(range, consumer)}
|
||||
${formatPurchaseOpportunities(consumer)}`;
|
||||
}
|
||||
|
||||
function formatWeekContext(
|
||||
range: { start: Date; end: Date },
|
||||
consumer: Consumer
|
||||
): string {
|
||||
const contexts = [];
|
||||
|
||||
if (range.start.getDate() <= 5) {
|
||||
contexts.push('Post-salary period');
|
||||
}
|
||||
|
||||
const events = consumer.context.upcoming_events.filter(event =>
|
||||
isDateInRange(new Date(event.date), range.start, range.end)
|
||||
);
|
||||
|
||||
if (events.length) {
|
||||
contexts.push(`Events: ${events.map(e => e.name).join(', ')}`);
|
||||
}
|
||||
|
||||
return contexts.map(c => `- ${c}`).join('\n');
|
||||
}
|
||||
|
||||
function formatPurchaseOpportunities(consumer: Consumer): string {
|
||||
const opportunities = [];
|
||||
|
||||
if (consumer.routines.commute.regular_stops.length) {
|
||||
opportunities.push('Regular purchase points:');
|
||||
consumer.routines.commute.regular_stops.forEach(stop => {
|
||||
opportunities.push(
|
||||
`- ${stop.frequency} ${stop.purpose} at ${stop.location}`
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
if (consumer.habits.exercise.length) {
|
||||
opportunities.push('Activity-related purchases:');
|
||||
consumer.habits.exercise.forEach(ex => {
|
||||
opportunities.push(`- ${ex.frequency} ${ex.activity} sessions`);
|
||||
});
|
||||
}
|
||||
|
||||
if (consumer.habits.social.length) {
|
||||
opportunities.push('Social spending occasions:');
|
||||
consumer.habits.social.forEach(soc => {
|
||||
opportunities.push(`- ${soc.frequency} ${soc.activity}`);
|
||||
});
|
||||
}
|
||||
|
||||
return opportunities.join('\n');
|
||||
}
|
||||
|
||||
export async function generatePrompt(
|
||||
consumer: Consumer,
|
||||
reflectionThreshold: number,
|
||||
targetDate: Date,
|
||||
numWeeks: number
|
||||
): Promise<string> {
|
||||
const weekRanges = getWeekRanges(targetDate, numWeeks);
|
||||
|
||||
return `consumer PROFILE:
|
||||
${formatConsumerCore(consumer.core)}
|
||||
|
||||
Daily Schedule:
|
||||
${formatDailySchedule(consumer.routines.weekday)}
|
||||
Commute: ${formatCommute(consumer.routines.commute)}
|
||||
|
||||
Weekend Activities:
|
||||
${consumer.routines.weekend.map(activity => `- ${activity}`).join('\n')}
|
||||
|
||||
${formatHabits(consumer.habits)}
|
||||
|
||||
Financial Profile:
|
||||
- Income: €${consumer.core.occupation.income.toLocaleString()}/year
|
||||
- Payment Methods: ${consumer.preferences.payment_methods.join(', ')}
|
||||
- Price Sensitivity: ${consumer.preferences.price_sensitivity}/10
|
||||
- Purchasing Style: ${formatPurchasingStyle(consumer.finances.spending_patterns.impulsive_score)}
|
||||
|
||||
Monthly Fixed Expenses:
|
||||
${formatSubscriptions(consumer.finances.subscriptions)}
|
||||
|
||||
Spending Categories:
|
||||
${formatCategories(consumer.finances.spending_patterns.categories)}
|
||||
|
||||
Brand Preferences:
|
||||
${formatBrands(consumer.preferences.brands)}
|
||||
|
||||
Dietary Preferences:
|
||||
${consumer.preferences.diet.join(', ')}
|
||||
|
||||
${formatContext(consumer.context)}
|
||||
|
||||
PURCHASE GENERATION GUIDELINES:
|
||||
|
||||
Generate ${numWeeks} weeks of purchases for ${consumer.core.name}:
|
||||
|
||||
${weekRanges
|
||||
.map((range, i) => formatWeekSection(range, i + 1, consumer))
|
||||
.join('\n\n')}
|
||||
|
||||
Purchase Format:
|
||||
[DATE] [TIME] | [LOCATION] | [ITEMS] | €[AMOUNT] | [CATEGORY] | [PLANNED/IMPULSE]
|
||||
|
||||
Reflection Guidelines (for purchases over €${reflectionThreshold}):
|
||||
- Consider purchase significance and context
|
||||
- Reference lifestyle impact and usage patterns
|
||||
- Include relation to events or stress factors
|
||||
- Account for social context where relevant
|
||||
|
||||
Reflection Format:
|
||||
[DATE] | Mood: [MOOD] | [REFLECTION] | Satisfaction: [SCORE]/10`;
|
||||
}
|
||||
83
utils/purchases/store.ts
Normal file
83
utils/purchases/store.ts
Normal file
@@ -0,0 +1,83 @@
|
||||
import { PurchaseList, purchaseListSchema } from './types';
|
||||
import { Tool } from './tool';
|
||||
import { BaseTool, makeRequest } from '../anthropicClient';
|
||||
import { generatePrompt } from './prompt';
|
||||
import { Consumer } from '../consumer/types';
|
||||
import prisma from '../../prisma/prisma';
|
||||
|
||||
export async function generate(
|
||||
id: number | undefined,
|
||||
editedConsumer: Consumer,
|
||||
date: Date
|
||||
) {
|
||||
const consumerPrompt = await generatePrompt(
|
||||
editedConsumer,
|
||||
parseInt(process.env.PURCHASE_REFLECTION_THRESHOLD ?? '50'),
|
||||
date,
|
||||
parseInt(process.env.NUMBER_OF_WEEKS ?? '4')
|
||||
);
|
||||
|
||||
const consumer = id
|
||||
? await prisma.consumer.update({
|
||||
where: {
|
||||
id
|
||||
},
|
||||
data: {
|
||||
editedValue: editedConsumer
|
||||
}
|
||||
})
|
||||
: await prisma.consumer.create({
|
||||
data: {
|
||||
editedValue: editedConsumer
|
||||
}
|
||||
});
|
||||
|
||||
const newPurchaseList = await prisma.purchaseList.create({
|
||||
data: {
|
||||
consumerId: consumer.id
|
||||
}
|
||||
});
|
||||
|
||||
console.info(
|
||||
`Generating purchase list with id ${newPurchaseList.id} for consumer with id ${consumer.id}`
|
||||
);
|
||||
|
||||
try {
|
||||
const result = (await makeRequest(
|
||||
consumerPrompt,
|
||||
Tool as BaseTool
|
||||
)) as PurchaseList;
|
||||
|
||||
const validPurchases = purchaseListSchema.safeParse(result);
|
||||
|
||||
if (validPurchases.error) {
|
||||
throw Error(`Invalid purchases: ${validPurchases.error.message}`);
|
||||
}
|
||||
|
||||
const totalPurchases = validPurchases.data.weeks.reduce(
|
||||
(acc, week) => acc + week.purchases.length,
|
||||
0
|
||||
);
|
||||
console.info(
|
||||
`Generated ${totalPurchases} purchases for purchase list with id ${newPurchaseList.id} for consumer wth id ${id}`
|
||||
);
|
||||
|
||||
await prisma.purchaseList.update({
|
||||
where: {
|
||||
id: newPurchaseList.id
|
||||
},
|
||||
data: {
|
||||
value: validPurchases.data
|
||||
}
|
||||
});
|
||||
|
||||
console.info(
|
||||
`Purchase list with id ${newPurchaseList.id} for consumer with id ${id} stored in database.`
|
||||
);
|
||||
|
||||
return validPurchases.data;
|
||||
} catch (error) {
|
||||
console.error('Error generating purchases:', error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
141
utils/purchases/tool.ts
Normal file
141
utils/purchases/tool.ts
Normal file
@@ -0,0 +1,141 @@
|
||||
export const Tool = {
|
||||
name: 'PurchasesSchema' as const,
|
||||
input_schema: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
weeks: {
|
||||
type: 'array' as const,
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
weekNumber: {
|
||||
type: 'number' as const,
|
||||
description: 'Sequential week number starting from 1'
|
||||
},
|
||||
startDate: {
|
||||
type: 'string' as const,
|
||||
description: 'Start date of the week in ISO 8601 format',
|
||||
format: 'date'
|
||||
},
|
||||
endDate: {
|
||||
type: 'string' as const,
|
||||
description: 'End date of the week in ISO 8601 format',
|
||||
format: 'date'
|
||||
},
|
||||
purchases: {
|
||||
type: 'array' as const,
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
name: {
|
||||
type: 'string' as const,
|
||||
description: 'Name of the purchased item or service'
|
||||
},
|
||||
amount: {
|
||||
type: 'number' as const,
|
||||
description: 'Purchase amount in EUR'
|
||||
},
|
||||
datetime: {
|
||||
type: 'string' as const,
|
||||
description: 'Purchase date and time in ISO 8601 format',
|
||||
format: 'date-time'
|
||||
},
|
||||
location: {
|
||||
type: 'string' as const,
|
||||
description: 'Purchase location'
|
||||
},
|
||||
category: {
|
||||
type: 'string' as const,
|
||||
description:
|
||||
'Spending category (must match consumer preferences)'
|
||||
},
|
||||
isPlanned: {
|
||||
type: 'boolean' as const,
|
||||
description: 'Whether the purchase was planned or impulse'
|
||||
},
|
||||
context: {
|
||||
type: 'string' as const,
|
||||
description: 'Optional context about purchase circumstances'
|
||||
},
|
||||
reflections: {
|
||||
type: 'array' as const,
|
||||
description: 'Reflections for purchases over threshold',
|
||||
items: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
comment: {
|
||||
type: 'string' as const,
|
||||
description: 'Reflective comment about the purchase'
|
||||
},
|
||||
satisfactionScore: {
|
||||
type: 'number' as const,
|
||||
description: 'Purchase satisfaction score (1-10)',
|
||||
minimum: 1,
|
||||
maximum: 10
|
||||
},
|
||||
date: {
|
||||
type: 'string' as const,
|
||||
description: 'Reflection date in ISO 8601 format',
|
||||
format: 'date-time'
|
||||
},
|
||||
mood: {
|
||||
type: 'string' as const,
|
||||
description: 'Mood during reflection'
|
||||
},
|
||||
relatedTo: {
|
||||
type: 'string' as const,
|
||||
description: 'Optional related event or context'
|
||||
}
|
||||
},
|
||||
required: [
|
||||
'comment',
|
||||
'satisfactionScore',
|
||||
'date',
|
||||
'mood'
|
||||
] as const
|
||||
}
|
||||
}
|
||||
},
|
||||
required: [
|
||||
'name',
|
||||
'amount',
|
||||
'datetime',
|
||||
'location',
|
||||
'category',
|
||||
'isPlanned'
|
||||
] as const
|
||||
},
|
||||
minItems: 12,
|
||||
maxItems: 20,
|
||||
description: 'List of purchases for this week'
|
||||
},
|
||||
weekContext: {
|
||||
type: 'object' as const,
|
||||
properties: {
|
||||
events: {
|
||||
type: 'array' as const,
|
||||
items: {
|
||||
type: 'string' as const
|
||||
},
|
||||
description: 'Notable events during this week'
|
||||
},
|
||||
stressLevel: {
|
||||
type: 'number' as const,
|
||||
minimum: 1,
|
||||
maximum: 10,
|
||||
description: 'Overall stress level for the week'
|
||||
},
|
||||
notes: {
|
||||
type: 'string' as const,
|
||||
description: 'Additional context about the week'
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['weekNumber', 'startDate', 'endDate', 'purchases'] as const
|
||||
}
|
||||
}
|
||||
},
|
||||
required: ['weeks'] as const
|
||||
}
|
||||
} as const;
|
||||
86
utils/purchases/types.ts
Normal file
86
utils/purchases/types.ts
Normal file
@@ -0,0 +1,86 @@
|
||||
import { consumerSchema } from '@utils/consumer/types';
|
||||
import { z } from 'zod';
|
||||
|
||||
const isoDateTimeString = z.string().refine(
|
||||
value => {
|
||||
try {
|
||||
return !isNaN(new Date(value).getTime());
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
},
|
||||
{ message: 'Invalid ISO 8601 datetime format' }
|
||||
);
|
||||
|
||||
const reflectionSchema = z.object({
|
||||
comment: z.string().min(1, 'Comment is required'),
|
||||
satisfactionScore: z.number().min(1).max(10),
|
||||
date: isoDateTimeString,
|
||||
mood: z.string().nullable().optional(),
|
||||
relatedTo: z.string().optional()
|
||||
});
|
||||
|
||||
const weekContextSchema = z.object({
|
||||
events: z.array(z.string()).optional(),
|
||||
stressLevel: z.number().min(1).max(10).optional(),
|
||||
notes: z.string().optional()
|
||||
});
|
||||
|
||||
const purchaseSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required'),
|
||||
amount: z.number().positive('Amount must be positive'),
|
||||
datetime: isoDateTimeString,
|
||||
location: z.string().min(1, 'Location is required'),
|
||||
category: z.string().min(1, 'Category is required'),
|
||||
isPlanned: z.boolean(),
|
||||
context: z.string().optional(),
|
||||
notes: z.string().optional(),
|
||||
reflections: z.array(reflectionSchema).optional()
|
||||
});
|
||||
|
||||
const weekSchema = z
|
||||
.object({
|
||||
weekNumber: z.number().positive().int(),
|
||||
startDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid date format'),
|
||||
endDate: z.string().regex(/^\d{4}-\d{2}-\d{2}$/, 'Invalid date format'),
|
||||
purchases: z
|
||||
.array(purchaseSchema)
|
||||
.min(7, 'Minimum 7 purchases required per week')
|
||||
.max(21, 'Maximum 21 purchases allowed per week'),
|
||||
weekContext: weekContextSchema.optional()
|
||||
})
|
||||
.refine(
|
||||
data => {
|
||||
const start = new Date(data.startDate);
|
||||
const end = new Date(data.endDate);
|
||||
return start <= end;
|
||||
},
|
||||
{
|
||||
message: 'End date must be after or equal to start date',
|
||||
path: ['endDate']
|
||||
}
|
||||
);
|
||||
|
||||
export const purchaseListSchema = z
|
||||
.object({
|
||||
weeks: z.array(weekSchema).min(1, 'At least one week is required')
|
||||
})
|
||||
.refine(
|
||||
data => {
|
||||
const weekNumbers = data.weeks
|
||||
.map(w => w.weekNumber)
|
||||
.sort((a, b) => a - b);
|
||||
return weekNumbers.every((num, idx) => num === idx + 1);
|
||||
},
|
||||
{
|
||||
message: 'Week numbers must be sequential starting from 1',
|
||||
path: ['weeks']
|
||||
}
|
||||
);
|
||||
|
||||
export type PurchaseList = z.infer<typeof purchaseListSchema>;
|
||||
|
||||
export const purchasesRequestSchema = z.object({
|
||||
id: z.number().optional(),
|
||||
consumer: consumerSchema
|
||||
});
|
||||
27
utils/rateLimiter.ts
Normal file
27
utils/rateLimiter.ts
Normal file
@@ -0,0 +1,27 @@
|
||||
import prisma from '../prisma/prisma';
|
||||
|
||||
export async function rateLimiter() {
|
||||
if (!process.env.RATE_LIMIT) {
|
||||
throw Error('Rate limit missing.');
|
||||
}
|
||||
|
||||
const yesterday = new Date(Date.now() - 24 * 60 * 60 * 1000);
|
||||
|
||||
const consumersCount = await prisma.consumer.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gt: yesterday
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
const purchaseListsCount = await prisma.purchaseList.count({
|
||||
where: {
|
||||
createdAt: {
|
||||
gt: yesterday
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
return consumersCount + purchaseListsCount > parseInt(process.env.RATE_LIMIT);
|
||||
}
|
||||
Reference in New Issue
Block a user