Skip to content

Commit 42b28b6

Browse files
authored
Merge pull request #6158 from mozilla/revert-6157-mntor-5013
Revert "Remove coupon and churn related code"
2 parents 2e7638f + 4842f3d commit 42b28b6

38 files changed

+2630
-32
lines changed

.env

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -169,6 +169,7 @@ SENTRY_AUTH_TOKEN=
169169
# Whether GA4 sends data or not. NOTE: must be set in build environment.
170170
NEXT_PUBLIC_GA4_DEBUG_MODE=true
171171

172+
CURRENT_COUPON_CODE_ID=
172173
GA4_API_SECRET=unsafe-default-secret-for-dev
173174

174175
# Data broker removal estimates data

functional-tests/fixtures/baseTest.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import { getBaseTestEnvUrl } from "../utils/environment";
1010
// Feature flags that are enabled by default locally
1111
export const defaultLocalForcedFeatureFlags: FeatureFlagName[] = [
1212
"CancellationFlow",
13+
"DiscountCouponNextThreeMonths",
1314
"AutomaticRemovalCsatSurvey",
1415
"AdditionalRemovalStatuses",
1516
"PromptNoneAuthFlow",

package.json

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@
1616
"dev:cron:db-delete-unverified-subscribers": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/deleteUnverifiedSubscribers.ts",
1717
"dev:cron:db-pull-breaches": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/syncBreaches.ts",
1818
"dev:cron:db-pull-data-brokers": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/syncOnerepDataBrokers.ts",
19+
"dev:cron:churn-discount": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/churnDiscount.tsx",
1920
"dev:cron:remote-settings-pull-breaches": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/updateBreachesInRemoteSettings.ts",
2021
"dev:cron:onerep-limits-alert": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/onerepStatsAlert.ts",
2122
"dev:cron:report-lighthouse-results": "tsx --tsconfig tsconfig.cronjobs.json src/scripts/cronjobs/reportLighthouseResults.ts",
@@ -35,6 +36,7 @@
3536
"cron:db-delete-unverified-subscribers": "node dist/scripts/cronjobs/deleteUnverifiedSubscribers.js",
3637
"cron:db-pull-breaches": "node dist/scripts/cronjobs/syncBreaches.js",
3738
"cron:db-pull-data-brokers": "node dist/scripts/cronjobs/syncOnerepDataBrokers.js",
39+
"cron:churn-discount": "node dist/scripts/cronjobs/churnDiscount.js",
3840
"cron:remote-settings-pull-breaches": "node dist/scripts/cronjobs/updateBreachesInRemoteSettings.js",
3941
"cron:onerep-limits-alert": "node dist/scripts/cronjobs/onerepStatsAlert.js",
4042
"cron:report-lighthouse-results": "node dist/scripts/cronjobs/reportLighthouseResults.js",
Lines changed: 121 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,121 @@
1+
@use "../../../../../tokens";
2+
3+
.wrapper {
4+
display: flex;
5+
flex-direction: column;
6+
gap: tokens.$spacing-2xl;
7+
background-color: tokens.$color-grey-05;
8+
height: 100%;
9+
padding: tokens.$layout-lg tokens.$layout-2xl;
10+
11+
@media screen and (max-width: tokens.$screen-lg) {
12+
padding: tokens.$spacing-lg;
13+
}
14+
}
15+
16+
.header {
17+
font: tokens.$text-title-xs;
18+
font-weight: normal;
19+
20+
b {
21+
font-weight: bold;
22+
}
23+
}
24+
25+
.form {
26+
display: flex;
27+
flex-direction: column;
28+
gap: tokens.$spacing-2xl;
29+
30+
.userPicker {
31+
flex: 1 0 auto;
32+
align-items: center;
33+
display: flex;
34+
flex-wrap: wrap;
35+
gap: tokens.$spacing-2xl;
36+
min-height: tokens.$layout-2xl;
37+
38+
label {
39+
display: flex;
40+
flex-direction: column;
41+
gap: tokens.$spacing-sm;
42+
min-width: 50%;
43+
}
44+
45+
input {
46+
padding: tokens.$spacing-sm;
47+
font: tokens.$text-body-md;
48+
}
49+
}
50+
51+
.actions {
52+
display: flex;
53+
flex-wrap: wrap;
54+
gap: tokens.$spacing-xl;
55+
56+
button {
57+
flex: 1 1 25%;
58+
}
59+
}
60+
}
61+
62+
.status {
63+
background-color: tokens.$color-yellow-05;
64+
border: 2px solid tokens.$color-yellow-20;
65+
border-radius: tokens.$border-radius-sm;
66+
padding: tokens.$spacing-md tokens.$spacing-lg;
67+
font: tokens.$text-body-md;
68+
}
69+
70+
.tableWrapper {
71+
margin: 1rem;
72+
overflow-x: auto;
73+
74+
table {
75+
width: 100%;
76+
border-collapse: collapse;
77+
78+
th,
79+
td {
80+
padding: 0.75rem;
81+
text-align: left;
82+
border-bottom: 1px solid #ddd;
83+
}
84+
85+
th {
86+
background-color: #f5f5f5;
87+
font-weight: bold;
88+
}
89+
90+
tr:hover {
91+
background-color: #f9f9f9;
92+
}
93+
}
94+
}
95+
96+
.uploadSection {
97+
margin: 1rem 0;
98+
padding: 1rem;
99+
border: 2px dashed #ccc;
100+
border-radius: 4px;
101+
102+
&:hover {
103+
border-color: #999;
104+
}
105+
}
106+
107+
.fileInput {
108+
display: block;
109+
width: 100%;
110+
padding: 0.5rem;
111+
}
112+
113+
.dangerButton {
114+
background-color: #dc3545;
115+
color: white;
116+
margin-top: 1rem;
117+
118+
&:hover {
119+
background-color: #c82333;
120+
}
121+
}
Lines changed: 190 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,190 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
"use client";
6+
7+
import { useState } from "react";
8+
import { useRouter } from "next/navigation";
9+
import { useSession } from "next-auth/react";
10+
import styles from "./ChurnAdmin.module.scss";
11+
import { SubscriberChurnRow } from "knex/types/tables";
12+
import { upsertAllChurns, clearAllChurns } from "./actions";
13+
14+
export type Props = {
15+
churningSubscribers: SubscriberChurnRow[];
16+
churnsToEmail: SubscriberChurnRow[];
17+
};
18+
19+
export const ChurnAdmin = (props: Props) => {
20+
const session = useSession();
21+
const [parsedData, setParsedData] = useState<SubscriberChurnRow[] | null>(
22+
null,
23+
);
24+
const router = useRouter();
25+
26+
const handleFileUpload = (event: React.ChangeEvent<HTMLInputElement>) => {
27+
const file = event.target.files?.[0];
28+
if (!file) return;
29+
30+
const reader = new FileReader();
31+
reader.onload = (e) => {
32+
const text = e.target?.result as string;
33+
const rows = text.split("\n");
34+
35+
// Validate minimum number of rows
36+
if (rows.length < 2) {
37+
alert("CSV file appears to be empty or missing headers");
38+
return;
39+
}
40+
41+
// Expected headers and validation
42+
const expectedHeaders = [
43+
"userid",
44+
"customer",
45+
"created",
46+
"nickname",
47+
"intervl",
48+
"plan_id",
49+
"product_id",
50+
"current_period_end",
51+
];
52+
const headers = rows[0]
53+
.toLowerCase()
54+
.split(",")
55+
.map((header) => header.trim());
56+
57+
console.log("Expected headers:", expectedHeaders);
58+
console.log("Actual headers:", headers);
59+
console.log(
60+
"Validation result:",
61+
expectedHeaders.every((expectedHeader) =>
62+
headers.includes(expectedHeader),
63+
),
64+
);
65+
66+
if (
67+
!expectedHeaders.every((expectedHeader) =>
68+
headers.includes(expectedHeader),
69+
)
70+
) {
71+
alert(
72+
`Invalid CSV format: Headers don't match expected format\n${expectedHeaders.toString()}\n${headers.toString()}`,
73+
);
74+
return;
75+
}
76+
77+
try {
78+
const parsedData: SubscriberChurnRow[] = rows
79+
.slice(1)
80+
.filter((row) => row.trim()) // Skip empty rows
81+
.map((row) => {
82+
const values = row.split(",");
83+
84+
return {
85+
userid: values[0],
86+
customer: values[1],
87+
created: values[2],
88+
nickname: values[3],
89+
intervl: values[4],
90+
plan_id: values[6],
91+
product_id: values[7],
92+
current_period_end: new Date(values[8]),
93+
};
94+
});
95+
96+
setParsedData(parsedData);
97+
} catch (error) {
98+
alert(`Error parsing CSV: ${(error as Error).message}`);
99+
setParsedData(null);
100+
}
101+
};
102+
reader.readAsText(file);
103+
};
104+
105+
return (
106+
<main className={styles.wrapper}>
107+
<header className={styles.header}>Churn Admin Dashboard</header>
108+
<p>
109+
Logged in as <b>{session.data?.user.email}</b>.
110+
</p>
111+
112+
<div className={styles.uploadSection}>
113+
<form
114+
onSubmit={(e) => {
115+
e.preventDefault();
116+
if (!parsedData) return;
117+
void upsertAllChurns(parsedData).then(() => {
118+
router.refresh();
119+
});
120+
}}
121+
>
122+
<input
123+
onChange={handleFileUpload}
124+
className={styles.fileInput}
125+
type="file"
126+
/>
127+
<button type="submit" disabled={!parsedData}>
128+
Upload Data
129+
</button>
130+
</form>
131+
</div>
132+
133+
<button
134+
className={styles.dangerButton}
135+
onClick={() => {
136+
if (
137+
window.confirm(
138+
"Are you sure you want to delete all churn data? This cannot be undone.",
139+
)
140+
) {
141+
void clearAllChurns().then(() => router.refresh());
142+
}
143+
}}
144+
>
145+
Clear All Data
146+
</button>
147+
148+
<div>
149+
<p>
150+
Total rows: <b>{props.churningSubscribers.length}</b> | Yearly:{" "}
151+
<b>
152+
{
153+
props.churningSubscribers.filter((s) => s.intervl === "year")
154+
.length
155+
}
156+
</b>{" "}
157+
| To email in next cron run: <b>{props.churnsToEmail.length}</b>
158+
</p>
159+
<div className={styles.tableWrapper}>
160+
<table>
161+
<thead>
162+
<tr>
163+
<th>FxA UID</th>
164+
<th>nickname</th>
165+
<th>Interval</th>
166+
<th>Plan ID</th>
167+
<th>Product ID</th>
168+
<th>Churn Date</th>
169+
</tr>
170+
</thead>
171+
<tbody>
172+
{props.churningSubscribers.map((row) => (
173+
<tr key={row.userid}>
174+
<td>{row.userid}</td>
175+
<td>{row.nickname}</td>
176+
<td>{row.intervl}</td>
177+
<td>{row.plan_id}</td>
178+
<td>{row.product_id}</td>
179+
<td>
180+
{new Date(row.current_period_end).toLocaleDateString()}
181+
</td>
182+
</tr>
183+
))}
184+
</tbody>
185+
</table>
186+
</div>
187+
</div>
188+
</main>
189+
);
190+
};
Lines changed: 45 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,45 @@
1+
/* This Source Code Form is subject to the terms of the Mozilla Public
2+
* License, v. 2.0. If a copy of the MPL was not distributed with this
3+
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */
4+
5+
"use server";
6+
import {
7+
getAllSubscriberChurns,
8+
upsertSubscriberChurns,
9+
deleteSubscriberChurns,
10+
} from "../../../../../../db/tables/subscriber_churns";
11+
import { SubscriberChurnRow } from "knex/types/tables";
12+
import { getServerSession } from "../../../../../functions/server/getServerSession";
13+
import { isAdmin } from "../../../../../api/utils/auth";
14+
15+
/**
16+
* Helper function to perform session + admin check.
17+
* Returns true if the current session belongs to an admin user.
18+
*/
19+
async function isAuthorized(): Promise<boolean> {
20+
const session = await getServerSession();
21+
return Boolean(session?.user?.email && isAdmin(session.user.email));
22+
}
23+
24+
export async function getAllChurns() {
25+
if (!(await isAuthorized())) {
26+
return null;
27+
}
28+
return getAllSubscriberChurns();
29+
}
30+
31+
export async function upsertAllChurns(
32+
churningSubscribers: SubscriberChurnRow[],
33+
) {
34+
if (!(await isAuthorized())) {
35+
return null;
36+
}
37+
return upsertSubscriberChurns(churningSubscribers);
38+
}
39+
40+
export async function clearAllChurns() {
41+
if (!(await isAuthorized())) {
42+
return null;
43+
}
44+
return deleteSubscriberChurns();
45+
}

0 commit comments

Comments
 (0)