|
|
@@ -0,0 +1,111 @@
|
|
|
+import { randomUUID } from 'node:crypto';
|
|
|
+import bcrypt from 'bcrypt';
|
|
|
+
|
|
|
+const EMAIL_RE = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
|
|
+const BCRYPT_ROUNDS = 12;
|
|
|
+const USERS = 'users';
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {string} email
|
|
|
+ * @returns {string}
|
|
|
+ */
|
|
|
+export function normalizeEmail(email) {
|
|
|
+ return String(email ?? '')
|
|
|
+ .trim()
|
|
|
+ .toLowerCase();
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {string} email
|
|
|
+ * @returns {boolean}
|
|
|
+ */
|
|
|
+export function isValidEmail(email) {
|
|
|
+ return EMAIL_RE.test(email);
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * @param {{ name?: string, email?: string, password?: string, confirmPassword?: string }} input
|
|
|
+ * @returns {{ ok: true, name: string, email: string, password: string } | { ok: false, message: string }}
|
|
|
+ */
|
|
|
+export function validateRegistration(input) {
|
|
|
+ const name = String(input.name ?? '').trim();
|
|
|
+ const email = normalizeEmail(input.email);
|
|
|
+ const password = String(input.password ?? '');
|
|
|
+ const confirmPassword = String(input.confirmPassword ?? '');
|
|
|
+
|
|
|
+ if (!name) {
|
|
|
+ return { ok: false, message: 'Name is required' };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (!email || !isValidEmail(email)) {
|
|
|
+ return { ok: false, message: 'Enter a valid email' };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (password !== confirmPassword) {
|
|
|
+ return { ok: false, message: 'Passwords do not match' };
|
|
|
+ }
|
|
|
+
|
|
|
+ if (password.length <= 12) {
|
|
|
+ return { ok: false, message: 'Password must be longer than 12 characters' };
|
|
|
+ }
|
|
|
+
|
|
|
+ return { ok: true, name, email, password };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Create a user document and insert it into MongoDB.
|
|
|
+ * @param {import('mongodb').Db} db
|
|
|
+ * @param {{ name: string, email: string, password: string }} data
|
|
|
+ * @returns {Promise<{ ok: true, user: { uuid: string, name: string, email: string } } | { ok: false, message: string }>}
|
|
|
+ */
|
|
|
+export async function createUser(db, data) {
|
|
|
+ const users = db.collection(USERS);
|
|
|
+
|
|
|
+ await users.createIndex({ email: 1 }, { unique: true });
|
|
|
+ await users.createIndex({ uuid: 1 }, { unique: true });
|
|
|
+
|
|
|
+ const existing = await users.findOne({ email: data.email }, { projection: { _id: 1 } });
|
|
|
+ if (existing) {
|
|
|
+ return { ok: false, message: 'Email already registered' };
|
|
|
+ }
|
|
|
+
|
|
|
+ const uuid = randomUUID();
|
|
|
+ const passwordHash = await bcrypt.hash(data.password, BCRYPT_ROUNDS);
|
|
|
+
|
|
|
+ const user = {
|
|
|
+ uuid,
|
|
|
+ name: data.name,
|
|
|
+ email: data.email,
|
|
|
+ password: passwordHash
|
|
|
+ };
|
|
|
+
|
|
|
+ try {
|
|
|
+ await users.insertOne(user);
|
|
|
+ } catch (error) {
|
|
|
+ if (error && typeof error === 'object' && 'code' in error && error.code === 11000) {
|
|
|
+ return { ok: false, message: 'Email already registered' };
|
|
|
+ }
|
|
|
+ throw error;
|
|
|
+ }
|
|
|
+
|
|
|
+ return {
|
|
|
+ ok: true,
|
|
|
+ user: { uuid: user.uuid, name: user.name, email: user.email }
|
|
|
+ };
|
|
|
+}
|
|
|
+
|
|
|
+/**
|
|
|
+ * Validate input and create a user.
|
|
|
+ * @param {import('mongodb').Db} db
|
|
|
+ * @param {{ name?: string, email?: string, password?: string, confirmPassword?: string }} input
|
|
|
+ */
|
|
|
+export async function registerUser(db, input) {
|
|
|
+ const validated = validateRegistration(input);
|
|
|
+ if (!validated.ok) return validated;
|
|
|
+
|
|
|
+ return createUser(db, {
|
|
|
+ name: validated.name,
|
|
|
+ email: validated.email,
|
|
|
+ password: validated.password
|
|
|
+ });
|
|
|
+}
|