Skip to content

Commit ab0612b

Browse files
VIA-629 SB Add isActive() method to Campaigns.
Also, make Campaigns a class, and use Zod for our deserialisation.
1 parent de0db7b commit ab0612b

File tree

6 files changed

+131
-53
lines changed

6 files changed

+131
-53
lines changed

src/services/content-api/types.ts

Lines changed: 0 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -220,10 +220,3 @@ export type ContentApiVaccinationsResponse = {
220220
];
221221
webpage: string;
222222
};
223-
224-
export interface Campaign {
225-
start: Date;
226-
end: Date;
227-
}
228-
229-
export type Campaigns = Record<string, Campaign[]>;

src/utils/campaigns/types.test.ts

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
import { VaccineType } from "@src/models/vaccine";
2+
import { Campaigns } from "@src/utils/campaigns/types";
3+
4+
jest.mock("sanitize-data", () => ({ sanitize: jest.fn() }));
5+
6+
describe("Campaigns", () => {
7+
it("should convert json to a Campaigns ", async () => {
8+
const jsonString = JSON.stringify({
9+
COVID_19: [
10+
{ start: "20251112", end: "20260301" },
11+
{ start: "20260901", end: "20270301" },
12+
],
13+
FLU_FOR_ADULTS: [
14+
{ start: "20251112", end: "20260301" },
15+
{ start: "20260901", end: "20270301" },
16+
],
17+
});
18+
19+
const actual = Campaigns.fromJson(jsonString);
20+
21+
expect(actual!.get(VaccineType.COVID_19)).toStrictEqual([
22+
{ start: new Date("2025-11-12"), end: new Date("2026-03-01") },
23+
{ start: new Date("2026-09-01"), end: new Date("2027-03-01") },
24+
]);
25+
26+
expect(actual!.get(VaccineType.FLU_FOR_ADULTS)).toStrictEqual([
27+
{ start: new Date("2025-11-12"), end: new Date("2026-03-01") },
28+
{ start: new Date("2026-09-01"), end: new Date("2027-03-01") },
29+
]);
30+
});
31+
32+
describe("isActive", () => {
33+
const jsonString = JSON.stringify({ COVID_19: [{ start: "20251112", end: "20260301" }] });
34+
const campaigns = Campaigns.fromJson(jsonString)!;
35+
36+
it.each([
37+
["is inactive before start date", "2025-11-11", false],
38+
["is active on start date", "2025-11-12", true],
39+
["is active during campaign", "2025-12-25", true],
40+
["is active on end date", "2026-03-01", true],
41+
["is inactive after end date", "2026-03-02", false],
42+
["is inactive in different year", "2024-01-01", false],
43+
])("%s (%s) -> %s", (_, dateStr, expected) => {
44+
const dateToCheck = new Date(dateStr);
45+
const actual = campaigns.isActive(VaccineType.COVID_19, dateToCheck);
46+
47+
expect(actual).toBe(expected);
48+
});
49+
});
50+
});

src/utils/campaigns/types.ts

Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { VaccineType } from "@src/models/vaccine";
2+
import { UtcDateFromStringSchema } from "@src/utils/date";
3+
import { logger } from "@src/utils/logger";
4+
import { Logger } from "pino";
5+
import { z } from "zod";
6+
7+
const log: Logger = logger.child({ module: "campaigns" });
8+
9+
const CampaignSchema = z
10+
.object({
11+
start: UtcDateFromStringSchema,
12+
end: UtcDateFromStringSchema,
13+
})
14+
.refine((data) => data.end >= data.start, {
15+
message: "End date must be after start date",
16+
path: ["end"],
17+
});
18+
19+
export type Campaign = z.infer<typeof CampaignSchema>;
20+
21+
export class Campaigns {
22+
private schedule: Partial<Record<VaccineType, Campaign[]>>;
23+
24+
constructor(schedule: Partial<Record<VaccineType, Campaign[]>>) {
25+
this.schedule = schedule;
26+
}
27+
28+
/**
29+
* Static Deserializer
30+
* Parses JSON string -> Validates with Zod -> Returns Class Instance
31+
*/
32+
static fromJson(jsonString: string): Campaigns | undefined {
33+
try {
34+
const GenericSchema = z.record(z.string(), z.array(CampaignSchema));
35+
36+
const rawObj = JSON.parse(jsonString);
37+
const parsedGeneric = GenericSchema.parse(rawObj);
38+
39+
const validSchedule: Partial<Record<VaccineType, Campaign[]>> = {};
40+
41+
const validVaccines = new Set(Object.values(VaccineType) as string[]);
42+
43+
for (const [key, campaigns] of Object.entries(parsedGeneric)) {
44+
if (validVaccines.has(key)) {
45+
validSchedule[key as VaccineType] = campaigns;
46+
} else {
47+
log.warn({ context: { key } }, "Ignored unknown vaccine key");
48+
}
49+
}
50+
51+
return new Campaigns(validSchedule);
52+
} catch (error) {
53+
log.warn({ context: { jsonString }, error }, "Failed to parse campaigns");
54+
return undefined;
55+
}
56+
}
57+
58+
/** Get all campaigns for a specific vaccine */
59+
get(vaccine: VaccineType): Campaign[] {
60+
return this.schedule[vaccine] ?? [];
61+
}
62+
63+
/** Check if a vaccine is currently active */
64+
isActive(vaccine: VaccineType, date: Date = new Date()): boolean {
65+
const campaigns = this.get(vaccine);
66+
return campaigns.some((c) => date >= c.start && date <= c.end);
67+
}
68+
69+
/** Get a list of all vaccine names in the schedule */
70+
listVaccines(): VaccineType[] {
71+
return Object.keys(this.schedule) as VaccineType[];
72+
}
73+
}

