import BaseService from './BaseService.js'; import Blog from '../models/community/blog.js'; import BlogPost from '../models/community/blog_post.js'; import User from '../models/community/user.js'; import UserParam from '../models/community/user_param.js'; import UserParamType from '../models/type/user_param.js'; import UserParamValue from '../models/type/user_param_value.js'; import { Op } from 'sequelize'; import Folder from '../models/community/folder.js'; import Image from '../models/community/image.js'; import ImageVisibilityType from '../models/type/image_visibility.js'; import FolderImageVisibility from '../models/community/folder_image_visibility.js'; import friendshipService from './friendshipService.js'; import { notifyUser } from '../utils/socket.js'; import { sendBlogShareEmail } from './emailService.js'; import { sequelize } from '../utils/sequelize.js'; export default class BlogService extends BaseService { async createBlog(hashedUserId, { title, description, visibility, ageMin, ageMax, genders }) { const user = await this.getUserByHashedId(hashedUserId); const blog = await Blog.create({ userId: user.id, title, description: description || null, visibility: visibility || 'public', ageMin: ageMin ?? null, ageMax: ageMax ?? null, genders: Array.isArray(genders) ? genders.join(',') : (genders || null), }); await this.ensureBlogFolder(user.id); await this.applyBlogFolderVisibility(user.id, blog.visibility); return blog; } async assertOwner(hashedUserId, blogId) { const user = await this.getUserByHashedId(hashedUserId); const blog = await Blog.findByPk(blogId); if (!blog) { const err = new Error('Blog not found'); err.status = 404; throw err; } if (blog.userId !== user.id) { const err = new Error('Access denied'); err.status = 403; throw err; } return { user, blog }; } async shareBlog(hashedUserId, blogId, { toFriends = false, emails = [] } = {}, url) { const { user, blog } = await this.assertOwner(hashedUserId, blogId); // Notify friends (accepted) let notifiedFriends = 0; if (toFriends) { try { const friends = await friendshipService.getFriendships(hashedUserId, true); for (const f of friends) { if (f.accepted && f.user?.hashedId) { notifiedFriends++; notifyUser(f.user.hashedId, 'blogShared', { blogId: blogId, title: blog.title, url, by: user.username, }); } } } catch (e) { // Continue even if notifications fail console.error('Error notifying friends for blog share:', e); } } // Send emails let emailsSent = 0; if (Array.isArray(emails) && emails.length) { const uniqueValid = Array.from(new Set( emails .map(e => (typeof e === 'string' ? e.trim() : '')) .filter(e => e && /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(e)) )); for (const to of uniqueValid) { try { await sendBlogShareEmail(to, url, blog.title, user.username); emailsSent++; } catch (e) { console.error('Error sending blog share email to', to, e); } } } return { url, notifiedFriends, emailsSent }; } async resolveSlug(slug) { // Try username+title match first (legacy), then title-only match. const sqlConcat = ` SELECT b.id FROM community.blog b JOIN community.user u ON u.id = b.user_id WHERE LOWER( regexp_replace(u.username, '[^a-zA-Z0-9_-]', '', 'g') || regexp_replace(regexp_replace(b.title, '\\s+', '', 'g'), '[^a-zA-Z0-9_-]', '', 'g') ) = LOWER(:slug) LIMIT 1 `; const [concatRows] = await sequelize.query(sqlConcat, { replacements: { slug } }); if (concatRows && concatRows.length) return concatRows[0].id; const sqlTitle = ` SELECT b.id FROM community.blog b WHERE LOWER( regexp_replace(regexp_replace(b.title, '\\s+', '', 'g'), '[^a-zA-Z0-9_-]', '', 'g') ) = LOWER(:slug) ORDER BY b.updated_at DESC LIMIT 1 `; const [titleRows] = await sequelize.query(sqlTitle, { replacements: { slug } }); if (titleRows && titleRows.length) return titleRows[0].id; return null; } async updateBlog(hashedUserId, blogId, data) { const user = await this.getUserByHashedId(hashedUserId); const blog = await Blog.findOne({ where: { id: blogId, userId: user.id } }); if (!blog) throw new Error('Blog not found or access denied'); const patch = { ...data }; if (Array.isArray(patch.genders)) patch.genders = patch.genders.join(','); const prevVisibility = blog.visibility; await blog.update(patch); if (patch.visibility && patch.visibility !== prevVisibility) { await this.applyBlogFolderVisibility(user.id, blog.visibility); } return blog; } async listBlogsForViewer(viewerHashedId = null) { // Public blogs are always visible. Logged-in can also see logged_in, plus filter by age/gender if provided (per blog when set). let viewer = null, age = null, gender = null; if (viewerHashedId) { try { viewer = await this.getUserByHashedId(viewerHashedId); } catch { viewer = null; } if (viewer) { const params = await this.getUserParams(viewer.id, ['birthdate', 'gender']); for (const p of params) { if (p.paramType.description === 'birthdate') age = this.calculateAge(p.value); if (p.paramType.description === 'gender') { const gv = await UserParamValue.findOne({ where: { id: p.value } }); gender = gv ? gv.value : null; } } } } const where = viewer ? { } : { visibility: 'public' }; const blogs = await Blog.findAll({ where, include: [{ model: User, as: 'owner', attributes: ['username', 'hashedId'] }] }); return blogs.filter(b => this.checkBlogAccess(b, !!viewer, age, gender)); } checkBlogAccess(blog, isLoggedIn, age, gender) { if (blog.visibility === 'public') return true; if (!isLoggedIn) return false; // age range check if provided if (blog.ageMin != null && (age == null || age < blog.ageMin)) return false; if (blog.ageMax != null && (age == null || age > blog.ageMax)) return false; // gender check if provided if (blog.genders) { const allowed = blog.genders.split(',').map(s => s.trim()).filter(Boolean); if (allowed.length && (!gender || !allowed.includes(gender))) return false; } return true; } async getBlog(blogId, viewerHashedId = null) { const blog = await Blog.findByPk(blogId, { include: [{ model: User, as: 'owner', attributes: ['username', 'hashedId'] }] }); if (!blog) throw new Error('Blog not found'); let age = null, gender = null, logged = false; if (viewerHashedId) { try { const viewer = await this.getUserByHashedId(viewerHashedId); logged = !!viewer; if (viewer) { const params = await this.getUserParams(viewer.id, ['birthdate', 'gender']); for (const p of params) { if (p.paramType.description === 'birthdate') age = this.calculateAge(p.value); if (p.paramType.description === 'gender') { const gv = await UserParamValue.findOne({ where: { id: p.value } }); gender = gv ? gv.value : null; } } } } catch {} } if (!this.checkBlogAccess(blog, logged, age, gender)) { const err = new Error('Access denied'); err.status = 403; throw err; } return blog; } async createPost(hashedUserId, blogId, { title, content }) { const user = await this.getUserByHashedId(hashedUserId); const blog = await Blog.findOne({ where: { id: blogId } }); if (!blog) throw new Error('Blog not found'); // Only logged-in users; editor access: any logged-in (as requested) return await BlogPost.create({ blogId: blog.id, userId: user.id, title, content }); } async listPosts(blogId, viewerHashedId = null) { // Enforce blog visibility before listing await this.getBlog(blogId, viewerHashedId); // pagination support via query params will be handled in controller; keep a method variant with args return await BlogPost.findAll({ where: { blogId }, order: [['created_at', 'desc']] }); } // Utilities for Blog image folder async ensureBlogFolder(userId) { // find root folder let root = await Folder.findOne({ where: { parentId: null, userId } }); if (!root) { // fallback: create minimal root with default everyone visibility root = await Folder.create({ name: 'root', parentId: null, userId }); const everyone = await ImageVisibilityType.findOne({ where: { description: 'everyone' } }); if (everyone) await FolderImageVisibility.create({ folderId: root.id, visibilityTypeId: everyone.id }); } let blogFolder = await Folder.findOne({ where: { parentId: root.id, userId, name: 'Blog' } }); if (!blogFolder) { blogFolder = await Folder.create({ name: 'Blog', parentId: root.id, userId }); } return blogFolder; } async applyBlogFolderVisibility(userId, blogVisibility) { const blogFolder = await this.ensureBlogFolder(userId); const everyone = await ImageVisibilityType.findOne({ where: { description: 'everyone' } }); if (!everyone) return; // Clear existing visibilities for the Blog folder await FolderImageVisibility.destroy({ where: { folderId: blogFolder.id } }); if (blogVisibility === 'public') { await FolderImageVisibility.create({ folderId: blogFolder.id, visibilityTypeId: everyone.id }); } // For non-public blogs we leave folder without 'everyone' (private by default) return blogFolder; } }