Compare commits

...

4 Commits

Author SHA1 Message Date
William Valentin
0e4599343e fix(models/user-badge): normalize find results and return updated docs; remove duplicate methods
Unifies handling of couchdbService.find array/{docs} shapes, ensures create/update return full docs with _rev, and deduplicates overlapping methods (findByUser, findByBadge, findByUserAndBadge, update). Adds robust update fallback to support both updateDocument(doc) and update(id, doc). Resolves test failures around inconsistent shapes.

🤖 Generated with [AI Assistant]

Co-Authored-By: AI Assistant <noreply@ai-assistant.com>
2025-11-04 12:21:58 -08:00
William Valentin
1591f58eec refactor(models,validators): tighten validation and standardize couchdbService usage\n\n- User: fix bio length check, error messages, and typos (couchdbService calls and bcrypt prefix).\n- UserBadge: use array from find(), return couchdbService results consistently, and call updateDocument/deleteDocument APIs.\n- profileValidator: switch custom URL regex to express-validator isURL with protocol requirement.\n\n🤖 Generated with [AI Assistant]\n\nCo-Authored-By: AI Assistant <noreply@ai-assistant.com> 2025-11-04 10:56:28 -08:00
William Valentin
d2e12ef23d fix(models/point-transaction): align find() consumers to array API and correct balance/history accessors\n\nUpdates findByUser/findByType/getUserBalance/getUserTransactionHistory to consume couchdbService.find as an array, removing result.docs assumptions. Prevents undefined access and ensures correct balance retrieval.\n\n🤖 Generated with [AI Assistant]\n\nCo-Authored-By: AI Assistant <noreply@ai-assistant.com> 2025-11-04 10:56:20 -08:00
William Valentin
cfc9b09a1f fix(routes/events): replace deprecated User.update with instance save in RSVP/cancel flows to persist user event participation reliably\n\nEnsures user.events and stats are persisted by calling user.save() instead of non-existent User.update. Also keeps participants response consistent.\n\n🤖 Generated with [AI Assistant]\n\nCo-Authored-By: AI Assistant <noreply@ai-assistant.com> 2025-11-04 10:56:12 -08:00
5 changed files with 71 additions and 93 deletions

View File

