diff --git a/api/db/migrations/20260806195442_create_news_page.ts b/api/db/migrations/20260806195442_create_news_page.ts new file mode 100644 index 00000000..d6a134db --- /dev/null +++ b/api/db/migrations/20260806195442_create_news_page.ts @@ -0,0 +1,37 @@ +import { Knex } from 'knex'; + +export async function up(knex: Knex): Promise { + console.log('Creating news table...'); + + await knex.schema.createTable('news', table => { + table.increments('id').primary(); + + table + .specificType('html', 'LONGTEXT') + .notNullable(); + + table + .integer('updated_by_member_id') + .unsigned() + .nullable() + .references('id') + .inTable('member') + .onDelete('SET NULL'); + + table + .timestamp('created_at') + .notNullable() + .defaultTo(knex.fn.now()); + + table + .timestamp('updated_at') + .notNullable() + .defaultTo(knex.fn.now()); + }); +} + +export async function down(knex: Knex): Promise { + console.log('Dropping news table...'); + + await knex.schema.dropTableIfExists('news'); +} \ No newline at end of file diff --git a/api/src/api.ts b/api/src/api.ts index df874925..849c84a6 100644 --- a/api/src/api.ts +++ b/api/src/api.ts @@ -19,6 +19,7 @@ import { memberRoutes, messageRoutes, messageboardRoutes, + newsRoutes, objectInstanceRoutes, objectRoutes, placeRoutes, @@ -68,6 +69,7 @@ app.use('/api/blackmarket', blackmarketRoutes); app.use('/api/fleamarket', fleamarketRoutes); app.use('/api/club', clubRoutes); app.use('/api/vote', voteRoutes); +app.use('/api/news', newsRoutes); app.use((request, response, next) => { const error = new Error('Not found'); diff --git a/api/src/controllers/news.controller.ts b/api/src/controllers/news.controller.ts new file mode 100644 index 00000000..22d8e04f --- /dev/null +++ b/api/src/controllers/news.controller.ts @@ -0,0 +1,126 @@ +import { Request, Response } from 'express'; +import { Container } from 'typedi'; + +import { + MemberService, + NewsService, +} from '../services'; + +class NewsController { + constructor( + private memberService: MemberService, + private newsService: NewsService, + ) {} + + public async getNews( + request: Request, + response: Response, + ): Promise { + try { + const news = await this.newsService.getNews(); + + response.status(200).json({ + news: news || null, + }); + } catch (error) { + console.log(error); + + response.status(400).json({ + error: 'A problem occurred while trying to fetch the News page.', + }); + } + } + + public async canEditNews( + request: Request, + response: Response, + ): Promise { + const session = this.memberService.decryptSession( + request, + response, + ); + + if (!session) { + return; + } + + try { + const canEdit = + await this.memberService.canEditNews(session.id); + + response.status(200).json({ + canEdit, + }); + } catch (error) { + console.log(error); + + response.status(400).json({ + error: 'A problem occurred while checking News permissions.', + }); + } + } + + public async updateNews( + request: Request, + response: Response, + ): Promise { + const session = this.memberService.decryptSession( + request, + response, + ); + + if (!session) { + return; + } + + const canEditNews = + await this.memberService.canEditNews(session.id); + + if (!canEditNews) { + response.status(403).json({ + error: 'Access Denied', + }); + + return; + } + + const uncleanHtml = request.body.html; + + if (typeof uncleanHtml !== 'string') { + response.status(400).json({ + error: 'News HTML is required.', + }); + + return; + } + + const cleanHtml = await this.newsService.sanitize( + uncleanHtml, + ); + + try { + await this.newsService.updateNews( + cleanHtml, + session.id, + ); + + response.status(200).json({ + success: 'News updated', + }); + } catch (error) { + console.log(error); + + response.status(400).json({ + error: 'A problem occurred while trying to update the News page.', + }); + } + } +} + +const memberService = Container.get(MemberService); +const newsService = Container.get(NewsService); + +export const newsController = new NewsController( + memberService, + newsService, +); diff --git a/api/src/repositories/index.ts b/api/src/repositories/index.ts index 7936f133..13ee3edc 100644 --- a/api/src/repositories/index.ts +++ b/api/src/repositories/index.ts @@ -21,3 +21,4 @@ export * from './wallet/wallet.repository'; export * from './messageboard/messageboard.repository'; export * from './inbox/inbox.repository'; export * from './virtual_pet/virtual-pet.repository'; +export * from './news/news.repository'; \ No newline at end of file diff --git a/api/src/repositories/news/news.repository.ts b/api/src/repositories/news/news.repository.ts new file mode 100644 index 00000000..cef0b271 --- /dev/null +++ b/api/src/repositories/news/news.repository.ts @@ -0,0 +1,48 @@ +import { Service } from 'typedi'; + +import { knex } from '../../db'; + +@Service() +export class NewsRepository { + public async getNews(): Promise { + return knex('news') + .leftJoin( + 'member', + 'news.updated_by_member_id', + 'member.id', + ) + .select( + 'news.id', + 'news.html', + 'news.updated_by_member_id', + 'news.created_at', + 'news.updated_at', + 'member.username as updated_by_username', + ) + .first(); + } + + public async updateNews( + html: string, + memberId: number, + ): Promise { + const existingNews = await knex('news').first(); + + if (existingNews) { + await knex('news') + .where('id', existingNews.id) + .update({ + html, + updated_by_member_id: memberId, + updated_at: knex.fn.now(), + }); + + return; + } + + await knex('news').insert({ + html, + updated_by_member_id: memberId, + }); + } +} diff --git a/api/src/routes/index.ts b/api/src/routes/index.ts index f3da49a6..f1099528 100644 --- a/api/src/routes/index.ts +++ b/api/src/routes/index.ts @@ -10,6 +10,7 @@ export * from './hood.routes'; export * from './mall.routes'; export * from './member.routes'; export * from './message.routes'; +export * from './news.routes'; export * from './object-instance.routes'; export * from './object.routes'; export * from './place.routes'; diff --git a/api/src/routes/news.routes.ts b/api/src/routes/news.routes.ts new file mode 100644 index 00000000..5b237d56 --- /dev/null +++ b/api/src/routes/news.routes.ts @@ -0,0 +1,23 @@ +import { Router } from 'express'; + +import { newsController } from '../controllers/news.controller'; + +export const newsRoutes = Router(); + +newsRoutes.get( + '/', + (request, response) => + newsController.getNews(request, response), +); + +newsRoutes.get( + '/can-edit', + (request, response) => + newsController.canEditNews(request, response), +); + +newsRoutes.post( + '/', + (request, response) => + newsController.updateNews(request, response), +); \ No newline at end of file diff --git a/api/src/services/index.ts b/api/src/services/index.ts index 9c0b10ec..6923fcd4 100644 --- a/api/src/services/index.ts +++ b/api/src/services/index.ts @@ -19,3 +19,4 @@ export * from './wallet/wallet.service'; export * from './messageboard/messageboard.service'; export * from './inbox/inbox.service'; export * from './vote/vote.service'; +export * from './news/news.service'; \ No newline at end of file diff --git a/api/src/services/member/member.service.ts b/api/src/services/member/member.service.ts index 8f033d45..9b9a5bc0 100644 --- a/api/src/services/member/member.service.ts +++ b/api/src/services/member/member.service.ts @@ -121,6 +121,20 @@ export class MemberService { return accessLevel; } + public async canEditNews(memberId: number): Promise { + const roleAssignments = + await this.roleAssignmentRepository.getByMemberId(memberId); + + const newsRoles = [ + this.roleRepository.roleMap.Admin, + this.roleRepository.roleMap.CityMayor, + this.roleRepository.roleMap.CVNEditor, + ]; + + return !!roleAssignments.find( + assignment => newsRoles.includes(assignment.role_id), + ); +} /** * Creates a new member with the given email, username, and password. If successful, distributes * daily login bonuses, and returns an encoded member token. diff --git a/api/src/services/news/news.service.ts b/api/src/services/news/news.service.ts new file mode 100644 index 00000000..4d05afb9 --- /dev/null +++ b/api/src/services/news/news.service.ts @@ -0,0 +1,32 @@ +import { Service } from 'typedi'; + +import sanitizeHtml from 'sanitize-html'; + +import { NewsRepository } from '../../repositories/news/news.repository'; + +@Service() +export class NewsService { + constructor( + private newsRepository: NewsRepository, + ) {} + + public async sanitize( + html: string, + ): Promise { + return sanitizeHtml(html); + } + + public async getNews(): Promise { + return this.newsRepository.getNews(); + } + + public async updateNews( + html: string, + memberId: number, + ): Promise { + return this.newsRepository.updateNews( + html, + memberId, + ); + } +} \ No newline at end of file diff --git a/spa/assets/img/anchor.jpg b/spa/assets/img/anchor.jpg new file mode 100644 index 00000000..2e002ede Binary files /dev/null and b/spa/assets/img/anchor.jpg differ diff --git a/spa/src/App.vue b/spa/src/App.vue index a4b7171e..129fbcd2 100644 --- a/spa/src/App.vue +++ b/spa/src/App.vue @@ -12,7 +12,12 @@
- + News
diff --git a/spa/src/pages/News.vue b/spa/src/pages/News.vue new file mode 100644 index 00000000..51b10e65 --- /dev/null +++ b/spa/src/pages/News.vue @@ -0,0 +1,131 @@ + + + + + \ No newline at end of file diff --git a/spa/src/pages/admin/admin.vue b/spa/src/pages/admin/admin.vue index 7ed83606..c7b9ba7c 100644 --- a/spa/src/pages/admin/admin.vue +++ b/spa/src/pages/admin/admin.vue @@ -1,5 +1,5 @@