From 858173ab3bf7fb2a707f230083a94eb58ba63c9e Mon Sep 17 00:00:00 2001 From: tsa96 Date: Mon, 2 Sep 2024 05:41:51 +0100 Subject: [PATCH] refactor(shared): replace MapSubmissionVersion with MapVersion --- apps/backend-e2e/src/admin.e2e-spec.ts | 207 +++++---- apps/backend-e2e/src/maps-2.e2e-spec.ts | 11 +- apps/backend-e2e/src/maps.e2e-spec.ts | 426 +++++++++++------- apps/backend-e2e/src/multi-stage.e2e-spec.ts | 14 +- apps/backend-e2e/src/session.e2e-spec.ts | 8 +- apps/backend/src/app/dto/index.ts | 2 +- .../src/app/dto/map/map-submission.dto.ts | 13 +- ...sion-version.dto.ts => map-version.dto.ts} | 32 +- apps/backend/src/app/dto/map/map.dto.ts | 60 +-- .../src/app/dto/queries/map-queries.dto.ts | 19 +- .../modules/map-review/map-review.service.ts | 26 +- .../src/app/modules/maps/map-list.service.ts | 28 +- .../src/app/modules/maps/maps.controller.ts | 15 +- .../src/app/modules/maps/maps.service.ts | 381 +++++++--------- .../session/run/run-processor.class.spec.ts | 3 +- .../session/run/run-processor.class.ts | 28 +- .../session/run/run-session.interface.ts | 2 +- .../session/run/run-session.service.ts | 2 +- .../map-list/map-list-item.component.html | 2 +- .../map-review/map-review-form.component.ts | 2 +- .../map-review-suggestions-form.component.ts | 4 +- .../pages/maps/map-edit/map-edit.component.ts | 12 +- .../maps/map-info/map-info.component.html | 6 +- .../pages/maps/map-info/map-info.component.ts | 8 +- .../map-submission.component.ts | 8 +- .../src/app/services/data/maps.service.ts | 4 +- .../src/app/util/download-zone-file.util.ts | 6 +- .../src/consts/file-store-paths.const.ts | 12 +- libs/constants/src/types/models/models.ts | 33 +- .../src/types/models/prisma-correspondence.ts | 10 +- .../src/types/queries/map-queries.model.ts | 17 +- .../20240903162533_map_versions/migration.sql | 66 +++ libs/db/src/schema.prisma | 97 ++-- libs/test-utils/src/utils/db.util.ts | 147 +++--- scripts/src/seed.script.ts | 253 +++++------ 35 files changed, 974 insertions(+), 990 deletions(-) rename apps/backend/src/app/dto/map/{map-submission-version.dto.ts => map-version.dto.ts} (79%) create mode 100644 libs/db/src/migrations/20240903162533_map_versions/migration.sql diff --git a/apps/backend-e2e/src/admin.e2e-spec.ts b/apps/backend-e2e/src/admin.e2e-spec.ts index 6f7638b3f..96017a71e 100644 --- a/apps/backend-e2e/src/admin.e2e-spec.ts +++ b/apps/backend-e2e/src/admin.e2e-spec.ts @@ -931,6 +931,7 @@ describe('Admin', () => { describe('PATCH', () => { const bspBuffer = readFileSync(path.join(FILES_PATH, 'map.bsp')); const vmfBuffer = readFileSync(path.join(FILES_PATH, 'map.vmf')); + const bspHash = createSha1Hash(bspBuffer); let mod, modToken, @@ -953,6 +954,26 @@ describe('Admin', () => { createMapData = { name: 'surf_map', submitter: { connect: { id: u1.id } }, + versions: { + createMany: { + data: [ + { + versionNum: 1, + bspHash: createSha1Hash( + 'apple banana cat dog elephant fox grape hat igloo joker' + ), + zones: BabyZonesStub as unknown as JsonValue, // TODO: #855 + submitterID: u1.id + }, + { + versionNum: 2, + bspHash, + zones: BabyZonesStub as unknown as JsonValue, // TODO: #855 + submitterID: u1.id + } + ] + } + }, submission: { create: { type: MapSubmissionType.ORIGINAL, @@ -970,16 +991,7 @@ describe('Admin', () => { tier: 1, type: LeaderboardType.IN_SUBMISSION } - ], - versions: { - create: { - versionNum: 1, - hash: createSha1Hash( - 'apple banana cat dog elephant fox grape hat igloo joker' - ), - zones: BabyZonesStub as unknown as JsonValue // TODO: #855 - } - } + ] } } }; @@ -1211,30 +1223,26 @@ describe('Admin', () => { const map = await db.createMap({ ...createMapData, - submission: { + versions: { create: { - ...createMapData.submission.create, - versions: { - create: { - zones: BabyZonesStub, - versionNum: 1, - hash: createSha1Hash(bspBuffer) - } - } + zones: BabyZonesStub as unknown as JsonValue, // TODO: #855 + versionNum: 1, + bspHash: createSha1Hash(bspBuffer), + submitterID: u1.id } }, status: s1 }); - await prisma.mapSubmission.update({ - where: { mapID: map.id }, - data: { currentVersionID: map.submission.versions[0].id } + await prisma.mMap.update({ + where: { id: map.id }, + data: { currentVersionID: map.versions[0].id } }); // Annoying to have to do this for every test but FA -> Approved // will throw otherwise. await fileStore.add( - `submissions/${map.submission.versions[0].id}.bsp`, + `submissions/${map.versions[0].id}.bsp`, bspBuffer ); @@ -1281,44 +1289,41 @@ describe('Admin', () => { const map = await db.createMap({ ...createMapData, - submission: { + versions: { create: { - ...createMapData.submission.create, - versions: { - create: { - zones: BabyZonesStub, - versionNum: 1, - hash: createSha1Hash(bspBuffer) - } - } + zones: BabyZonesStub as unknown as JsonValue, // TODO: #855 + versionNum: 1, + bspHash: createSha1Hash(bspBuffer), + submitterID: u1.id } }, status: MapStatus.DISABLED }); - await prisma.mapSubmission.update({ - where: { mapID: map.id }, + await prisma.mMap.update({ + where: { id: map.id }, data: { - currentVersionID: map.submission.versions[0].id, - dates: [ - { - status: MapStatus.APPROVED, - date: new Date(Date.now() - 2000) - }, - { - status: MapStatus.DISABLED, - date: new Date(Date.now() - 1000) + currentVersionID: map.versions[0].id, + submission: { + update: { + dates: [ + { + status: MapStatus.APPROVED, + date: new Date(Date.now() - 2000) + }, + { + status: MapStatus.DISABLED, + date: new Date(Date.now() - 1000) + } + ] } - ] + } } }); // Annoying to have to do this for every test but FA -> Approved // will throw otherwise. - await fileStore.add( - `submissions/${map.submission.versions[0].id}.bsp`, - bspBuffer - ); + await fileStore.add(`submissions/${map.versions[0].id}.bsp`, bspBuffer); await req.patch({ url: `admin/maps/${map.id}`, @@ -1349,16 +1354,17 @@ describe('Admin', () => { const map = await db.createMap({ ...createMapData, + versions: { + create: { + versionNum: 1, + bspHash: createSha1Hash(bspBuffer), + zones: BabyZonesStub as unknown as JsonValue, // TODO: #855 + submitterID: u1.id + } + }, submission: { create: { ...createMapData.submission.create, - versions: { - create: { - versionNum: 1, - hash: createSha1Hash(bspBuffer), - zones: BabyZonesStub - } - }, placeholders: [ { type: MapCreditType.CONTRIBUTOR, @@ -1371,15 +1377,12 @@ describe('Admin', () => { status: MapStatus.FINAL_APPROVAL }); - await prisma.mapSubmission.update({ - where: { mapID: map.id }, - data: { currentVersionID: map.submission.versions[0].id } + await prisma.mMap.update({ + where: { id: map.id }, + data: { currentVersionID: map.versions[0].id } }); - await fileStore.add( - `submissions/${map.submission.versions[0].id}.bsp`, - bspBuffer - ); + await fileStore.add(`submissions/${map.versions[0].id}.bsp`, bspBuffer); await req.patch({ url: `admin/maps/${map.id}`, @@ -1444,17 +1447,24 @@ describe('Admin', () => { beforeEach(async () => { map = await db.createMap({ ...createMapData, - submission: { - create: { - ...createMapData.submission.create, - versions: { - create: { + versions: { + createMany: { + data: [ + { versionNum: 1, - hash: createSha1Hash(bspBuffer), - zones: ZonesStub, - hasVmf: true + bspHash, + zones: ZonesStub as any, + hasVmf: true, + submitterID: u1.id + }, + { + versionNum: 2, + bspHash, + zones: ZonesStub as any, + hasVmf: true, + submitterID: u1.id } - } + ] } }, status: MapStatus.FINAL_APPROVAL @@ -1462,16 +1472,11 @@ describe('Admin', () => { const vmfZip = new Zip(); vmfZip.addFile('map.vmf', vmfBuffer); - - await fileStore.add( - `submissions/${map.submission.versions[0].id}_VMFs.zip`, - vmfZip.toBuffer() - ); - - await fileStore.add( - `submissions/${map.submission.versions[0].id}.bsp`, - bspBuffer - ); + for (const i of [0, 1]) { + const id = map.versions[i].id; + await fileStore.add(`maps/${id}_VMFs.zip`, vmfZip.toBuffer()); + await fileStore.add(`maps/${id}.bsp`, bspBuffer); + } await prisma.leaderboard.createMany({ data: ZonesStubLeaderboards.map((lb) => ({ @@ -1483,7 +1488,7 @@ describe('Admin', () => { }); }); - it('should copy latest submission version files to maps/ after map status changed from FA to approved', async () => { + it('should delete old version files after map status changed from FA to approved', async () => { const bspHash = createSha1Hash(bspBuffer); const vmfHash = createSha1Hash(vmfBuffer); @@ -1495,33 +1500,26 @@ describe('Admin', () => { }); const updatedMap = await prisma.mMap.findUnique({ - where: { id: map.id } + where: { id: map.id }, + include: { currentVersion: true, versions: true } }); expect(updatedMap).toMatchObject({ status: MapStatus.APPROVED, - hash: bspHash, - hasVmf: true + currentVersion: { bspHash, versionNum: 2 } }); - expect( - await fileStore.exists( - `submissions/${map.submission.versions[0].id}.bsp` - ) - ).toBeFalsy(); - expect( - await fileStore.exists( - `submissions/${map.submission.versions[0].id}_VMFs.zip` - ) - ).toBeFalsy(); - - expect( - createSha1Hash(await fileStore.get(`maps/${map.name}.bsp`)) - ).toBe(bspHash); + const id1 = map.versions[0].id; + const id2 = map.versions[1].id; + expect(await fileStore.exists(`maps/${id1}.bsp`)).toBeFalsy(); + expect(await fileStore.exists(`maps/${id1}_VMFs.zip`)).toBeFalsy(); + expect(createSha1Hash(await fileStore.get(`maps/${id2}.bsp`))).toBe( + bspHash + ); expect( createSha1Hash( - new Zip(await fileStore.get(`maps/${map.name}_VMFs.zip`)) + new Zip(await fileStore.get(`maps/${id2}_VMFs.zip`)) .getEntry('map.vmf') .getData() ) @@ -1925,12 +1923,13 @@ describe('Admin', () => { token: adminToken }); - const updated = await prisma.mMap.findFirst({ where: { id: map.id } }); - expect(updated).toMatchObject({ - status: MapStatus.DISABLED, - hash: null + const updated = await prisma.mMap.findFirst({ + where: { id: map.id }, + include: { versions: true } }); + expect(updated.status).toBe(MapStatus.DISABLED); + expect(updated.versions.at(-1).bspHash).toBeNull(); expect(await fileStore.exists(`maps/${fileName}.bsp`)).toBeFalsy(); // We used to delete these, check we don't anymore diff --git a/apps/backend-e2e/src/maps-2.e2e-spec.ts b/apps/backend-e2e/src/maps-2.e2e-spec.ts index 4cc722518..0f9fdbb1d 100644 --- a/apps/backend-e2e/src/maps-2.e2e-spec.ts +++ b/apps/backend-e2e/src/maps-2.e2e-spec.ts @@ -49,6 +49,7 @@ import { import { MapListVersionDto } from '../../backend/src/app/dto/map/map-list-version.dto'; import path from 'node:path'; import { LeaderboardStatsDto } from '../../backend/src/app/dto/run/leaderboard-stats.dto'; +import { JsonValue } from 'type-fest'; describe('Maps Part 2', () => { let app, @@ -759,7 +760,15 @@ describe('Maps Part 2', () => { async () => ([token, map] = await Promise.all([ db.loginNewUser(), - db.createMap({ zones: ZonesStub }) + db.createMap({ + versions: { + create: { + zones: ZonesStub as unknown as JsonValue, // TODO: #855 + versionNum: 1, + submitter: db.getNewUserCreateData() + } + } + }) ])) ); diff --git a/apps/backend-e2e/src/maps.e2e-spec.ts b/apps/backend-e2e/src/maps.e2e-spec.ts index f4f026ddd..4099d597e 100644 --- a/apps/backend-e2e/src/maps.e2e-spec.ts +++ b/apps/backend-e2e/src/maps.e2e-spec.ts @@ -45,6 +45,7 @@ import { setupE2ETestEnvironment, teardownE2ETestEnvironment } from './support/environment'; +import { JsonValue } from 'type-fest'; describe('Maps', () => { let app, @@ -129,8 +130,6 @@ describe('Maps', () => { 'id', 'name', 'status', - 'hash', - 'downloadURL', 'submitterID', 'createdAt', 'updatedAt' @@ -274,29 +273,52 @@ describe('Maps', () => { await prisma.mMap.delete({ where: { id: map.id } }); }); - it('should respond with expanded submitter data using the zones expand parameter', async () => { - await prisma.mMap.updateMany({ data: { submitterID: u2.id } }); + it('should respond with expanded submitter data using the currentVersion expand parameter', () => + req.expandTest({ + url: 'maps', + expand: 'currentVersion', + paged: true, + validate: MapDto, + token: u1Token + })); - await req.expandTest({ + it('should respond with expanded submitter data using the currentVersionWithZones expand parameter', () => + req.expandTest({ url: 'maps', - expand: 'zones', + expand: 'currentVersionWithZones', paged: true, validate: MapDto, + expectedPropertyName: 'currentVersion.zones', token: u1Token - }); - }); + })); - it('should respond with expanded submitter data using the leaderboards expand parameter', async () => { - await prisma.mMap.updateMany({ data: { submitterID: u2.id } }); + it('should respond with expanded submitter data using the versions expand parameter', () => + req.expandTest({ + url: 'maps', + expand: 'versions', + paged: true, + validate: MapDto, + token: u1Token + })); - await req.expandTest({ + it('should respond with expanded submitter data using the versionsWithZones expand parameter', () => + req.expandTest({ + url: 'maps', + expand: 'versionsWithZones', + expectedPropertyName: 'versions[0].zones', + paged: true, + validate: MapDto, + token: u1Token + })); + + it('should respond with expanded submitter data using the leaderboards expand parameter', () => + req.expandTest({ url: 'maps', expand: 'leaderboards', paged: true, validate: MapDto, token: u1Token - }); - }); + })); it('should respond with expanded map data using the credits expand parameter', async () => { await prisma.mapCredit.createMany({ @@ -802,7 +824,9 @@ describe('Maps', () => { stats: true, credits: true, leaderboards: true, - submission: { include: { currentVersion: true, versions: true } } + currentVersion: true, + versions: true, + submission: true } }); }); @@ -867,13 +891,12 @@ describe('Maps', () => { expect( Date.now() - new Date(createdMap.submission.dates[0].date).getTime() ).toBeLessThan(1000); - expect(createdMap.submission.currentVersion.zones).toMatchObject( - zones + expect(createdMap.currentVersion.zones).toMatchObject(zones); + expect(createdMap.currentVersion.submitterID).toBe(user.id); + expect(createdMap.versions[0]).toMatchObject( + createdMap.currentVersion ); - expect(createdMap.submission.versions[0]).toMatchObject( - createdMap.submission.currentVersion - ); - expect(createdMap.submission.versions).toHaveLength(1); + expect(createdMap.versions).toHaveLength(1); }); it('should create a ton of leaderboards', async () => { @@ -896,10 +919,10 @@ describe('Maps', () => { it('should upload the BSP file', async () => { expect(res.body.downloadURL).toBeUndefined(); - const currentVersion = res.body.submission.currentVersion; + const currentVersion = res.body.currentVersion; expect(currentVersion.downloadURL.split('/').slice(-2)).toEqual([ - 'submissions', + 'maps', `${currentVersion.id}.bsp` ]); @@ -908,15 +931,15 @@ describe('Maps', () => { ); const downloadHash = createSha1Hash(downloadBuffer); - expect(bspHash).toBe(currentVersion.hash); - expect(downloadHash).toBe(currentVersion.hash); + expect(bspHash).toBe(currentVersion.bspHash); + expect(downloadHash).toBe(currentVersion.bspHash); }); it('should upload the VMF file', async () => { - const currentVersion = res.body.submission.currentVersion; + const currentVersion = res.body.currentVersion; expect(currentVersion.vmfDownloadURL.split('/').slice(-2)).toEqual([ - 'submissions', + 'maps', `${currentVersion.id}_VMFs.zip` ]); @@ -1458,6 +1481,22 @@ describe('Maps', () => { map = await db.createMap({ name: 'my_epic_map', + versions: { + createMany: { + data: [ + { + versionNum: 1, + bspHash: createSha1Hash('bats'), + submitterID: u1.id + }, + { + versionNum: 2, + bspHash: createSha1Hash('wigs'), + submitterID: u1.id + } + ] + } + }, submission: { create: { type: MapSubmissionType.ORIGINAL, @@ -1471,14 +1510,6 @@ describe('Maps', () => { comment: 'I will kill again' } ], - versions: { - createMany: { - data: [ - { versionNum: 1, hash: createSha1Hash('bats') }, - { versionNum: 2, hash: createSha1Hash('wigs') } - ] - } - }, dates: [{ status: MapStatus.APPROVED, date: new Date().toJSON() }] } }, @@ -1493,11 +1524,9 @@ describe('Maps', () => { } }); - await prisma.mapSubmission.update({ - where: { mapID: map.id }, - data: { - currentVersion: { connect: { id: map.submission.versions[1].id } } - } + await prisma.mMap.update({ + where: { id: map.id }, + data: { currentVersion: { connect: { id: map.versions[1].id } } } }); }); @@ -1574,11 +1603,11 @@ describe('Maps', () => { token: u1Token })); - it('should respond with expanded map data using the zones expand parameter', () => + it('should respond with expanded map data using the currentVersion expand parameter', () => req.expandTest({ url: `maps/${map.id}`, validate: MapDto, - expand: 'zones', + expand: 'currentVersion', token: u1Token })); @@ -1680,20 +1709,34 @@ describe('Maps', () => { url: `maps/${map.id}`, validate: MapDto, expand: 'versions', - expectedPropertyName: 'submission.versions[1]', + expectedPropertyName: 'versions[1]', + token: u1Token + })); + + it('should respond with expanded map data using the versionsWithZones expand parameter', () => + req.expandTest({ + url: `maps/${map.id}`, + validate: MapDto, + expand: 'versionsWithZones', + expectedPropertyName: 'versions[1].zones', token: u1Token })); - // This map is APPROVED but has a currentVersion, which will usually - // be removed when a map gets approval. We only really need to test - // that the expand works however - is the user has read access to the map, - // they can access submission/submission versions/reviews if they exist. it('should respond with expanded map data using the currentVersion expand parameter', () => req.expandTest({ url: `maps/${map.id}`, validate: MapDto, expand: 'currentVersion', - expectedPropertyName: 'submission.currentVersion', + expectedPropertyName: 'currentVersion', + token: u1Token + })); + + it('should respond with expanded map data using the currentVersionWithZones expand parameter', () => + req.expandTest({ + url: `maps/${map.id}`, + validate: MapDto, + expand: 'currentVersionWithZones', + expectedPropertyName: 'currentVersion.zones', token: u1Token })); @@ -1750,6 +1793,14 @@ describe('Maps', () => { name: 'surf_map', // This is actually RJ now. deal with it lol submitter: { connect: { id: u1.id } }, status: MapStatus.PRIVATE_TESTING, + versions: { + create: { + zones: ZonesStub as unknown as JsonValue, // TODO: #855 + versionNum: 1, + bspHash: createSha1Hash(Buffer.from('hash browns')), + submitter: { connect: { id: u1.id } } + } + }, submission: { create: { type: MapSubmissionType.ORIGINAL, @@ -1762,13 +1813,6 @@ describe('Maps', () => { type: LeaderboardType.RANKED } ], - versions: { - create: { - zones: ZonesStub, - versionNum: 1, - hash: createSha1Hash(Buffer.from('hash browns')) - } - }, dates: [ { status: MapStatus.PRIVATE_TESTING, @@ -1790,7 +1834,7 @@ describe('Maps', () => { ]) ); - it('should add a new map submission version', async () => { + it('should add a new map version', async () => { const changelog = 'Added walls, floors etc...'; await uploadBspToPreSignedUrl(bspBuffer, u1Token); @@ -1803,26 +1847,29 @@ describe('Maps', () => { token: u1Token }); - expect(res.body.submission).toMatchObject({ + expect(res.body).toMatchObject({ currentVersion: { versionNum: 2, + submitterID: u1.id, changelog }, versions: expect.arrayContaining([ expect.objectContaining({ versionNum: 1 }), - expect.objectContaining({ versionNum: 2, changelog }) + expect.objectContaining({ + versionNum: 2, + changelog, + submitterID: u1.id + }) ]) }); - const submissionDB = await prisma.mapSubmission.findUnique({ - where: { mapID: map.id }, + const mapDB = await prisma.mMap.findUnique({ + where: { id: map.id }, include: { currentVersion: true } }); - expect(submissionDB.currentVersion.versionNum).toBe(2); - expect(res.body.submission.currentVersion.id).toBe( - submissionDB.currentVersion.id - ); + expect(mapDB.currentVersion.versionNum).toBe(2); + expect(res.body.currentVersion.id).toBe(mapDB.currentVersion.id); }); it('should upload the BSP and VMF files', async () => { @@ -1837,10 +1884,10 @@ describe('Maps', () => { token: u1Token }); - const currentVersion = res.body.submission.currentVersion; + const currentVersion = res.body.currentVersion; expect(currentVersion.downloadURL.split('/').slice(-2)).toEqual([ - 'submissions', + 'maps', `${currentVersion.id}.bsp` ]); @@ -1849,11 +1896,11 @@ describe('Maps', () => { ); const bspDownloadHash = createSha1Hash(bspDownloadBuffer); - expect(bspHash).toBe(currentVersion.hash); - expect(bspDownloadHash).toBe(currentVersion.hash); + expect(bspHash).toBe(currentVersion.bspHash); + expect(bspDownloadHash).toBe(currentVersion.bspHash); expect(currentVersion.vmfDownloadURL.split('/').slice(-2)).toEqual([ - 'submissions', + 'maps', `${currentVersion.id}_VMFs.zip` ]); @@ -2455,14 +2502,15 @@ describe('Maps', () => { tier: 1, type: LeaderboardType.UNRANKED } - ], - versions: { - create: { - zones: ZonesStub, - versionNum: 1, - hash: createSha1Hash(Buffer.from('shashashs')) - } - } + ] + } + }, + versions: { + create: { + zones: ZonesStub, + versionNum: 1, + bspHash: createSha1Hash(Buffer.from('shashashs')), + submitter: { connect: { id: user.id } } } } }; @@ -2622,14 +2670,15 @@ describe('Maps', () => { status: s1, date: new Date().toJSON() } - ], - versions: { - create: { - zones: ZonesStub, - versionNum: 1, - hash: createSha1Hash(Buffer.from('shashashs')) - } - } + ] + } + }, + versions: { + create: { + zones: ZonesStub, + versionNum: 1, + bspHash: createSha1Hash(Buffer.from('shashashs')), + submitter: { connect: { id: user.id } } } } }); @@ -2679,14 +2728,15 @@ describe('Maps', () => { tier: 1, type: LeaderboardType.RANKED } - ], - versions: { - create: { - zones: ZonesStub, - versionNum: 1, - hash: createSha1Hash(Buffer.from('shashashs')) - } - } + ] + } + }, + versions: { + create: { + zones: ZonesStub, + versionNum: 1, + bspHash: createSha1Hash(Buffer.from('shashashs')), + submitter: { connect: { id: user.id } } } } }); @@ -2732,14 +2782,15 @@ describe('Maps', () => { tier: 1, type: LeaderboardType.RANKED } - ], - versions: { - create: { - zones: ZonesStub, - versionNum: 1, - hash: createSha1Hash(Buffer.from('shashashs')) - } - } + ] + } + }, + versions: { + create: { + zones: ZonesStub, + versionNum: 1, + bspHash: createSha1Hash(Buffer.from('shashashs')), + submitter: { connect: { id: user.id } } } } }); @@ -2781,14 +2832,15 @@ describe('Maps', () => { tier: 1, type: LeaderboardType.RANKED } - ], - versions: { - create: { - zones: ZonesStub, - versionNum: 1, - hash: createSha1Hash(Buffer.from('shashashs')) - } - } + ] + } + }, + versions: { + create: { + zones: ZonesStub, + versionNum: 1, + bspHash: createSha1Hash(Buffer.from('shashashs')), + submitter: { connect: { id: user.id } } } } }); @@ -3028,14 +3080,6 @@ describe('Maps', () => { submission: { create: { type: MapSubmissionType.PORT, - versions: { - create: { - // Has a bonus - zones: ZonesStub, - versionNum: 1, - hash: createSha1Hash(Buffer.from('shashashs')) - } - }, // No bonus, so should fail suggestions: [ { @@ -3047,6 +3091,15 @@ describe('Maps', () => { } ] } + }, + versions: { + create: { + // Has a bonus + zones: ZonesStub, + versionNum: 1, + bspHash: createSha1Hash(Buffer.from('shashashs')), + submitter: { connect: { id: user.id } } + } } }); @@ -3121,40 +3174,51 @@ describe('Maps', () => { db.createUser() ]); - const submissionCreate = { - create: { - type: MapSubmissionType.ORIGINAL, - dates: [ - { - status: MapStatus.PRIVATE_TESTING, - date: new Date().toJSON() - } - ], - suggestions: [ - { - track: 1, - trackType: TrackType.MAIN, - trackNum: 1, - gamemode: Gamemode.CONC, - tier: 1, - type: LeaderboardType.RANKED, - comment: 'My dad made this' - } - ], - versions: { - createMany: { - data: [ - { versionNum: 1, hash: createSha1Hash('dogs') }, - { versionNum: 2, hash: createSha1Hash('elves') } - ] - } + const mapCreate = { + submission: { + create: { + type: MapSubmissionType.ORIGINAL, + dates: [ + { + status: MapStatus.PRIVATE_TESTING, + date: new Date().toJSON() + } + ], + suggestions: [ + { + track: 1, + trackType: TrackType.MAIN, + trackNum: 1, + gamemode: Gamemode.CONC, + tier: 1, + type: LeaderboardType.RANKED, + comment: 'My dad made this' + } + ] + } + }, + versions: { + createMany: { + data: [ + { + versionNum: 1, + bspHash: createSha1Hash('dogs'), + submitterID: u1.id + }, + { + versionNum: 2, + bspHash: createSha1Hash('elves'), + submitterID: u1.id + } + ] } } }; + await db.createMap({ status: MapStatus.APPROVED }); pubMap1 = await db.createMap({ status: MapStatus.PUBLIC_TESTING, - submission: submissionCreate, + ...mapCreate, reviews: { create: { mainText: 'Appalling', @@ -3164,30 +3228,26 @@ describe('Maps', () => { }); pubMap2 = await db.createMap({ status: MapStatus.PUBLIC_TESTING, - submission: submissionCreate + ...mapCreate }); privMap = await db.createMap({ status: MapStatus.PRIVATE_TESTING, - submission: submissionCreate + ...mapCreate }); faMap = await db.createMap({ status: MapStatus.FINAL_APPROVAL, - submission: submissionCreate + ...mapCreate }); caMap = await db.createMap({ status: MapStatus.CONTENT_APPROVAL, - submission: submissionCreate + ...mapCreate }); await Promise.all( [pubMap1, pubMap2, privMap].map((map) => - prisma.mapSubmission.update({ - where: { mapID: map.id }, - data: { - currentVersion: { - connect: { id: map.submission.versions[1].id } - } - } + prisma.mMap.update({ + where: { id: map.id }, + data: { currentVersion: { connect: { id: map.versions[1].id } } } }) ) ); @@ -3205,19 +3265,15 @@ describe('Maps', () => { }); for (const item of res.body.data) { - for (const prop of [ + [ 'submission', 'id', 'name', 'status', - 'hash', 'submitterID', 'createdAt', 'updatedAt' - ]) { - expect(item).toHaveProperty(prop); - } - expect(item).not.toHaveProperty('zones'); + ].forEach((prop) => expect(item).toHaveProperty(prop)); } }); @@ -3300,35 +3356,69 @@ describe('Maps', () => { }); }); - it('should respond with expanded current submission version data using the currentVersion expand parameter', () => + it('should respond with expanded current version data using the currentVersion expand parameter', () => req.expandTest({ url: 'maps/submissions', expand: 'currentVersion', - expectedPropertyName: 'submission.currentVersion', paged: true, validate: MapDto, token: u1Token })); - it('should respond with expanded submission versions data using the version expand parameter', async () => { + it('should respond with expanded current version data using the currentVersionWithZones expand parameter', () => + req.expandTest({ + url: 'maps/submissions', + expand: 'currentVersionWithZones', + expectedPropertyName: 'currentVersion.zones', + paged: true, + validate: MapDto, + token: u1Token + })); + + it('should respond with expanded versions data using the version expand parameter', async () => { const res = await req.expandTest({ url: 'maps/submissions', expand: 'versions', - expectedPropertyName: 'submission.versions[1]', + expectedPropertyName: 'versions[1]', paged: true, validate: MapDto, token: u1Token }); for (const item of res.body.data) { - for (const version of item.submission.versions) { + for (const version of item.versions) { expect(version).toHaveProperty('id'); expect(version).toHaveProperty('downloadURL'); - expect(version).toHaveProperty('hash'); + expect(version).toHaveProperty('bspHash'); + expect(version).toHaveProperty('zoneHash'); expect(version).toHaveProperty('versionNum'); expect(version).toHaveProperty('createdAt'); + expect(version).toHaveProperty('changelog'); expect(version).not.toHaveProperty('zones'); - expect(version).not.toHaveProperty('changelog'); + } + } + }); + + it('should respond with expanded versions data using the versionsWithZones expand parameter', async () => { + const res = await req.expandTest({ + url: 'maps/submissions', + expand: 'versionsWithZones', + expectedPropertyName: 'versions[1]', + paged: true, + validate: MapDto, + token: u1Token + }); + + for (const item of res.body.data) { + for (const version of item.versions) { + expect(version).toHaveProperty('id'); + expect(version).toHaveProperty('downloadURL'); + expect(version).toHaveProperty('bspHash'); + expect(version).toHaveProperty('zoneHash'); + expect(version).toHaveProperty('versionNum'); + expect(version).toHaveProperty('createdAt'); + expect(version).toHaveProperty('changelog'); + expect(version).toHaveProperty('zones'); } } }); diff --git a/apps/backend-e2e/src/multi-stage.e2e-spec.ts b/apps/backend-e2e/src/multi-stage.e2e-spec.ts index 30cc17923..05a60e189 100644 --- a/apps/backend-e2e/src/multi-stage.e2e-spec.ts +++ b/apps/backend-e2e/src/multi-stage.e2e-spec.ts @@ -231,7 +231,7 @@ describe('Multi-stage E2E tests', () => { const { body: mapRes } = await req.get({ url: `maps/${mapID}`, - query: { expand: 'credits,info,leaderboards' }, + query: { expand: 'credits,info,leaderboards,currentVersion' }, status: 200, token }); @@ -268,12 +268,18 @@ describe('Multi-stage E2E tests', () => { expect(bsp2Hash).not.toBe(bspHash); expect(vmf2Hash).not.toBe(vmfHash); expect( - (mapRes.downloadURL as string).endsWith('surf_todd_howard.bsp') + (mapRes.currentVersion.downloadURL as string).endsWith( + `${mapRes.currentVersion.id}.bsp` + ) ).toBeTruthy(); - const bspDownloadBuffer = await fileStore.downloadHttp(mapRes.downloadURL); + const bspDownloadBuffer = await fileStore.downloadHttp( + mapRes.currentVersion.downloadURL + ); expect(createSha1Hash(bspDownloadBuffer)).toBe(bsp2Hash); - const vmfZip = new Zip(await fileStore.downloadHttp(mapRes.vmfDownloadURL)); + const vmfZip = new Zip( + await fileStore.downloadHttp(mapRes.currentVersion.vmfDownloadURL) + ); expect( createSha1Hash(vmfZip.getEntry('surf_todd_howard_main.vmf').getData()) ).toBe(vmf2Hash); diff --git a/apps/backend-e2e/src/session.e2e-spec.ts b/apps/backend-e2e/src/session.e2e-spec.ts index ad82b0ea8..d0b162536 100644 --- a/apps/backend-e2e/src/session.e2e-spec.ts +++ b/apps/backend-e2e/src/session.e2e-spec.ts @@ -482,7 +482,7 @@ describe('Session', () => { runFlags: 0, mapID: map.id, mapName: map.name, - mapHash: map.hash, + mapHash: map.currentVersion.bspHash, steamID: user.steamID, tickRate: Tickrates.get(map.type), startTick: 0, @@ -545,7 +545,11 @@ describe('Session', () => { // So we can screw around with zones in specific tests await prisma.mMap.update({ where: { id: map.id }, - data: { zones: ZonesStub as unknown as JsonValue } + data: { + currentVersion: { + update: { zones: ZonesStub as unknown as JsonValue } + } + } }); await prisma.mapStats.update({ diff --git a/apps/backend/src/app/dto/index.ts b/apps/backend/src/app/dto/index.ts index d855c3edc..51fda10a2 100644 --- a/apps/backend/src/app/dto/index.ts +++ b/apps/backend/src/app/dto/index.ts @@ -18,7 +18,7 @@ export * from './map/map-submission.dto'; export * from './map/map-submission-approval.dto'; export * from './map/map-submission-placeholder.dto'; export * from './map/map-submission-suggestion.dto'; -export * from './map/map-submission-version.dto'; +export * from './map/map-version.dto'; export * from './map/map-review.dto'; export * from './map/map-review-comment.dto'; export * from './map/map-review-suggestions.dto'; diff --git a/apps/backend/src/app/dto/map/map-submission.dto.ts b/apps/backend/src/app/dto/map/map-submission.dto.ts index a63652bc2..53c613e9b 100644 --- a/apps/backend/src/app/dto/map/map-submission.dto.ts +++ b/apps/backend/src/app/dto/map/map-submission.dto.ts @@ -1,10 +1,9 @@ import { ApiProperty } from '@nestjs/swagger'; import { MapSubmission, MapSubmissionType } from '@momentum/constants'; -import { IsArray, IsOptional, IsUUID } from 'class-validator'; +import { IsArray, IsOptional } from 'class-validator'; import { Exclude } from 'class-transformer'; import { NestedProperty } from '../decorators'; import { MapSubmissionSuggestionDto } from './map-submission-suggestion.dto'; -import { MapSubmissionVersionDto } from './map-submission-version.dto'; import { MapSubmissionDateDto } from './map-submission-dates.dto'; import { MapSubmissionPlaceholderDto } from './map-submission-placeholder.dto'; @@ -30,14 +29,4 @@ export class MapSubmissionDto implements MapSubmission { @Exclude() readonly mapID: number; - - @NestedProperty(MapSubmissionVersionDto, { required: false }) - readonly currentVersion: MapSubmissionVersionDto; - - @ApiProperty() - @IsUUID() - readonly currentVersionID: string; - - @NestedProperty(MapSubmissionVersionDto, { required: false, isArray: true }) - readonly versions: MapSubmissionVersionDto[]; } diff --git a/apps/backend/src/app/dto/map/map-submission-version.dto.ts b/apps/backend/src/app/dto/map/map-version.dto.ts similarity index 79% rename from apps/backend/src/app/dto/map/map-submission-version.dto.ts rename to apps/backend/src/app/dto/map/map-version.dto.ts index 987f610bc..1579e1599 100644 --- a/apps/backend/src/app/dto/map/map-submission-version.dto.ts +++ b/apps/backend/src/app/dto/map/map-version.dto.ts @@ -1,11 +1,11 @@ import { ApiProperty } from '@nestjs/swagger'; import { - CreateMapSubmissionVersion, + bspPath, + CreateMapVersion, DateString, - MapSubmissionVersion, + MapVersion, MAX_CHANGELOG_LENGTH, - submissionBspPath, - submissionVmfsPath + vmfsPath } from '@momentum/constants'; import { IsBoolean, @@ -18,14 +18,14 @@ import { MaxLength } from 'class-validator'; import { Exclude, Expose } from 'class-transformer'; -import { CreatedAtProperty, NestedProperty } from '../decorators'; +import { CreatedAtProperty, IdProperty, NestedProperty } from '../decorators'; import { Config } from '../../config'; import { MapSubmissionDto } from './map-submission.dto'; import { MapZonesDto } from './map-zones.dto'; const CDN_URL = Config.url.cdn; -export class MapSubmissionVersionDto implements MapSubmissionVersion { +export class MapVersionDto implements MapVersion { @ApiProperty() @IsUUID() readonly id: string; @@ -62,13 +62,18 @@ export class MapSubmissionVersionDto implements MapSubmissionVersion { // We store BSPs relative to their UUID and don't expose maps to submission // to users that don't have permission (see MapsService.getMapAndCheckReadAccces) // so this is a reasonably secure way to keep maps hidden from most users. - return `${CDN_URL}/${submissionBspPath(this.id)}`; + return `${CDN_URL}/${bspPath(this.id)}`; } @ApiProperty({ description: 'SHA1 hash of the BSP file', type: String }) @IsHash('sha1') @IsOptional() - readonly hash: string; + readonly bspHash: string; + + @ApiProperty({ description: 'SHA1 hash of the zones', type: String }) + @IsHash('sha1') + @IsOptional() + readonly zoneHash: string; @ApiProperty({ type: String, description: 'URL to VMF in cloud storage' }) @Expose() @@ -76,21 +81,20 @@ export class MapSubmissionVersionDto implements MapSubmissionVersion { @IsString() @IsUrl({ require_tld: false }) get vmfDownloadURL() { - return this.hasVmf - ? `${CDN_URL}/${submissionVmfsPath(this.id)}` - : undefined; + return this.hasVmf ? `${CDN_URL}/${vmfsPath(this.id)}` : undefined; } @Exclude() readonly hasVmf: boolean; + @IdProperty() + readonly submitterID: number; + @CreatedAtProperty() readonly createdAt: DateString; } -export class CreateMapSubmissionVersionDto - implements CreateMapSubmissionVersion -{ +export class CreateMapVersionDto implements CreateMapVersion { @NestedProperty(MapZonesDto, { required: false, description: 'The contents of the map zone file as JSON' diff --git a/apps/backend/src/app/dto/map/map.dto.ts b/apps/backend/src/app/dto/map/map.dto.ts index 0e3ca0872..b29cd9b00 100644 --- a/apps/backend/src/app/dto/map/map.dto.ts +++ b/apps/backend/src/app/dto/map/map.dto.ts @@ -1,6 +1,4 @@ import { - approvedBspPath, - approvedVmfsPath, CreateMap, CreateMapWithFiles, DateString, @@ -17,18 +15,15 @@ import { ArrayMinSize, IsArray, IsBoolean, - IsHash, IsInt, IsOptional, IsPositive, - IsString, - IsUrl, + IsUUID, MaxLength, MinLength } from 'class-validator'; -import { Exclude, Expose, plainToInstance, Transform } from 'class-transformer'; +import { Expose, plainToInstance, Transform } from 'class-transformer'; import { UserDto } from '../user/user.dto'; -import { Config } from '../../config'; import { CreatedAtProperty, EnumProperty, @@ -50,8 +45,7 @@ import { MapSubmissionPlaceholderDto } from './map-submission-placeholder.dto'; import { MapZonesDto } from './map-zones.dto'; import { MapSubmissionApprovalDto } from './map-submission-approval.dto'; import { MapTestInviteDto } from './map-test-invite.dto'; - -const CDN_URL = Config.url.cdn; +import { MapVersionDto } from './map-version.dto'; export class MapDto implements MMap { @IdProperty() @@ -70,42 +64,6 @@ export class MapDto implements MMap { @EnumProperty(MapStatus) readonly status: MapStatus; - @NestedProperty(MapZonesDto, { - required: false, - description: 'Zones for the map' - }) - readonly zones: MapZonesDto; - - @ApiProperty({ type: String, description: 'URL to BSP in storage' }) - @Expose() - @IsOptional() - @IsString() - @IsUrl({ require_tld: false }) - get downloadURL() { - return this.status === MapStatus.APPROVED - ? `${CDN_URL}/${approvedBspPath(this.name)}` - : undefined; - } - - @ApiProperty({ description: 'SHA1 hash of the BSP file', type: String }) - @IsHash('sha1') - @IsOptional() - readonly hash: string; - - @ApiProperty({ type: String, description: 'URL to VMF in storage' }) - @Expose() - @IsOptional() - @IsString() - @IsUrl({ require_tld: false }) - get vmfDownloadURL() { - return this.status === MapStatus.APPROVED && this.hasVmf - ? `${CDN_URL}/${approvedVmfsPath(this.name)}` - : undefined; - } - - @Exclude() - readonly hasVmf: boolean; - @ApiProperty() @IsPositive() @IsOptional() @@ -133,7 +91,7 @@ export class MapDto implements MMap { // correctly. It only works because my DtoFactory setup is ridiculous and // seems to transform TWICE. We're going to rework/replace CT/CV in future // anyway so leaving for now. UUUUGH - value.map((image) => + value?.map((image) => typeof image == 'string' ? { id: image } : plainToInstance(MapImageDto, image) @@ -150,6 +108,16 @@ export class MapDto implements MMap { @NestedProperty(MapStatsDto) readonly stats: MapStatsDto; + @NestedProperty(MapVersionDto, { required: false }) + readonly currentVersion: MapVersionDto; + + @ApiProperty() + @IsUUID() + readonly currentVersionID: string; + + @NestedProperty(MapVersionDto, { required: false, isArray: true }) + readonly versions: MapVersionDto[]; + @NestedProperty(MapCreditDto, { isArray: true }) readonly credits: MapCreditDto[]; diff --git a/apps/backend/src/app/dto/queries/map-queries.dto.ts b/apps/backend/src/app/dto/queries/map-queries.dto.ts index 1052302a3..d668b3188 100644 --- a/apps/backend/src/app/dto/queries/map-queries.dto.ts +++ b/apps/backend/src/app/dto/queries/map-queries.dto.ts @@ -73,9 +73,12 @@ export class MapsGetAllQueryDto implements MapsGetAllQuery { @ExpandQueryProperty([ - 'zones', 'leaderboards', 'info', + 'currentVersion', + 'currentVersionWithZones', + 'versions', + 'versionsWithZones', 'stats', 'submitter', 'credits', @@ -130,17 +133,18 @@ export class MapsGetAllSubmissionQueryDto implements MapsGetAllSubmissionQuery { @ExpandQueryProperty([ - 'zones', 'leaderboards', 'info', + 'currentVersion', + 'currentVersionWithZones', + 'versions', + 'versionsWithZones', 'stats', 'submitter', 'credits', 'inFavorites', 'personalBest', 'worldRecord', - 'currentVersion', - 'versions', 'reviews' ]) readonly expand?: MapsGetAllSubmissionExpand; @@ -163,9 +167,12 @@ export class MapsGetAllUserSubmissionQueryDto export class MapsGetQueryDto extends QueryDto implements MapsGetQuery { @ExpandQueryProperty([ - 'zones', 'leaderboards', 'info', + 'currentVersion', + 'currentVersionWithZones', + 'versions', + 'versionsWithZones', 'credits', 'submitter', 'stats', @@ -174,8 +181,6 @@ export class MapsGetQueryDto extends QueryDto implements MapsGetQuery { 'personalBest', 'worldRecord', 'submission', - 'currentVersion', - 'versions', 'reviews', 'testInvites' ]) diff --git a/apps/backend/src/app/modules/map-review/map-review.service.ts b/apps/backend/src/app/modules/map-review/map-review.service.ts index 9d1ce09e6..03719dcb7 100644 --- a/apps/backend/src/app/modules/map-review/map-review.service.ts +++ b/apps/backend/src/app/modules/map-review/map-review.service.ts @@ -16,13 +16,7 @@ import { MapZones, Role } from '@momentum/constants'; -import { - MapReview, - MapSubmission, - MapSubmissionVersion, - Prisma, - User -} from '@prisma/client'; +import { MapReview, Prisma, User } from '@prisma/client'; import { File } from '@nest-lab/fastify-multer'; import { expandToIncludes, @@ -172,7 +166,7 @@ export class MapReviewService { userID, select: { status: true, - submission: { select: { currentVersion: { select: { zones: true } } } } + currentVersion: { select: { zones: true } } }, submissionOnly: true }); @@ -185,11 +179,7 @@ export class MapReviewService { try { validateSuggestions( body.suggestions, - ( - map.submission as MapSubmission & { - currentVersion: MapSubmissionVersion; - } - ).currentVersion.zones as unknown as MapZones, // TODO: #855 + map.currentVersion.zones as unknown as MapZones, // TODO: #855 SuggestionType.REVIEW ); } catch (error) { @@ -299,20 +289,14 @@ export class MapReviewService { mapID: review.mapID, userID, submissionOnly: true, - include: { - submission: { select: { currentVersion: { select: { zones: true } } } } - } + include: { currentVersion: { select: { zones: true } } } }); if (body.suggestions) { try { validateSuggestions( body.suggestions, - ( - map.submission as MapSubmission & { - currentVersion: MapSubmissionVersion; - } - ).currentVersion.zones as unknown as MapZones, // TODO: #855 + map.currentVersion.zones as unknown as MapZones, // TODO: #855 SuggestionType.REVIEW ); } catch (error) { diff --git a/apps/backend/src/app/modules/maps/map-list.service.ts b/apps/backend/src/app/modules/maps/map-list.service.ts index cbd3f8ac5..7dcc5668f 100644 --- a/apps/backend/src/app/modules/maps/map-list.service.ts +++ b/apps/backend/src/app/modules/maps/map-list.service.ts @@ -69,7 +69,6 @@ export class MapListService implements OnModuleInit { select: { id: true, name: true, - hash: true, status: true, images: true, info: true, @@ -83,28 +82,11 @@ export class MapListService implements OnModuleInit { } } }, - submission: - type === FlatMapList.SUBMISSION - ? { - select: { - currentVersion: { - select: { - id: true, - versionNum: true, - hash: true, - changelog: true, - zones: true, - createdAt: true - } - }, - type: true, - placeholders: true, - suggestions: true, - dates: true - } - } - : undefined, - createdAt: true + createdAt: true, + currentVersion: { omit: { zones: true, changelog: true } }, + ...(type === FlatMapList.SUBMISSION + ? { submission: true, versions: { omit: { zones: true } } } + : {}) } }); diff --git a/apps/backend/src/app/modules/maps/maps.controller.ts b/apps/backend/src/app/modules/maps/maps.controller.ts index 49189618e..23b0d2bba 100644 --- a/apps/backend/src/app/modules/maps/maps.controller.ts +++ b/apps/backend/src/app/modules/maps/maps.controller.ts @@ -53,7 +53,6 @@ import { CreateMapDto, CreateMapReviewDto, CreateMapReviewWithFilesDto, - CreateMapSubmissionVersionDto, CreateMapTestInviteDto, CreateMapWithFilesDto, LeaderboardRunDto, @@ -77,7 +76,8 @@ import { UpdateMapImagesDto, UpdateMapTestInviteDto, MapPreSignedUrlDto, - VALIDATION_PIPE_CONFIG + VALIDATION_PIPE_CONFIG, + CreateMapVersionDto } from '../../dto'; import { BypassJwtAuth, LoggedInUser, Roles } from '../../decorators'; import { ParseIntSafePipe } from '../../pipes'; @@ -241,7 +241,7 @@ export class MapsController { 'if those are being changed by the user, be sure to send the /:id PATCH first!' }) @ApiBody({ - type: CreateMapSubmissionVersionDto, + type: CreateMapVersionDto, required: true }) @ApiOkResponse({ type: MapDto, description: 'Map with new version attached' }) @@ -251,18 +251,13 @@ export class MapsController { ) submitMapVersion( @Param('mapID', ParseIntSafePipe) mapID: number, - @Body('data') data: CreateMapSubmissionVersionDto, + @Body('data') data: CreateMapVersionDto, @UploadedFiles() files: { vmfs: File[] }, @LoggedInUser('id') userID: number ): Promise { this.mapSubmissionFileValidation(files.vmfs); - return this.mapsService.submitMapSubmissionVersion( - mapID, - data, - userID, - files.vmfs - ); + return this.mapsService.submitMapVersion(mapID, data, userID, files.vmfs); } private mapSubmissionFileValidation(vmfFiles: File[]) { diff --git a/apps/backend/src/app/modules/maps/maps.service.ts b/apps/backend/src/app/modules/maps/maps.service.ts index da15b0163..f2d34e268 100644 --- a/apps/backend/src/app/modules/maps/maps.service.ts +++ b/apps/backend/src/app/modules/maps/maps.service.ts @@ -12,16 +12,14 @@ import { LeaderboardRun, MapCredit, MapSubmission, - MapSubmissionVersion, MMap, Prisma } from '@prisma/client'; import { ActivityType, AdminActivityType, - approvedBspPath, - approvedVmfsPath, Ban, + bspPath, CombinedMapStatuses, CombinedRoles, FlatMapList, @@ -35,11 +33,11 @@ import { MapSubmissionPlaceholder, MapSubmissionSuggestion, MapTestInviteState, + MapVersion, MapZones, Role, - submissionBspPath, - submissionVmfsPath, - TrackType + TrackType, + vmfsPath } from '@momentum/constants'; import * as Bitflags from '@momentum/bitflags'; import { @@ -71,7 +69,7 @@ import { import { EXTENDED_PRISMA_SERVICE } from '../database/db.constants'; import { CreateMapDto, - CreateMapSubmissionVersionDto, + CreateMapVersionDto, DtoFactory, MapDto, MapInfoDto, @@ -93,6 +91,7 @@ import { } from './leaderboard-handler.util'; import { MapListService } from './map-list.service'; import { MapReviewService } from '../map-review/map-review.service'; +import { createHash } from 'node:crypto'; @Injectable() export class MapsService { @@ -110,18 +109,6 @@ export class MapsService { private readonly mapListService: MapListService ) {} - private readonly baseMapsSelect: Prisma.MMapSelect = { - id: true, - name: true, - status: true, - hash: true, - hasVmf: true, - images: true, - createdAt: true, - updatedAt: true, - submitterID: true - }; - //#region Gets async getAll( @@ -323,67 +310,44 @@ export class MapsService { // Select (and include) // For admins we don't need dynamic expands, just give em everything. - let select: Prisma.MMapSelect; + let include: Prisma.MMapInclude; if (query instanceof MapsGetAllAdminQueryDto) { - select = { - ...this.baseMapsSelect, - submission: { include: { currentVersion: true, versions: true } }, + include = { + versions: true, + currentVersion: true, + submission: true, info: true, leaderboards: true, - images: true, submitter: true, credits: { include: { user: true } } }; } else { - const submissionInclude: Prisma.MapSubmissionInclude = expandToIncludes( - query.expand, - { - only: ['currentVersion', 'versions'], - mappings: [ - { - expand: 'versions', - model: 'versions', - value: { - select: { - hash: true, - hasVmf: true, - versionNum: true, - id: true, - createdAt: true, - // Changelog and zones are quite large structures so not worth - // ever including on the paginated query - make clients query - // for a specific submission if they want all that stuff - zones: false, - changelog: false - } - } - } - ] - } - ); + include = expandToIncludes(query.expand, { + without: ['personalBest', 'worldRecord'], + mappings: [ + // Changelog and zones are quite large structures so not worth ever + // including on the paginated query - make clients query for a specific + // submission if they want all that stuff + { expand: 'currentVersion', value: { omit: { zones: true } } }, + { expand: 'currentVersionWithZones', model: 'currentVersion' }, + { expand: 'versions', value: { omit: { zones: true } } }, + { expand: 'versionsWithZones', model: 'versions' }, + { expand: 'credits', value: { include: { user: true } } }, + { + expand: 'inFavorites', + model: 'favorites', + value: { where: { userID: userID } } + } + ] + }); - select = { - ...this.baseMapsSelect, - submission: isEmpty(submissionInclude) - ? true - : { include: submissionInclude }, - ...expandToIncludes(query.expand, { - without: [ - 'currentVersion', - 'versions', - 'personalBest', - 'worldRecord' - ], - mappings: [ - { expand: 'credits', value: { include: { user: true } } }, - { - expand: 'inFavorites', - model: 'favorites', - value: { where: { userID: userID } } - } - ] - }) - }; + if (query instanceof MapsGetAllSubmissionQueryDto) { + if (include) { + include.submission = true; + } else { + include = { submission: true }; + } + } if ( query instanceof MapsGetAllQueryDto || @@ -391,13 +355,13 @@ export class MapsService { ) { incPB = query.expand?.includes('personalBest'); incWR = query.expand?.includes('worldRecord'); - this.handleMapGetIncludes(select, incPB, incWR, userID); + this.handleMapGetIncludes(include, incPB, incWR, userID); } } const dbResponse = await this.db.mMap.findManyAndCount({ where, - select, + include, orderBy: { createdAt: 'desc' }, skip: query.skip, take: query.take @@ -416,37 +380,33 @@ export class MapsService { userID?: number, expand?: MapsGetExpand ): Promise { - const select: Prisma.MMapSelect = { - ...this.baseMapsSelect, - ...expandToIncludes(expand, { - without: ['currentVersion', 'versions', 'personalBest', 'worldRecord'], - mappings: [ - { expand: 'credits', value: { include: { user: true } } }, - { expand: 'testInvites', value: { include: { user: true } } }, - { - expand: 'inFavorites', - model: 'favorites', - value: { where: { userID: userID } } - } - ] - }) - }; - - const submissionIncludes: Prisma.MapSubmissionInclude = expandToIncludes( - expand, - { only: ['currentVersion', 'versions'] } - ); - - if (!isEmpty(submissionIncludes)) { - select.submission = { include: submissionIncludes }; - } + const include: Prisma.MMapInclude = expandToIncludes(expand, { + without: ['personalBest', 'worldRecord'], + mappings: [ + { expand: 'currentVersion', value: { omit: { zones: true } } }, + { expand: 'currentVersionWithZones', model: 'currentVersion' }, + { expand: 'versions', value: { omit: { zones: true } } }, + { expand: 'versionsWithZones', model: 'versions' }, + { expand: 'credits', value: { include: { user: true } } }, + { expand: 'testInvites', value: { include: { user: true } } }, + { + expand: 'inFavorites', + model: 'favorites', + value: { where: { userID: userID } } + } + ] + }); const incPB = expand?.includes('personalBest'); const incWR = expand?.includes('worldRecord'); - this.handleMapGetIncludes(select, incPB, incWR, userID); + this.handleMapGetIncludes(include, incPB, incWR, userID); - const map = await this.getMapAndCheckReadAccess({ mapID, userID, select }); + const map = await this.getMapAndCheckReadAccess({ + mapID, + userID, + include + }); if (incPB || incWR) { this.handleMapGetPrismaResponse(map, userID, incPB, incWR); @@ -585,7 +545,7 @@ export class MapsService { const hasVmf = vmfFiles?.length > 0; const bspHash = FileStoreService.getHashForBuffer(bspFile.buffer); - let map; + let map: Awaited>; await this.db.$transaction(async (tx) => { map = await this.createMapDbEntry(tx, dto, userID, bspHash, hasVmf); @@ -605,7 +565,7 @@ export class MapsService { })) }); - const version = map.submission.currentVersion; + const version = map.currentVersion; const tasks: Promise[] = [ (async () => { @@ -613,11 +573,7 @@ export class MapsService { ? await this.zipVmfFiles(dto.name, 1, vmfFiles) : undefined; - return this.uploadMapSubmissionVersionFiles( - version.id, - bspFile, - zippedVmf - ); + return this.uploadMapVersionFiles(version.id, bspFile, zippedVmf); })(), this.createMapUploadedActivities(tx, map.id, map.credits) @@ -639,16 +595,18 @@ export class MapsService { return DtoFactory(MapDto, map); } - async submitMapSubmissionVersion( + async submitMapVersion( mapID: number, - dto: CreateMapSubmissionVersionDto, + dto: CreateMapVersionDto, userID: number, vmfFiles?: File[] ): Promise { const map = await this.db.mMap.findUnique({ where: { id: mapID }, include: { - submission: { include: { currentVersion: true, versions: true } } + currentVersion: true, + versions: { omit: { zones: true } }, + submission: true } }); @@ -661,7 +619,7 @@ export class MapsService { // This should never happen but stops someone flooding S3 storage with // garbage. - if (map.submission.versions?.length > 100) { + if (map.versions?.length > 100) { throw new ForbiddenException('Reached map version limit'); } @@ -695,21 +653,22 @@ export class MapsService { this.checkZones(dto.zones); zones = dto.zones; } else { - zones = map.submission.currentVersion.zones as unknown as MapZones; // TODO: #855 + zones = map.currentVersion.zones as unknown as MapZones; // TODO: #855 } - const oldVersion = map.submission.currentVersion; + const oldVersion = map.currentVersion; const newVersionNum = oldVersion.versionNum + 1; await this.db.$transaction(async (tx) => { - const newVersion = await tx.mapSubmissionVersion.create({ + const newVersion = await tx.mapVersion.create({ data: { versionNum: newVersionNum, + submitter: { connect: { id: userID } }, hasVmf, zones: zones as unknown as JsonValue, // TODO: #855 - hash: bspHash, + bspHash, changelog: dto.changelog, - submission: { connect: { mapID } } + mmap: { connect: { id: mapID } } } }); @@ -746,15 +705,11 @@ export class MapsService { ? await this.zipVmfFiles(map.name, newVersionNum, vmfFiles) : undefined; - await this.uploadMapSubmissionVersionFiles( - newVersion.id, - bspFile, - zippedVmf - ); + await this.uploadMapVersionFiles(newVersion.id, bspFile, zippedVmf); }, - tx.mapSubmission.update({ - where: { mapID }, + tx.mMap.update({ + where: { id: mapID }, data: { currentVersion: { connect: { id: newVersion.id } } } }) ); @@ -770,9 +725,7 @@ export class MapsService { MapDto, await this.db.mMap.findUnique({ where: { id: mapID }, - include: { - submission: { include: { currentVersion: true, versions: true } } - } + include: { currentVersion: true, versions: true, submission: true } }) ); } @@ -824,25 +777,29 @@ export class MapsService { ? MapStatus.PRIVATE_TESTING : MapStatus.CONTENT_APPROVAL; - // Prisma doesn't let you do nested createMany https://github.com/prisma/prisma/issues/5455) - // so we have to do this shit in parts... Fortunately this doesn't run often. - const initialMap = await tx.mMap.create({ + const zoneHash = createHash('sha1') + .update(JSON.stringify(createMapDto.zones)) + .digest('hex'); + + const initialMap = (await tx.mMap.create({ data: { submitter: { connect: { id: submitterID } }, name: createMapDto.name, + versions: { + create: { + versionNum: 1, + bspHash, + zoneHash, + hasVmf, + submitter: { connect: { id: submitterID } }, + zones: createMapDto.zones + } + }, submission: { create: { type: createMapDto.submissionType, placeholders: createMapDto.placeholders, suggestions: createMapDto.suggestions, - versions: { - create: { - versionNum: 1, - hash: bspHash, - hasVmf, - zones: createMapDto.zones as unknown as JsonValue // TODO: #855 - } - }, dates: [{ status, date: new Date().toJSON() }] } }, @@ -870,25 +827,16 @@ export class MapsService { }, select: { id: true, - zones: true, - submission: { select: { versions: true } } + versions: true } - }); + })) as unknown as { id: number; versions: MapVersion[] }; const map = await tx.mMap.update({ where: { id: initialMap.id }, data: { - submission: { - update: { - currentVersion: { - connect: { id: initialMap.submission.versions[0].id } - } - } + currentVersion: { + connect: { id: initialMap.versions[0].id } } - }, - include: { - info: true, - credits: true } }); @@ -897,7 +845,9 @@ export class MapsService { include: { info: true, stats: true, - submission: { include: { currentVersion: true, versions: true } }, + currentVersion: true, + versions: true, + submission: true, submitter: true, credits: { include: { user: true } } } @@ -1017,7 +967,7 @@ export class MapsService { return zip.toBuffer(); } - private async uploadMapSubmissionVersionFiles( + private async uploadMapVersionFiles( uuid: string, bspFile: File, vmfZip?: Buffer @@ -1027,19 +977,17 @@ export class MapsService { if (bspFile.path) { storeFns.push( this.fileStoreService - .copyFile(bspFile.path, submissionBspPath(uuid)) + .copyFile(bspFile.path, bspPath(uuid)) .then(() => this.fileStoreService.deleteFile(bspFile.path)) ); } else { storeFns.push( - this.fileStoreService.storeFile(bspFile.buffer, submissionBspPath(uuid)) + this.fileStoreService.storeFile(bspFile.buffer, bspPath(uuid)) ); } if (vmfZip) - storeFns.push( - this.fileStoreService.storeFile(vmfZip, submissionVmfsPath(uuid)) - ); + storeFns.push(this.fileStoreService.storeFile(vmfZip, vmfsPath(uuid))); return Promise.all(storeFns); } @@ -1055,9 +1003,7 @@ export class MapsService { const map = (await this.db.mMap.findUnique({ where: { id: mapID }, - include: { - submission: { include: { currentVersion: true, versions: true } } - } + include: { submission: true, currentVersion: true, versions: true } })) as unknown as MapWithSubmission; // TODO: #855; if (!map) throw new NotFoundException('Map does not exist'); @@ -1072,15 +1018,14 @@ export class MapsService { // If this requests has new suggestions, use those, otherwise use the // existing ones. // - // A map submission version could've updated the zones to something that - // doesn't work with the current suggestions, in this case, the submitter - // will be forced to update suggestions next time they do a general - // update, including if they want to change the map status. Frontend - // explains this to the user. + // A map version could've updated the zones to something that doesn't work + // with the current suggestions, in this case, the submitter will be forced + // to update suggestions next time they do a general update, including if + // they want to change the map status. Frontend explains this to the user. const suggs = dto.suggestions ?? (map.submission.suggestions as unknown as MapSubmissionSuggestion[]); - const zones = map.submission.currentVersion.zones as unknown as MapZones; // TODO: #855 + const zones = map.currentVersion.zones as unknown as MapZones; // TODO: #855 this.checkSuggestionsAndZones(suggs, zones, SuggestionType.SUBMISSION); @@ -1143,9 +1088,7 @@ export class MapsService { const map = (await this.db.mMap.findUnique({ where: { id: mapID }, - include: { - submission: { include: { currentVersion: true, versions: true } } - } + include: { currentVersion: true, versions: true, submission: true } })) as unknown as MapWithSubmission; // TODO: #855; if (!map) { @@ -1466,21 +1409,15 @@ export class MapsService { }); const { - currentVersion: { id: currentVersionID, hash, hasVmf, zones: dbZones }, + currentVersion: { zones: dbZones }, versions - } = await this.db.mapSubmission.findFirst({ - where: { mapID: map.id }, - include: { currentVersion: true, versions: true } - }); - const zones = dbZones as unknown as MapZones; // TODO: #855 - - // Set hash of MMap to final version BSP's hash, and hasVmf is just whether - // final version had a VMF. - await tx.mMap.update({ + } = await this.db.mMap.findFirst({ where: { id: map.id }, - data: { hash, hasVmf, zones: dbZones } // TODO: e2e test zoines + include: { currentVersion: true, versions: { omit: { zones: true } } } }); + const zones = dbZones as unknown as MapZones; // TODO: #855 + // Is it getting approved for first time? if ( !map.submission.dates.some((date) => date.status === MapStatus.APPROVED) @@ -1534,40 +1471,15 @@ export class MapsService { // *should* behave well in production, but it's still complex stuff and // we can't rollback S3 operations like we can a Postgres transaction. - // Copy final MapSubmissionVersion BSP and VMFs to maps/ - const [bspSuccess, vmfSuccess] = await parallel( - this.fileStoreService.copyFile( - submissionBspPath(currentVersionID), - approvedBspPath(map.name) - ), - hasVmf - ? this.fileStoreService.copyFile( - submissionVmfsPath(currentVersionID), - approvedVmfsPath(map.name) - ) - : () => Promise.resolve(true) - ); - - if (!bspSuccess) - throw new InternalServerErrorException( - `BSP file for map submission version ${currentVersionID} not in object store` - ); - if (!vmfSuccess) - throw new InternalServerErrorException( - `VMF file for map submission version ${currentVersionID} not in object store` - ); - - // Delete all the submission files - these would take up a LOT of space otherwise + // Delete all the previous submission files - these would take up a LOT of + // space otherwise await parallel( this.fileStoreService .deleteFiles( - versions.flatMap((v) => [ - submissionBspPath(v.id), - submissionVmfsPath(v.id) - ]) + versions.slice(0, -1).flatMap((v) => [bspPath(v.id), vmfsPath(v.id)]) ) .catch((error) => { - error.message = `Failed to delete map submission version file for ${map.name}: ${error.message}`; + error.message = `Failed to delete map version file for ${map.name}: ${error.message}`; throw new InternalServerErrorException(error); }), this.mapReviewService @@ -1586,32 +1498,41 @@ export class MapsService { async delete(mapID: number, adminID: number): Promise { const map = await this.db.mMap.findUnique({ where: { id: mapID }, - include: { submission: { include: { versions: true } } } + include: { versions: { omit: { zones: true } } } }); if (!map) throw new NotFoundException('No map found'); - // Set hashes to null to give frontend easy to tell files have been deleted - await this.db.mMap.update({ + // Bump version with bspHash set to null to give frontend easy to tell bsp + // file has been deleted + const updated = await this.db.mMap.update({ where: { id: mapID }, - data: { status: MapStatus.DISABLED, hash: null } + data: { + status: MapStatus.DISABLED, + versions: { + create: { + versionNum: map.versions.at(-1).versionNum + 1, + bspHash: null, + submitter: { connect: { id: adminID } }, + zones: null + } + } + }, + include: { versions: true } }); - await this.db.mapSubmissionVersion.updateMany({ - where: { submissionID: mapID }, - data: { hash: null } + await this.db.mMap.update({ + where: { id: mapID }, + data: { currentVersion: { connect: { id: updated.versions.at(-1).id } } } }); // Delete any stored map files. Doesn't matter if any of these don't exist. await Promise.all( [ - this.fileStoreService.deleteFile(approvedBspPath(map.name)), - this.fileStoreService.deleteFile(approvedVmfsPath(map.name)), + this.fileStoreService.deleteFile(bspPath(map.name)), + this.fileStoreService.deleteFile(vmfsPath(map.name)), this.fileStoreService.deleteFiles( - map.submission.versions.flatMap((v) => [ - submissionBspPath(v.id), - submissionVmfsPath(v.id) - ]) + map.versions.flatMap((v) => [bspPath(v.id), vmfsPath(v.id)]) ), ...map.images.map((imageID) => this.mapImageService.deleteStoredMapImage(imageID) @@ -1659,15 +1580,15 @@ export class MapsService { async getZones(mapID: number): Promise { const mapWithZones = await this.db.mMap.findUnique({ where: { id: mapID }, - select: { zones: true } + select: { currentVersion: { select: { zones: true } } } }); - if (!mapWithZones || !mapWithZones.zones) - throw new NotFoundException('Map not found'); + if (!mapWithZones || !mapWithZones.currentVersion?.zones) + throw new NotFoundException('Map/zones not found'); return DtoFactory( MapZonesDto, - mapWithZones.zones as Record + mapWithZones.currentVersion.zones as Record ); } @@ -1914,18 +1835,18 @@ export class MapsService { //#endregion } -type MapWithSubmission = MMap & { +interface MapWithSubmission extends MMap { + currentVersion: MapVersion; + versions: MapVersion[]; submission: Merge< MapSubmission, { dates: MapSubmissionDate[]; placeholders: MapSubmissionPlaceholder[]; suggestions: MapSubmissionSuggestion[]; - currentVersion: MapSubmissionVersion; - versions: MapSubmissionVersion[]; } >; -}; +} type GetMMapUnique = { id: number; status: MapStatus } & Prisma.Result< ExtendedPrismaService['mMap'], diff --git a/apps/backend/src/app/modules/session/run/run-processor.class.spec.ts b/apps/backend/src/app/modules/session/run/run-processor.class.spec.ts index 8be236ce8..033de6ecf 100644 --- a/apps/backend/src/app/modules/session/run/run-processor.class.spec.ts +++ b/apps/backend/src/app/modules/session/run/run-processor.class.spec.ts @@ -40,8 +40,7 @@ describe('RunProcessor', () => { mmap: { id: 1, name: mapName, - hash: mapHash, - zones: mapZones as any + currentVersion: { zones: mapZones as any, bspHash: mapHash } } as any, user: { id: 1, steamID: 1n } as any }, diff --git a/apps/backend/src/app/modules/session/run/run-processor.class.ts b/apps/backend/src/app/modules/session/run/run-processor.class.ts index 713837379..c3927009d 100644 --- a/apps/backend/src/app/modules/session/run/run-processor.class.ts +++ b/apps/backend/src/app/modules/session/run/run-processor.class.ts @@ -1,4 +1,4 @@ -import { MMap, RunSessionTimestamp, User } from '@prisma/client'; +import { MMap, MapVersion, RunSessionTimestamp, User } from '@prisma/client'; import { RunValidationError, Gamemode, @@ -19,7 +19,7 @@ export class RunProcessor { replayFile: ReplayFileReader; replay: Replay; timestamps: RunSessionTimestamp[]; - map: MMap; + map: MMap & { currentVersion: MapVersion }; zones: MapZones; gamemode: Gamemode; userID: number; @@ -36,7 +36,7 @@ export class RunProcessor { this.startTime = session.createdAt.getTime(); this.timestamps = session.timestamps; this.map = session.mmap; - this.zones = session.mmap.zones as unknown as MapZones; // TODO: #855 + this.zones = session.mmap.currentVersion.zones as unknown as MapZones; // TODO: #855 this.userID = user.id; this.steamID = user.steamID; } @@ -223,23 +223,23 @@ export class RunProcessor { // prettier-ignore this.validate([ - [this.replayFile.isOK, ErrorType.BAD_REPLAY_FILE], - [this.trackNum === this.replay.header.trackNum, ErrorType.BAD_META], - [this.replay.magic === 0x524D4F4D, ErrorType.BAD_META], - [this.replay.header.steamID === this.steamID, ErrorType.BAD_META], - [this.replay.header.mapHash === this.map.hash, ErrorType.BAD_META], - [this.replay.header.mapName === this.map.name, ErrorType.BAD_META], - [ticks > 0, ErrorType.BAD_TIMESTAMPS], + [this.replayFile.isOK, ErrorType.BAD_REPLAY_FILE], + [this.trackNum === this.replay.header.trackNum, ErrorType.BAD_META], + [this.replay.magic === 0x524D4F4D, ErrorType.BAD_META], + [this.replay.header.steamID === this.steamID, ErrorType.BAD_META], + [this.replay.header.mapHash === this.map.currentVersion.bspHash,ErrorType.BAD_META], + [this.replay.header.mapName === this.map.name, ErrorType.BAD_META], + [ticks > 0, ErrorType.BAD_TIMESTAMPS], // TODO: Dunno what's going on with these yet // [this.replay.header.trackNum === this.trackNum, ErrorType.BAD_META], // [this.replay.header.runFlags === 0, ErrorType.BAD_META], // Remove after runFlags are added // [this.replay.header.zoneNum === this.trackType, ErrorType.BAD_META], - [!Number.isNaN(Number(this.replay.header.runDate)), ErrorType.BAD_REPLAY_FILE], - [Number(this.replay.header.runDate) <= nowDate, ErrorType.OUT_OF_SYNC], + [!Number.isNaN(Number(this.replay.header.runDate)), ErrorType.BAD_REPLAY_FILE], + [Number(this.replay.header.runDate) <= nowDate, ErrorType.OUT_OF_SYNC], [Math.abs(this.replay.header.tickRate - Tickrates.get(this.gamemode)) - < epsilon, ErrorType.OUT_OF_SYNC], - [runTime * 1000 <= sessionDiff, ErrorType.OUT_OF_SYNC], + < epsilon, ErrorType.OUT_OF_SYNC], + [runTime * 1000 <= sessionDiff, ErrorType.OUT_OF_SYNC], ]); } diff --git a/apps/backend/src/app/modules/session/run/run-session.interface.ts b/apps/backend/src/app/modules/session/run/run-session.interface.ts index 15e1e0e24..23c927d7b 100644 --- a/apps/backend/src/app/modules/session/run/run-session.interface.ts +++ b/apps/backend/src/app/modules/session/run/run-session.interface.ts @@ -4,7 +4,7 @@ import { RunStats, Style } from '@momentum/constants'; export const RUN_SESSION_COMPLETED_INCLUDE = { timestamps: true, user: true, - mmap: true + mmap: { include: { currentVersion: true } } }; const runSessionCompletedIncludeValidator = diff --git a/apps/backend/src/app/modules/session/run/run-session.service.ts b/apps/backend/src/app/modules/session/run/run-session.service.ts index 7bbc95954..4d06bf4cc 100644 --- a/apps/backend/src/app/modules/session/run/run-session.service.ts +++ b/apps/backend/src/app/modules/session/run/run-session.service.ts @@ -167,7 +167,7 @@ export class RunSessionService { include: { timestamps: { orderBy: { createdAt: 'asc' } }, user: true, - mmap: true + mmap: { include: { currentVersion: true } } } }); diff --git a/apps/frontend/src/app/components/map-list/map-list-item.component.html b/apps/frontend/src/app/components/map-list/map-list-item.component.html index 3f9a5c7fc..504369207 100644 --- a/apps/frontend/src/app/components/map-list/map-list-item.component.html +++ b/apps/frontend/src/app/components/map-list/map-list-item.component.html @@ -106,7 +106,7 @@ } @else { Current Status: {{ MapStatusName.get(map.status) }} - @if (!map.hash && !map.submission?.currentVersion?.hash) { + @if (!map.currentVersion?.bspHash) { Files deleted! } } diff --git a/apps/frontend/src/app/components/map-review/map-review-form.component.ts b/apps/frontend/src/app/components/map-review/map-review-form.component.ts index fcd3d2eb0..15af737bc 100644 --- a/apps/frontend/src/app/components/map-review/map-review-form.component.ts +++ b/apps/frontend/src/app/components/map-review/map-review-form.component.ts @@ -59,7 +59,7 @@ export class MapReviewFormComponent { // validator that fetches the current map zones on the class whenever // validator is run. suggestionsValidator( - () => this.map?.zones ?? this.map?.submission?.currentVersion?.zones, + () => this.map?.currentVersion?.zones, SuggestionType.REVIEW ) ] diff --git a/apps/frontend/src/app/components/map-review/map-review-suggestions-form.component.ts b/apps/frontend/src/app/components/map-review/map-review-suggestions-form.component.ts index fc607cde2..c93f3ec53 100644 --- a/apps/frontend/src/app/components/map-review/map-review-suggestions-form.component.ts +++ b/apps/frontend/src/app/components/map-review/map-review-suggestions-form.component.ts @@ -59,9 +59,7 @@ export class MapReviewSuggestionsFormComponent implements ControlValueAccessor { label: GamemodeInfo.get(gamemode).name })); this.availableBonusTracks = - ( - map?.zones ?? map?.submission?.currentVersion?.zones - )?.tracks?.bonuses?.map((_, i) => ({ + map?.currentVersion?.zones?.tracks?.bonuses?.map((_, i) => ({ trackNum: i + 1, label: i + 1 })) ?? []; diff --git a/apps/frontend/src/app/pages/maps/map-edit/map-edit.component.ts b/apps/frontend/src/app/pages/maps/map-edit/map-edit.component.ts index afac50f86..8f1dcdbe7 100644 --- a/apps/frontend/src/app/pages/maps/map-edit/map-edit.component.ts +++ b/apps/frontend/src/app/pages/maps/map-edit/map-edit.component.ts @@ -172,7 +172,7 @@ export class MapEditComponent implements OnInit, ConfirmDeactivate { suggestions: new FormControl([], { validators: [ suggestionsValidator( - () => this.map?.zones ?? this.map?.submission?.currentVersion?.zones, + () => this.map?.currentVersion.zones, SuggestionType.SUBMISSION ) ] @@ -242,10 +242,9 @@ export class MapEditComponent implements OnInit, ConfirmDeactivate { expand: [ 'submission', 'versions', - 'currentVersion', + 'currentVersionWithZones', 'info', 'credits', - 'zones', 'leaderboards', 'reviews', 'testInvites' @@ -311,13 +310,12 @@ export class MapEditComponent implements OnInit, ConfirmDeactivate { ) ); - this.lbSelection.zones = - this.map?.zones ?? this.map.submission?.currentVersion?.zones; + this.lbSelection.zones = this.map?.currentVersion?.zones; this.suggestions.setValue(this.map.submission.suggestions); if (this.map.status === MapStatus.FINAL_APPROVAL) { const validatorFn = suggestionsValidator( - () => this.map?.zones ?? this.map.submission?.currentVersion?.zones, + () => this.map?.currentVersion?.zones, SuggestionType.APPROVAL ); this.status.valueChanges @@ -550,7 +548,7 @@ export class MapEditComponent implements OnInit, ConfirmDeactivate { } catch (error) { this.messageService.add({ severity: 'error', - summary: 'Failed to post submission version!', + summary: 'Failed to post version!', detail: JSON.stringify(error.error.message) }); this.isUploading = false; diff --git a/apps/frontend/src/app/pages/maps/map-info/map-info.component.html b/apps/frontend/src/app/pages/maps/map-info/map-info.component.html index 2ec44f2df..26f4dc139 100644 --- a/apps/frontend/src/app/pages/maps/map-info/map-info.component.html +++ b/apps/frontend/src/app/pages/maps/map-info/map-info.component.html @@ -34,7 +34,7 @@ Play - @if (map.downloadURL ?? map.submission?.currentVersion?.downloadURL; as bsp) { + @if (map.currentVersion; as bsp) { @@ -48,7 +48,7 @@

Download Zones

- @if (map.vmfDownloadURL ?? map.submission?.currentVersion?.vmfDownloadURL; as vmf) { + @if (map.currentVersion?.vmfDownloadURL; as vmf) {

Download VMF

} @@ -168,7 +168,7 @@ } @else if (map.status === MapStatus.DISABLED) {
Map Disabled - @if (map.hash || map.submission?.currentVersion?.hash) { + @if (map.currentVersion?.bspHash) { This map is currently disabled. It's only visible to moderators and admins, and can only be re-enabled by admins. } @else { diff --git a/apps/frontend/src/app/pages/maps/map-info/map-info.component.ts b/apps/frontend/src/app/pages/maps/map-info/map-info.component.ts index 0e4102289..4fc8cfb5c 100644 --- a/apps/frontend/src/app/pages/maps/map-info/map-info.component.ts +++ b/apps/frontend/src/app/pages/maps/map-info/map-info.component.ts @@ -123,7 +123,6 @@ export class MapInfoComponent implements OnInit { this.mapService.getMap(params.get('name'), { expand: [ 'info', - 'zones', 'leaderboards', 'credits', 'submitter', @@ -131,7 +130,12 @@ export class MapInfoComponent implements OnInit { 'inFavorites', 'submission', 'versions', - 'currentVersion' + // Map review system needs zones to work, so we have to fetch + // zones. This is very annoying; we'd really rather avoid + // having to fetch so much data. However, we don't know whether + // we map review stuff prior to response to this query since we + // don't know the status. Maybe worth rethinking in future. + 'currentVersionWithZones' ] }) ), diff --git a/apps/frontend/src/app/pages/maps/map-info/map-submission/map-submission.component.ts b/apps/frontend/src/app/pages/maps/map-info/map-submission/map-submission.component.ts index c89216e6f..186ea65ee 100644 --- a/apps/frontend/src/app/pages/maps/map-info/map-submission/map-submission.component.ts +++ b/apps/frontend/src/app/pages/maps/map-info/map-submission/map-submission.component.ts @@ -4,9 +4,9 @@ import { MapSubmissionType, MapStatusName, TrackType, - MapSubmissionVersion, LeaderboardType, - GamemodeInfo + GamemodeInfo, + MapVersion } from '@momentum/constants'; import { SharedModule } from '../../../../shared.module'; import { @@ -32,7 +32,7 @@ export class MapSubmissionComponent { protected readonly downloadZoneFile = downloadZoneFile; protected suggestions: GroupedMapSubmissionSuggestions; - protected versions: MapSubmissionVersion[]; + protected versions: MapVersion[]; protected visibleVersions: number; private _map: MMap; @@ -42,7 +42,7 @@ export class MapSubmissionComponent { @Input({ required: true }) set map(map: MMap) { this._map = map; this.suggestions = groupMapSuggestions(map.submission.suggestions); - this.versions = map?.submission?.versions.toReversed(); + this.versions = map?.versions.toReversed(); this.visibleVersions = 2; } } diff --git a/apps/frontend/src/app/services/data/maps.service.ts b/apps/frontend/src/app/services/data/maps.service.ts index 297bde2a4..b38bc66c9 100644 --- a/apps/frontend/src/app/services/data/maps.service.ts +++ b/apps/frontend/src/app/services/data/maps.service.ts @@ -21,7 +21,7 @@ import { UpdateMapReview, UpdateMap, UpdateMapImagesWithFiles, - CreateMapSubmissionVersionWithFiles, + CreateMapVersionWithFiles, CreateMapTestInvite, MapPreSignedUrl } from '@momentum/constants'; @@ -92,7 +92,7 @@ export class MapsService { submitMapVersion( mapID: number, - { data, vmfs }: CreateMapSubmissionVersionWithFiles + { data, vmfs }: CreateMapVersionWithFiles ): Observable> { const formData = new FormData(); diff --git a/apps/frontend/src/app/util/download-zone-file.util.ts b/apps/frontend/src/app/util/download-zone-file.util.ts index c1134d687..730671d30 100644 --- a/apps/frontend/src/app/util/download-zone-file.util.ts +++ b/apps/frontend/src/app/util/download-zone-file.util.ts @@ -1,4 +1,4 @@ -import { CombinedMapStatuses, MMap } from '@momentum/constants'; +import { MMap } from '@momentum/constants'; /** * Creates a blob of prettified map zones and downloads it using silly browser @@ -6,9 +6,7 @@ import { CombinedMapStatuses, MMap } from '@momentum/constants'; * https://stackoverflow.com/a/52183135 */ export function downloadZoneFile(map: MMap) { - const zones = CombinedMapStatuses.IN_SUBMISSION.includes(map.status) - ? map.submission?.currentVersion?.zones - : map.zones; + const zones = map?.currentVersion?.zones; if (!zones?.formatVersion || !map.name) { console.error('Bad map data, could not create zones file!'); return; diff --git a/libs/constants/src/consts/file-store-paths.const.ts b/libs/constants/src/consts/file-store-paths.const.ts index e61a61a00..ef9ff793d 100644 --- a/libs/constants/src/consts/file-store-paths.const.ts +++ b/libs/constants/src/consts/file-store-paths.const.ts @@ -1,21 +1,13 @@ import { FlatMapList } from '../enums/flat-map-list.enum'; -export function approvedBspPath(key: string | number): string { +export function bspPath(key: string | number): string { return `maps/${key}.bsp`; } -export function approvedVmfsPath(key: string | number): string { +export function vmfsPath(key: string | number): string { return `maps/${key}_VMFs.zip`; } -export function submissionBspPath(key: string | number): string { - return `submissions/${key}.bsp`; -} - -export function submissionVmfsPath(key: string | number): string { - return `submissions/${key}_VMFs.zip`; -} - export function imgSmallPath(key: string): string { return `img/${key}-small.jpg`; } diff --git a/libs/constants/src/types/models/models.ts b/libs/constants/src/types/models/models.ts index fb989a40e..4200f2df4 100644 --- a/libs/constants/src/types/models/models.ts +++ b/libs/constants/src/types/models/models.ts @@ -174,13 +174,12 @@ export interface MMap { id: number; name: string; status: MapStatus; - downloadURL: string; - hash: string; - vmfDownloadURL: string; submitterID: number; createdAt: DateString; updatedAt: DateString; - zones: MapZones; + currentVersion: MapVersion; + currentVersionID: string; + versions: MapVersion[]; info: MapInfo; submission: MapSubmission; submitter: User; @@ -195,6 +194,19 @@ export interface MMap { testInvites?: MapTestInvite[]; } +export interface MapVersion { + id: string; + versionNum: number; + submitterID: number | null; + changelog: string; + zones: MapZones; + bspHash: string; + zoneHash: string; + downloadURL: string; + vmfDownloadURL?: string; + createdAt: DateString; +} + export interface MapInfo { description: string; youtubeID: string; @@ -309,8 +321,6 @@ export interface MapSubmission { suggestions: MapSubmissionSuggestion[]; placeholders: MapSubmissionPlaceholder[]; dates: MapSubmissionDate[]; - currentVersion: MapSubmissionVersion; - versions: MapSubmissionVersion[]; } export interface MapSubmissionApproval { @@ -342,17 +352,6 @@ export interface MapSubmissionSuggestion { // TODO: Tags! } -export interface MapSubmissionVersion { - id: string; - versionNum: number; - changelog: string; - zones: MapZones; - hash: string; - downloadURL: string; - vmfDownloadURL?: string; - createdAt: DateString; -} - export type MapTags = string[]; export interface MapTestInvite { diff --git a/libs/constants/src/types/models/prisma-correspondence.ts b/libs/constants/src/types/models/prisma-correspondence.ts index 46ae967da..ce712ce05 100644 --- a/libs/constants/src/types/models/prisma-correspondence.ts +++ b/libs/constants/src/types/models/prisma-correspondence.ts @@ -127,12 +127,12 @@ assertTypeCorrespondence< { mapID: OmitMe; currentVersionID: OmitMe } >(); -import { MapSubmissionVersion } from './models'; -import { MapSubmissionVersion as PMapSubmissionVersion } from '@prisma/client'; +import { MapVersion } from './models'; +import { MapVersion as PMapVersion } from '@prisma/client'; assertTypeCorrespondence< - MapSubmissionVersion, - PMapSubmissionVersion, - { hasVmf: OmitMe; submissionID: OmitMe } + MapVersion, + PMapVersion, + { hasVmf: OmitMe; mapID: OmitMe } >(); //#endregion diff --git a/libs/constants/src/types/queries/map-queries.model.ts b/libs/constants/src/types/queries/map-queries.model.ts index 18c8adb95..b0b403b33 100644 --- a/libs/constants/src/types/queries/map-queries.model.ts +++ b/libs/constants/src/types/queries/map-queries.model.ts @@ -13,7 +13,7 @@ import { MapSubmissionApproval, MapSubmissionPlaceholder, MapSubmissionSuggestion, - MapSubmissionVersion, + MapVersion, MapZones, MMap } from '../../'; @@ -21,11 +21,14 @@ import { //#region Map type BaseMapsGetAllExpand = - | 'zones' | 'leaderboards' | 'info' | 'stats' | 'submitter' + | 'currentVersionWithZones' + | 'currentVersion' + | 'versions' + | 'versionsWithZones' | 'credits'; export type MapsGetAllExpand = Array< @@ -37,8 +40,6 @@ export type MapsGetAllSubmissionExpand = Array< | 'inFavorites' | 'personalBest' | 'worldRecord' - | 'currentVersion' - | 'versions' | 'reviews' >; @@ -199,14 +200,14 @@ export type MapLeaderboardGetRunQuery = PagedQuery & { //#endregion //#region Submissions -export interface CreateMapSubmissionVersion - extends Pick { +export interface CreateMapVersion + extends Pick { resetLeaderboards?: boolean; } -export interface CreateMapSubmissionVersionWithFiles { +export interface CreateMapVersionWithFiles { vmfs: File[]; - data: CreateMapSubmissionVersion; + data: CreateMapVersion; } //#endregion diff --git a/libs/db/src/migrations/20240903162533_map_versions/migration.sql b/libs/db/src/migrations/20240903162533_map_versions/migration.sql new file mode 100644 index 000000000..546bd905e --- /dev/null +++ b/libs/db/src/migrations/20240903162533_map_versions/migration.sql @@ -0,0 +1,66 @@ +/* + Warnings: + + - You are about to drop the column `updatedAt` on the `Follow` table. All the data in the column will be lost. + - You are about to drop the column `hasVmf` on the `MMap` table. All the data in the column will be lost. + - You are about to drop the column `hash` on the `MMap` table. All the data in the column will be lost. + - You are about to drop the column `zones` on the `MMap` table. All the data in the column will be lost. + - You are about to drop the column `currentVersionID` on the `MapSubmission` table. All the data in the column will be lost. + - You are about to drop the `MapSubmissionVersion` table. If the table is not empty, all the data it contains will be lost. + - A unique constraint covering the columns `[currentVersionID]` on the table `MMap` will be added. If there are existing duplicate values, this will fail. + +*/ +-- DropForeignKey +ALTER TABLE "MapSubmission" DROP CONSTRAINT "MapSubmission_currentVersionID_fkey"; + +-- DropForeignKey +ALTER TABLE "MapSubmissionVersion" DROP CONSTRAINT "MapSubmissionVersion_submissionID_fkey"; + +-- DropIndex +DROP INDEX "MapSubmission_currentVersionID_key"; + +-- AlterTable +ALTER TABLE "Follow" DROP COLUMN "updatedAt"; + +-- AlterTable +ALTER TABLE "MMap" DROP COLUMN "hasVmf", +DROP COLUMN "hash", +DROP COLUMN "zones", +ADD COLUMN "currentVersionID" UUID; + +-- AlterTable +ALTER TABLE "MapSubmission" DROP COLUMN "currentVersionID"; + +-- DropTable +DROP TABLE "MapSubmissionVersion"; + +-- CreateTable +CREATE TABLE "MapVersion" ( + "id" UUID NOT NULL, + "versionNum" SMALLINT NOT NULL, + "changelog" TEXT, + "bspHash" CHAR(40), + "zoneHash" CHAR(40), + "hasVmf" BOOLEAN NOT NULL DEFAULT false, + "zones" JSONB, + "submitterID" INTEGER, + "mapID" INTEGER NOT NULL, + "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, + + CONSTRAINT "MapVersion_pkey" PRIMARY KEY ("id") +); + +-- CreateIndex +CREATE INDEX "MapVersion_mapID_idx" ON "MapVersion"("mapID"); + +-- CreateIndex +CREATE UNIQUE INDEX "MMap_currentVersionID_key" ON "MMap"("currentVersionID"); + +-- AddForeignKey +ALTER TABLE "MMap" ADD CONSTRAINT "MMap_currentVersionID_fkey" FOREIGN KEY ("currentVersionID") REFERENCES "MapVersion"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MapVersion" ADD CONSTRAINT "MapVersion_submitterID_fkey" FOREIGN KEY ("submitterID") REFERENCES "User"("id") ON DELETE SET NULL ON UPDATE CASCADE; + +-- AddForeignKey +ALTER TABLE "MapVersion" ADD CONSTRAINT "MapVersion_mapID_fkey" FOREIGN KEY ("mapID") REFERENCES "MMap"("id") ON DELETE CASCADE ON UPDATE CASCADE; diff --git a/libs/db/src/schema.prisma b/libs/db/src/schema.prisma index ecf3b72b6..f6c668454 100644 --- a/libs/db/src/schema.prisma +++ b/libs/db/src/schema.prisma @@ -20,27 +20,28 @@ model User { avatar String? country String? @db.Char(2) - userAuth UserAuth? - profile Profile? - userStats UserStats? - submittedMaps MMap[] - mapCredits MapCredit[] - mapFavorites MapFavorite[] - activities Activity[] - follows Follow[] @relation("follow_follow") - followers Follow[] @relation("follow_follower") - mapNotifies MapNotify[] - notifications Notification[] - runSessions RunSession[] - leaderboardRuns LeaderboardRun[] - pastRuns PastRun[] - reportSubmitted Report[] @relation("report_submitter") - reportResolved Report[] @relation("report_resolver") - testInvites MapTestInvite[] - reviewsSubmitted MapReview[] @relation("mapreview_reviewer") - reviewsResolved MapReview[] @relation("mapreview_resolver") - reviewComments MapReviewComment[] - adminActivities AdminActivity[] + userAuth UserAuth? + profile Profile? + userStats UserStats? + submittedMaps MMap[] + submittedMapVersions MapVersion[] + mapCredits MapCredit[] + mapFavorites MapFavorite[] + activities Activity[] + follows Follow[] @relation("follow_follow") + followers Follow[] @relation("follow_follower") + mapNotifies MapNotify[] + notifications Notification[] + runSessions RunSession[] + leaderboardRuns LeaderboardRun[] + pastRuns PastRun[] + reportSubmitted Report[] @relation("report_submitter") + reportResolved Report[] @relation("report_resolver") + testInvites MapTestInvite[] + reviewsSubmitted MapReview[] @relation("mapreview_reviewer") + reviewsResolved MapReview[] @relation("mapreview_resolver") + reviewComments MapReviewComment[] + adminActivities AdminActivity[] createdAt DateTime @default(now()) } @@ -173,9 +174,6 @@ model MMap { name String @unique status Int @db.SmallInt /// map-status.enum.ts - hash String? @db.Char(40) - hasVmf Boolean @default(false) - zones Json? /// map-zones.model.ts images String[] submitter User? @relation(fields: [submitterID], references: [id], onDelete: SetNull) @@ -184,6 +182,10 @@ model MMap { stats MapStats? info MapInfo? + currentVersion MapVersion? @relation(name: "current_version", fields: [currentVersionID], references: [id]) + currentVersionID String? @unique @db.Uuid + + versions MapVersion[] leaderboards Leaderboard[] leaderboardRuns LeaderboardRun[] pastRuns PastRun[] @@ -204,6 +206,29 @@ model MMap { @@index([status, createdAt(sort: Desc)]) } +model MapVersion { + id String @id @default(uuid()) @db.Uuid // BSP file stored relative to this + + versionNum Int @db.SmallInt + changelog String? + bspHash String? @db.Char(40) // Nullable as we set to null if we delete files if map gets "deleted" (disabled) + zoneHash String? @db.Char(40) // Nullable as we set to null if we delete files if map gets "deleted" (disabled) + hasVmf Boolean @default(false) + zones Json? /// map-zones.model.ts + + submitterID Int? + submitter User? @relation(fields: [submitterID], references: [id]) + + currentVersion MMap? @relation(name: "current_version") + + mmap MMap @relation(fields: [mapID], references: [id], onDelete: Cascade) + mapID Int + + createdAt DateTime @default(now()) + + @@index([mapID]) +} + model MapCredit { type Int @db.SmallInt /// map-credit-type.enum.ts description String? @@ -281,30 +306,6 @@ model MapSubmission { suggestions Json @default("[]") /// Array of map-submission-suggestions.model.ts placeholders Json? /// Array of map-submission-placeholder.model.ts dates Json @default("[]") /// Array of map-submission-dates.model.ts - - versions MapSubmissionVersion[] - - currentVersion MapSubmissionVersion? @relation(name: "current_version", fields: [currentVersionID], references: [id]) - currentVersionID String? @unique @db.Uuid -} - -model MapSubmissionVersion { - id String @id @default(uuid()) @db.Uuid // BSP file stored relative to this - - currentVersion MapSubmission? @relation(name: "current_version") - - versionNum Int @db.SmallInt - changelog String? - hash String? @db.Char(40) // Nullable as we set to null if we delete files if map gets "deleted" (disabled) - hasVmf Boolean @default(false) - zones Json? /// map-zones.model.ts - - submission MapSubmission @relation(fields: [submissionID], references: [mapID], onDelete: Cascade) - submissionID Int - - createdAt DateTime @default(now()) - - @@index([submissionID]) } // This model will be greatly expanded in the future to include screenshots, diff --git a/libs/test-utils/src/utils/db.util.ts b/libs/test-utils/src/utils/db.util.ts index 65b3ab8d4..1b39ae41a 100644 --- a/libs/test-utils/src/utils/db.util.ts +++ b/libs/test-utils/src/utils/db.util.ts @@ -4,7 +4,7 @@ MapInfo, MapStats, MapSubmission, - MapSubmissionVersion, + MapVersion, MMap, PastRun, Prisma, @@ -68,12 +68,14 @@ export class DbUtil { //#region Users - private createUserData() { + getNewUserCreateData() { return { - steamID: randomSteamID(), - alias: `User ${++this.users}`, - profile: { create: {} }, - userStats: { create: {} } + create: { + steamID: randomSteamID(), + alias: `User ${++this.users}`, + profile: { create: {} }, + userStats: { create: {} } + } }; } @@ -81,7 +83,7 @@ export class DbUtil { return this.prisma.user.create( merge( { - data: this.createUserData() + data: this.getNewUserCreateData().create }, args ) as Prisma.UserCreateArgs @@ -128,89 +130,87 @@ export class DbUtil { info: MapInfo; stats: MapStats; leaderboards: Leaderboard[]; - submission: MapSubmission & { versions: MapSubmissionVersion[] }; + currentVersion: MapVersion; + versions: MapVersion[]; + submission: MapSubmission; } > { const createdMap = await this.prisma.mMap.create({ data: { - ...merge( - { - name: `ahop_map${++this.maps}`, - zones: ZonesStub, - status: MapStatus.APPROVED, - hash: randomHash(), - info: { - create: { - creationDate: new Date(), - description: 'Maps have a minimum description length now!!' - } - }, - images: mmap?.images ?? [], - stats: mmap?.stats ?? { create: {} }, - submission: mmap?.submission ?? { - create: { - type: MapSubmissionType.ORIGINAL, - suggestions: [ - { + ...({ + name: `ahop_map${++this.maps}`, + status: MapStatus.APPROVED, + info: { + create: { + creationDate: new Date(), + description: 'Maps have a minimum description length now!!' + } + }, + images: [], + stats: { create: {} }, + versions: { + create: { + versionNum: 1, + changelog: 'hello', + submitter: this.getNewUserCreateData(), + bspHash: createSha1Hash(Math.random().toString()), + zones: ZonesStub as unknown as JsonValue, // TODO: #855 + zoneHash: createSha1Hash(JSON.stringify(ZonesStub)) + } + }, + submission: { + create: { + type: MapSubmissionType.ORIGINAL, + suggestions: [ + { + gamemode: Gamemode.AHOP, + trackType: TrackType.MAIN, + trackNum: 1, + tier: 1, + type: LeaderboardType.RANKED + } + ] + } + }, + submitter: this.getNewUserCreateData(), + // Just creating the one leaderboard here, most maps will have more, + // but isn't needed or worth the test perf hit + leaderboards: + (mmap?.leaderboards ?? noLeaderboards) + ? undefined + : { + create: { gamemode: Gamemode.AHOP, trackType: TrackType.MAIN, trackNum: 1, + style: 0, tier: 1, + linear: true, type: LeaderboardType.RANKED } - ], - versions: { - create: { - versionNum: 1, - changelog: 'hello', - hash: createSha1Hash(Math.random().toString()), - zones: ZonesStub - } } - } - }, - submitter: mmap?.submitter ?? { create: this.createUserData() }, - // Just creating the one leaderboard here, most maps will have more, - // but isn't needed or worth the test perf hit - leaderboards: - (mmap?.leaderboards ?? noLeaderboards) - ? undefined - : { - create: { - gamemode: Gamemode.AHOP, - trackType: TrackType.MAIN, - trackNum: 1, - style: 0, - tier: 1, - linear: true, - type: LeaderboardType.RANKED - } - } - } as CreateMapMMapArgs, - mmap - ) + } as CreateMapMMapArgs), + // Spread will replace any default values above with given values + ...mmap } as any, - include: { submission: { include: { versions: true } } } + include: { versions: true } }); + const latestVersion = createdMap?.versions?.at(-1)?.id; return this.prisma.mMap.update({ where: { id: createdMap.id }, data: { - submission: createdMap?.submission?.versions?.[0] - ? { - update: { - currentVersion: { - connect: { id: createdMap.submission.versions[0].id } - } - } - } + currentVersion: latestVersion + ? { connect: { id: latestVersion } } : undefined }, include: { info: true, stats: true, + versions: true, + currentVersion: true, leaderboards: true, - submission: { include: { versions: true, currentVersion: true } } + submission: true } }); } @@ -240,7 +240,16 @@ export class DbUtil { ) { return this.createMap({ ...mmap, - zones: ZonesStub, + versions: { + create: { + versionNum: 1, + changelog: 'hello', + submitter: this.getNewUserCreateData(), + bspHash: createSha1Hash(Math.random().toString()), + zones: ZonesStub as unknown as JsonValue, // TODO: #855 + zoneHash: createSha1Hash(JSON.stringify(ZonesStub)) + } + }, leaderboards: { createMany: { data: gamemodes.flatMap((gamemode) => [ @@ -403,6 +412,6 @@ export class DbUtil { //#region Types type CreateUserArgs = PartialDeep; -type CreateMapMMapArgs = PartialDeep; +type CreateMapMMapArgs = Partial; //#endregion diff --git a/scripts/src/seed.script.ts b/scripts/src/seed.script.ts index 07eebd261..52be87670 100644 --- a/scripts/src/seed.script.ts +++ b/scripts/src/seed.script.ts @@ -35,10 +35,9 @@ import { imgLargePath, AdminActivityType, imgXlPath, - approvedBspPath, - submissionBspPath, MapZones, - GamemodeInfo + GamemodeInfo, + bspPath } from '@momentum/constants'; import * as Bitflags from '@momentum/bitflags'; import { nuke } from '@momentum/db'; @@ -419,13 +418,19 @@ prismaWrapper(async (prisma: PrismaClient) => { return dates; }; - const versions = arrayFrom(randRange(vars.submissionVersions), (i) => ({ - versionNum: i + 1, - hash: mapHash, - hasVmf: false, // Could add a VMF if we really want but leaving for now - zones: randomZones() as unknown as JsonValue, // TODO: #855, - changelog: faker.lorem.paragraphs({ min: 1, max: 10 }) - })); + const versions = arrayFrom(randRange(vars.submissionVersions), (i) => { + const zones = randomZones(); + return { + versionNum: i + 1, + bspHash: mapHash, + hasVmf: false, // Could add a VMF if we really want but leaving for now + zones: zones as unknown as JsonValue, // TODO: #855 + zoneHash: createHash('sha1') + .update(JSON.stringify(zones)) + .digest('hex'), + changelog: faker.lorem.paragraphs({ min: 1, max: 10 }) + }; + }); const status = Random.weighted(weights.mapStatusWeights); const inSubmission = CombinedMapStatuses.IN_SUBMISSION.includes(status); @@ -553,125 +558,105 @@ prismaWrapper(async (prisma: PrismaClient) => { //#endregion - const [map] = await parallel( - prisma.mMap.create({ - data: { - name, - status, - submitterID: Random.element(potentialMappers).id, - ...Random.createdUpdatedDates(), - info: { - create: { - description: faker.lorem.paragraphs().slice(0, 999), - creationDate: Random.pastDateInYears(), - youtubeID: Math.random() < 0.01 ? 'kahsl8rggF4' : undefined - } - }, - images: await Promise.all( - arrayFrom(randRange(vars.images), async () => { - const id = uuidv4(); - - const buffer = Random.element(imageBuffers); - - // Could be fancy and bubble up all the promises here to do in parallel - // but not worth the insane code - await Promise.all( - ['small', 'medium', 'large', 'xl'].map((size) => - s3.send( - new PutObjectCommand({ - Bucket: s3BucketName, - Key: `img/${id}-${size}.jpg`, - Body: buffer[size], - ContentType: 'image/jpeg' - }) - ) + const map = await prisma.mMap.create({ + data: { + name, + status, + submitterID: Random.element(potentialMappers).id, + ...Random.createdUpdatedDates(), + info: { + create: { + description: faker.lorem.paragraphs().slice(0, 999), + creationDate: Random.pastDateInYears(), + youtubeID: Math.random() < 0.01 ? 'kahsl8rggF4' : undefined + } + }, + images: await Promise.all( + arrayFrom(randRange(vars.images), async () => { + const id = uuidv4(); + + const buffer = Random.element(imageBuffers); + + // Could be fancy and bubble up all the promises here to do in parallel + // but not worth the insane code + await Promise.all( + ['small', 'medium', 'large', 'xl'].map((size) => + s3.send( + new PutObjectCommand({ + Bucket: s3BucketName, + Key: `img/${id}-${size}.jpg`, + Body: buffer[size], + ContentType: 'image/jpeg' + }) ) - ); - - return id; - }) - ), - stats: { - create: { - reviews: Random.int(10000), - downloads: Random.int(10000), - subscriptions: Random.int(10000), - plays: Random.int(10000), - favorites: Random.int(10000), - completions: Random.int(10000), - uniqueCompletions: Random.int(10000), - timePlayed: Random.int(10000) - } - }, - reviews: { - createMany: { - data: await Promise.all( - arrayFrom(randRange(vars.reviewsPerMap), review) ) - } - }, - submission: { - create: { - type: Random.weighted([ - [MapSubmissionType.ORIGINAL, 1], - [MapSubmissionType.PORT, 1], - [MapSubmissionType.SPECIAL, 0.2] - ]), - placeholders: arrayFrom( - randRange(vars.submissionPlaceholders), - () => ({ - alias: faker.internet.userName(), - type: Random.enumValue(MapCreditType), - description: faker.lorem.words({ min: 1, max: 4 }) - }) - ), - dates: submissionsDates(), - versions: { createMany: { data: versions } }, - suggestions: submissionSuggestions() - } - }, - leaderboards: { createMany: { data: leaderboards } } + ); + + return id; + }) + ), + stats: { + create: { + reviews: Random.int(10000), + downloads: Random.int(10000), + subscriptions: Random.int(10000), + plays: Random.int(10000), + favorites: Random.int(10000), + completions: Random.int(10000), + uniqueCompletions: Random.int(10000), + timePlayed: Random.int(10000) + } }, - include: { - submission: { include: { versions: true } }, - reviews: { include: { comments: true } } - } - }), - status === MapStatus.APPROVED - ? s3.send( - new PutObjectCommand({ - Bucket: s3BucketName, - Key: approvedBspPath(name), - Body: mapBuffer - }) - ) - : Promise.resolve() - ); + reviews: { + createMany: { + data: await Promise.all( + arrayFrom(randRange(vars.reviewsPerMap), review) + ) + } + }, + versions: { createMany: { data: versions } }, + submission: { + create: { + type: Random.weighted([ + [MapSubmissionType.ORIGINAL, 1], + [MapSubmissionType.PORT, 1], + [MapSubmissionType.SPECIAL, 0.2] + ]), + placeholders: arrayFrom( + randRange(vars.submissionPlaceholders), + () => ({ + alias: faker.internet.userName(), + type: Random.enumValue(MapCreditType), + description: faker.lorem.words({ min: 1, max: 4 }) + }) + ), + dates: submissionsDates(), + suggestions: submissionSuggestions() + } + }, + leaderboards: { createMany: { data: leaderboards } } + }, + include: { + versions: true, + submission: true, + reviews: { include: { comments: true } } + } + }); - const lastVersion = map.submission.versions.at(-1); - await prisma.mapSubmission.update({ - where: { mapID: map.id }, + const lastVersion = map.versions.at(-1); + await prisma.mMap.update({ + where: { id: map.id }, data: { currentVersion: { connect: { id: lastVersion.id } } } }); await s3.send( new PutObjectCommand({ Bucket: s3BucketName, - Key: submissionBspPath(lastVersion.id), + Key: bspPath(lastVersion.id), Body: mapBuffer }) ); - if ([MapStatus.APPROVED, MapStatus.DISABLED].includes(map.status)) - await prisma.mMap.update({ - where: { id: map.id }, - data: { - hash: lastVersion.hash, - zones: lastVersion.zones, - hasVmf: false - } - }); - for (const review of map.reviews) { await prisma.mapReviewComment.createMany({ data: arrayFrom(randRange(vars.commentsPerReview), () => ({ @@ -1037,12 +1022,10 @@ prismaWrapper(async (prisma: PrismaClient) => { select: { id: true, name: true, - hash: true, status: true, images: true, info: true, leaderboards: true, - createdAt: true, credits: { select: { type: true, @@ -1052,27 +1035,11 @@ prismaWrapper(async (prisma: PrismaClient) => { } } }, - submission: - type === FlatMapList.SUBMISSION - ? { - select: { - currentVersion: { - select: { - id: true, - versionNum: true, - hash: true, - changelog: true, - zones: true, - createdAt: true - } - }, - type: true, - placeholders: true, - suggestions: true, - dates: true - } - } - : undefined + createdAt: true, + currentVersion: { omit: { zones: true, changelog: true } }, + ...(type === FlatMapList.SUBMISSION + ? { submission: true, versions: { omit: { zones: true } } } + : {}) } }); @@ -1082,13 +1049,9 @@ prismaWrapper(async (prisma: PrismaClient) => { for (const map of maps as any[]) { delete map.info.mapID; - map.downloadURL = `${cdnUrl}/${approvedBspPath(map.name)}`; - - if (map?.submission?.currentVersion) { - map.submission.currentVersion.downloadURL = `${cdnUrl}/${submissionBspPath( - map.submission.currentVersion.id - )}`; - } + map.currentVersion.downloadURL = `${cdnUrl}/${bspPath( + map.currentVersion.id + )}`; map.images = map.images.map((image) => ({ id: image,