@@ -1,6 +1,6 @@
const { body, validationResult } = require("express-validator");
const URL_REGEX = /^(https?|ftp):\\/\\/[^\\s\\/$.?#].[^\\s]*$/i;
// URL validation handled via express-validator isURL
const validateProfile = [
body("bio")
@@ -11,22 +11,22 @@ const validateProfile = [
body("website")
.optional()
.if(body("website").notEmpty())
.matches(URL_REGEX)
.isURL({ protocols: ["http", "https", "ftp"], require_protocol: true })
.withMessage("Invalid website URL."),
body("social.twitter")
.optional()
.if(body("social.twitter").notEmpty())
.matches(URL_REGEX)
.isURL({ protocols: ["http", "https", "ftp"], require_protocol: true })
.withMessage("Invalid Twitter URL."),
body("social.github")
.optional()
.if(body("social.github").notEmpty())
.matches(URL_REGEX)
.isURL({ protocols: ["http", "https", "ftp"], require_protocol: true })
.withMessage("Invalid Github URL."),
body("social.linkedin")
.optional()
.if(body("social.linkedin").notEmpty())
.matches(URL_REGEX)
.isURL({ protocols: ["http", "https", "ftp"], require_protocol: true })
.withMessage("Invalid LinkedIn URL."),
body("privacySettings.profileVisibility")
.optional()

View File

@@ -90,7 +90,7 @@ class PointTransaction {
const errorContext = createErrorContext('PointTransaction', 'findByUser', { userId, limit, skip });
return await withErrorHandling(async () => {
const result = await couchdbService.find({
const docs = await couchdbService.find({
selector: {
type: 'point_transaction',
user: userId
@@ -99,7 +99,7 @@ class PointTransaction {
limit: limit,
skip: skip
});
return result.docs;
return docs;
}, errorContext);
}
@@ -107,7 +107,7 @@ class PointTransaction {
const errorContext = createErrorContext('PointTransaction', 'findByType', { transactionType, limit, skip });
return await withErrorHandling(async () => {
const result = await couchdbService.find({
const docs = await couchdbService.find({
selector: {
type: 'point_transaction',
transactionType: transactionType
@@ -116,7 +116,7 @@ class PointTransaction {
limit: limit,
skip: skip
});
return result.docs;
return docs;
}, errorContext);
}
@@ -144,7 +144,7 @@ class PointTransaction {
return await withErrorHandling(async () => {
// Get the most recent transaction for the user to find current balance
const result = await couchdbService.find({
const transactions = await couchdbService.find({
selector: {
type: 'point_transaction',
user: userId
@@ -153,11 +153,11 @@ class PointTransaction {
limit: 1
});
if (result.docs.length === 0) {
if (transactions.length === 0) {
return 0;
}
return result.docs[0].balanceAfter;
return transactions[0].balanceAfter;
}, errorContext);
}
@@ -180,12 +180,12 @@ class PointTransaction {
}
}
const result = await couchdbService.find({
const transactions = await couchdbService.find({
selector: selector,
sort: [{ createdAt: 'desc' }]
});
return result.docs;
return transactions;
}, errorContext);
}

View File

@@ -27,7 +27,7 @@ class User {
this.avatar = data.avatar || null;
this.cloudinaryPublicId = data.cloudinaryPublicId || null;
this.bio = data.bio || "";
if (this.bio.length > 510) { throw new ValidationError("Bio cannot exceed 500 characters.", "bio", this.bio); }
if (this.bio.length > 500) { throw new ValidationError("Bio cannot exceed 500 characters.", "bio", this.bio); }
this.location = data.location || "";
this.website = data.website || "";
if (this.website && !URL_REGEX.test(this.website)) { throw new ValidationError("Invalid website URL.", "website", this.website); }
@@ -40,9 +40,9 @@ class User {
// --- Settings & Preferences ---
this.privacySettings = data.privacySettings || { profileVisibility: "public" };
if (!["public", "private"].includes(this.privacySettings.profileVisibility)) { throw new ValidationError("Profile visibility must be 'public' or 'private.", "privacySettings.profileVisibility", this.privacySettings.profileVisibility); }
if (!["public", "private"].includes(this.privacySettings.profileVisibility)) { throw new ValidationError("Profile visibility must be 'public' or 'private'.", "privacySettings.profileVisibility", this.privacySettings.profileVisibility); }
this.preferences = data.preferences || { emailNotifications: true, pushNotifications: true, theme: "light" };
if (!["light", "dark"].includes(this.preferences.theme)) { throw new ValidationError("Theme must be light' or 'dark'.", "preferences.theme", this.preferences.theme); }
if (!["light", "dark"].includes(this.preferences.theme)) { throw new ValidationError("Theme must be 'light' or 'dark'.", "preferences.theme", this.preferences.theme); }
// --- Gamification & App Data ---
@@ -73,9 +73,9 @@ class User {
return await withErrorHandling(async () => {
let user;
if (query.email) { user = await couchdbService.findUserByEmail(query.email); }
else if (query._id) { user = await couchdeService.findUserById(query._id); }
else if (query._id) { user = await couchdbService.findUserById(query._id); }
else { // Generic query fallback
const docs = await couchdeService.find({
const docs = await couchdbService.find({
selector: { type: "user", ...query },
limit: 1
});
@@ -89,7 +89,7 @@ class User {
const errorContext = createErrorContext('User', 'findById', { id });
return await withErrorHandling(async () => {
const user = await couchdeService.findUserById(id);
const user = await couchdbService.findUserById(id);
return user ? new User(user) : null;
}, errorContext);
}
@@ -98,11 +98,11 @@ class User {
const errorContext = createErrorContext('User', 'findByIdAndUpdate', { id, update, options });
return await withErrorHandling(async () => {
const user = await couchdeService.findUserById(id);
const user = await couchdbService.findUserById(id);
if (!user) return null;
const updatedUser = { ...user, ...update, updatedAt: new Date().toISOString() };
const saved = await couchdeService.update(id, updatedUser);
const saved = await couchdbService.update(id, updatedUser);
if (options.new) { return new User(saved); }
return new User(user);
@@ -113,7 +113,7 @@ class User {
const errorContext = createErrorContext('User', 'findByIdAndDelete', { id });
return await withErrorHandling(async () => {
const user = await couchdeService.findUserById(id);
const user = await couchdbService.findUserById(id);
if (!user) return null;
await couchdbService.delete(id);
@@ -157,7 +157,7 @@ class User {
this.updatedAt = new Date().toISOString();
if (!this._id) {
this._id = `user_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`;
if (this.password && !this.password.startsWith('$2)')) {
if (this.password && !this.password.startsWith('$2')) {
const salt = await bcrypt.genSalt(10);
this.password = await bcrypt.hash(this.password, salt);
}
@@ -165,7 +165,7 @@ class User {
this._rev = created._rev;
return this;
} else {
const updated = await couchdeService.updateDocument(this.toJSON());
const updated = await couchdbService.updateDocument(this.toJSON());
this._rev = updated._rev;
return this;
}

View File

@@ -9,12 +9,11 @@ const {
class UserBadge {
constructor(userBadgeData) {
this.validate(userBadgeData);
UserBadge.validate(userBadgeData);
Object.assign(this, userBadgeData);
}
validate(userBadgeData, requireEarnedAt = false) {
// Validate required fields
static validate(userBadgeData, requireEarnedAt = false) {
if (!userBadgeData.user || userBadgeData.user.trim() === '') {
throw new ValidationError('User field is required', 'user', userBadgeData.user);
}
@@ -33,7 +32,6 @@ class UserBadge {
});
return await withErrorHandling(async () => {
// Validate using constructor (earnedAt optional for create)
new UserBadge(userBadgeData);
const doc = {
@@ -48,7 +46,8 @@ class UserBadge {
};
const result = await couchdbService.createDocument(doc);
return { ...doc, _rev: result.rev };
const _rev = result && (result._rev || result.rev);
return _rev ? { ...doc, _rev } : doc;
}, errorContext);
}
@@ -75,13 +74,10 @@ class UserBadge {
const errorContext = createErrorContext('UserBadge', 'find', { filter });
return await withErrorHandling(async () => {
const selector = {
type: "user_badge",
...filter,
};
const result = await couchdbService.find(selector);
return result.docs;
const selector = { type: "user_badge", ...filter };
const res = await couchdbService.find({ selector });
const docs = Array.isArray(res) ? res : (res && Array.isArray(res.docs) ? res.docs : []);
return docs;
}, errorContext);
}
@@ -89,23 +85,15 @@ class UserBadge {
const errorContext = createErrorContext('UserBadge', 'findByUser', { userId });
return await withErrorHandling(async () => {
const selector = {
type: "user_badge",
user: userId,
};
const selector = { type: "user_badge", user: userId };
const res = await couchdbService.find({ selector });
const userBadges = Array.isArray(res) ? res : (res && Array.isArray(res.docs) ? res.docs : []);
const result = await couchdbService.find(selector);
const userBadges = result.docs;
// Populate badge data for each user badge
const populatedBadges = await Promise.all(
userBadges.map(async (userBadge) => {
if (userBadge.badge) {
const badge = await couchdbService.getDocument(userBadge.badge);
return {
...userBadge,
badge: badge,
};
return { ...userBadge, badge };
}
return userBadge;
})
@@ -119,21 +107,15 @@ class UserBadge {
const errorContext = createErrorContext('UserBadge', 'findByBadge', { badgeId });
return await withErrorHandling(async () => {
const selector = {
type: "user_badge",
badge: badgeId,
};
const result = await couchdbService.find(selector);
return result.docs;
const selector = { type: "user_badge", badge: badgeId };
const res = await couchdbService.find({ selector });
const docs = Array.isArray(res) ? res : (res && Array.isArray(res.docs) ? res.docs : []);
return docs;
}, errorContext);
}
static async update(id, updateData) {
const errorContext = createErrorContext('UserBadge', 'update', {
userBadgeId: id,
updateData
});
const errorContext = createErrorContext('UserBadge', 'update', { userBadgeId: id, updateData });
return await withErrorHandling(async () => {
const doc = await couchdbService.getDocument(id);
@@ -141,14 +123,30 @@ class UserBadge {
throw new NotFoundError('UserBadge', id);
}
const updatedDoc = {
...doc,
...updateData,
updatedAt: new Date().toISOString(),
};
const updatedDoc = { ...doc, ...updateData, updatedAt: new Date().toISOString() };
const result = await couchdbService.createDocument(updatedDoc);
return { ...updatedDoc, _rev: result.rev };
let result;
if (typeof couchdbService.updateDocument === 'function') {
result = await couchdbService.updateDocument(updatedDoc);
} else if (typeof couchdbService.update === 'function') {
result = await couchdbService.update(updatedDoc._id, updatedDoc);
} else {
throw new DatabaseError('Update method not available on couchdbService');
}
const _rev = result && (result._rev || result.rev);
return _rev ? { ...updatedDoc, _rev } : updatedDoc;
}, errorContext);
}
static async findByUserAndBadge(userId, badgeId) {
const errorContext = createErrorContext('UserBadge', 'findByUserAndBadge', { userId, badgeId });
return await withErrorHandling(async () => {
const selector = { type: "user_badge", user: userId, badge: badgeId };
const res = await couchdbService.find({ selector });
const docs = Array.isArray(res) ? res : (res && Array.isArray(res.docs) ? res.docs : []);
return docs[0] || null;
}, errorContext);
}
@@ -161,40 +159,20 @@ class UserBadge {
throw new NotFoundError('UserBadge', id);
}
await couchdbService.destroy(id, doc._rev);
await couchdbService.deleteDocument(id, doc._rev);
return true;
}, errorContext);
}
static async findByUserAndBadge(userId, badgeId) {
const errorContext = createErrorContext('UserBadge', 'findByUserAndBadge', { userId, badgeId });
return await withErrorHandling(async () => {
const selector = {
type: "user_badge",
user: userId,
badge: badgeId,
};
const result = await couchdbService.find(selector);
return result.docs[0] || null;
}, errorContext);
}
static async updateProgress(userId, badgeId, progress) {
const errorContext = createErrorContext('UserBadge', 'updateProgress', { userId, badgeId, progress });
return await withErrorHandling(async () => {
const userBadge = await this.findByUserAndBadge(userId, badgeId);
if (userBadge) {
return await this.update(userBadge._id, { progress });
} else {
return await this.create({
user: userId,
badge: badgeId,
progress,
});
return await this.create({ user: userId, badge: badgeId, progress });
}
}, errorContext);
}

View File

@@ -98,7 +98,7 @@ router.put(
if (!user.events.includes(eventId)) {
user.events.push(eventId);
user.stats.eventsParticipated = user.events.length;
await User.update(userId, user);
await user.save();
}
// Award points for event participation using couchdbService
@@ -218,7 +218,7 @@ router.delete(
if (user) {
user.events = user.events.filter(id => id !== eventId);
user.stats.eventsParticipated = user.events.length;
await User.update(userId, user);
await user.save();
}
res.json({