diff --git a/api/db/migrations/20260811151000_create_live_event_table.ts b/api/db/migrations/20260811151000_create_live_event_table.ts new file mode 100644 index 00000000..73725c5e --- /dev/null +++ b/api/db/migrations/20260811151000_create_live_event_table.ts @@ -0,0 +1,48 @@ +import { Knex } from 'knex'; + +const tableName = 'live_event'; +const COLLATE = 'utf8mb4_unicode_ci'; + +function applyCommon(table: Knex.CreateTableBuilder) { + table.collate(COLLATE); + table.increments('id').primary(); + table.timestamps(false, true); +} + +export async function up(knex: Knex): Promise { + if (!await knex.schema.hasTable(tableName)) { + await knex.schema.createTable(tableName, table => { + console.log(`Creating ${tableName} table...`); + applyCommon(table); + + table.integer('place_id') + .unsigned() + .nullable(); + + table.foreign('place_id') + .references('place.id'); + + table.boolean('enabled') + .notNullable() + .defaultTo(false); + + table.integer('updated_by') + .unsigned() + .nullable(); + + table.foreign('updated_by') + .references('member.id'); + }); + + await knex(tableName).insert({ + enabled: false, + }); + } +} + +export async function down(knex: Knex): Promise { + if (await knex.schema.hasTable(tableName)) { + console.log(`Dropping ${tableName} table`); + await knex.schema.dropTable(tableName); + } +} diff --git a/api/src/api.ts b/api/src/api.ts index df874925..5d2fce55 100644 --- a/api/src/api.ts +++ b/api/src/api.ts @@ -15,6 +15,7 @@ import { homeRoutes, hoodRoutes, inboxRoutes, + liveEventRoutes, mallRoutes, memberRoutes, messageRoutes, @@ -63,6 +64,7 @@ app.use('/api/home', homeRoutes); app.use('/api/messageboard', messageboardRoutes); app.use('/api/admin', adminRoutes); app.use('/api/inbox', inboxRoutes); +app.use('/api/live-event', liveEventRoutes); app.use('/api/mall', mallRoutes); app.use('/api/blackmarket', blackmarketRoutes); app.use('/api/fleamarket', fleamarketRoutes); diff --git a/api/src/controllers/index.ts b/api/src/controllers/index.ts index 2d35e7fe..9f1145ae 100644 --- a/api/src/controllers/index.ts +++ b/api/src/controllers/index.ts @@ -16,3 +16,4 @@ export * from './object-instance.controller'; export * from './object.controller'; export * from './place.controller'; export * from './vote.controller'; +export * from './live-event.controller'; diff --git a/api/src/controllers/live-event.controller.ts b/api/src/controllers/live-event.controller.ts new file mode 100644 index 00000000..5ff9abae --- /dev/null +++ b/api/src/controllers/live-event.controller.ts @@ -0,0 +1,71 @@ +import { Request, Response } from 'express'; +import { Container, Service } from 'typedi'; + +import { LiveEventService, MemberService } from '../services'; + +@Service() +export class LiveEventController { + constructor( + private liveEventService: LiveEventService, + private memberService: MemberService, + ) {} + + public async getCurrent(request: Request, response: Response): Promise { + const session = this.memberService.decryptSession(request, response); + if (!session) return; + + try { + const liveEvent = await this.liveEventService.getCurrent(); + response.status(200).json({ liveEvent }); + } catch (error) { + console.log(error); + response.status(400).json({ error }); + } + } + + public async update(request: Request, response: Response): Promise { + const session = this.memberService.decryptSession(request, response); + if (!session) return; + + const canManage = await this.liveEventService.canManage(session.id); + + if (!canManage) { + response.status(403).json({ message: 'Access Denied' }); + return; + } + + const placeId = request.body.placeId === null + ? null + : Number.parseInt(request.body.placeId); + + const enabled = request.body.enabled === true; + + if (enabled && !placeId) { + response.status(400).json({ + message: 'A destination is required when Live Event is enabled.', + }); + return; + } + + try { + await this.liveEventService.update( + session.id, + placeId, + enabled, + ); + + response.status(200).json({ status: 'success' }); + } catch (error) { + console.log(error); + response.status(400).json({ error }); + } + } +} + +const liveEventService = Container.get(LiveEventService); +const memberService = Container.get(MemberService); + +export const liveEventController = new LiveEventController( + liveEventService, + memberService, +); diff --git a/api/src/controllers/place.controller.ts b/api/src/controllers/place.controller.ts index 0ad1b615..f85cb485 100644 --- a/api/src/controllers/place.controller.ts +++ b/api/src/controllers/place.controller.ts @@ -149,6 +149,18 @@ class PlaceController { } } + public async getLiveEventDestinations(request: Request, response: Response,): Promise { + const session = this.memberService.decryptSession(request, response); + if (!session) return; + try { + const destinations = await this.placeService.getLiveEventDestinations(); + response.status(200).json({ destinations }); + } catch (error) { + console.log(error); + response.status(400).json({ error }); + } +} + public async addStorage(request: Request, response: Response): Promise { const session = this.memberService.decryptSession(request, response); if(!session) return; diff --git a/api/src/repositories/index.ts b/api/src/repositories/index.ts index 7936f133..d1aeec5b 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 './live-event/live-event.repository'; \ No newline at end of file diff --git a/api/src/repositories/live-event/live-event.repository.ts b/api/src/repositories/live-event/live-event.repository.ts new file mode 100644 index 00000000..5e45aed3 --- /dev/null +++ b/api/src/repositories/live-event/live-event.repository.ts @@ -0,0 +1,29 @@ +import { Service } from 'typedi'; + +import { Db } from '../../db/db.class'; + +@Service() +export class LiveEventRepository { + constructor(private db: Db) {} + + public async getCurrent(): Promise { + return this.db.knex('live_event') + .where({ id: 1 }) + .first(); + } + + public async update( + placeId: number | null, + enabled: boolean, + updatedBy: number, + ): Promise { + await this.db.knex('live_event') + .where({ id: 1 }) + .update({ + place_id: placeId, + enabled, + updated_by: updatedBy, + updated_at: new Date(), + }); + } +} diff --git a/api/src/repositories/place/place.repository.ts b/api/src/repositories/place/place.repository.ts index d1fb639e..94cdf69e 100644 --- a/api/src/repositories/place/place.repository.ts +++ b/api/src/repositories/place/place.repository.ts @@ -53,6 +53,20 @@ export class PlaceRepository { .orderBy(orderBy, 'asc'); } + public async findLiveEventDestinations(): Promise { + return this.db.place + .select( + 'place.id', + 'place.name', + 'place.slug', + 'place.type', + ) + .whereIn('place.type', ['colony', 'public']) + .where('place.status', 1) + .orderBy('place.type', 'asc') + .orderBy('place.name', 'asc'); + } + public async removePlace(id: number): Promise { await this.db.place .where('id', id) diff --git a/api/src/routes/index.ts b/api/src/routes/index.ts index f3da49a6..b53c42cd 100644 --- a/api/src/routes/index.ts +++ b/api/src/routes/index.ts @@ -15,4 +15,5 @@ export * from './object.routes'; export * from './place.routes'; export * from './messageboard.routes'; export * from './inbox.routes'; +export * from './live-event.routes'; export * from './vote.routes'; diff --git a/api/src/routes/live-event.routes.ts b/api/src/routes/live-event.routes.ts new file mode 100644 index 00000000..b3e20453 --- /dev/null +++ b/api/src/routes/live-event.routes.ts @@ -0,0 +1,13 @@ +import Router from 'express'; + +import { liveEventController } from '../controllers'; + +const liveEventRoutes = Router(); + +liveEventRoutes.get('/', (request, response) => + liveEventController.getCurrent(request, response)); + +liveEventRoutes.post('/', (request, response) => + liveEventController.update(request, response)); + +export { liveEventRoutes }; diff --git a/api/src/routes/place.routes.ts b/api/src/routes/place.routes.ts index 5683eaca..a1ca0a2d 100644 --- a/api/src/routes/place.routes.ts +++ b/api/src/routes/place.routes.ts @@ -13,7 +13,9 @@ placeRoutes.get('/getAccessInfo/:slug/:id?', (request, response) => placeController.getAccessInfoByUsername(request, response)); placeRoutes.get('/getSecurityInfo', (request, response) => placeController.getSecurityInfo(request, response)); -placeRoutes.get('/:placeId/object_instance', +placeRoutes.get('/live-event-destinations', + (request, response) => placeController.getLiveEventDestinations(request, response),); + placeRoutes.get('/:placeId/object_instance', (request, response) => placeController.getPlaceObjects(request, response)); placeRoutes.get('/:slug', (request, response) => placeController.getPlace(request, response)); diff --git a/api/src/services/index.ts b/api/src/services/index.ts index 9c0b10ec..ce165eb6 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 './live-event/live-event.service'; \ No newline at end of file diff --git a/api/src/services/live-event/live-event.service.ts b/api/src/services/live-event/live-event.service.ts new file mode 100644 index 00000000..f0253a74 --- /dev/null +++ b/api/src/services/live-event/live-event.service.ts @@ -0,0 +1,61 @@ +import { Service } from 'typedi'; + +import { + LiveEventRepository, + PlaceRepository, + RoleAssignmentRepository, + RoleRepository, +} from '../../repositories'; + +@Service() +export class LiveEventService { + constructor( + private liveEventRepository: LiveEventRepository, + private placeRepository: PlaceRepository, + private roleAssignmentRepository: RoleAssignmentRepository, + private roleRepository: RoleRepository, + ) {} + + public async getCurrent(): Promise { + const liveEvent = await this.liveEventRepository.getCurrent(); + + if (!liveEvent || !liveEvent.place_id) { + return { + enabled: false, + place: null, + }; + } + + const place = await this.placeRepository.findById(liveEvent.place_id); + + return { + enabled: liveEvent.enabled, + place, + updatedBy: liveEvent.updated_by, + updatedAt: liveEvent.updated_at, + }; + } + + public async canManage(memberId: number): Promise { + const roleAssignments = await this.roleAssignmentRepository.getByMemberId(memberId); + + const allowedRoles = [ + this.roleRepository.roleMap.Admin, + this.roleRepository.roleMap.CityMayor, + this.roleRepository.roleMap.PlacesChief, + this.roleRepository.roleMap.ColonyRepresentative, + ]; + + return !!roleAssignments.find( + assignment => allowedRoles.includes(assignment.role_id), + ); + } + + public async update( + memberId: number, + placeId: number | null, + enabled: boolean, + ): Promise { + await this.liveEventRepository.update(placeId, enabled, memberId); + } +} diff --git a/api/src/services/member/member.service.ts b/api/src/services/member/member.service.ts index 8f033d45..eaea6dcd 100644 --- a/api/src/services/member/member.service.ts +++ b/api/src/services/member/member.service.ts @@ -78,6 +78,21 @@ export class MemberService { return !!roleAssignments.find(assignment => LEADER_ROLES.includes(assignment.role_id)); } + public async canManageLiveEvent(memberId: number): Promise { + const roleAssignments = await this.roleAssignmentRepository.getByMemberId(memberId); + + const LIVE_EVENT_ROLES = [ + this.roleRepository.roleMap.Admin, + this.roleRepository.roleMap.CityMayor, + this.roleRepository.roleMap.PlacesChief, + this.roleRepository.roleMap.ColonyRepresentative, + ]; + + return !!roleAssignments.find( + assignment => LIVE_EVENT_ROLES.includes(assignment.role_id), + ); + } + public async canStaff(memberId: number): Promise { const roleAssignments = await this.roleAssignmentRepository.getByMemberId(memberId); // Extracted staff roles into a constant for easy management @@ -105,6 +120,7 @@ export class MemberService { const security = await this.canAdmin(memberId); const roleAssignments = await this.roleAssignmentRepository.getByMemberId(memberId); const leader = await this.canLeader(memberId); + const liveEvent = await this.canManageLiveEvent(memberId); const admin = !!roleAssignments.find( assignment => assignment.role_id === this.roleRepository.roleMap.Admin, ); @@ -118,6 +134,9 @@ export class MemberService { if (leader) { accessLevel.push('leader'); } + if (liveEvent) { + accessLevel.push('live-event'); + } return accessLevel; } diff --git a/api/src/services/place/place.service.ts b/api/src/services/place/place.service.ts index 6f3c1090..6f0e75ef 100644 --- a/api/src/services/place/place.service.ts +++ b/api/src/services/place/place.service.ts @@ -164,6 +164,10 @@ export class PlaceService { return await this.placeRepository.findByUserId(userId); } + public async getLiveEventDestinations(): Promise { + return this.placeRepository.findLiveEventDestinations(); +} + public async removeVirtualPet(id: number): Promise { await this.virtualPetRepository.removeVirtualPet(id); } diff --git a/spa/assets/img/live-event.gif b/spa/assets/img/live-event.gif new file mode 100644 index 00000000..a0f1f2f6 Binary files /dev/null and b/spa/assets/img/live-event.gif differ diff --git a/spa/src/App.vue b/spa/src/App.vue index a4b7171e..b1d1202f 100644 --- a/spa/src/App.vue +++ b/spa/src/App.vue @@ -84,7 +84,8 @@ class="cursor-pointer" style="width: 85px; height: 70px;" />
- + +