feat: add campaign configuration controls
This commit is contained in:
117
lib/campaign-form.ts
Normal file
117
lib/campaign-form.ts
Normal file
@@ -0,0 +1,117 @@
|
||||
import { z } from "zod/v4";
|
||||
|
||||
const CAMPAIGN_NAME_MIN = 3;
|
||||
const CAMPAIGN_NAME_MAX = 120;
|
||||
export const CAMPAIGN_COUNTRY_CODE = "DE";
|
||||
export const CAMPAIGN_COUNTRY_NAME = "Deutschland";
|
||||
const MIN_RADIUS_KM = 1;
|
||||
const MAX_RADIUS_KM = 5000;
|
||||
const MIN_LEADS_PER_RUN = 1;
|
||||
const MAX_LEADS_PER_RUN = 9999;
|
||||
const MIN_AUDITS_PER_RUN = 1;
|
||||
const MAX_AUDITS_PER_RUN = 9999;
|
||||
|
||||
export const CAMPAIGN_CATEGORY_MODES = ["preset", "custom"] as const;
|
||||
export type CampaignCategoryMode = (typeof CAMPAIGN_CATEGORY_MODES)[number];
|
||||
|
||||
export const CAMPAIGN_RECURRENCES = [
|
||||
"manual",
|
||||
"daily",
|
||||
"weekly",
|
||||
"monthly",
|
||||
] as const;
|
||||
export type CampaignRecurrence = (typeof CAMPAIGN_RECURRENCES)[number];
|
||||
|
||||
export const CAMPAIGN_STATUSES = ["active", "paused"] as const;
|
||||
export type CampaignStatus = (typeof CAMPAIGN_STATUSES)[number];
|
||||
|
||||
export const ORTHODOX_POSTAL_CODE_MESSAGE =
|
||||
"Bitte eine gültige deutsche PLZ (Postleitzahl) mit genau 5 Ziffern angeben.";
|
||||
|
||||
const postalCodeSchema = z
|
||||
.string()
|
||||
.regex(/^\d{5}$/, ORTHODOX_POSTAL_CODE_MESSAGE);
|
||||
|
||||
const nonEmptyString = (label: string) =>
|
||||
z
|
||||
.string()
|
||||
.trim()
|
||||
.min(1, `${label} ist erforderlich.`);
|
||||
|
||||
const positiveBoundedInt = (label: string, min: number, max: number) =>
|
||||
z
|
||||
.number({ message: `${label} muss eine Zahl sein.` })
|
||||
.finite(`${label} muss eine Zahl sein.`)
|
||||
.min(min, `${label} muss mindestens ${min} sein.`)
|
||||
.int(`${label} muss eine ganze Zahl sein.`)
|
||||
.max(max, `${label} muss maximal ${max} sein.`);
|
||||
|
||||
export const campaignFormSchema = z
|
||||
.object({
|
||||
name: nonEmptyString("Name").min(CAMPAIGN_NAME_MIN).max(CAMPAIGN_NAME_MAX),
|
||||
categoryMode: z.union(
|
||||
CAMPAIGN_CATEGORY_MODES.map((value) => z.literal(value)),
|
||||
{
|
||||
error: "Bitte zwischen vorgegebener Kategorie oder eigener Kategorie wählen.",
|
||||
},
|
||||
),
|
||||
category: nonEmptyString("Kategorie"),
|
||||
customSearchTerm: z.string().optional(),
|
||||
postalCode: postalCodeSchema,
|
||||
radiusKm: positiveBoundedInt("Radius", MIN_RADIUS_KM, MAX_RADIUS_KM),
|
||||
maxNewLeadsPerRun: positiveBoundedInt(
|
||||
"Max. neue Leads",
|
||||
MIN_LEADS_PER_RUN,
|
||||
MAX_LEADS_PER_RUN,
|
||||
),
|
||||
maxAuditsPerRun: positiveBoundedInt(
|
||||
"Max. Audits",
|
||||
MIN_AUDITS_PER_RUN,
|
||||
MAX_AUDITS_PER_RUN,
|
||||
),
|
||||
recurrence: z.union(
|
||||
CAMPAIGN_RECURRENCES.map((value) => z.literal(value)),
|
||||
{
|
||||
error:
|
||||
"Bitte eine gültige Häufigkeit wählen: manuell, täglich, wöchentlich oder monatlich.",
|
||||
},
|
||||
),
|
||||
status: z.union(
|
||||
CAMPAIGN_STATUSES.map((value) => z.literal(value)),
|
||||
{ error: "Status ist ungültig." },
|
||||
),
|
||||
})
|
||||
.superRefine((values, ctx) => {
|
||||
const needsCustomSearchTerm =
|
||||
values.categoryMode === "custom" || values.category === "Anderes";
|
||||
|
||||
if (needsCustomSearchTerm && !values.customSearchTerm?.trim()) {
|
||||
ctx.addIssue({
|
||||
code: "custom",
|
||||
path: ["customSearchTerm"],
|
||||
message:
|
||||
"Für Kategorie 'Anderes' ist ein eigener Suchbegriff erforderlich.",
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export const campaignFormDefaults = {
|
||||
name: "",
|
||||
status: "active" as CampaignStatus,
|
||||
categoryMode: "preset" as CampaignCategoryMode,
|
||||
category: "Anwalt",
|
||||
customSearchTerm: "",
|
||||
recurrence: "daily" as CampaignRecurrence,
|
||||
radiusKm: 10,
|
||||
maxNewLeadsPerRun: 5,
|
||||
maxAuditsPerRun: 5,
|
||||
postalCode: "10115",
|
||||
};
|
||||
|
||||
export function mapCampaignFormToPayload(values: Record<string, unknown>) {
|
||||
return {
|
||||
...values,
|
||||
countryCode: CAMPAIGN_COUNTRY_CODE as "DE",
|
||||
country: CAMPAIGN_COUNTRY_NAME as "Deutschland",
|
||||
};
|
||||
}
|
||||
103
lib/campaign-scheduling.ts
Normal file
103
lib/campaign-scheduling.ts
Normal file
@@ -0,0 +1,103 @@
|
||||
import { CAMPAIGN_RECURRENCES, CAMPAIGN_STATUSES } from "./campaign-form";
|
||||
|
||||
export type CampaignRecurrence = (typeof CAMPAIGN_RECURRENCES)[number];
|
||||
export type CampaignStatus = (typeof CAMPAIGN_STATUSES)[number];
|
||||
|
||||
export type CampaignFormRecurrenceInput = {
|
||||
recurrence: CampaignRecurrence | (string & {});
|
||||
status: CampaignStatus;
|
||||
lastRunAt?: number | null;
|
||||
now?: number;
|
||||
};
|
||||
|
||||
export type CampaignRunInfo = {
|
||||
campaignStatus: CampaignStatus;
|
||||
agentRuns?: Array<{
|
||||
status: string;
|
||||
updatedAt?: number;
|
||||
}>;
|
||||
};
|
||||
|
||||
export function isAllowedCampaignRecurrence(value: string): boolean {
|
||||
return CAMPAIGN_RECURRENCES.includes(value as CampaignRecurrence);
|
||||
}
|
||||
|
||||
function addDaysUTC(base: number, days: number): number {
|
||||
const date = new Date(base);
|
||||
return Date.UTC(
|
||||
date.getUTCFullYear(),
|
||||
date.getUTCMonth(),
|
||||
date.getUTCDate() + days,
|
||||
date.getUTCHours(),
|
||||
date.getUTCMinutes(),
|
||||
date.getUTCSeconds(),
|
||||
date.getUTCMilliseconds(),
|
||||
);
|
||||
}
|
||||
|
||||
function addMonthsUTC(base: number, months: number): number {
|
||||
const date = new Date(base);
|
||||
return Date.UTC(
|
||||
date.getUTCFullYear(),
|
||||
date.getUTCMonth() + months,
|
||||
date.getUTCDate(),
|
||||
date.getUTCHours(),
|
||||
date.getUTCMinutes(),
|
||||
date.getUTCSeconds(),
|
||||
date.getUTCMilliseconds(),
|
||||
);
|
||||
}
|
||||
|
||||
export function calculateNextRunAt(input: CampaignFormRecurrenceInput): number | null {
|
||||
if (input.status !== "active") {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (!isAllowedCampaignRecurrence(input.recurrence)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (input.recurrence === "manual") {
|
||||
return null;
|
||||
}
|
||||
|
||||
const anchor = input.lastRunAt ?? input.now ?? Date.now();
|
||||
if (input.recurrence === "daily") {
|
||||
return addDaysUTC(anchor, 1);
|
||||
}
|
||||
|
||||
if (input.recurrence === "weekly") {
|
||||
return addDaysUTC(anchor, 7);
|
||||
}
|
||||
|
||||
return addMonthsUTC(anchor, 1);
|
||||
}
|
||||
|
||||
export function getCampaignCurrentRunStatus(input: CampaignRunInfo): string {
|
||||
const agentRuns = input.agentRuns ?? [];
|
||||
if (agentRuns.length > 0) {
|
||||
const ordered = [...agentRuns].sort((a, b) => {
|
||||
const aUpdatedAt = typeof a.updatedAt === "number" ? a.updatedAt : 0;
|
||||
const bUpdatedAt = typeof b.updatedAt === "number" ? b.updatedAt : 0;
|
||||
|
||||
return bUpdatedAt - aUpdatedAt;
|
||||
});
|
||||
|
||||
const latestStatus = ordered.at(0)?.status;
|
||||
if (latestStatus === "running") {
|
||||
return "running";
|
||||
}
|
||||
if (latestStatus === "pending") {
|
||||
return "pending";
|
||||
}
|
||||
if (
|
||||
latestStatus === "succeeded" ||
|
||||
latestStatus === "failed" ||
|
||||
latestStatus === "canceled"
|
||||
) {
|
||||
return latestStatus;
|
||||
}
|
||||
}
|
||||
|
||||
return input.campaignStatus === "paused" ? "paused" : "idle";
|
||||
}
|
||||
218
lib/campaign-validation.ts
Normal file
218
lib/campaign-validation.ts
Normal file
@@ -0,0 +1,218 @@
|
||||
import { CAMPAIGN_COUNTRY_CODE, CAMPAIGN_COUNTRY_NAME, CAMPAIGN_RECURRENCES, CAMPAIGN_STATUSES, ORTHODOX_POSTAL_CODE_MESSAGE } from "./campaign-form";
|
||||
import { CampaignRecurrence, CampaignStatus } from "./campaign-scheduling";
|
||||
|
||||
const CAMPAIGN_POSTAL_CODE_REGEX = /^\d{5}$/;
|
||||
const MIN_RADIUS_KM = 1;
|
||||
const MAX_RADIUS_KM = 5000;
|
||||
const MIN_LEADS_PER_RUN = 1;
|
||||
const MAX_LEADS_PER_RUN = 9999;
|
||||
const MIN_AUDITS_PER_RUN = 1;
|
||||
const MAX_AUDITS_PER_RUN = 9999;
|
||||
|
||||
function assert(condition: boolean, message: string): asserts condition {
|
||||
if (!condition) {
|
||||
throw new Error(message);
|
||||
}
|
||||
}
|
||||
|
||||
function assertFiniteInteger(
|
||||
value: number,
|
||||
fieldLabel: string,
|
||||
min: number,
|
||||
max: number,
|
||||
) {
|
||||
assert(Number.isFinite(value), `${fieldLabel} muss eine Zahl sein.`);
|
||||
assert(Number.isInteger(value), `${fieldLabel} muss eine ganze Zahl sein.`);
|
||||
assert(
|
||||
value >= min,
|
||||
`${fieldLabel} muss mindestens ${min} sein.`,
|
||||
);
|
||||
assert(
|
||||
value <= max,
|
||||
`${fieldLabel} darf höchstens ${max} sein.`,
|
||||
);
|
||||
}
|
||||
|
||||
function assertAllowed<T extends string>(
|
||||
value: string,
|
||||
allowed: readonly T[],
|
||||
errorMessage: string,
|
||||
): T {
|
||||
assert(allowed.includes(value as T), errorMessage);
|
||||
return value as T;
|
||||
}
|
||||
|
||||
function assertCountryContext(countryCode?: string, country?: string) {
|
||||
const hasCountryCode = countryCode !== undefined;
|
||||
const hasCountry = country !== undefined;
|
||||
|
||||
assert(
|
||||
!(hasCountryCode || hasCountry) || (hasCountryCode && hasCountry),
|
||||
"Deutschland-Kontext muss vollständig gesetzt oder ausgelassen werden.",
|
||||
);
|
||||
|
||||
if (hasCountryCode || hasCountry) {
|
||||
assert(
|
||||
countryCode === CAMPAIGN_COUNTRY_CODE && country === CAMPAIGN_COUNTRY_NAME,
|
||||
"Nur Deutschland-Context ist erlaubt.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export type CampaignCreatePayload = {
|
||||
status: CampaignStatus;
|
||||
recurrence: CampaignRecurrence;
|
||||
postalCode: string;
|
||||
radiusKm: number;
|
||||
maxNewLeadsPerRun: number;
|
||||
maxAuditsPerRun: number;
|
||||
countryCode: string;
|
||||
country: string;
|
||||
};
|
||||
|
||||
export type CampaignUpdatePayload = Partial<
|
||||
Omit<CampaignCreatePayload, "status" | "recurrence">
|
||||
> & {
|
||||
status?: CampaignStatus;
|
||||
recurrence?: CampaignRecurrence;
|
||||
countryCode: string;
|
||||
country: string;
|
||||
};
|
||||
|
||||
type CampaignCreateInput = {
|
||||
status: string;
|
||||
recurrence: string;
|
||||
postalCode: string;
|
||||
radiusKm: number;
|
||||
maxNewLeadsPerRun: number;
|
||||
maxAuditsPerRun: number;
|
||||
countryCode?: string;
|
||||
country?: string;
|
||||
};
|
||||
|
||||
type CampaignUpdateInput = {
|
||||
postalCode?: string;
|
||||
radiusKm?: number;
|
||||
maxNewLeadsPerRun?: number;
|
||||
maxAuditsPerRun?: number;
|
||||
status?: string;
|
||||
recurrence?: string;
|
||||
countryCode?: string;
|
||||
country?: string;
|
||||
};
|
||||
|
||||
export function validateCampaignCreateInput(
|
||||
input: CampaignCreateInput,
|
||||
): CampaignCreatePayload {
|
||||
assertCountryContext(input.countryCode, input.country);
|
||||
const status = assertAllowed(
|
||||
input.status,
|
||||
CAMPAIGN_STATUSES,
|
||||
"Status ist ungültig.",
|
||||
);
|
||||
const recurrence = assertAllowed(
|
||||
input.recurrence,
|
||||
CAMPAIGN_RECURRENCES,
|
||||
"Frequenz ist ungültig.",
|
||||
);
|
||||
|
||||
assert(
|
||||
CAMPAIGN_POSTAL_CODE_REGEX.test(input.postalCode),
|
||||
ORTHODOX_POSTAL_CODE_MESSAGE,
|
||||
);
|
||||
assertFiniteInteger(input.radiusKm, "Radius", MIN_RADIUS_KM, MAX_RADIUS_KM);
|
||||
assertFiniteInteger(
|
||||
input.maxNewLeadsPerRun,
|
||||
"Max. neue Leads",
|
||||
MIN_LEADS_PER_RUN,
|
||||
MAX_LEADS_PER_RUN,
|
||||
);
|
||||
assertFiniteInteger(
|
||||
input.maxAuditsPerRun,
|
||||
"Max. Audits",
|
||||
MIN_AUDITS_PER_RUN,
|
||||
MAX_AUDITS_PER_RUN,
|
||||
);
|
||||
|
||||
return {
|
||||
status,
|
||||
recurrence,
|
||||
postalCode: input.postalCode,
|
||||
radiusKm: input.radiusKm,
|
||||
maxNewLeadsPerRun: input.maxNewLeadsPerRun,
|
||||
maxAuditsPerRun: input.maxAuditsPerRun,
|
||||
countryCode: CAMPAIGN_COUNTRY_CODE,
|
||||
country: CAMPAIGN_COUNTRY_NAME,
|
||||
};
|
||||
}
|
||||
|
||||
export function validateCampaignUpdateInput(
|
||||
input: CampaignUpdateInput,
|
||||
): CampaignUpdatePayload {
|
||||
const updates: CampaignUpdatePayload = {
|
||||
countryCode: CAMPAIGN_COUNTRY_CODE,
|
||||
country: CAMPAIGN_COUNTRY_NAME,
|
||||
};
|
||||
|
||||
assertCountryContext(input.countryCode, input.country);
|
||||
|
||||
if (input.status !== undefined) {
|
||||
updates.status = assertAllowed(
|
||||
input.status,
|
||||
CAMPAIGN_STATUSES,
|
||||
"Status ist ungültig.",
|
||||
);
|
||||
}
|
||||
if (input.recurrence !== undefined) {
|
||||
updates.recurrence = assertAllowed(
|
||||
input.recurrence,
|
||||
CAMPAIGN_RECURRENCES,
|
||||
"Frequenz ist ungültig.",
|
||||
);
|
||||
}
|
||||
if (input.postalCode !== undefined) {
|
||||
assert(
|
||||
CAMPAIGN_POSTAL_CODE_REGEX.test(input.postalCode),
|
||||
ORTHODOX_POSTAL_CODE_MESSAGE,
|
||||
);
|
||||
updates.postalCode = input.postalCode;
|
||||
}
|
||||
if (input.radiusKm !== undefined) {
|
||||
assertFiniteInteger(input.radiusKm, "Radius", MIN_RADIUS_KM, MAX_RADIUS_KM);
|
||||
updates.radiusKm = input.radiusKm;
|
||||
}
|
||||
if (input.maxNewLeadsPerRun !== undefined) {
|
||||
assertFiniteInteger(
|
||||
input.maxNewLeadsPerRun,
|
||||
"Max. neue Leads",
|
||||
MIN_LEADS_PER_RUN,
|
||||
MAX_LEADS_PER_RUN,
|
||||
);
|
||||
updates.maxNewLeadsPerRun = input.maxNewLeadsPerRun;
|
||||
}
|
||||
if (input.maxAuditsPerRun !== undefined) {
|
||||
assertFiniteInteger(
|
||||
input.maxAuditsPerRun,
|
||||
"Max. Audits",
|
||||
MIN_AUDITS_PER_RUN,
|
||||
MAX_AUDITS_PER_RUN,
|
||||
);
|
||||
updates.maxAuditsPerRun = input.maxAuditsPerRun;
|
||||
}
|
||||
|
||||
if (
|
||||
input.countryCode !== undefined
|
||||
|| input.country !== undefined
|
||||
|| input.status !== undefined
|
||||
|| input.recurrence !== undefined
|
||||
|| input.postalCode !== undefined
|
||||
|| input.radiusKm !== undefined
|
||||
|| input.maxNewLeadsPerRun !== undefined
|
||||
|| input.maxAuditsPerRun !== undefined
|
||||
) {
|
||||
return updates;
|
||||
}
|
||||
|
||||
return updates;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user