feat: Implement blog and blog post models, routes, and services

- Added Blog and BlogPost models with necessary fields and relationships.
- Created blogRouter for handling blog-related API endpoints including CRUD operations.
- Developed BlogService for business logic related to blogs and posts, including sharing functionality.
- Implemented API client methods for frontend to interact with blog-related endpoints.
- Added internationalization support for blog-related text in English and German.
- Created Vue components for blog editing, listing, and viewing, including a rich text editor for post content.
- Enhanced user experience with form validations and dynamic visibility settings based on user input.
This commit is contained in:
Torsten Schulz (local)
2025-08-18 13:41:37 +02:00
parent 19ee6ba0a1
commit 53c748a074
27 changed files with 1342 additions and 19 deletions

View File

@@ -0,0 +1,246 @@
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;
}
}