src/utils/config.test.ts

Lines changed: 5 additions & 20 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,4 @@
1+
import { VaccineType } from "@src/models/vaccine";
12
import config, { ConfigError } from "@src/utils/config";
23
import getSecret from "@src/utils/get-secret";
34
import { randomString } from "@test-data/meta-builder";
@@ -88,29 +89,13 @@ describe("lazyConfig", () => {
8889

8990
it("should convert CAMPAIGNS to a Campaigns ", async () => {
9091
setupTestEnvVars("test/");
91-
process.env.CAMPAIGNS = JSON.stringify({
92-
covid: [
93-
{ start: "20251112", end: "20260301" },
94-
{ start: "20260901", end: "20270301" },
95-
],
96-
flu: [
97-
{ start: "20251112", end: "20260301" },
98-
{ start: "20260901", end: "20270301" },
99-
],
100-
});
92+
process.env.CAMPAIGNS = JSON.stringify({ COVID_19: [{ start: "20251112", end: "20260301" }] });
10193

10294
const actual = await config.CAMPAIGNS;
10395

104-
expect(actual).toStrictEqual({
105-
covid: [
106-
{ start: new Date("2025-11-12"), end: new Date("2026-03-01") },
107-
{ start: new Date("2026-09-01"), end: new Date("2027-03-01") },
108-
],
109-
flu: [
110-
{ start: new Date("2025-11-12"), end: new Date("2026-03-01") },
111-
{ start: new Date("2026-09-01"), end: new Date("2027-03-01") },
112-
],
113-
});
96+
expect(actual.get(VaccineType.COVID_19)).toStrictEqual([
97+
{ start: new Date("2025-11-12"), end: new Date("2026-03-01") },
98+
]);
11499
});
115100

116101
it("should throw for invalid URL", async () => {

src/utils/config.ts

Lines changed: 2 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { Campaigns } from "@src/services/content-api/types";
21
import { EligibilityApiError } from "@src/services/eligibility-api/gateway/exceptions";
3-
import { UtcDateFromStringSchema } from "@src/utils/date";
2+
import { Campaigns } from "@src/utils/campaigns/types";
43
import getSecret from "@src/utils/get-secret";
54
import { logger } from "@src/utils/logger";
65
import { retry } from "es-toolkit";
@@ -89,29 +88,7 @@ class Config {
8988
if (lower === "false") return false;
9089
return undefined;
9190
};
92-
private static readonly toCampaigns = (rawValue: string): Campaigns | undefined => {
93-
try {
94-
interface RawCampaign {
95-
start: string;
96-
end: string;
97-
}
98-
99-
const rawObj: Record<string, RawCampaign[]> = JSON.parse(rawValue);
10091

101-
const parsedSchedule: Campaigns = {};
102-
103-
for (const [vaccineName, campaigns] of Object.entries(rawObj)) {
104-
parsedSchedule[vaccineName] = campaigns.map((c) => ({
105-
start: UtcDateFromStringSchema.parse(c.start),
106-
end: UtcDateFromStringSchema.parse(c.end),
107-
}));
108-
}
109-
110-
return parsedSchedule;
111-
} catch {
112-
return undefined;
113-
}
114-
};
11592
static readonly converters: Record<string, (value: string) => ConfigValue> = {
11693
APIM_AUTH_URL: Config.toUrl,
11794
CONTENT_API_ENDPOINT: Config.toUrl,
@@ -123,7 +100,7 @@ class Config {
123100
NHS_APP_REDIRECT_LOGIN_URL: Config.toUrl,
124101
IS_APIM_AUTH_ENABLED: Config.toBoolean,
125102
MAX_SESSION_AGE_MINUTES: Config.toNumber,
126-
CAMPAIGNS: Config.toCampaigns,
103+
CAMPAIGNS: Campaigns.fromJson,
127104
};
128105

129106
/**

src/utils/date.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,7 @@ export const UtcDateFromStringSchema = z
1010
const utcTimestamp = Date.UTC(year, month - 1, day);
1111
const date = new Date(utcTimestamp);
1212
if (date.getUTCFullYear() !== year || date.getUTCMonth() !== month - 1 || date.getUTCDate() !== day) {
13-
ctx.addIssue("invalid_value");
13+
ctx.addIssue({ code: "custom", message: "Invalid date value" });
1414
return z.NEVER;
1515
}
1616
return date;

0 commit comments

Comments
 (0)