move the database and mailer to own log sub dir, add all email change logic
This commit is contained in:
parent
ddd00e3f85
commit
a783a0e663
26 changed files with 808 additions and 225 deletions
10
bun.lock
10
bun.lock
|
@ -5,15 +5,15 @@
|
||||||
"name": "void.backend",
|
"name": "void.backend",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atums/echo": "latest",
|
"@atums/echo": "latest",
|
||||||
"@types/nodemailer": "^6.4.17",
|
|
||||||
"cassandra-driver": "latest",
|
"cassandra-driver": "latest",
|
||||||
"fast-jwt": "latest",
|
"fast-jwt": "latest",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "latest",
|
||||||
"pika-id": "latest",
|
"pika-id": "latest",
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "latest",
|
||||||
"@types/bun": "latest",
|
"@types/bun": "latest",
|
||||||
|
"@types/nodemailer": "latest",
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
@ -21,7 +21,7 @@
|
||||||
"@biomejs/biome",
|
"@biomejs/biome",
|
||||||
],
|
],
|
||||||
"packages": {
|
"packages": {
|
||||||
"@atums/echo": ["@atums/echo@1.0.6", "", { "dependencies": { "date-fns-tz": "^3.2.0" } }, "sha512-2v0coX0Ptau6pjh4aTJDXMMJ2z/Q+0r8tvLokjeyUnLWGOPMwg+i4saBrkvDtHvQbNiq/NiEwMFLCxeIlxEyLQ=="],
|
"@atums/echo": ["@atums/echo@1.0.7", "", { "dependencies": { "date-fns-tz": "latest" } }, "sha512-RLHRmAmf/4a4CCGaNJIA1xkgycKBonVoWjyQ5bwW/srLjwhTgxkPbn6o56cRgmRMDguribqv5mWxEol3UL0srg=="],
|
||||||
|
|
||||||
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
|
"@biomejs/biome": ["@biomejs/biome@1.9.4", "", { "optionalDependencies": { "@biomejs/cli-darwin-arm64": "1.9.4", "@biomejs/cli-darwin-x64": "1.9.4", "@biomejs/cli-linux-arm64": "1.9.4", "@biomejs/cli-linux-arm64-musl": "1.9.4", "@biomejs/cli-linux-x64": "1.9.4", "@biomejs/cli-linux-x64-musl": "1.9.4", "@biomejs/cli-win32-arm64": "1.9.4", "@biomejs/cli-win32-x64": "1.9.4" }, "bin": { "biome": "bin/biome" } }, "sha512-1rkd7G70+o9KkTn5KLmDYXihGoTaIGO9PIIN2ZB7UJxFrWw04CZHPYiMRjYsaDvVV7hP1dYNRLxSANLaBFGpog=="],
|
||||||
|
|
||||||
|
@ -43,7 +43,7 @@
|
||||||
|
|
||||||
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
|
"@lukeed/ms": ["@lukeed/ms@2.0.2", "", {}, "sha512-9I2Zn6+NJLfaGoz9jN3lpwDgAYvfGeNYdbAIjJOqzs4Tpc+VU3Jqq4IofSUBKajiDS8k9fZIg18/z13mpk1bsA=="],
|
||||||
|
|
||||||
"@types/bun": ["@types/bun@1.2.15", "", { "dependencies": { "bun-types": "1.2.15" } }, "sha512-U1ljPdBEphF0nw1MIk0hI7kPg7dFdPyM7EenHsp6W5loNHl7zqy6JQf/RKCgnUn2KDzUpkBwHPnEJEjII594bA=="],
|
"@types/bun": ["@types/bun@1.2.16", "", { "dependencies": { "bun-types": "1.2.16" } }, "sha512-1aCZJ/6nSiViw339RsaNhkNoEloLaPzZhxMOYEa7OzRzO41IGg5n/7I43/ZIAW/c+Q6cT12Vf7fOZOoVIzb5BQ=="],
|
||||||
|
|
||||||
"@types/node": ["@types/node@18.19.111", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-90sGdgA+QLJr1F9X79tQuEut0gEYIfkX9pydI4XGRgvFo9g2JWswefI+WUSUHPYVBHYSEfTEqBxA5hQvAZB3Mw=="],
|
"@types/node": ["@types/node@18.19.111", "", { "dependencies": { "undici-types": "~5.26.4" } }, "sha512-90sGdgA+QLJr1F9X79tQuEut0gEYIfkX9pydI4XGRgvFo9g2JWswefI+WUSUHPYVBHYSEfTEqBxA5hQvAZB3Mw=="],
|
||||||
|
|
||||||
|
@ -55,7 +55,7 @@
|
||||||
|
|
||||||
"bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="],
|
"bn.js": ["bn.js@4.12.2", "", {}, "sha512-n4DSx829VRTRByMRGdjQ9iqsN0Bh4OolPsFnaZBLcbi8iXcB+kJ9s7EnRt4wILZNV3kPLHkRVfOc/HvhC3ovDw=="],
|
||||||
|
|
||||||
"bun-types": ["bun-types@1.2.15", "", { "dependencies": { "@types/node": "*" } }, "sha512-NarRIaS+iOaQU1JPfyKhZm4AsUOrwUOqRNHY0XxI8GI8jYxiLXLcdjYMG9UKS+fwWasc1uw1htV9AX24dD+p4w=="],
|
"bun-types": ["bun-types@1.2.16", "", { "dependencies": { "@types/node": "*" } }, "sha512-ciXLrHV4PXax9vHvUrkvun9VPVGOVwbbbBF/Ev1cXz12lyEZMoJpIJABOfPcN9gDJRaiKF9MVbSygLg4NXu3/A=="],
|
||||||
|
|
||||||
"cassandra-driver": ["cassandra-driver@4.8.0", "", { "dependencies": { "@types/node": "^18.11.18", "adm-zip": "~0.5.10", "long": "~5.2.3" } }, "sha512-HritfMGq9V7SuESeSodHvArs0mLuMk7uh+7hQK2lqdvXrvm50aWxb4RPxkK3mPDdsgHjJ427xNRFITMH2ei+Sw=="],
|
"cassandra-driver": ["cassandra-driver@4.8.0", "", { "dependencies": { "@types/node": "^18.11.18", "adm-zip": "~0.5.10", "long": "~5.2.3" } }, "sha512-HritfMGq9V7SuESeSodHvArs0mLuMk7uh+7hQK2lqdvXrvm50aWxb4RPxkK3mPDdsgHjJ427xNRFITMH2ei+Sw=="],
|
||||||
|
|
||||||
|
|
|
@ -12,14 +12,14 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@biomejs/biome": "latest",
|
"@biomejs/biome": "latest",
|
||||||
"@types/bun": "latest"
|
"@types/bun": "latest",
|
||||||
|
"@types/nodemailer": "latest"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@atums/echo": "latest",
|
"@atums/echo": "latest",
|
||||||
"@types/nodemailer": "^6.4.17",
|
|
||||||
"cassandra-driver": "latest",
|
"cassandra-driver": "latest",
|
||||||
"fast-jwt": "latest",
|
"fast-jwt": "latest",
|
||||||
"nodemailer": "^7.0.3",
|
"nodemailer": "latest",
|
||||||
"pika-id": "latest"
|
"pika-id": "latest"
|
||||||
},
|
},
|
||||||
"trustedDependencies": ["@biomejs/biome"]
|
"trustedDependencies": ["@biomejs/biome"]
|
||||||
|
|
|
@ -31,3 +31,4 @@ export * from "./server";
|
||||||
export * from "./validation";
|
export * from "./validation";
|
||||||
export * from "./database";
|
export * from "./database";
|
||||||
export * from "./mailer";
|
export * from "./mailer";
|
||||||
|
export * from "./user";
|
||||||
|
|
1
src/environment/constants/user/index.ts
Normal file
1
src/environment/constants/user/index.ts
Normal file
|
@ -0,0 +1 @@
|
||||||
|
export * from "./update";
|
6
src/environment/constants/user/update.ts
Normal file
6
src/environment/constants/user/update.ts
Normal file
|
@ -0,0 +1,6 @@
|
||||||
|
const emailUpdateTimes = {
|
||||||
|
coolDownMinutes: 5,
|
||||||
|
tokenExpiryHours: 3,
|
||||||
|
};
|
||||||
|
|
||||||
|
export { emailUpdateTimes };
|
|
@ -0,0 +1,40 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{subject}}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #0066cc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Email Address Changed - {{companyName}}</h1>
|
||||||
|
<p>Hi {{displayName}},</p>
|
||||||
|
<p><strong>Your email address has been successfully changed.</strong></p>
|
||||||
|
|
||||||
|
<p><strong>Change Details:</strong></p>
|
||||||
|
<p>Previous Email: {{oldEmail}} (this address)<br>
|
||||||
|
New Email: {{newEmail}}<br>
|
||||||
|
Changed On: {{changeTime}}</p>
|
||||||
|
|
||||||
|
<p><strong>Important:</strong> You will no longer receive account emails at this address ({{oldEmail}}). All future communications will be sent to {{newEmail}}.</p>
|
||||||
|
|
||||||
|
<p><strong>For future logins, please use:</strong></p>
|
||||||
|
<p>Email: {{newEmail}}<br>
|
||||||
|
Password: (unchanged)</p>
|
||||||
|
|
||||||
|
<p><strong>If this change was not authorized by you:</strong> Contact our support team immediately at {{supportEmail}}. Your account may have been compromised and we will help you recover it.</p>
|
||||||
|
<hr>
|
||||||
|
<p><small>User ID: {{id}} | {{companyName}}</small></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,41 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{subject}}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
a {
|
||||||
|
color: #0066cc;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Email Change Request - {{companyName}}</h1>
|
||||||
|
<p>Hi {{displayName}},</p>
|
||||||
|
<p><strong>Security Notice:</strong> Someone requested to change your account email address.</p>
|
||||||
|
|
||||||
|
<p><strong>Change Details:</strong></p>
|
||||||
|
<p>Current Email: {{currentEmail}}<br>
|
||||||
|
Requested New Email: {{newEmail}}<br>
|
||||||
|
Request Time: {{requestTime}}</p>
|
||||||
|
|
||||||
|
<p>A verification email has been sent to <strong>{{newEmail}}</strong>.</p>
|
||||||
|
<p>{{willExpire}} if not completed.</p>
|
||||||
|
|
||||||
|
<p><strong>If this was you:</strong> Check your new email ({{newEmail}}) and click the verification link to complete the change.</p>
|
||||||
|
|
||||||
|
<p><strong>If this was NOT you:</strong> Your account may be compromised. Please change your password immediately and contact our support team at {{supportEmail}}.</p>
|
||||||
|
|
||||||
|
<p>Questions? Contact {{supportEmail}}</p>
|
||||||
|
<hr>
|
||||||
|
<p><small>User ID: {{id}} | {{companyName}}</small></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -0,0 +1,41 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>{{subject}}</title>
|
||||||
|
<style>
|
||||||
|
body {
|
||||||
|
font-family: Arial, sans-serif;
|
||||||
|
max-width: 600px;
|
||||||
|
margin: 0 auto;
|
||||||
|
padding: 20px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
.verify-button {
|
||||||
|
display: inline-block;
|
||||||
|
background-color: #0066cc;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin: 15px 0;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<h1>Email Change Verification - {{companyName}}</h1>
|
||||||
|
<p>Hi {{displayName}},</p>
|
||||||
|
<p>You requested to change your email address from <strong>{{currentEmail}}</strong> to <strong>{{newEmail}}</strong>.</p>
|
||||||
|
<p>To complete this email change, please click the button below:</p>
|
||||||
|
|
||||||
|
<a href="{{verificationUrl}}" class="verify-button">Verify Email Change</a>
|
||||||
|
|
||||||
|
<p>{{willExpire}} for security reasons.</p>
|
||||||
|
<p><strong>Important:</strong> After verification, your account email will be changed to this address and you'll need to use it for future logins.</p>
|
||||||
|
<p>If you did not request this email change, contact {{supportEmail}} immediately.</p>
|
||||||
|
<p>Questions? Contact {{supportEmail}}</p>
|
||||||
|
<hr>
|
||||||
|
<p><small>User ID: {{id}} | {{companyName}}</small></p>
|
||||||
|
</body>
|
||||||
|
</html>
|
|
@ -1,6 +1,5 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
@ -13,35 +12,28 @@
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.reset-button {
|
||||||
a {
|
display: inline-block;
|
||||||
color: #0066cc;
|
background-color: #0066cc;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin: 15px 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<h1>Password Reset - {{companyName}}</h1>
|
<h1>Password Reset - {{companyName}}</h1>
|
||||||
|
|
||||||
<p>Hi {{displayName}},</p>
|
<p>Hi {{displayName}},</p>
|
||||||
|
<p>You requested a password reset for your account. Click the button below to reset your password:</p>
|
||||||
|
|
||||||
<p>You requested a password reset for your account. Click the link below to reset your password:</p>
|
<a href="{{resetUrl}}" class="reset-button">Reset Password</a>
|
||||||
|
|
||||||
<p><a href="{{resetUrl}}">{{resetUrl}}</a></p>
|
|
||||||
|
|
||||||
<p>{{willExpire}} for security reasons.</p>
|
<p>{{willExpire}} for security reasons.</p>
|
||||||
|
<p>If you did not request this password reset, please contact {{supportEmail}} immediately. Your password will remain unchanged.</p>
|
||||||
<p>If you did not request this password reset, please ignore this email. Your password will remain unchanged.</p>
|
|
||||||
|
|
||||||
<p>Questions? Contact {{supportEmail}}</p>
|
<p>Questions? Contact {{supportEmail}}</p>
|
||||||
|
|
||||||
<p>Best regards,<br>
|
|
||||||
The {{companyName}} team</p>
|
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
<p><small>User ID: {{id}} | {{companyName}}</small></p>
|
||||||
<p><small>User ID: {{id}} | © {{currentYear}} {{companyName}}</small></p>
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
</html>
|
||||||
</html>
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
<!DOCTYPE html>
|
<!DOCTYPE html>
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
|
|
||||||
<head>
|
<head>
|
||||||
<meta charset="UTF-8">
|
<meta charset="UTF-8">
|
||||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
@ -13,33 +12,29 @@
|
||||||
padding: 20px;
|
padding: 20px;
|
||||||
line-height: 1.5;
|
line-height: 1.5;
|
||||||
}
|
}
|
||||||
|
.verify-button {
|
||||||
a {
|
display: inline-block;
|
||||||
color: #0066cc;
|
background-color: #0066cc;
|
||||||
|
color: white;
|
||||||
|
padding: 10px 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
border-radius: 3px;
|
||||||
|
margin: 15px 0;
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
</head>
|
</head>
|
||||||
|
|
||||||
<body>
|
<body>
|
||||||
<h1>Welcome to {{companyName}}!</h1>
|
<h1>Welcome to {{companyName}}!</h1>
|
||||||
|
|
||||||
<p>Hi {{displayName}},</p>
|
<p>Hi {{displayName}},</p>
|
||||||
|
|
||||||
<p>Please verify your email address:</p>
|
<p>Please verify your email address:</p>
|
||||||
|
|
||||||
<p><a href="{{verificationUrl}}">{{verificationUrl}}</a></p>
|
<a href="{{verificationUrl}}" class="verify-button">Verify Email</a>
|
||||||
|
|
||||||
<p>{{willExpire}} for security reasons.</p>
|
<p>{{willExpire}} for security reasons.</p>
|
||||||
|
|
||||||
<p>Questions? Contact {{supportEmail}}</p>
|
<p>Questions? Contact {{supportEmail}}</p>
|
||||||
|
|
||||||
<p>Best regards,<br>
|
<p>Best regards,<br>
|
||||||
The {{companyName}} team</p>
|
The {{companyName}} team</p>
|
||||||
|
|
||||||
<hr>
|
<hr>
|
||||||
|
<p><small>User ID: {{id}} | {{companyName}}</small></p>
|
||||||
<p><small>User ID: {{id}} | © {{currentYear}} {{companyName}}</small></p>
|
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
|
</html>
|
||||||
</html>
|
|
||||||
|
|
|
@ -1,6 +1,5 @@
|
||||||
import { echo } from "@atums/echo";
|
import { Echo } from "@atums/echo";
|
||||||
import { cassandraConfig as config } from "#environment/database";
|
import { cassandraConfig as config } from "#environment/database";
|
||||||
import { noFileLog } from "#index";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Client,
|
Client,
|
||||||
|
@ -15,8 +14,10 @@ class CassandraService {
|
||||||
private static instance: Client | null = null;
|
private static instance: Client | null = null;
|
||||||
private static isConnecting = false;
|
private static isConnecting = false;
|
||||||
private static connectionPromise: Promise<void> | null = null;
|
private static connectionPromise: Promise<void> | null = null;
|
||||||
|
private static logger: Echo = new Echo({ subDirectory: "cassandra" });
|
||||||
|
private static loggerNoFile: Echo = new Echo({ disableFile: true });
|
||||||
|
|
||||||
private constructor() {}
|
private constructor() { }
|
||||||
|
|
||||||
public static getClient(): Client {
|
public static getClient(): Client {
|
||||||
if (!CassandraService.instance) {
|
if (!CassandraService.instance) {
|
||||||
|
@ -96,7 +97,7 @@ class CassandraService {
|
||||||
const clientOptions = CassandraService.buildClientOptions(options);
|
const clientOptions = CassandraService.buildClientOptions(options);
|
||||||
|
|
||||||
if (options.logging !== false) {
|
if (options.logging !== false) {
|
||||||
noFileLog.info({
|
CassandraService.loggerNoFile.info({
|
||||||
message: "Connecting to Cassandra...",
|
message: "Connecting to Cassandra...",
|
||||||
contactPoints: config.contactPoints,
|
contactPoints: config.contactPoints,
|
||||||
datacenter: config.datacenter,
|
datacenter: config.datacenter,
|
||||||
|
@ -114,7 +115,7 @@ class CassandraService {
|
||||||
const hostCount = hosts.length;
|
const hostCount = hosts.length;
|
||||||
|
|
||||||
if (options.logging !== false) {
|
if (options.logging !== false) {
|
||||||
noFileLog.info(
|
CassandraService.loggerNoFile.info(
|
||||||
`Connected to Cassandra successfully. Active hosts: ${hostCount}`,
|
`Connected to Cassandra successfully. Active hosts: ${hostCount}`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -126,16 +127,23 @@ class CassandraService {
|
||||||
"log",
|
"log",
|
||||||
(level: string, className: string, message: string) => {
|
(level: string, className: string, message: string) => {
|
||||||
if (level === "error") {
|
if (level === "error") {
|
||||||
echo.error(`Cassandra ${className}: ${message}`);
|
CassandraService.logger.error(
|
||||||
|
`Cassandra ${className}: ${message}`,
|
||||||
|
);
|
||||||
} else if (level === "warning") {
|
} else if (level === "warning") {
|
||||||
echo.warn(`Cassandra ${className}: ${message}`);
|
CassandraService.logger.warn(
|
||||||
|
`Cassandra ${className}: ${message}`,
|
||||||
|
);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
echo.error({ message: "Failed to connect to Cassandra:", error });
|
CassandraService.logger.error({
|
||||||
await client.shutdown().catch(() => {});
|
message: "Failed to connect to Cassandra:",
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
await client.shutdown().catch(() => { });
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -157,9 +165,11 @@ class CassandraService {
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await client.execute(query);
|
await client.execute(query);
|
||||||
noFileLog.debug(`Keyspace '${config.keyspace}' ensured to exist`);
|
CassandraService.loggerNoFile.debug(
|
||||||
|
`Keyspace '${config.keyspace}' ensured to exist`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
echo.error({
|
CassandraService.logger.error({
|
||||||
message: `Failed to create keyspace '${config.keyspace}':`,
|
message: `Failed to create keyspace '${config.keyspace}':`,
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
@ -178,7 +188,7 @@ class CassandraService {
|
||||||
const result = await client.execute(query, params, options);
|
const result = await client.execute(query, params, options);
|
||||||
return result;
|
return result;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
echo.error({
|
CassandraService.logger.error({
|
||||||
message: "Cassandra query failed:",
|
message: "Cassandra query failed:",
|
||||||
query: query.substring(0, 100) + (query.length > 100 ? "..." : ""),
|
query: query.substring(0, 100) + (query.length > 100 ? "..." : ""),
|
||||||
error,
|
error,
|
||||||
|
@ -192,10 +202,15 @@ class CassandraService {
|
||||||
try {
|
try {
|
||||||
await CassandraService.instance.shutdown();
|
await CassandraService.instance.shutdown();
|
||||||
if (!disableLogging) {
|
if (!disableLogging) {
|
||||||
noFileLog.info("Cassandra client shut down gracefully");
|
CassandraService.loggerNoFile.info(
|
||||||
|
"Cassandra client shut down gracefully",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
echo.error({ message: "Error during Cassandra shutdown:", error });
|
CassandraService.logger.error({
|
||||||
|
message: "Error during Cassandra shutdown:",
|
||||||
|
error,
|
||||||
|
});
|
||||||
} finally {
|
} finally {
|
||||||
CassandraService.instance = null;
|
CassandraService.instance = null;
|
||||||
}
|
}
|
||||||
|
@ -242,11 +257,11 @@ class CassandraService {
|
||||||
});
|
});
|
||||||
|
|
||||||
if (tableNames.length > 0) {
|
if (tableNames.length > 0) {
|
||||||
noFileLog.warn(
|
CassandraService.loggerNoFile.warn(
|
||||||
`About to drop keyspace '${config.keyspace}' containing tables: ${tableNames.join(", ")}`,
|
`About to drop keyspace '${config.keyspace}' containing tables: ${tableNames.join(", ")}`,
|
||||||
);
|
);
|
||||||
} else {
|
} else {
|
||||||
noFileLog.info(
|
CassandraService.loggerNoFile.info(
|
||||||
`Keyspace '${config.keyspace}' is empty or doesn't exist`,
|
`Keyspace '${config.keyspace}' is empty or doesn't exist`,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -254,9 +269,11 @@ class CassandraService {
|
||||||
const dropQuery = `DROP KEYSPACE IF EXISTS ${config.keyspace}`;
|
const dropQuery = `DROP KEYSPACE IF EXISTS ${config.keyspace}`;
|
||||||
await client.execute(dropQuery);
|
await client.execute(dropQuery);
|
||||||
|
|
||||||
noFileLog.info(`Keyspace '${config.keyspace}' dropped successfully`);
|
CassandraService.loggerNoFile.info(
|
||||||
|
`Keyspace '${config.keyspace}' dropped successfully`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
echo.error({
|
CassandraService.logger.error({
|
||||||
message: `Failed to drop keyspace '${config.keyspace}':`,
|
message: `Failed to drop keyspace '${config.keyspace}':`,
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
@ -267,7 +284,7 @@ class CassandraService {
|
||||||
try {
|
try {
|
||||||
await CassandraService.shutdown(true);
|
await CassandraService.shutdown(true);
|
||||||
} catch (shutdownError) {
|
} catch (shutdownError) {
|
||||||
noFileLog.warn({
|
CassandraService.loggerNoFile.warn({
|
||||||
message: "Error during shutdown after drop:",
|
message: "Error during shutdown after drop:",
|
||||||
error: shutdownError,
|
error: shutdownError,
|
||||||
});
|
});
|
||||||
|
@ -278,7 +295,9 @@ class CassandraService {
|
||||||
CassandraService.isConnecting = false;
|
CassandraService.isConnecting = false;
|
||||||
CassandraService.connectionPromise = null;
|
CassandraService.connectionPromise = null;
|
||||||
|
|
||||||
noFileLog.info("Cassandra client state reset after dropping keyspace");
|
CassandraService.loggerNoFile.info(
|
||||||
|
"Cassandra client state reset after dropping keyspace",
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
public static async resetDatabase(): Promise<void> {
|
public static async resetDatabase(): Promise<void> {
|
||||||
|
@ -287,7 +306,7 @@ class CassandraService {
|
||||||
"Reset operation is only allowed in development environment",
|
"Reset operation is only allowed in development environment",
|
||||||
);
|
);
|
||||||
|
|
||||||
noFileLog.info("Starting database reset...");
|
CassandraService.loggerNoFile.info("Starting database reset...");
|
||||||
|
|
||||||
await CassandraService.dropEverything();
|
await CassandraService.dropEverything();
|
||||||
|
|
||||||
|
@ -295,7 +314,7 @@ class CassandraService {
|
||||||
await CassandraService.createKeyspaceIfNotExists();
|
await CassandraService.createKeyspaceIfNotExists();
|
||||||
await CassandraService.shutdown(true);
|
await CassandraService.shutdown(true);
|
||||||
|
|
||||||
noFileLog.info(
|
CassandraService.loggerNoFile.info(
|
||||||
"Database reset complete. Restart your application to run migrations.",
|
"Database reset complete. Restart your application to run migrations.",
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
@ -1,15 +1,16 @@
|
||||||
import { readFile, readdir } from "node:fs/promises";
|
import { readFile, readdir } from "node:fs/promises";
|
||||||
import { resolve } from "node:path";
|
import { resolve } from "node:path";
|
||||||
import { echo } from "@atums/echo";
|
import { Echo } from "@atums/echo";
|
||||||
import { environment } from "#environment/config";
|
import { environment } from "#environment/config";
|
||||||
import { migrationsPath } from "#environment/constants";
|
import { migrationsPath } from "#environment/constants";
|
||||||
import { noFileLog } from "#index";
|
|
||||||
import { cassandra } from "#lib/database";
|
import { cassandra } from "#lib/database";
|
||||||
|
|
||||||
import type { SqlMigration } from "#types/config";
|
import type { SqlMigration } from "#types/config";
|
||||||
|
|
||||||
class MigrationRunner {
|
class MigrationRunner {
|
||||||
private migrations: SqlMigration[] = [];
|
private migrations: SqlMigration[] = [];
|
||||||
|
private static logger: Echo = new Echo({ subDirectory: "migrations" });
|
||||||
|
private static loggerNoFile: Echo = new Echo({ disableFile: true });
|
||||||
|
|
||||||
async loadMigrations(): Promise<void> {
|
async loadMigrations(): Promise<void> {
|
||||||
try {
|
try {
|
||||||
|
@ -28,7 +29,7 @@ class MigrationRunner {
|
||||||
const name = nameParts.join("_") || "migration";
|
const name = nameParts.join("_") || "migration";
|
||||||
|
|
||||||
if (!id || id.trim() === "") {
|
if (!id || id.trim() === "") {
|
||||||
noFileLog.debug(
|
MigrationRunner.loggerNoFile.debug(
|
||||||
`Skipping migration file with invalid ID: ${sqlFile}`,
|
`Skipping migration file with invalid ID: ${sqlFile}`,
|
||||||
);
|
);
|
||||||
continue;
|
continue;
|
||||||
|
@ -50,16 +51,18 @@ class MigrationRunner {
|
||||||
...(downSql && { downSql: downSql.trim() }),
|
...(downSql && { downSql: downSql.trim() }),
|
||||||
});
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
echo.error({
|
MigrationRunner.logger.error({
|
||||||
message: `Failed to load migration ${sqlFile}:`,
|
message: `Failed to load migration ${sqlFile}:`,
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
noFileLog.debug(`Loaded ${this.migrations.length} migrations`);
|
MigrationRunner.loggerNoFile.debug(
|
||||||
|
`Loaded ${this.migrations.length} migrations`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
noFileLog.debug({
|
MigrationRunner.loggerNoFile.debug({
|
||||||
message: "No migrations directory found or error reading:",
|
message: "No migrations directory found or error reading:",
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
@ -76,7 +79,7 @@ class MigrationRunner {
|
||||||
)
|
)
|
||||||
`;
|
`;
|
||||||
await cassandra.execute(query);
|
await cassandra.execute(query);
|
||||||
noFileLog.debug("Schema migrations table ready");
|
MigrationRunner.loggerNoFile.debug("Schema migrations table ready");
|
||||||
}
|
}
|
||||||
|
|
||||||
private async getExecutedMigrations(): Promise<Set<string>> {
|
private async getExecutedMigrations(): Promise<Set<string>> {
|
||||||
|
@ -86,7 +89,7 @@ class MigrationRunner {
|
||||||
)) as { rows: Array<{ id: string }> };
|
)) as { rows: Array<{ id: string }> };
|
||||||
return new Set(result.rows.map((row) => row.id));
|
return new Set(result.rows.map((row) => row.id));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
noFileLog.debug({
|
MigrationRunner.loggerNoFile.debug({
|
||||||
message: "Could not fetch executed migrations:",
|
message: "Could not fetch executed migrations:",
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
|
@ -133,7 +136,7 @@ class MigrationRunner {
|
||||||
|
|
||||||
async runMigrations(): Promise<void> {
|
async runMigrations(): Promise<void> {
|
||||||
if (this.migrations.length === 0) {
|
if (this.migrations.length === 0) {
|
||||||
noFileLog.debug("No migrations to run");
|
MigrationRunner.loggerNoFile.debug("No migrations to run");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
await this.createMigrationsTable();
|
await this.createMigrationsTable();
|
||||||
|
@ -142,29 +145,31 @@ class MigrationRunner {
|
||||||
(migration) => !executedMigrations.has(migration.id),
|
(migration) => !executedMigrations.has(migration.id),
|
||||||
);
|
);
|
||||||
if (pendingMigrations.length === 0) {
|
if (pendingMigrations.length === 0) {
|
||||||
noFileLog.debug("All migrations are up to date");
|
MigrationRunner.loggerNoFile.debug("All migrations are up to date");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
noFileLog.debug(
|
MigrationRunner.loggerNoFile.debug(
|
||||||
`Running ${pendingMigrations.length} pending migrations...`,
|
`Running ${pendingMigrations.length} pending migrations...`,
|
||||||
);
|
);
|
||||||
for (const migration of pendingMigrations) {
|
for (const migration of pendingMigrations) {
|
||||||
try {
|
try {
|
||||||
noFileLog.debug(
|
MigrationRunner.loggerNoFile.debug(
|
||||||
`Running migration: ${migration.id} - ${migration.name}`,
|
`Running migration: ${migration.id} - ${migration.name}`,
|
||||||
);
|
);
|
||||||
await this.executeSql(migration.upSql);
|
await this.executeSql(migration.upSql);
|
||||||
await this.markMigrationExecuted(migration);
|
await this.markMigrationExecuted(migration);
|
||||||
noFileLog.debug(`Migration ${migration.id} completed`);
|
MigrationRunner.loggerNoFile.debug(
|
||||||
|
`Migration ${migration.id} completed`,
|
||||||
|
);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
echo.error({
|
MigrationRunner.logger.error({
|
||||||
message: `Failed to run migration ${migration.id}:`,
|
message: `Failed to run migration ${migration.id}:`,
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
noFileLog.debug("All migrations completed successfully");
|
MigrationRunner.loggerNoFile.debug("All migrations completed successfully");
|
||||||
}
|
}
|
||||||
|
|
||||||
async initialize(): Promise<void> {
|
async initialize(): Promise<void> {
|
||||||
|
|
|
@ -1,9 +1,8 @@
|
||||||
import { readdir } from "node:fs/promises";
|
import { readdirSync } from "node:fs";
|
||||||
import { echo } from "@atums/echo";
|
import { Echo } from "@atums/echo";
|
||||||
import nodemailer from "nodemailer";
|
import nodemailer from "nodemailer";
|
||||||
import { templatesPath } from "#environment/constants";
|
import { templatesPath } from "#environment/constants";
|
||||||
import { mailerConfig } from "#environment/mailer";
|
import { mailerConfig } from "#environment/mailer";
|
||||||
import { noFileLog } from "#index";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
EmailResult,
|
EmailResult,
|
||||||
|
@ -13,6 +12,9 @@ import type {
|
||||||
|
|
||||||
class MailerService {
|
class MailerService {
|
||||||
private transporter: nodemailer.Transporter;
|
private transporter: nodemailer.Transporter;
|
||||||
|
private templates: string[] = [];
|
||||||
|
private static logger: Echo = new Echo({ subDirectory: "mailer" });
|
||||||
|
private static loggerNoFile: Echo = new Echo({ disableFile: true });
|
||||||
|
|
||||||
constructor() {
|
constructor() {
|
||||||
this.transporter = nodemailer.createTransport({
|
this.transporter = nodemailer.createTransport({
|
||||||
|
@ -33,15 +35,20 @@ class MailerService {
|
||||||
rejectUnauthorized: true,
|
rejectUnauthorized: true,
|
||||||
},
|
},
|
||||||
} as nodemailer.TransportOptions);
|
} as nodemailer.TransportOptions);
|
||||||
|
|
||||||
|
this.populateTemplates();
|
||||||
}
|
}
|
||||||
|
|
||||||
async verifyConnection(): Promise<boolean> {
|
async verifyConnection(): Promise<boolean> {
|
||||||
try {
|
try {
|
||||||
await this.transporter.verify();
|
await this.transporter.verify();
|
||||||
noFileLog.info("SMTP connection verified successfully");
|
MailerService.loggerNoFile.info("SMTP connection verified successfully");
|
||||||
return true;
|
return true;
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
echo.error({ message: "SMTP connection verification failed:", error });
|
MailerService.logger.error({
|
||||||
|
message: "SMTP connection verification failed:",
|
||||||
|
error,
|
||||||
|
});
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -50,15 +57,18 @@ class MailerService {
|
||||||
this.transporter.close();
|
this.transporter.close();
|
||||||
}
|
}
|
||||||
|
|
||||||
private async listTemplates(): Promise<string[]> {
|
public populateTemplates(): void {
|
||||||
try {
|
try {
|
||||||
const files = await readdir(templatesPath, { recursive: true });
|
const files = readdirSync(templatesPath, { recursive: true });
|
||||||
return files
|
this.templates = files
|
||||||
.filter((file) => typeof file === "string" && file.endsWith(".html"))
|
.filter((file) => typeof file === "string" && file.endsWith(".html"))
|
||||||
.map((file) => (file as string).replace(/\.html$/, ""));
|
.map((file) => (file as string).replace(/\.html$/, ""));
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
echo.error({ message: "Failed to list templates:", error });
|
MailerService.logger.error({
|
||||||
return [];
|
message: "Failed to list templates:",
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
this.templates = [];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -66,7 +76,7 @@ class MailerService {
|
||||||
templateName: string,
|
templateName: string,
|
||||||
variables: TemplateVariables,
|
variables: TemplateVariables,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const templates = await this.listTemplates();
|
const templates = this.templates;
|
||||||
if (!templates.includes(templateName)) {
|
if (!templates.includes(templateName)) {
|
||||||
throw new Error(`Template "${templateName}" not found`);
|
throw new Error(`Template "${templateName}" not found`);
|
||||||
}
|
}
|
||||||
|
@ -78,34 +88,20 @@ class MailerService {
|
||||||
const file = Bun.file(templatePath);
|
const file = Bun.file(templatePath);
|
||||||
templateContent = await file.text();
|
templateContent = await file.text();
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
echo.error({
|
MailerService.logger.error({
|
||||||
message: `Failed to load template "${templateName}":`,
|
message: `Failed to load template "${templateName}":`,
|
||||||
error,
|
error,
|
||||||
});
|
});
|
||||||
throw error;
|
throw error;
|
||||||
}
|
}
|
||||||
|
|
||||||
const rewriter = new HTMLRewriter().on("*", {
|
let processedContent = templateContent;
|
||||||
text(text) {
|
for (const [key, value] of Object.entries(variables)) {
|
||||||
if (text.text) {
|
const regex = new RegExp(`{{${key}}}`, "g");
|
||||||
let modifiedText = text.text;
|
processedContent = processedContent.replace(regex, String(value));
|
||||||
for (const [key, value] of Object.entries(variables)) {
|
}
|
||||||
const regex = new RegExp(`{{${key}}}`, "g");
|
|
||||||
modifiedText = modifiedText.replace(regex, String(value));
|
|
||||||
}
|
|
||||||
if (modifiedText !== text.text) {
|
|
||||||
text.replace(modifiedText);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
});
|
|
||||||
|
|
||||||
const response = new Response(templateContent, {
|
return processedContent;
|
||||||
headers: { "Content-Type": "text/html" },
|
|
||||||
});
|
|
||||||
|
|
||||||
const transformedResponse = rewriter.transform(response);
|
|
||||||
return await transformedResponse.text();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async sendEmail(options: SendMailOptions): Promise<EmailResult> {
|
async sendEmail(options: SendMailOptions): Promise<EmailResult> {
|
||||||
|
@ -155,7 +151,7 @@ class MailerService {
|
||||||
|
|
||||||
const info = await this.transporter.sendMail(mailOptions);
|
const info = await this.transporter.sendMail(mailOptions);
|
||||||
|
|
||||||
echo.debug({
|
MailerService.logger.debug({
|
||||||
message: `Email sent successfully to ${options.to}`,
|
message: `Email sent successfully to ${options.to}`,
|
||||||
messageId: info.messageId,
|
messageId: info.messageId,
|
||||||
subject: options.subject,
|
subject: options.subject,
|
||||||
|
@ -168,7 +164,7 @@ class MailerService {
|
||||||
response: info.response,
|
response: info.response,
|
||||||
};
|
};
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
echo.error({
|
MailerService.logger.error({
|
||||||
message: "Failed to send email:",
|
message: "Failed to send email:",
|
||||||
error,
|
error,
|
||||||
to: options.to,
|
to: options.to,
|
||||||
|
|
|
@ -1,5 +1,4 @@
|
||||||
import { echo } from "@atums/echo";
|
import { echo } from "@atums/echo";
|
||||||
import { sessionManager } from "#lib/auth";
|
|
||||||
import { cassandra } from "#lib/database";
|
import { cassandra } from "#lib/database";
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
|
@ -20,7 +19,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const { id: identifier } = request.params;
|
const { id: identifier } = request.params;
|
||||||
|
|
||||||
const session = await sessionManager.getSession(request);
|
const { session } = request;
|
||||||
|
|
||||||
let userQuery: string;
|
let userQuery: string;
|
||||||
let queryParams: string[];
|
let queryParams: string[];
|
||||||
|
|
|
@ -103,7 +103,6 @@ async function handler(
|
||||||
willExpire: "This link will expire in 1 hour",
|
willExpire: "This link will expire in 1 hour",
|
||||||
resetUrl: `${environment.frontendFqdn}/user/reset-password?token=${resetToken}`,
|
resetUrl: `${environment.frontendFqdn}/user/reset-password?token=${resetToken}`,
|
||||||
supportEmail: extraValues.supportEmail,
|
supportEmail: extraValues.supportEmail,
|
||||||
currentYear: new Date().getFullYear(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await mailerService.sendTemplateEmail(
|
await mailerService.sendTemplateEmail(
|
||||||
|
|
|
@ -13,7 +13,7 @@ const routeDef: RouteDef = {
|
||||||
|
|
||||||
async function handler(request: ExtendedRequest): Promise<Response> {
|
async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const session = await sessionManager.getSession(request);
|
const { session } = request;
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
const response: LogoutResponse = {
|
const response: LogoutResponse = {
|
||||||
|
|
|
@ -173,7 +173,6 @@ async function handler(
|
||||||
willExpire: "This link will expire in 3 hours",
|
willExpire: "This link will expire in 3 hours",
|
||||||
verificationUrl: `${environment.frontendFqdn}/user/verify?token=${verificationToken}`,
|
verificationUrl: `${environment.frontendFqdn}/user/verify?token=${verificationToken}`,
|
||||||
supportEmail: extraValues.supportEmail,
|
supportEmail: extraValues.supportEmail,
|
||||||
currentYear: new Date().getFullYear(),
|
|
||||||
};
|
};
|
||||||
|
|
||||||
await mailerService.sendTemplateEmail(
|
await mailerService.sendTemplateEmail(
|
||||||
|
|
495
src/routes/user/update/email.ts
Normal file
495
src/routes/user/update/email.ts
Normal file
|
@ -0,0 +1,495 @@
|
||||||
|
import { echo } from "@atums/echo";
|
||||||
|
import { redis } from "bun";
|
||||||
|
import { environment } from "#environment/config";
|
||||||
|
import { emailUpdateTimes } from "#environment/constants";
|
||||||
|
import { extraValues } from "#environment/extra";
|
||||||
|
import { sessionManager } from "#lib/auth";
|
||||||
|
import { cassandra } from "#lib/database";
|
||||||
|
import { mailerService } from "#lib/mailer";
|
||||||
|
import { isValidEmail } from "#lib/validation";
|
||||||
|
|
||||||
|
import type { UserSession } from "#types/config";
|
||||||
|
import type {
|
||||||
|
EmailChangeData,
|
||||||
|
EmailChangeRequest,
|
||||||
|
EmailChangeResponse,
|
||||||
|
ExtendedRequest,
|
||||||
|
RouteDef,
|
||||||
|
UserResponse,
|
||||||
|
UserRow,
|
||||||
|
} from "#types/server";
|
||||||
|
|
||||||
|
const routeDef: RouteDef = {
|
||||||
|
method: ["POST", "GET"],
|
||||||
|
accepts: ["application/json", "*/*"],
|
||||||
|
returns: "application/json",
|
||||||
|
needsBody: "json",
|
||||||
|
};
|
||||||
|
|
||||||
|
async function handler(
|
||||||
|
request: ExtendedRequest,
|
||||||
|
requestBody: unknown,
|
||||||
|
): Promise<Response> {
|
||||||
|
try {
|
||||||
|
const { session } = request;
|
||||||
|
|
||||||
|
if (!session) {
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 401,
|
||||||
|
success: false,
|
||||||
|
error: "Not authenticated",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.method === "GET") {
|
||||||
|
return await handleEmailVerification(request, session);
|
||||||
|
}
|
||||||
|
|
||||||
|
return await handleEmailChangeRequest(request, requestBody, session);
|
||||||
|
} catch (error) {
|
||||||
|
echo.error({
|
||||||
|
message: "Email change operation failed",
|
||||||
|
error,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 500,
|
||||||
|
success: false,
|
||||||
|
error: "Internal server error",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEmailChangeRequest(
|
||||||
|
request: ExtendedRequest,
|
||||||
|
requestBody: unknown,
|
||||||
|
session: UserSession,
|
||||||
|
): Promise<Response> {
|
||||||
|
const { newEmail } = requestBody as EmailChangeRequest;
|
||||||
|
|
||||||
|
if (!newEmail) {
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 400,
|
||||||
|
success: false,
|
||||||
|
error: "New email is required",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailValidation = isValidEmail(newEmail);
|
||||||
|
if (!emailValidation.valid) {
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 400,
|
||||||
|
success: false,
|
||||||
|
error: emailValidation.error || "Invalid email",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedEmail = newEmail.trim().toLowerCase();
|
||||||
|
|
||||||
|
const currentUserQuery = `
|
||||||
|
SELECT id, username, display_name, email, is_verified, created_at, updated_at
|
||||||
|
FROM users WHERE id = ? LIMIT 1
|
||||||
|
`;
|
||||||
|
const currentUserResult = (await cassandra.execute(currentUserQuery, [
|
||||||
|
session.id,
|
||||||
|
])) as { rows: UserRow[] };
|
||||||
|
|
||||||
|
if (!currentUserResult?.rows || currentUserResult.rows.length === 0) {
|
||||||
|
await sessionManager.invalidateSession(request);
|
||||||
|
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 404,
|
||||||
|
success: false,
|
||||||
|
error: "User not found",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const currentUser = currentUserResult.rows[0];
|
||||||
|
if (!currentUser) {
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 404,
|
||||||
|
success: false,
|
||||||
|
error: "User not found",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (normalizedEmail === currentUser.email) {
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 400,
|
||||||
|
success: false,
|
||||||
|
error: "New email must be different from current email",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const cooldownKey = `email-change-cooldown:${session.id}`;
|
||||||
|
const lastRequest = await redis.get(cooldownKey);
|
||||||
|
|
||||||
|
if (lastRequest) {
|
||||||
|
const lastRequestTime = Number.parseInt(lastRequest, 10);
|
||||||
|
const timeSince = Date.now() - lastRequestTime;
|
||||||
|
const cooldownMs = emailUpdateTimes.coolDownMinutes * 60 * 1000;
|
||||||
|
|
||||||
|
if (timeSince < cooldownMs) {
|
||||||
|
const remainingMs = cooldownMs - timeSince;
|
||||||
|
const remainingMinutes = Math.ceil(remainingMs / (60 * 1000));
|
||||||
|
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 429,
|
||||||
|
success: false,
|
||||||
|
error: `Please wait ${remainingMinutes} minute(s) before requesting another email change`,
|
||||||
|
cooldownRemaining: remainingMinutes,
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 429 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEmailQuery = "SELECT id FROM users WHERE email = ? LIMIT 1";
|
||||||
|
const existingEmailResult = (await cassandra.execute(existingEmailQuery, [
|
||||||
|
normalizedEmail,
|
||||||
|
])) as { rows: Array<{ id: string }> };
|
||||||
|
|
||||||
|
if (existingEmailResult.rows.length > 0) {
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 409,
|
||||||
|
success: false,
|
||||||
|
error: "Email already exists",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const verificationToken = Bun.randomUUIDv7();
|
||||||
|
const emailChangeKey = `email-change:${verificationToken}`;
|
||||||
|
const now = Date.now();
|
||||||
|
|
||||||
|
try {
|
||||||
|
const emailChangeData: EmailChangeData = {
|
||||||
|
userId: currentUser.id,
|
||||||
|
currentEmail: currentUser.email,
|
||||||
|
newEmail: normalizedEmail,
|
||||||
|
requestedAt: now,
|
||||||
|
};
|
||||||
|
|
||||||
|
await redis.set(
|
||||||
|
emailChangeKey,
|
||||||
|
JSON.stringify(emailChangeData),
|
||||||
|
"EX",
|
||||||
|
emailUpdateTimes.tokenExpiryHours * 60 * 60,
|
||||||
|
);
|
||||||
|
|
||||||
|
await redis.set(
|
||||||
|
cooldownKey,
|
||||||
|
now.toString(),
|
||||||
|
"EX",
|
||||||
|
emailUpdateTimes.coolDownMinutes * 60,
|
||||||
|
);
|
||||||
|
|
||||||
|
// send verification email to NEW email
|
||||||
|
const emailVariables = {
|
||||||
|
subject: `Email Change Verification - ${extraValues.companyName}`,
|
||||||
|
companyName: extraValues.companyName,
|
||||||
|
id: currentUser.id,
|
||||||
|
displayName: currentUser.display_name || currentUser.username,
|
||||||
|
currentEmail: currentUser.email,
|
||||||
|
newEmail: normalizedEmail,
|
||||||
|
willExpire: `This link will expire in ${emailUpdateTimes.tokenExpiryHours} hours`,
|
||||||
|
verificationUrl: `${environment.frontendFqdn}/user/email?token=${verificationToken}`,
|
||||||
|
supportEmail: extraValues.supportEmail,
|
||||||
|
};
|
||||||
|
|
||||||
|
await mailerService.sendTemplateEmail(
|
||||||
|
normalizedEmail,
|
||||||
|
`Email Change Verification - ${extraValues.companyName}`,
|
||||||
|
"email-change-verification",
|
||||||
|
emailVariables,
|
||||||
|
);
|
||||||
|
|
||||||
|
// send notification email to OLD email about the change request
|
||||||
|
const notificationVariables = {
|
||||||
|
subject: `Email Change Request - ${extraValues.companyName}`,
|
||||||
|
companyName: extraValues.companyName,
|
||||||
|
displayName: currentUser.display_name || currentUser.username,
|
||||||
|
id: currentUser.id,
|
||||||
|
currentEmail: currentUser.email,
|
||||||
|
newEmail: normalizedEmail,
|
||||||
|
requestTime: new Date().toLocaleString(),
|
||||||
|
willExpire: `This request will expire in ${emailUpdateTimes.tokenExpiryHours} hours`,
|
||||||
|
supportEmail: extraValues.supportEmail,
|
||||||
|
};
|
||||||
|
|
||||||
|
try {
|
||||||
|
await mailerService.sendTemplateEmail(
|
||||||
|
currentUser.email,
|
||||||
|
`Email Change Request - ${extraValues.companyName}`,
|
||||||
|
"email-change-request-notification",
|
||||||
|
notificationVariables,
|
||||||
|
);
|
||||||
|
} catch (notificationError) {
|
||||||
|
echo.warn({
|
||||||
|
message: "Failed to send email change notification to old address",
|
||||||
|
error: notificationError,
|
||||||
|
userId: currentUser.id,
|
||||||
|
currentEmail: currentUser.email,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 200,
|
||||||
|
success: true,
|
||||||
|
message: `Email change verification sent to ${normalizedEmail}. Please check your new email to confirm the change. A notification has also been sent to your current email.`,
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 200 });
|
||||||
|
} catch (error) {
|
||||||
|
await redis.del(emailChangeKey).catch(() => {});
|
||||||
|
await redis.del(cooldownKey).catch(() => {});
|
||||||
|
|
||||||
|
echo.error({
|
||||||
|
message: "Failed to send email change verification",
|
||||||
|
error,
|
||||||
|
userId: currentUser.id,
|
||||||
|
currentEmail: currentUser.email,
|
||||||
|
newEmail: normalizedEmail,
|
||||||
|
});
|
||||||
|
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 500,
|
||||||
|
success: false,
|
||||||
|
error: "Failed to send verification email. Please try again.",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 500 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleEmailVerification(
|
||||||
|
request: ExtendedRequest,
|
||||||
|
session: UserSession,
|
||||||
|
): Promise<Response> {
|
||||||
|
const { token } = request.query;
|
||||||
|
|
||||||
|
if (!token || typeof token !== "string" || token.trim() === "") {
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 400,
|
||||||
|
success: false,
|
||||||
|
error: "Email change verification token is required",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const emailChangeKey = `email-change:${token}`;
|
||||||
|
const emailChangeDataRaw = await redis.get(emailChangeKey);
|
||||||
|
|
||||||
|
if (!emailChangeDataRaw) {
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 400,
|
||||||
|
success: false,
|
||||||
|
error: "Invalid or expired email change token",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let emailChangeData: EmailChangeData;
|
||||||
|
try {
|
||||||
|
emailChangeData = JSON.parse(emailChangeDataRaw);
|
||||||
|
} catch {
|
||||||
|
await redis.del(emailChangeKey);
|
||||||
|
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 400,
|
||||||
|
success: false,
|
||||||
|
error: "Invalid email change token format",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (
|
||||||
|
!emailChangeData.userId ||
|
||||||
|
!emailChangeData.currentEmail ||
|
||||||
|
!emailChangeData.newEmail
|
||||||
|
) {
|
||||||
|
await redis.del(emailChangeKey);
|
||||||
|
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 400,
|
||||||
|
success: false,
|
||||||
|
error: "Invalid email change data",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const userQuery = `
|
||||||
|
SELECT id, username, display_name, email, is_verified, created_at, updated_at
|
||||||
|
FROM users WHERE id = ? LIMIT 1
|
||||||
|
`;
|
||||||
|
const userResult = (await cassandra.execute(userQuery, [
|
||||||
|
emailChangeData.userId,
|
||||||
|
])) as { rows: UserRow[] };
|
||||||
|
|
||||||
|
if (!userResult?.rows || userResult.rows.length === 0) {
|
||||||
|
await redis.del(emailChangeKey);
|
||||||
|
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 404,
|
||||||
|
success: false,
|
||||||
|
error: "User not found",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const user = userResult.rows[0];
|
||||||
|
if (!user) {
|
||||||
|
await redis.del(emailChangeKey);
|
||||||
|
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 404,
|
||||||
|
success: false,
|
||||||
|
error: "User not found",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 404 });
|
||||||
|
}
|
||||||
|
|
||||||
|
if (user.email !== emailChangeData.currentEmail) {
|
||||||
|
await redis.del(emailChangeKey);
|
||||||
|
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 400,
|
||||||
|
success: false,
|
||||||
|
error:
|
||||||
|
"Email change token is no longer valid - current email has changed",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 400 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const existingEmailQuery = "SELECT id FROM users WHERE email = ? LIMIT 1";
|
||||||
|
const existingEmailResult = (await cassandra.execute(existingEmailQuery, [
|
||||||
|
emailChangeData.newEmail,
|
||||||
|
])) as { rows: Array<{ id: string }> };
|
||||||
|
|
||||||
|
if (
|
||||||
|
existingEmailResult.rows.length > 0 &&
|
||||||
|
existingEmailResult.rows[0]?.id !== user.id
|
||||||
|
) {
|
||||||
|
await redis.del(emailChangeKey);
|
||||||
|
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 409,
|
||||||
|
success: false,
|
||||||
|
error: "New email address is no longer available",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 409 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const updateQuery = `
|
||||||
|
UPDATE users
|
||||||
|
SET email = ?, is_verified = ?, updated_at = ?
|
||||||
|
WHERE id = ?
|
||||||
|
`;
|
||||||
|
await cassandra.execute(updateQuery, [
|
||||||
|
emailChangeData.newEmail,
|
||||||
|
true,
|
||||||
|
new Date(),
|
||||||
|
user.id,
|
||||||
|
]);
|
||||||
|
|
||||||
|
await redis.del(emailChangeKey);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const changeNotificationVariables = {
|
||||||
|
subject: `Email Address Changed - ${extraValues.companyName}`,
|
||||||
|
companyName: extraValues.companyName,
|
||||||
|
displayName: user.display_name || user.username,
|
||||||
|
id: user.id,
|
||||||
|
oldEmail: emailChangeData.currentEmail,
|
||||||
|
newEmail: emailChangeData.newEmail,
|
||||||
|
changeTime: new Date().toLocaleString(),
|
||||||
|
supportEmail: extraValues.supportEmail,
|
||||||
|
};
|
||||||
|
|
||||||
|
await mailerService.sendTemplateEmail(
|
||||||
|
emailChangeData.currentEmail,
|
||||||
|
`Email Address Changed - ${extraValues.companyName}`,
|
||||||
|
"email-change-completed-notification",
|
||||||
|
changeNotificationVariables,
|
||||||
|
);
|
||||||
|
} catch (notificationError) {
|
||||||
|
echo.warn({
|
||||||
|
message:
|
||||||
|
"Failed to send email change completion notification to old address",
|
||||||
|
error: notificationError,
|
||||||
|
userId: user.id,
|
||||||
|
oldEmail: emailChangeData.currentEmail,
|
||||||
|
newEmail: emailChangeData.newEmail,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
const updatedUserResult = (await cassandra.execute(userQuery, [user.id])) as {
|
||||||
|
rows: UserRow[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const updatedUser = updatedUserResult.rows[0];
|
||||||
|
if (!updatedUser) {
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 500,
|
||||||
|
success: false,
|
||||||
|
error: "Failed to fetch updated user data",
|
||||||
|
};
|
||||||
|
return Response.json(response, { status: 500 });
|
||||||
|
}
|
||||||
|
|
||||||
|
let sessionCookie: string | undefined;
|
||||||
|
|
||||||
|
if (session && session.id === user.id) {
|
||||||
|
try {
|
||||||
|
const userAgent = request.headers.get("User-Agent") || "Unknown";
|
||||||
|
const updatedSessionPayload = {
|
||||||
|
id: updatedUser.id,
|
||||||
|
username: updatedUser.username,
|
||||||
|
email: updatedUser.email,
|
||||||
|
isVerified: updatedUser.is_verified,
|
||||||
|
displayName: updatedUser.display_name,
|
||||||
|
createdAt: updatedUser.created_at.toISOString(),
|
||||||
|
updatedAt: updatedUser.updated_at.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
sessionCookie = await sessionManager.updateSession(
|
||||||
|
request,
|
||||||
|
updatedSessionPayload,
|
||||||
|
userAgent,
|
||||||
|
);
|
||||||
|
} catch (sessionError) {
|
||||||
|
echo.warn({
|
||||||
|
message: "Failed to update session after email change",
|
||||||
|
error: sessionError,
|
||||||
|
userId: user.id,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const responseUser: UserResponse = {
|
||||||
|
id: updatedUser.id,
|
||||||
|
username: updatedUser.username,
|
||||||
|
displayName: updatedUser.display_name,
|
||||||
|
email: updatedUser.email,
|
||||||
|
isVerified: updatedUser.is_verified,
|
||||||
|
createdAt: updatedUser.created_at.toISOString(),
|
||||||
|
};
|
||||||
|
|
||||||
|
const response: EmailChangeResponse = {
|
||||||
|
code: 200,
|
||||||
|
success: true,
|
||||||
|
message: `Email successfully changed to ${updatedUser.email} and verified.`,
|
||||||
|
user: responseUser,
|
||||||
|
};
|
||||||
|
|
||||||
|
return Response.json(response, {
|
||||||
|
status: 200,
|
||||||
|
headers: sessionCookie ? { "Set-Cookie": sessionCookie } : {},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export { handler, routeDef };
|
|
@ -1,11 +1,7 @@
|
||||||
import { echo } from "@atums/echo";
|
import { echo } from "@atums/echo";
|
||||||
import { sessionManager } from "#lib/auth";
|
import { sessionManager } from "#lib/auth";
|
||||||
import { cassandra } from "#lib/database";
|
import { cassandra } from "#lib/database";
|
||||||
import {
|
import { isValidDisplayName, isValidUsername } from "#lib/validation";
|
||||||
isValidDisplayName,
|
|
||||||
isValidEmail,
|
|
||||||
isValidUsername,
|
|
||||||
} from "#lib/validation";
|
|
||||||
|
|
||||||
import type {
|
import type {
|
||||||
ExtendedRequest,
|
ExtendedRequest,
|
||||||
|
@ -28,7 +24,7 @@ async function handler(
|
||||||
requestBody: unknown,
|
requestBody: unknown,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const session = await sessionManager.getSession(request);
|
const { session } = request;
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
const response: UpdateInfoResponse = {
|
const response: UpdateInfoResponse = {
|
||||||
|
@ -39,18 +35,13 @@ async function handler(
|
||||||
return Response.json(response, { status: 401 });
|
return Response.json(response, { status: 401 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const { username, displayName, email } = requestBody as UpdateInfoRequest;
|
const { username, displayName } = requestBody as UpdateInfoRequest;
|
||||||
|
|
||||||
if (
|
if (username === undefined && displayName === undefined) {
|
||||||
username === undefined &&
|
|
||||||
displayName === undefined &&
|
|
||||||
email === undefined
|
|
||||||
) {
|
|
||||||
const response: UpdateInfoResponse = {
|
const response: UpdateInfoResponse = {
|
||||||
code: 400,
|
code: 400,
|
||||||
success: false,
|
success: false,
|
||||||
error:
|
error: "At least one field must be provided (username, displayName)",
|
||||||
"At least one field must be provided (username, displayName, email)",
|
|
||||||
};
|
};
|
||||||
return Response.json(response, { status: 400 });
|
return Response.json(response, { status: 400 });
|
||||||
}
|
}
|
||||||
|
@ -88,7 +79,6 @@ async function handler(
|
||||||
const updates: {
|
const updates: {
|
||||||
username?: string;
|
username?: string;
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
email?: string;
|
|
||||||
} = {};
|
} = {};
|
||||||
|
|
||||||
if (username !== undefined) {
|
if (username !== undefined) {
|
||||||
|
@ -143,43 +133,6 @@ async function handler(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (email !== undefined) {
|
|
||||||
const emailValidation = isValidEmail(email);
|
|
||||||
if (!emailValidation.valid) {
|
|
||||||
const response: UpdateInfoResponse = {
|
|
||||||
code: 400,
|
|
||||||
success: false,
|
|
||||||
error: emailValidation.error || "Invalid email",
|
|
||||||
};
|
|
||||||
return Response.json(response, { status: 400 });
|
|
||||||
}
|
|
||||||
|
|
||||||
const normalizedEmail = email.trim().toLowerCase();
|
|
||||||
|
|
||||||
if (normalizedEmail !== currentUser.email) {
|
|
||||||
const existingEmailQuery =
|
|
||||||
"SELECT id FROM users WHERE email = ? LIMIT 1";
|
|
||||||
const existingEmailResult = (await cassandra.execute(
|
|
||||||
existingEmailQuery,
|
|
||||||
[normalizedEmail],
|
|
||||||
)) as { rows: Array<{ id: string }> };
|
|
||||||
|
|
||||||
if (
|
|
||||||
existingEmailResult.rows.length > 0 &&
|
|
||||||
existingEmailResult.rows[0]?.id !== session.id
|
|
||||||
) {
|
|
||||||
const response: UpdateInfoResponse = {
|
|
||||||
code: 409,
|
|
||||||
success: false,
|
|
||||||
error: "Email already exists",
|
|
||||||
};
|
|
||||||
return Response.json(response, { status: 409 });
|
|
||||||
}
|
|
||||||
|
|
||||||
updates.email = normalizedEmail;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (Object.keys(updates).length === 0) {
|
if (Object.keys(updates).length === 0) {
|
||||||
const response: UpdateInfoResponse = {
|
const response: UpdateInfoResponse = {
|
||||||
code: 200,
|
code: 200,
|
||||||
|
@ -210,16 +163,8 @@ async function handler(
|
||||||
updateValues.push(updates.displayName);
|
updateValues.push(updates.displayName);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (updates.email !== undefined) {
|
|
||||||
updateFields.push("email = ?");
|
|
||||||
updateValues.push(updates.email);
|
|
||||||
updateFields.push("is_verified = ?");
|
|
||||||
updateValues.push(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
updateFields.push("updated_at = ?");
|
updateFields.push("updated_at = ?");
|
||||||
updateValues.push(new Date());
|
updateValues.push(new Date());
|
||||||
|
|
||||||
updateValues.push(session.id);
|
updateValues.push(session.id);
|
||||||
|
|
||||||
const updateQuery = `
|
const updateQuery = `
|
||||||
|
@ -244,47 +189,22 @@ async function handler(
|
||||||
return Response.json(response, { status: 500 });
|
return Response.json(response, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
if (Object.keys(updates).length > 0) {
|
const userAgent = request.headers.get("User-Agent") || "Unknown";
|
||||||
const userAgent = request.headers.get("User-Agent") || "Unknown";
|
const updatedSessionPayload = {
|
||||||
const updatedSessionPayload = {
|
id: updatedUser.id,
|
||||||
id: updatedUser.id,
|
username: updatedUser.username,
|
||||||
username: updatedUser.username,
|
email: updatedUser.email,
|
||||||
email: updatedUser.email,
|
isVerified: updatedUser.is_verified,
|
||||||
isVerified: updatedUser.is_verified,
|
displayName: updatedUser.display_name,
|
||||||
displayName: updatedUser.display_name,
|
createdAt: updatedUser.created_at.toISOString(),
|
||||||
createdAt: updatedUser.created_at.toISOString(),
|
updatedAt: updatedUser.updated_at.toISOString(),
|
||||||
updatedAt: updatedUser.updated_at.toISOString(),
|
};
|
||||||
};
|
|
||||||
|
|
||||||
const sessionCookie = await sessionManager.updateSession(
|
const sessionCookie = await sessionManager.updateSession(
|
||||||
request,
|
request,
|
||||||
updatedSessionPayload,
|
updatedSessionPayload,
|
||||||
userAgent,
|
userAgent,
|
||||||
);
|
);
|
||||||
|
|
||||||
const responseUser: UserResponse = {
|
|
||||||
id: updatedUser.id,
|
|
||||||
username: updatedUser.username,
|
|
||||||
displayName: updatedUser.display_name,
|
|
||||||
email: updatedUser.email,
|
|
||||||
isVerified: updatedUser.is_verified,
|
|
||||||
createdAt: updatedUser.created_at.toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
const response: UpdateInfoResponse = {
|
|
||||||
code: 200,
|
|
||||||
success: true,
|
|
||||||
message: "User information updated successfully",
|
|
||||||
user: responseUser,
|
|
||||||
};
|
|
||||||
|
|
||||||
return Response.json(response, {
|
|
||||||
status: 200,
|
|
||||||
headers: {
|
|
||||||
"Set-Cookie": sessionCookie,
|
|
||||||
},
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const responseUser: UserResponse = {
|
const responseUser: UserResponse = {
|
||||||
id: updatedUser.id,
|
id: updatedUser.id,
|
||||||
|
@ -302,7 +222,12 @@ async function handler(
|
||||||
user: responseUser,
|
user: responseUser,
|
||||||
};
|
};
|
||||||
|
|
||||||
return Response.json(response, { status: 200 });
|
return Response.json(response, {
|
||||||
|
status: 200,
|
||||||
|
headers: {
|
||||||
|
"Set-Cookie": sessionCookie,
|
||||||
|
},
|
||||||
|
});
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
echo.error({
|
echo.error({
|
||||||
message: "Error updating user information",
|
message: "Error updating user information",
|
||||||
|
|
|
@ -23,7 +23,7 @@ async function handler(
|
||||||
requestBody: unknown,
|
requestBody: unknown,
|
||||||
): Promise<Response> {
|
): Promise<Response> {
|
||||||
try {
|
try {
|
||||||
const session = await sessionManager.getSession(request);
|
const { session } = request;
|
||||||
|
|
||||||
if (!session) {
|
if (!session) {
|
||||||
const response: UpdatePasswordResponse = {
|
const response: UpdatePasswordResponse = {
|
||||||
|
|
|
@ -175,7 +175,7 @@ async function handler(request: ExtendedRequest): Promise<Response> {
|
||||||
return Response.json(response, { status: 500 });
|
return Response.json(response, { status: 500 });
|
||||||
}
|
}
|
||||||
|
|
||||||
const session = await sessionManager.getSession(request);
|
const { session } = request;
|
||||||
let sessionCookie: string | undefined;
|
let sessionCookie: string | undefined;
|
||||||
|
|
||||||
if (session && session.id === user.id) {
|
if (session && session.id === user.id) {
|
||||||
|
|
|
@ -12,6 +12,7 @@ import {
|
||||||
type Server,
|
type Server,
|
||||||
} from "bun";
|
} from "bun";
|
||||||
|
|
||||||
|
import { sessionManager } from "#lib/auth";
|
||||||
import type { ExtendedRequest, RouteModule } from "#types/server";
|
import type { ExtendedRequest, RouteModule } from "#types/server";
|
||||||
|
|
||||||
class ServerHandler {
|
class ServerHandler {
|
||||||
|
@ -249,6 +250,8 @@ class ServerHandler {
|
||||||
extendedRequest.params = params;
|
extendedRequest.params = params;
|
||||||
extendedRequest.query = query;
|
extendedRequest.query = query;
|
||||||
|
|
||||||
|
extendedRequest.session = await sessionManager.getSession(request);
|
||||||
|
|
||||||
response = await routeModule.handler(
|
response = await routeModule.handler(
|
||||||
extendedRequest,
|
extendedRequest,
|
||||||
requestBody,
|
requestBody,
|
||||||
|
|
23
types/server/requests/user/update/email.ts
Normal file
23
types/server/requests/user/update/email.ts
Normal file
|
@ -0,0 +1,23 @@
|
||||||
|
import type { UserResponse } from "../base";
|
||||||
|
|
||||||
|
interface EmailChangeRequest {
|
||||||
|
newEmail: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmailChangeData {
|
||||||
|
userId: string;
|
||||||
|
currentEmail: string;
|
||||||
|
newEmail: string;
|
||||||
|
requestedAt: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface EmailChangeResponse {
|
||||||
|
code: number;
|
||||||
|
success: boolean;
|
||||||
|
error?: string;
|
||||||
|
message?: string;
|
||||||
|
user?: UserResponse;
|
||||||
|
cooldownRemaining?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
export type { EmailChangeRequest, EmailChangeData, EmailChangeResponse };
|
|
@ -1,2 +1,3 @@
|
||||||
export * from "./info";
|
export * from "./info";
|
||||||
export * from "./password";
|
export * from "./password";
|
||||||
|
export * from "./email";
|
||||||
|
|
|
@ -4,7 +4,6 @@ import type { UserResponse } from "../base";
|
||||||
interface UpdateInfoRequest {
|
interface UpdateInfoRequest {
|
||||||
username?: string;
|
username?: string;
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
email?: string;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
interface UpdateInfoResponse extends BaseResponse {
|
interface UpdateInfoResponse extends BaseResponse {
|
||||||
|
|
|
@ -1,3 +1,5 @@
|
||||||
|
import type { UserSession } from "#types/config";
|
||||||
|
|
||||||
type Query = Record<string, string>;
|
type Query = Record<string, string>;
|
||||||
type Params = Record<string, string>;
|
type Params = Record<string, string>;
|
||||||
|
|
||||||
|
@ -5,6 +7,7 @@ interface ExtendedRequest extends Request {
|
||||||
startPerf: number;
|
startPerf: number;
|
||||||
query: Query;
|
query: Query;
|
||||||
params: Params;
|
params: Params;
|
||||||
|
session?: UserSession | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type { ExtendedRequest, Query, Params };
|
export type { ExtendedRequest, Query, Params };
|
||||||
|
|
Loading…
Add table
Add a link
Reference in a new issue