diff --git a/src/app/models/broadcast-show.ts b/src/app/models/broadcast-show.ts index 56ef0bc..f788ee4 100644 --- a/src/app/models/broadcast-show.ts +++ b/src/app/models/broadcast-show.ts @@ -21,18 +21,37 @@ export enum BroadcastShowStatus { export interface IBroadcastShowHost { name: string; - description: string; + bio: string; gender: HumanGender; role: string; speech: ISpeechPersonality; } -export const BroadcastShowHostSchema = new Schema({ - name: { type: String, required: true }, - description: { type: String, required: true }, - gender: { type: String, enum: HumanGender, required: true }, - role: { type: String, required: true }, - personality: { type: SpeechPersonalitySchema, required: true }, -}); +export const BroadcastShowHostSchema = new Schema( + { + name: { type: String, required: true }, + bio: { type: String, required: true }, + gender: { type: String, enum: HumanGender, required: true }, + role: { type: String, required: true }, + personality: { type: SpeechPersonalitySchema, required: true }, + }, + { _id: false } +); + +export interface IBroadcastShowWriter { + name: string; + bio: string; + gender: HumanGender; + role: string; +} +export const BroadcastShowWriterSchema = new Schema( + { + name: { type: String, required: true }, + bio: { type: String, required: true }, + gender: { type: String, enum: HumanGender, required: true }, + role: { type: String, required: true }, + }, + { _id: false } +); export interface IBroadcastShowProducer { name: string; @@ -40,12 +59,15 @@ export interface IBroadcastShowProducer { gender: HumanGender; role: string; } -export const BroadcastShowProducerSchema = new Schema({ - name: { type: String, required: true }, - description: { type: String, required: true }, - gender: { type: String, enum: HumanGender, required: true }, - role: { type: String, required: true }, -}); +export const BroadcastShowProducerSchema = new Schema( + { + name: { type: String, required: true }, + description: { type: String, required: true }, + gender: { type: String, enum: HumanGender, required: true }, + role: { type: String, required: true }, + }, + { _id: false } +); export interface IBroadcastShow { _id: Types.ObjectId; // MongoDB concern @@ -55,10 +77,10 @@ export interface IBroadcastShow { title: string; description: string; producers: Array; + writer: IBroadcastShowWriter; hosts: Array; recentEpisodes: Array; } - export const BroadcastShowSchema = new Schema({ status: { type: String, @@ -73,6 +95,7 @@ export const BroadcastShowSchema = new Schema({ default: [], required: true, }, + writer: { type: BroadcastShowWriterSchema, required: true }, hosts: { type: [BroadcastShowHostSchema], default: [], required: true }, recentEpisodes: { type: [Types.ObjectId], diff --git a/src/app/models/post.ts b/src/app/models/post.ts new file mode 100644 index 0000000..c60cba6 --- /dev/null +++ b/src/app/models/post.ts @@ -0,0 +1,67 @@ +// app/models/post.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import { Schema, Types, model } from "mongoose"; + +import { + BroadcastShowWriterSchema, + IBroadcastShow, + IBroadcastShowWriter, +} from "./broadcast-show.js"; +import { IImage } from "./image.js"; + +export enum PostStatus { + Draft = "draft", + Live = "live", + Removed = "removed", +} + +/** + * Virtual authors will compose written articles for the site in blog format. + */ +export interface IPost { + _id: Types.ObjectId; // MongoDB concern + __v: number; // MongoDB concern + + created: Date; + status: PostStatus; + show: IBroadcastShow | Types.ObjectId; + slug: string; + author: IBroadcastShowWriter; + title: string; + summary: string; + body: string; + tags: Array; + + headerImage: IImage | Types.ObjectId; + bodyImages?: Array; +} + +export const PostSchema = new Schema({ + created: { type: Date, default: Date.now, required: true, index: -1 }, + status: { + type: String, + enum: PostStatus, + default: PostStatus.Draft, + required: true, + index: 1, + }, + show: { + type: Schema.ObjectId, + required: true, + index: 1, + ref: "BroadcastShow", + }, + slug: { type: String, lowercase: true, required: true, index: 1 }, + author: { type: BroadcastShowWriterSchema, required: true }, + title: { type: String, required: true }, + summary: { type: String, required: true }, + body: { type: String, required: true }, + tags: { type: [String], default: [], required: true }, + headerImage: { Type: Schema.ObjectId, required: true, ref: "Image" }, + bodyImages: { Type: [Schema.ObjectId], ref: "Image" }, +}); + +export const Post = model("Post", PostSchema); +export default Post; diff --git a/src/app/services/broadcast-show.ts b/src/app/services/broadcast-show.ts index b13cf5a..97dc7a8 100644 --- a/src/app/services/broadcast-show.ts +++ b/src/app/services/broadcast-show.ts @@ -138,7 +138,9 @@ export class BroadcastShowService extends DtpService { exclude = [exclude]; } pipeline.push({ - $match: { $nin: exclude.map((show) => show._id) }, + $match: { + _id: { $nin: exclude.map((show) => show._id) }, + }, }); } diff --git a/src/app/services/post.ts b/src/app/services/post.ts new file mode 100644 index 0000000..57e36de --- /dev/null +++ b/src/app/services/post.ts @@ -0,0 +1,168 @@ +// app/services/broadcast-show.ts +// Copyright (C) 2025 DTP Technologies, LLC +// All Rights Reserved + +import mongoose from "mongoose"; +import slug from "slug"; + +import { IImage } from "../models/image.js"; +import { + IBroadcastShow, + IBroadcastShowWriter, +} from "../models/broadcast-show.js"; +import Post, { IPost, PostStatus } from "../models/post.js"; + +import { + DtpPlatform, + DtpService, + DtpServiceUpdate, + WebError, + WebPaginationParameters, +} from "../../lib/dtplib.js"; + +export interface PostDefinition { + show: IBroadcastShow | mongoose.Types.ObjectId; + author: IBroadcastShowWriter; + title: string; + summary: string; + body: string; + tags: Array; + headerImage: IImage | mongoose.Types.ObjectId; +} + +export interface PostLibrary { + posts: Array; + totalPostCount: number; +} + +/** + * A service for managing blog posts on the Newsroom platform. + */ +export class PostService extends DtpService { + populatePost: Array; + + static get name() { + return "PostService"; + } + static get slug() { + return "post"; + } + + constructor(platform: DtpPlatform) { + super(platform, PostService); + this.populatePost = [ + { + path: "headerImage", + }, + { + path: "bodyImages", + }, + ]; + } + + async create( + show: IBroadcastShow | mongoose.Types.ObjectId, + definition: PostDefinition + ): Promise { + const NOW = new Date(); + const post = new Post(); + + let titleSlug = definition.title.split(" ").slice(0, 5).join(" "); + titleSlug = slug(titleSlug); + titleSlug += `-${post._id.toString()}`; + + post.created = NOW; + post.show = show._id; + post.slug = titleSlug; + post.author = definition.author; + post.title = definition.title; + post.summary = definition.summary; + post.body = definition.body; + post.tags = definition.tags; + post.headerImage = definition.headerImage._id; + + this.log.info("creating blog post", { + _id: post._id, + slug: titleSlug, + title: post.title, + }); + + await post.save(); + return post.toObject(); + } + + async update(post: IPost, definition: PostDefinition): Promise { + const update: DtpServiceUpdate = {}; + update.$set = {}; + + update.$set.author = definition.author; + update.$set.title = definition.title; + update.$set.summary = definition.summary; + update.$set.body = definition.body; + update.$set.tags = definition.tags; + update.$set.headerImage = definition.headerImage._id; + + const newPost = await Post.findByIdAndUpdate(post._id, update, { + new: true, + populate: this.populatePost, + }).lean(); + if (!newPost) { + throw new WebError(404, "Post not found"); + } + + return newPost; + } + + async getBySlug(urlSlug: string): Promise { + return Post.findOne({ slug: urlSlug.trim().toLowerCase() }) + .populate(this.populatePost) + .lean(); + } + + async getById(postId: mongoose.Types.ObjectId): Promise { + return Post.findById(postId).populate(this.populatePost).lean(); + } + + async getLive(pagination: WebPaginationParameters): Promise { + const search = { status: PostStatus.Live }; + const posts = await Post.find(search) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populatePost) + .lean(); + + const totalPostCount = await Post.countDocuments(search); + return { posts, totalPostCount }; + } + + async getForShow( + show: IBroadcastShow | mongoose.Types.ObjectId, + pagination: WebPaginationParameters + ): Promise { + const search = { show: show._id, status: PostStatus.Live }; + const posts = await Post.find(search) + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populatePost) + .lean(); + + const totalPostCount = await Post.countDocuments(search); + return { posts, totalPostCount }; + } + + async getAll(pagination: WebPaginationParameters): Promise { + const posts = await Post.find() + .sort({ created: -1 }) + .skip(pagination.skip) + .limit(pagination.cpp) + .populate(this.populatePost) + .lean(); + + const totalPostCount = await Post.estimatedDocumentCount(); + return { posts, totalPostCount }; + } +} + +export default PostService;